# 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: -------------------------------------------------------------------------------- ``` v20 ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` { "semi": true, "trailingComma": "all", "singleQuote": false, "printWidth": 100, "tabWidth": 2, "useTabs": false } ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules .pnpm-store package-lock.json # Build output dist # Environment variables .env .env.local .env.*.local # IDE .vscode/* !.vscode/extensions.json !.vscode/settings.json .idea *.suo *.ntvs* *.njsproj *.sln *.sw? # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Testing coverage test-output # OS .DS_Store Thumbs.db # mcp-publisher CLI tool files .mcpregistry* ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` # Your Figma API access token # Get it from your Figma account settings: https://www.figma.com/developers/api#access-tokens FIGMA_API_KEY=your_figma_api_key_here # Figma file key for testing # This is the ID in your Figma URL: https://www.figma.com/file/{FILE_KEY}/filename FIGMA_FILE_KEY=your_figma_file_key_here # Figma node ID for Testing # This is the node-id parameter in your Figma URL: ?node-id={NODE_ID} FIGMA_NODE_ID=your_figma_node_id_here # Server configuration PORT=3333 # Output format can either be "yaml" or "json". Is YAML by default since it's # smaller, but JSON is understood by most LLMs better. # # OUTPUT_FORMAT="json" ``` -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- ```markdown # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) ``` -------------------------------------------------------------------------------- /src/extractors/README.md: -------------------------------------------------------------------------------- ```markdown # Flexible Figma Data Extractors 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. ## Architecture The system is built in clean layers: 1. **Strategy Layer**: Define what you want to extract 2. **Traversal Layer**: Single-pass tree walking with configurable extractors 3. **Extraction Layer**: Pure functions that transform individual node data ## Basic Usage ```typescript import { extractFromDesign, allExtractors, layoutAndText, contentOnly } from "figma-mcp/extractors"; // Extract everything (equivalent to current parseNode) const fullData = extractFromDesign(nodes, allExtractors); // Extract only layout + text for content planning const layoutData = extractFromDesign(nodes, layoutAndText, { maxDepth: 3, }); // Extract only text content for copy audits const textData = extractFromDesign(nodes, contentOnly, { nodeFilter: (node) => node.type === "TEXT", }); ``` ## Built-in Extractors ### Individual Extractors - `layoutExtractor` - Layout properties (positioning, sizing, flex properties) - `textExtractor` - Text content and typography styles - `visualsExtractor` - Visual appearance (fills, strokes, effects, opacity, borders) - `componentExtractor` - Component instance data ### Convenience Combinations - `allExtractors` - Everything (replicates current behavior) - `layoutAndText` - Layout + text (good for content analysis) - `contentOnly` - Text only (good for copy extraction) - `visualsOnly` - Visual styles only (good for design systems) - `layoutOnly` - Layout only (good for structure analysis) ## Creating Custom Extractors ```typescript import type { ExtractorFn } from "figma-mcp/extractors"; // Custom extractor that identifies design system components const designSystemExtractor: ExtractorFn = (node, result, context) => { if (node.name.startsWith("DS/")) { result.isDesignSystemComponent = true; result.dsCategory = node.name.split("/")[1]; } }; // Use it with other extractors const data = extractFromDesign(nodes, [layoutExtractor, designSystemExtractor]); ``` ## Filtering and Options ```typescript // Limit traversal depth const shallowData = extractFromDesign(nodes, allExtractors, { maxDepth: 2, }); // Filter to specific node types const frameData = extractFromDesign(nodes, layoutAndText, { nodeFilter: (node) => ["FRAME", "GROUP"].includes(node.type), }); // Custom filtering logic const buttonData = extractFromDesign(nodes, allExtractors, { nodeFilter: (node) => node.name.toLowerCase().includes("button"), }); ``` ## LLM Context Optimization The flexible system is designed for different LLM use cases: ```typescript // For large designs - extract incrementally function extractForLLM(nodes, phase) { switch (phase) { case "structure": return extractFromDesign(nodes, layoutOnly, { maxDepth: 3 }); case "content": return extractFromDesign(nodes, contentOnly); case "styling": return extractFromDesign(nodes, visualsOnly, { maxDepth: 2 }); case "full": return extractFromDesign(nodes, allExtractors); } } ``` ## Benefits 1. **Single Tree Walk** - Efficient processing, no matter how many extractors 2. **Composable** - Mix and match extractors for your specific needs 3. **Extensible** - Easy to add custom extractors for domain-specific logic 4. **Type Safe** - Full TypeScript support with proper inference 5. **Context Optimized** - Perfect for LLM context window management 6. **Backward Compatible** - Works alongside existing parsing logic ## Migration Path The new system works alongside the current `parseNode` function. You can: 1. Start using the new extractors for new use cases 2. Gradually migrate existing functionality 3. Keep the current API for general-purpose parsing The `allExtractors` combination provides equivalent functionality to the current `parseNode` behavior. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown <a href="https://www.framelink.ai/?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank" rel="noopener"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://www.framelink.ai/github/HeaderDark.png" /> <img alt="Framelink" src="https://www.framelink.ai/github/HeaderLight.png" /> </picture> </a> <div align="center"> <h1>Framelink Figma MCP Server</h1> <p> 🌐 Available in: <a href="README.ko.md">한국어 (Korean)</a> | <a href="README.ja.md">日本語 (Japanese)</a> | <a href="README.zh-cn.md">简体中文 (Simplified Chinese)</a> | <a href="README.zh-tw.md">繁體中文 (Traditional Chinese)</a> </p> <h3>Give your coding agent access to your Figma data.<br/>Implement designs in any framework in one-shot.</h3> <a href="https://npmcharts.com/compare/figma-developer-mcp?interval=30"> <img alt="weekly downloads" src="https://img.shields.io/npm/dm/figma-developer-mcp.svg"> </a> <a href="https://github.com/GLips/Figma-Context-MCP/blob/main/LICENSE"> <img alt="MIT License" src="https://img.shields.io/github/license/GLips/Figma-Context-MCP" /> </a> <a href="https://framelink.ai/discord"> <img alt="Discord" src="https://img.shields.io/discord/1352337336913887343?color=7389D8&label&logo=discord&logoColor=ffffff" /> </a> <br /> <a href="https://twitter.com/glipsman"> <img alt="Twitter" src="https://img.shields.io/twitter/url?url=https%3A%2F%2Fx.com%2Fglipsman&label=%40glipsman" /> </a> </div> <br/> 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. When Cursor has access to Figma design data, it's **way** better at one-shotting designs accurately than alternative approaches like pasting screenshots. <h3><a href="https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=referral&utm_campaign=readme">See quickstart instructions →</a></h3> ## Demo [Watch a demo of building a UI in Cursor with Figma design data](https://youtu.be/6G9yb-LrEqg) [](https://youtu.be/6G9yb-LrEqg) ## How it works 1. Open your IDE's chat (e.g. agent mode in Cursor). 2. Paste a link to a Figma file, frame, or group. 3. Ask Cursor to do something with the Figma file—e.g. implement the design. 4. Cursor will fetch the relevant metadata from Figma and use it to write your code. 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. Reducing the amount of context provided to the model helps make the AI more accurate and the responses more relevant. ## Getting Started Many code editors and other AI clients use a configuration file to manage MCP servers. The `figma-developer-mcp` server can be configured by adding the following to your configuration file. > 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). ### MacOS / Linux ```json { "mcpServers": { "Framelink Figma MCP": { "command": "npx", "args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"] } } } ``` ### Windows ```json { "mcpServers": { "Framelink Figma MCP": { "command": "cmd", "args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"] } } } ``` Or you can set `FIGMA_API_KEY` and `PORT` in the `env` field. 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). ## Star History <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> ## Learn More 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. ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown # Contributing to Framelink Figma MCP Server 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. ## Philosophy ### Unix Philosophy for Tools 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. ### MCP Server Scope 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: - Image conversion, cropping, or other image manipulation - Syncing design data to CMSes or databases - Code generation or framework-specific output - Third-party integrations unrelated to design ingestion This focused approach ensures: - Clear boundaries and responsibilities - Better maintainability - Easier testing and debugging - More reliable integration with AI tools ## Getting Started ### Prerequisites - Node.js 18.0.0 or higher - pnpm (recommended package manager) - A Figma API access token ([how to create one](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens)) ### Development Setup 1. **Clone the repository:** ```bash git clone https://github.com/GLips/Figma-Context-MCP.git cd Figma-Context-MCP ``` 2. **Install dependencies:** ```bash pnpm install ``` 3. **Set up environment variables:** Create a `.env` file in the root directory: ``` FIGMA_API_KEY=your_figma_api_key_here ``` 4. **Build the project:** ```bash pnpm build ``` 5. **Run tests:** ```bash pnpm test ``` 6. **Start development server:** ```bash pnpm dev ``` 7. **Test locally:** `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. ```bash "mcpServers": { "Framelink Figma MCP - Local StreamableHTTP": { "url": "http://localhost:3333/mcp" }, } ``` ### Development Commands - `pnpm dev` - Start development server with watch mode - `pnpm build` - Build the project - `pnpm type-check` - Run TypeScript type checking - `pnpm test` - Run tests - `pnpm lint` - Run ESLint - `pnpm format` - Format code with Prettier - `pnpm inspect` - Run MCP inspector for debugging ## Code Style and Standards ### TypeScript - Use TypeScript for all new code - Follow TypeScript settings as defined in `tsconfig.json` ### Code Formatting - Use Prettier for code formatting (run `pnpm format`) - Use ESLint for code linting (run `pnpm lint`) - Follow existing code patterns and conventions ## Project Structure ``` src/ ├── cli.ts # Command line interface ├── config.ts # Configuration management ├── index.ts # Main entry point ├── server.ts # MCP server implementation ├── mcp/ # MCP-specific code │ ├── index.ts │ └── tools/ # MCP tools ├── services/ # Core business logic ├── transformers/ # Data transformation logic ├── utils/ # Utility functions └── tests/ # Test files ``` ## Contributing Guidelines ### Before You Start 1. Check existing issues and PRs to avoid duplicates 2. For major changes, create an issue first to discuss the approach 3. Keep changes focused and atomic ### Pull Request Process 1. **Fork the repository** and create a feature branch 2. **Make your changes** following the code style guidelines 3. **Add tests** for new functionality 4. **Run the test suite** to ensure nothing is broken: ```bash pnpm test pnpm type-check pnpm lint ``` 5. **Update documentation** if needed 6. **Submit a pull request** with a clear description that includes context and motivation for the changes ### Commit Messages - Use clear, descriptive commit messages - Follow conventional commit format when possible - Reference issue numbers when applicable ### What We're Looking For - **New features** - Expand the server's capabilities to support more Figma features - **Bug fixes** - Help us improve reliability - **Performance improvements** - Make the server faster - **Documentation improvements** - Help others understand the project - **Test coverage** - Improve our test suite - **Code quality** - Refactoring and clean-up ### What We're Not Looking For - Features that go beyond design ingestion (see Philosophy section) - Breaking changes without discussion - Code that doesn't follow our style guidelines - Features without tests ## Getting Help - **Documentation**: Check the [Framelink docs](https://framelink.ai/docs) - **Issues**: Search existing issues or create a new one - **Discord**: Join our [Discord community](https://framelink.ai/discord) ## License By contributing to this project, you agree that your contributions will be licensed under the MIT License. ``` -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- ```yaml # These are supported funding model platforms github: GLips ``` -------------------------------------------------------------------------------- /src/mcp/tools/index.ts: -------------------------------------------------------------------------------- ```typescript export { getFigmaDataTool } from "./get-figma-data-tool.js"; export { downloadFigmaImagesTool } from "./download-figma-images-tool.js"; export type { DownloadImagesParams } from "./download-figma-images-tool.js"; export type { GetFigmaDataParams } from "./get-figma-data-tool.js"; ``` -------------------------------------------------------------------------------- /src/mcp-server.ts: -------------------------------------------------------------------------------- ```typescript // Re-export server-related functionality for users who want MCP server capabilities export { createServer } from "./mcp/index.js"; export type { FigmaService } from "./services/figma.js"; export { getServerConfig } from "./config.js"; export { startServer, startHttpServer, stopHttpServer } from "./server.js"; ``` -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json", "changelog": [ "@changesets/changelog-github", { "repo": "GLips/Figma-Context-MCP" } ], "commit": true, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] } ``` -------------------------------------------------------------------------------- /src/tests/benchmark.test.ts: -------------------------------------------------------------------------------- ```typescript import yaml from "js-yaml"; describe("Benchmarks", () => { const data = { name: "John Doe", age: 30, email: "[email protected]", }; it("YAML should be token efficient", () => { const yamlResult = yaml.dump(data); const jsonResult = JSON.stringify(data); expect(yamlResult.length).toBeLessThan(jsonResult.length); }); }); ``` -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { config } from "dotenv"; import { resolve } from "path"; import { startServer } from "./server.js"; // Load .env from the current working directory config({ path: resolve(process.cwd(), ".env") }); // Start the server immediately - this file is only for execution startServer().catch((error) => { console.error("Failed to start server:", error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- ```yaml name: "Setup and install" description: "Common setup steps for Actions" runs: using: composite steps: - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 10.10.0 - name: Install Node.js v20 uses: actions/setup-node@v4 with: node-version: 20.17.0 cache: "pnpm" - name: Install PNPM Dependencies shell: bash run: pnpm install ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript // Re-export extractor types only export type { SimplifiedDesign } from "./extractors/types.js"; // Flexible extractor system export type { ExtractorFn, TraversalContext, TraversalOptions, GlobalVars, StyleTypes, } from "./extractors/index.js"; export { extractFromDesign, simplifyRawFigmaObject, layoutExtractor, textExtractor, visualsExtractor, componentExtractor, allExtractors, layoutAndText, contentOnly, visualsOnly, layoutOnly, } from "./extractors/index.js"; ``` -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- ```typescript import { defineConfig } from "tsup"; const isDev = process.env.npm_lifecycle_event === "dev"; const packageVersion = process.env.npm_package_version; export default defineConfig({ clean: true, entry: ["src/index.ts", "src/bin.ts", "src/mcp-server.ts"], format: ["esm"], minify: !isDev, target: "esnext", outDir: "dist", outExtension: ({ format }) => ({ js: ".js", }), onSuccess: isDev ? "node dist/bin.js" : undefined, define: { "process.env.NPM_PACKAGE_VERSION": JSON.stringify(packageVersion), }, }); ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript export default { preset: "ts-jest/presets/default-esm", extensionsToTreatAsEsm: [".ts"], testEnvironment: "node", transform: { "^.+\\.tsx?$": [ "ts-jest", { useESM: true, tsconfig: { module: "ESNext", verbatimModuleSyntax: false, }, }, ], }, moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], moduleNameMapper: { "^~/(.*)\.js$": "<rootDir>/src/$1.ts", "^~/(.*)$": "<rootDir>/src/$1", "^(\\.{1,2}/.*)\\.js$": "$1", }, modulePaths: ["<rootDir>/src"], }; ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "baseUrl": "./", "rootDir": "src", "paths": { "~/*": ["./src/*"] }, "target": "ES2022", "lib": ["ES2022", "DOM"], "module": "NodeNext", "moduleResolution": "NodeNext", "resolveJsonModule": true, "verbatimModuleSyntax": true, "allowJs": true, "checkJs": true, /* EMIT RULES */ "outDir": "./dist", "declaration": true, "declarationMap": true, "sourceMap": true, "removeComments": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"] } ``` -------------------------------------------------------------------------------- /.github/changeset-version.js: -------------------------------------------------------------------------------- ```javascript // ORIGINALLY FROM CREATE-T3-APP: // https://github.com/t3-oss/create-t3-app/blob/main/.github/changeset-version.js import { execSync } from "child_process"; // This script is used by the `release.yml` workflow to update the version of the packages being released. // The standard step is only to run `changeset version` but this does not update the package-lock.json file. // So we also run `npm install`, which does this update. // This is a workaround until this is handled automatically by `changeset version`. // See https://github.com/changesets/changesets/issues/421. execSync("pnpm exec changeset version"); execSync("pnpm install --lockfile-only"); ``` -------------------------------------------------------------------------------- /.github/changeset-beta-version.js: -------------------------------------------------------------------------------- ```javascript // BASED ON CREATE-T3-APP APPROACH: // https://github.com/t3-oss/create-t3-app/blob/main/.github/changeset-version.js import { execSync } from "child_process"; // This script is used by the `beta-release.yml` workflow to update the version of packages for beta releases. // It enters prerelease mode, runs changeset version, and updates the package-lock.json file. // This ensures beta releases are properly tagged and don't interfere with main releases. // Enter prerelease mode for beta execSync("pnpm exec changeset pre enter beta"); // Version the packages execSync("pnpm exec changeset version"); // Update lockfile execSync("pnpm install --lockfile-only"); ``` -------------------------------------------------------------------------------- /src/extractors/index.ts: -------------------------------------------------------------------------------- ```typescript // Types export type { ExtractorFn, TraversalContext, TraversalOptions, GlobalVars, StyleTypes, } from "./types.js"; // Core traversal function export { extractFromDesign } from "./node-walker.js"; // Design-level extraction (unified nodes + components) export { simplifyRawFigmaObject } from "./design-extractor.js"; // Built-in extractors and afterChildren helpers export { layoutExtractor, textExtractor, visualsExtractor, componentExtractor, // Convenience combinations allExtractors, layoutAndText, contentOnly, visualsOnly, layoutOnly, // afterChildren helpers collapseSvgContainers, SVG_ELIGIBLE_TYPES, } from "./built-in.js"; ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import js from "@eslint/js"; import tseslint from "@typescript-eslint/eslint-plugin"; import tsparser from "@typescript-eslint/parser"; import prettier from "eslint-config-prettier"; export default [ js.configs.recommended, { files: ["**/*.ts", "**/*.tsx"], languageOptions: { parser: tsparser, parserOptions: { ecmaVersion: 2022, sourceType: "module", }, }, plugins: { "@typescript-eslint": tseslint, }, rules: { ...tseslint.configs.recommended.rules, "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", }, }, { files: ["**/*.ts", "**/*.tsx"], rules: prettier.rules, }, { ignores: ["dist/**", "node_modules/**"], }, ]; ``` -------------------------------------------------------------------------------- /.github/workflows/beta-release.yml: -------------------------------------------------------------------------------- ```yaml # Beta Release Workflow # Triggers when the beta branch is pushed and publishes packages with a -beta tag name: Beta Release on: push: branches: - beta jobs: beta-release: if: ${{ github.repository_owner == 'GLips' }} name: Create a beta release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: ./.github/actions/setup - name: Check for errors run: pnpm type-check - name: Build the package run: pnpm build - name: Create Version and Publish Beta id: changesets uses: changesets/action@v1 with: commit: "chore(beta): version packages" title: "chore(beta): version packages" version: node .github/changeset-beta-version.js publish: npx changeset publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} NODE_ENV: "production" ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml # Originally inspired by create-t3-app # https://github.com/t3-oss/create-t3-app/blob/main/.github/workflows/release.yml name: Release on: push: branches: - main jobs: release: if: ${{ github.repository_owner == 'GLips' }} name: Create a PR for release workflow runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: ./.github/actions/setup - name: Check for errors run: pnpm type-check - name: Build the package run: pnpm build - name: Create Version PR or Publish to NPM id: changesets uses: changesets/action@v1 with: commit: "chore(release): version packages" title: "chore(release): version packages" version: node .github/changeset-version.js publish: npx changeset publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} NODE_ENV: "production" ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript import fs from "fs"; export const Logger = { isHTTP: false, log: (...args: any[]) => { if (Logger.isHTTP) { console.log("[INFO]", ...args); } else { console.error("[INFO]", ...args); } }, error: (...args: any[]) => { console.error("[ERROR]", ...args); }, }; export function writeLogs(name: string, value: any): void { if (process.env.NODE_ENV !== "development") return; try { const logsDir = "logs"; const logPath = `${logsDir}/${name}`; // Check if we can write to the current directory fs.accessSync(process.cwd(), fs.constants.W_OK); // Create logs directory if it doesn't exist if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } fs.writeFileSync(logPath, JSON.stringify(value, null, 2)); Logger.log(`Debug log written to: ${logPath}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); Logger.log(`Failed to write logs to ${name}: ${errorMessage}`); } } ``` -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.github.GLips/Figma-Context-MCP", "description": "Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.", "status": "active", "repository": { "url": "https://github.com/GLips/Figma-Context-MCP", "source": "github" }, "version": "0.5.2", "packages": [ { "registry_type": "npm", "registry_base_url": "https://registry.npmjs.org", "identifier": "figma-developer-mcp", "version": "0.5.2", "transport": { "type": "stdio" }, "package_arguments": [ { "type": "positional", "value": "--stdio" } ], "environment_variables": [ { "description": "Your Figma Personal Access Token, learn more here: https://www.figma.com/developers/api#access-tokens", "is_required": true, "format": "string", "is_secret": true, "name": "FIGMA_API_KEY" }, { "name": "NODE_ENV", "description": "Start the server in stdio mode, keep as CLI", "default": "cli" } ] } ] } ``` -------------------------------------------------------------------------------- /src/transformers/component.ts: -------------------------------------------------------------------------------- ```typescript import type { Component, ComponentPropertyType, ComponentSet } from "@figma/rest-api-spec"; export interface ComponentProperties { name: string; value: string; type: ComponentPropertyType; } export interface SimplifiedComponentDefinition { id: string; key: string; name: string; componentSetId?: string; } export interface SimplifiedComponentSetDefinition { id: string; key: string; name: string; description?: string; } /** * Remove unnecessary component properties and convert to simplified format. */ export function simplifyComponents( aggregatedComponents: Record<string, Component>, ): Record<string, SimplifiedComponentDefinition> { return Object.fromEntries( Object.entries(aggregatedComponents).map(([id, comp]) => [ id, { id, key: comp.key, name: comp.name, componentSetId: comp.componentSetId, }, ]), ); } /** * Remove unnecessary component set properties and convert to simplified format. */ export function simplifyComponentSets( aggregatedComponentSets: Record<string, ComponentSet>, ): Record<string, SimplifiedComponentSetDefinition> { return Object.fromEntries( Object.entries(aggregatedComponentSets).map(([id, set]) => [ id, { id, key: set.key, name: set.name, description: set.description, }, ]), ); } ``` -------------------------------------------------------------------------------- /src/transformers/text.ts: -------------------------------------------------------------------------------- ```typescript import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; import { hasValue, isTruthy } from "~/utils/identity.js"; export type SimplifiedTextStyle = Partial<{ fontFamily: string; fontWeight: number; fontSize: number; lineHeight: string; letterSpacing: string; textCase: string; textAlignHorizontal: string; textAlignVertical: string; }>; export function isTextNode( n: FigmaDocumentNode, ): n is Extract<FigmaDocumentNode, { type: "TEXT" }> { return n.type === "TEXT"; } export function hasTextStyle( n: FigmaDocumentNode, ): n is FigmaDocumentNode & { style: Extract<FigmaDocumentNode, { style: any }>["style"] } { return hasValue("style", n) && Object.keys(n.style).length > 0; } // Keep other simple properties directly export function extractNodeText(n: FigmaDocumentNode) { if (hasValue("characters", n, isTruthy)) { return n.characters; } } export function extractTextStyle(n: FigmaDocumentNode) { if (hasTextStyle(n)) { const style = n.style; const textStyle: SimplifiedTextStyle = { fontFamily: style.fontFamily, fontWeight: style.fontWeight, fontSize: style.fontSize, lineHeight: "lineHeightPx" in style && style.lineHeightPx && style.fontSize ? `${style.lineHeightPx / style.fontSize}em` : undefined, letterSpacing: style.letterSpacing && style.letterSpacing !== 0 && style.fontSize ? `${(style.letterSpacing / style.fontSize) * 100}%` : undefined, textCase: style.textCase, textAlignHorizontal: style.textAlignHorizontal, textAlignVertical: style.textAlignVertical, }; return textStyle; } } ``` -------------------------------------------------------------------------------- /src/mcp/index.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { FigmaService, type FigmaAuthOptions } from "../services/figma.js"; import { Logger } from "../utils/logger.js"; import { downloadFigmaImagesTool, getFigmaDataTool, type DownloadImagesParams, type GetFigmaDataParams, } from "./tools/index.js"; const serverInfo = { name: "Figma MCP Server", version: process.env.NPM_PACKAGE_VERSION ?? "unknown", }; type CreateServerOptions = { isHTTP?: boolean; outputFormat?: "yaml" | "json"; skipImageDownloads?: boolean; }; function createServer( authOptions: FigmaAuthOptions, { isHTTP = false, outputFormat = "yaml", skipImageDownloads = false }: CreateServerOptions = {}, ) { const server = new McpServer(serverInfo); const figmaService = new FigmaService(authOptions); registerTools(server, figmaService, { outputFormat, skipImageDownloads }); Logger.isHTTP = isHTTP; return server; } function registerTools( server: McpServer, figmaService: FigmaService, options: { outputFormat: "yaml" | "json"; skipImageDownloads: boolean; }, ): void { // Register get_figma_data tool server.tool( getFigmaDataTool.name, getFigmaDataTool.description, getFigmaDataTool.parameters, (params: GetFigmaDataParams) => getFigmaDataTool.handler(params, figmaService, options.outputFormat), ); // Register download_figma_images tool if CLI flag or env var is not set if (!options.skipImageDownloads) { server.tool( downloadFigmaImagesTool.name, downloadFigmaImagesTool.description, downloadFigmaImagesTool.parameters, (params: DownloadImagesParams) => downloadFigmaImagesTool.handler(params, figmaService), ); } } export { createServer }; ``` -------------------------------------------------------------------------------- /src/tests/integration.test.ts: -------------------------------------------------------------------------------- ```typescript import { createServer } from "../mcp/index.js"; import { config } from "dotenv"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; import yaml from "js-yaml"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; config(); describe("Figma MCP Server Tests", () => { let server: McpServer; let client: Client; let figmaApiKey: string; let figmaFileKey: string; beforeAll(async () => { figmaApiKey = process.env.FIGMA_API_KEY || ""; if (!figmaApiKey) { throw new Error("FIGMA_API_KEY is not set in environment variables"); } figmaFileKey = process.env.FIGMA_FILE_KEY || ""; if (!figmaFileKey) { throw new Error("FIGMA_FILE_KEY is not set in environment variables"); } server = createServer({ figmaApiKey, figmaOAuthToken: "", useOAuth: false, }); client = new Client( { name: "figma-test-client", version: "1.0.0", }, { capabilities: { tools: {}, }, }, ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); }); afterAll(async () => { await client.close(); }); describe("Get Figma Data", () => { it("should be able to get Figma file data", async () => { const args: any = { fileKey: figmaFileKey, }; const result = await client.request( { method: "tools/call", params: { name: "get_figma_data", arguments: args, }, }, CallToolResultSchema, ); const content = result.content[0].text as string; const parsed = yaml.load(content); expect(parsed).toBeDefined(); }, 60000); }); }); ``` -------------------------------------------------------------------------------- /src/transformers/effects.ts: -------------------------------------------------------------------------------- ```typescript import type { DropShadowEffect, InnerShadowEffect, BlurEffect, Node as FigmaDocumentNode, } from "@figma/rest-api-spec"; import { formatRGBAColor } from "~/transformers/style.js"; import { hasValue } from "~/utils/identity.js"; export type SimplifiedEffects = { boxShadow?: string; filter?: string; backdropFilter?: string; textShadow?: string; }; export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects { if (!hasValue("effects", n)) return {}; const effects = n.effects.filter((e) => e.visible); // Handle drop and inner shadows (both go into CSS box-shadow) const dropShadows = effects .filter((e): e is DropShadowEffect => e.type === "DROP_SHADOW") .map(simplifyDropShadow); const innerShadows = effects .filter((e): e is InnerShadowEffect => e.type === "INNER_SHADOW") .map(simplifyInnerShadow); const boxShadow = [...dropShadows, ...innerShadows].join(", "); // Handle blur effects - separate by CSS property // Layer blurs use the CSS 'filter' property const filterBlurValues = effects .filter((e): e is BlurEffect => e.type === "LAYER_BLUR") .map(simplifyBlur) .join(" "); // Background blurs use the CSS 'backdrop-filter' property const backdropFilterValues = effects .filter((e): e is BlurEffect => e.type === "BACKGROUND_BLUR") .map(simplifyBlur) .join(" "); const result: SimplifiedEffects = {}; if (boxShadow) { if (n.type === "TEXT") { result.textShadow = boxShadow; } else { result.boxShadow = boxShadow; } } if (filterBlurValues) result.filter = filterBlurValues; if (backdropFilterValues) result.backdropFilter = backdropFilterValues; return result; } function simplifyDropShadow(effect: DropShadowEffect) { return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; } function simplifyInnerShadow(effect: InnerShadowEffect) { return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; } function simplifyBlur(effect: BlurEffect) { return `blur(${effect.radius}px)`; } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "figma-developer-mcp", "version": "0.6.4", "mcpName": "io.github.GLips/Figma-Context-MCP", "description": "Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.", "type": "module", "main": "dist/index.js", "bin": { "figma-developer-mcp": "dist/bin.js" }, "files": [ "dist", "README.md" ], "scripts": { "build": "tsup --dts", "type-check": "tsc --noEmit", "test": "jest", "start": "node dist/bin.js", "start:cli": "cross-env NODE_ENV=cli node dist/bin.js", "start:http": "node dist/bin.js", "dev": "cross-env NODE_ENV=development tsup --watch", "dev:cli": "cross-env NODE_ENV=development tsup --watch -- --stdio", "lint": "eslint .", "format": "prettier --write \"src/**/*.ts\"", "inspect": "pnpx @modelcontextprotocol/inspector", "prepack": "pnpm build", "changeset": "changeset add", "version": "changeset version && git add -A", "beta:start": "changeset pre enter beta", "beta:end": "changeset pre exit", "beta:version": "changeset version && pnpm install --lockfile-only", "beta:publish": "changeset publish", "prerelease": "pnpm build", "release": "changeset publish && git push --follow-tags", "pub:release": "pnpm build && npm publish", "pub:release:beta": "pnpm build && npm publish --tag beta" }, "engines": { "node": ">=18.0.0" }, "packageManager": "[email protected]", "repository": { "type": "git", "url": "git+https://github.com/GLips/Figma-Context-MCP.git" }, "homepage": "https://www.framelink.ai", "keywords": [ "figma", "mcp", "typescript" ], "author": "", "license": "MIT", "dependencies": { "@figma/rest-api-spec": "^0.33.0", "@modelcontextprotocol/sdk": "^1.10.2", "@types/yargs": "^17.0.33", "cross-env": "^7.0.3", "dotenv": "^16.4.7", "express": "^4.21.2", "js-yaml": "^4.1.0", "remeda": "^2.20.1", "sharp": "^0.34.3", "yargs": "^17.7.2", "zod": "^3.24.2" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.29.2", "@eslint/js": "^9.33.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/js-yaml": "^4.0.9", "@types/node": "^20.17.0", "@typescript-eslint/eslint-plugin": "^8.24.0", "@typescript-eslint/parser": "^8.24.0", "eslint": "^9.20.1", "eslint-config-prettier": "^10.0.1", "jest": "^29.7.0", "prettier": "^3.5.0", "ts-jest": "^29.2.5", "tsup": "^8.4.0", "tsx": "^4.19.2", "typescript": "^5.7.3" } } ``` -------------------------------------------------------------------------------- /src/utils/identity.ts: -------------------------------------------------------------------------------- ```typescript import type { Rectangle, HasLayoutTrait, StrokeWeights, HasFramePropertiesTrait, } from "@figma/rest-api-spec"; import { isTruthy } from "remeda"; import type { CSSHexColor, CSSRGBAColor } from "~/transformers/style.js"; export { isTruthy }; export function hasValue<K extends PropertyKey, T>( key: K, obj: unknown, typeGuard?: (val: unknown) => val is T, ): obj is Record<K, T> { const isObject = typeof obj === "object" && obj !== null; if (!isObject || !(key in obj)) return false; const val = (obj as Record<K, unknown>)[key]; return typeGuard ? typeGuard(val) : val !== undefined; } export function isFrame(val: unknown): val is HasFramePropertiesTrait { return ( typeof val === "object" && !!val && "clipsContent" in val && typeof val.clipsContent === "boolean" ); } export function isLayout(val: unknown): val is HasLayoutTrait { return ( typeof val === "object" && !!val && "absoluteBoundingBox" in val && typeof val.absoluteBoundingBox === "object" && !!val.absoluteBoundingBox && "x" in val.absoluteBoundingBox && "y" in val.absoluteBoundingBox && "width" in val.absoluteBoundingBox && "height" in val.absoluteBoundingBox ); } /** * Checks if: * 1. A node is a child to an auto layout frame * 2. The child adheres to the auto layout rules—i.e. it's not absolutely positioned * * @param node - The node to check. * @param parent - The parent node. * @returns True if the node is a child of an auto layout frame, false otherwise. */ export function isInAutoLayoutFlow(node: unknown, parent: unknown): boolean { const autoLayoutModes = ["HORIZONTAL", "VERTICAL"]; return ( isFrame(parent) && autoLayoutModes.includes(parent.layoutMode ?? "NONE") && isLayout(node) && node.layoutPositioning !== "ABSOLUTE" ); } export function isStrokeWeights(val: unknown): val is StrokeWeights { return ( typeof val === "object" && val !== null && "top" in val && "right" in val && "bottom" in val && "left" in val ); } export function isRectangle<T, K extends string>( key: K, obj: T, ): obj is T & { [P in K]: Rectangle } { const recordObj = obj as Record<K, unknown>; return ( typeof obj === "object" && !!obj && key in recordObj && typeof recordObj[key] === "object" && !!recordObj[key] && "x" in recordObj[key] && "y" in recordObj[key] && "width" in recordObj[key] && "height" in recordObj[key] ); } export function isRectangleCornerRadii(val: unknown): val is number[] { return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number"); } export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor { return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba")); } ``` -------------------------------------------------------------------------------- /src/extractors/design-extractor.ts: -------------------------------------------------------------------------------- ```typescript import type { GetFileResponse, GetFileNodesResponse, Node as FigmaDocumentNode, Component, ComponentSet, Style, } from "@figma/rest-api-spec"; import { simplifyComponents, simplifyComponentSets } from "~/transformers/component.js"; import { isVisible } from "~/utils/common.js"; import type { ExtractorFn, TraversalOptions, SimplifiedDesign, TraversalContext } from "./types.js"; import { extractFromDesign } from "./node-walker.js"; /** * Extract a complete SimplifiedDesign from raw Figma API response using extractors. */ export function simplifyRawFigmaObject( apiResponse: GetFileResponse | GetFileNodesResponse, nodeExtractors: ExtractorFn[], options: TraversalOptions = {}, ): SimplifiedDesign { // Extract components, componentSets, and raw nodes from API response const { metadata, rawNodes, components, componentSets, extraStyles } = parseAPIResponse(apiResponse); // Process nodes using the flexible extractor system const globalVars: TraversalContext["globalVars"] = { styles: {}, extraStyles }; const { nodes: extractedNodes, globalVars: finalGlobalVars } = extractFromDesign( rawNodes, nodeExtractors, options, globalVars, ); // Return complete design return { ...metadata, nodes: extractedNodes, components: simplifyComponents(components), componentSets: simplifyComponentSets(componentSets), globalVars: { styles: finalGlobalVars.styles }, }; } /** * Parse the raw Figma API response to extract metadata, nodes, and components. */ function parseAPIResponse(data: GetFileResponse | GetFileNodesResponse) { const aggregatedComponents: Record<string, Component> = {}; const aggregatedComponentSets: Record<string, ComponentSet> = {}; let extraStyles: Record<string, Style> = {}; let nodesToParse: Array<FigmaDocumentNode>; if ("nodes" in data) { // GetFileNodesResponse const nodeResponses = Object.values(data.nodes); nodeResponses.forEach((nodeResponse) => { if (nodeResponse.components) { Object.assign(aggregatedComponents, nodeResponse.components); } if (nodeResponse.componentSets) { Object.assign(aggregatedComponentSets, nodeResponse.componentSets); } if (nodeResponse.styles) { Object.assign(extraStyles, nodeResponse.styles); } }); nodesToParse = nodeResponses.map((n) => n.document).filter(isVisible); } else { // GetFileResponse Object.assign(aggregatedComponents, data.components); Object.assign(aggregatedComponentSets, data.componentSets); if (data.styles) { extraStyles = data.styles; } nodesToParse = data.document.children.filter(isVisible); } const { name } = data; return { metadata: { name, }, rawNodes: nodesToParse, extraStyles, components: aggregatedComponents, componentSets: aggregatedComponentSets, }; } ``` -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- ```markdown # Release Guide 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). ## Main Branch Releases (Stable) 1. **Create feature branch from main**: ```bash git checkout main git pull git checkout -b feature/my-feature ``` 2. **Make your changes and add changeset**: ```bash # ... implement your feature ... pnpm changeset # Describe your changes and semver impact git push ``` 3. **Create PR to main**: Include the changeset in your PR 4. **After PR is merged**: The GitHub Action automatically handles versioning and publishing ### Why This Works - Changesets are created per-PR, ensuring each feature gets proper changelog entries - Automated workflow handles the complexity of versioning and publishing - Clean, linear history on main branch ## Beta Branch Releases (Testing) > **Note**: Beta release instructions are primarily for repo owner use. Contributors should use the main branch release instructions. 1. **Create feature branch and implement**: ```bash git checkout -b feature/experimental-thing # ... make changes (NO changeset yet) ... git commit -m "implement experimental feature" ``` 2. **Merge to beta and add changeset there if needed**: > **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. ```bash git checkout beta git merge feature/experimental-thing pnpm changeset # Add changeset on beta branch git push # Triggers automated beta release ``` 3. **Keep beta updated with main**: ```bash git checkout beta git merge main # Bring in latest stable changes ``` 4. **When ready for stable release**: If a changeset already exists on the feature branch, it's ready to be merged into `main` once approved, otherwise: ```bash # Create PR from feature branch to main with changeset git checkout feature/experimental-thing pnpm changeset # Create changeset for stable release git push # Then merge PR to main - automated release happens after merge ``` ### Why Create Changesets on Beta Branch - **Prevents duplicates**: Changesets are consumed during beta release, so creating them on feature branches would lead to duplicate changelog entries when merging to main - **Better descriptions**: You can write changeset after the complete feature has been tested and refined in beta context ## Release Versions ### Stable Releases - Published to npm with `latest` tag - Follow semver: `1.2.3` - Users install with: `npm install figma-developer-mcp` ### Beta Releases - Published to npm with `beta` tag - Follow semver with prerelease suffix: `1.2.3-beta.0` - Users install with: `npm install figma-developer-mcp@beta` ``` -------------------------------------------------------------------------------- /src/extractors/types.ts: -------------------------------------------------------------------------------- ```typescript import type { Node as FigmaDocumentNode, Style } from "@figma/rest-api-spec"; import type { SimplifiedTextStyle } from "~/transformers/text.js"; import type { SimplifiedLayout } from "~/transformers/layout.js"; import type { SimplifiedFill, SimplifiedStroke } from "~/transformers/style.js"; import type { SimplifiedEffects } from "~/transformers/effects.js"; import type { ComponentProperties, SimplifiedComponentDefinition, SimplifiedComponentSetDefinition, } from "~/transformers/component.js"; export type StyleTypes = | SimplifiedTextStyle | SimplifiedFill[] | SimplifiedLayout | SimplifiedStroke | SimplifiedEffects | string; export type GlobalVars = { styles: Record<string, StyleTypes>; }; export interface TraversalContext { globalVars: GlobalVars & { extraStyles?: Record<string, Style> }; currentDepth: number; parent?: FigmaDocumentNode; } export interface TraversalOptions { maxDepth?: number; nodeFilter?: (node: FigmaDocumentNode) => boolean; /** * Called after children are processed, allowing modification of the parent node * and control over which children to include in the output. * * @param node - Original Figma node * @param result - SimplifiedNode being built (can be mutated) * @param children - Processed children * @returns Children to include (return empty array to omit children) */ afterChildren?: ( node: FigmaDocumentNode, result: SimplifiedNode, children: SimplifiedNode[], ) => SimplifiedNode[]; } /** * An extractor function that can modify a SimplifiedNode during traversal. * * @param node - The current Figma node being processed * @param result - SimplifiedNode object being built—this can be mutated inside the extractor * @param context - Traversal context including globalVars and parent info. This can also be mutated inside the extractor. */ export type ExtractorFn = ( node: FigmaDocumentNode, result: SimplifiedNode, context: TraversalContext, ) => void; export interface SimplifiedDesign { name: string; nodes: SimplifiedNode[]; components: Record<string, SimplifiedComponentDefinition>; componentSets: Record<string, SimplifiedComponentSetDefinition>; globalVars: GlobalVars; } export interface SimplifiedNode { id: string; name: string; type: string; // e.g. FRAME, TEXT, INSTANCE, RECTANGLE, etc. // text text?: string; textStyle?: string; // appearance fills?: string; styles?: string; strokes?: string; // Non-stylable stroke properties are kept on the node when stroke uses a named color style strokeWeight?: string; strokeDashes?: number[]; strokeWeights?: string; effects?: string; opacity?: number; borderRadius?: string; // layout & alignment layout?: string; // for rect-specific strokes, etc. componentId?: string; componentProperties?: ComponentProperties[]; // children children?: SimplifiedNode[]; } export interface BoundingBox { x: number; y: number; width: number; height: number; } ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- ```markdown --- name: Bug report about: Create a report to help us improve title: "" labels: bug assignees: "" --- **Describe the bug** A clear and concise description of what the bug is. **Software Versions** - Figma Developer MCP: Run the MCP with `--version`—either npx or locally, depending on how you're running it. - Node.js: `node --version` - NPM: `npm --version` - Operating System: - Client: e.g. Cursor, VSCode, Claude Desktop, etc. - Client Version: **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** 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. **Server Configuration** Provide your MCP JSON configuration, if applicable. E.g. ``` "figma-developer-mcp": { "command": "npx", "args": [ "figma-developer-mcp", "--figma-api-key=REDACTED", "--stdio" ] } ``` **Command Line Logs** If you're running the MCP locally on the command line, include all the logs for those like so: ``` > npx figma-developer-mcp --figma-api-key=REDACTED Configuration: - FIGMA_API_KEY: ****8pXg (source: cli) - PORT: 3333 (source: default) Initializing Figma MCP Server in HTTP mode on port 3333... HTTP server listening on port 3333 SSE endpoint available at http://localhost:3333/sse Message endpoint available at http://localhost:3333/messages New SSE connection established ``` **MCP Logs** 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. ``` 2025-03-18 11:36:22.251 [info] pnpx: Handling CreateClient action 2025-03-18 11:36:22.251 [info] pnpx: getOrCreateClient for stdio server. process.platform: darwin isElectron: true 2025-03-18 11:36:22.251 [info] pnpx: Starting new stdio process with command: pnpx figma-developer-mcp --figma-api-key=REDACTED --stdio 2025-03-18 11:36:23.987 [info] pnpx: Successfully connected to stdio server 2025-03-18 11:36:23.987 [info] pnpx: Storing stdio client 2025-03-18 11:36:23.988 [info] MCP: Handling ListOfferings action 2025-03-18 11:36:23.988 [error] MCP: No server info found 2025-03-18 11:36:23.988 [info] pnpx: Handling ListOfferings action 2025-03-18 11:36:23.988 [info] pnpx: Listing offerings 2025-03-18 11:36:23.988 [info] pnpx: getOrCreateClient for stdio server. process.platform: darwin isElectron: true 2025-03-18 11:36:23.988 [info] pnpx: Reusing existing stdio client 2025-03-18 11:36:23.988 [info] pnpx: Connected to stdio server, fetching offerings 2025-03-18 11:36:24.005 [info] listOfferings: Found 2 tools 2025-03-18 11:36:24.005 [info] pnpx: Found 2 tools, 0 resources, and 0 resource templates 2025-03-18 11:36:24.005 [info] npx: Handling ListOfferings action 2025-03-18 11:36:24.005 [error] npx: No server info found ``` **Additional context** Add any other context about the problem here. ``` -------------------------------------------------------------------------------- /src/mcp/tools/get-figma-data-tool.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import type { GetFileResponse, GetFileNodesResponse } from "@figma/rest-api-spec"; import { FigmaService } from "~/services/figma.js"; import { simplifyRawFigmaObject, allExtractors, collapseSvgContainers, } from "~/extractors/index.js"; import yaml from "js-yaml"; import { Logger, writeLogs } from "~/utils/logger.js"; const parameters = { fileKey: z .string() .regex(/^[a-zA-Z0-9]+$/, "File key must be alphanumeric") .describe( "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)/<fileKey>/...", ), nodeId: z .string() .regex( /^I?\d+[:|-]\d+(?:;\d+[:|-]\d+)*$/, "Node ID must be like '1234:5678' or 'I5666:180910;1:10515;1:10336'", ) .optional() .describe( "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.", ), depth: z .number() .optional() .describe( "OPTIONAL. Do NOT use unless explicitly requested by the user. Controls how many levels deep to traverse the node tree.", ), }; const parametersSchema = z.object(parameters); export type GetFigmaDataParams = z.infer<typeof parametersSchema>; // Simplified handler function async function getFigmaData( params: GetFigmaDataParams, figmaService: FigmaService, outputFormat: "yaml" | "json", ) { try { const { fileKey, nodeId: rawNodeId, depth } = parametersSchema.parse(params); // Replace - with : in nodeId for our query—Figma API expects : const nodeId = rawNodeId?.replace(/-/g, ":"); Logger.log( `Fetching ${depth ? `${depth} layers deep` : "all layers"} of ${ nodeId ? `node ${nodeId} from file` : `full file` } ${fileKey}`, ); // Get raw Figma API response let rawApiResponse: GetFileResponse | GetFileNodesResponse; if (nodeId) { rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth); } else { rawApiResponse = await figmaService.getRawFile(fileKey, depth); } // Use unified design extraction (handles nodes + components consistently) const simplifiedDesign = simplifyRawFigmaObject(rawApiResponse, allExtractors, { maxDepth: depth, afterChildren: collapseSvgContainers, }); writeLogs("figma-simplified.json", simplifiedDesign); Logger.log( `Successfully extracted data: ${simplifiedDesign.nodes.length} nodes, ${ Object.keys(simplifiedDesign.globalVars.styles).length } styles`, ); const { nodes, globalVars, ...metadata } = simplifiedDesign; const result = { metadata, nodes, globalVars, }; Logger.log(`Generating ${outputFormat.toUpperCase()} result from extracted data`); const formattedResult = outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); Logger.log("Sending result to client"); return { content: [{ type: "text" as const, text: formattedResult }], }; } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); Logger.error(`Error fetching file ${params.fileKey}:`, message); return { isError: true, content: [{ type: "text" as const, text: `Error fetching file: ${message}` }], }; } } // Export tool configuration export const getFigmaDataTool = { name: "get_figma_data", description: "Get comprehensive Figma file data including layout, content, visuals, and component information", parameters, handler: getFigmaData, } as const; ``` -------------------------------------------------------------------------------- /src/extractors/node-walker.ts: -------------------------------------------------------------------------------- ```typescript import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; import { isVisible } from "~/utils/common.js"; import { hasValue } from "~/utils/identity.js"; import type { ExtractorFn, TraversalContext, TraversalOptions, GlobalVars, SimplifiedNode, } from "./types.js"; /** * Extract data from Figma nodes using a flexible, single-pass approach. * * @param nodes - The Figma nodes to process * @param extractors - Array of extractor functions to apply during traversal * @param options - Traversal options (filtering, depth limits, etc.) * @param globalVars - Global variables for style deduplication * @returns Object containing processed nodes and updated global variables */ export function extractFromDesign( nodes: FigmaDocumentNode[], extractors: ExtractorFn[], options: TraversalOptions = {}, globalVars: GlobalVars = { styles: {} }, ): { nodes: SimplifiedNode[]; globalVars: GlobalVars } { const context: TraversalContext = { globalVars, currentDepth: 0, }; const processedNodes = nodes .filter((node) => shouldProcessNode(node, options)) .map((node) => processNodeWithExtractors(node, extractors, context, options)) .filter((node): node is SimplifiedNode => node !== null); return { nodes: processedNodes, globalVars: context.globalVars, }; } /** * Process a single node with all provided extractors in one pass. */ function processNodeWithExtractors( node: FigmaDocumentNode, extractors: ExtractorFn[], context: TraversalContext, options: TraversalOptions, ): SimplifiedNode | null { if (!shouldProcessNode(node, options)) { return null; } // Always include base metadata const result: SimplifiedNode = { id: node.id, name: node.name, type: node.type === "VECTOR" ? "IMAGE-SVG" : node.type, }; // Apply all extractors to this node in a single pass for (const extractor of extractors) { extractor(node, result, context); } // Handle children recursively if (shouldTraverseChildren(node, context, options)) { const childContext: TraversalContext = { ...context, currentDepth: context.currentDepth + 1, parent: node, }; // Use the same pattern as the existing parseNode function if (hasValue("children", node) && node.children.length > 0) { const children = node.children .filter((child) => shouldProcessNode(child, options)) .map((child) => processNodeWithExtractors(child, extractors, childContext, options)) .filter((child): child is SimplifiedNode => child !== null); if (children.length > 0) { // Allow custom logic to modify parent and control which children to include const childrenToInclude = options.afterChildren ? options.afterChildren(node, result, children) : children; if (childrenToInclude.length > 0) { result.children = childrenToInclude; } } } } return result; } /** * Determine if a node should be processed based on filters. */ function shouldProcessNode(node: FigmaDocumentNode, options: TraversalOptions): boolean { // Skip invisible nodes if (!isVisible(node)) { return false; } // Apply custom node filter if provided if (options.nodeFilter && !options.nodeFilter(node)) { return false; } return true; } /** * Determine if we should traverse into a node's children. */ function shouldTraverseChildren( node: FigmaDocumentNode, context: TraversalContext, options: TraversalOptions, ): boolean { // Check depth limit if (options.maxDepth !== undefined && context.currentDepth >= options.maxDepth) { return false; } return true; } ``` -------------------------------------------------------------------------------- /src/utils/fetch-with-retry.ts: -------------------------------------------------------------------------------- ```typescript import { execFile } from "child_process"; import { promisify } from "util"; import { Logger } from "./logger.js"; const execFileAsync = promisify(execFile); type RequestOptions = RequestInit & { /** * Force format of headers to be a record of strings, e.g. { "Authorization": "Bearer 123" } * * Avoids complexity of needing to deal with `instanceof Headers`, which is not supported in some environments. */ headers?: Record<string, string>; }; export async function fetchWithRetry<T extends { status?: number }>( url: string, options: RequestOptions = {}, ): Promise<T> { try { const response = await fetch(url, options); if (!response.ok) { throw new Error(`Fetch failed with status ${response.status}: ${response.statusText}`); } return (await response.json()) as T; } catch (fetchError: any) { Logger.log( `[fetchWithRetry] Initial fetch failed for ${url}: ${fetchError.message}. Likely a corporate proxy or SSL issue. Attempting curl fallback.`, ); const curlHeaders = formatHeadersForCurl(options.headers); // Most options here are to ensure stderr only contains errors, so we can use it to confidently check if an error occurred. // -s: Silent mode—no progress bar in stderr // -S: Show errors in stderr // --fail-with-body: curl errors with code 22, and outputs body of failed request, e.g. "Fetch failed with status 404" // -L: Follow redirects const curlArgs = ["-s", "-S", "--fail-with-body", "-L", ...curlHeaders, url]; try { // Fallback to curl for corporate networks that have proxies that sometimes block fetch Logger.log(`[fetchWithRetry] Executing curl with args: ${JSON.stringify(curlArgs)}`); const { stdout, stderr } = await execFileAsync("curl", curlArgs); if (stderr) { // curl often outputs progress to stderr, so only treat as error if stdout is empty // or if stderr contains typical error keywords. if ( !stdout || stderr.toLowerCase().includes("error") || stderr.toLowerCase().includes("fail") ) { throw new Error(`Curl command failed with stderr: ${stderr}`); } Logger.log( `[fetchWithRetry] Curl command for ${url} produced stderr (but might be informational): ${stderr}`, ); } if (!stdout) { throw new Error("Curl command returned empty stdout."); } const result = JSON.parse(stdout) as T; // Successful Figma requests don't have a status property, and some endpoints return 200 with an // error status in the body, e.g. https://www.figma.com/developers/api#get-images-endpoint if (result.status && result.status !== 200) { throw new Error(`Curl command failed: ${result}`); } return result; } catch (curlError: any) { Logger.error(`[fetchWithRetry] Curl fallback also failed for ${url}: ${curlError.message}`); // Re-throw the original fetch error to give context about the initial failure // or throw a new error that wraps both, depending on desired error reporting. // For now, re-throwing the original as per the user example's spirit. throw fetchError; } } } /** * Converts HeadersInit to an array of curl header arguments for execFile. * @param headers Headers to convert. * @returns Array of strings for curl arguments: ["-H", "key: value", "-H", "key2: value2"] */ function formatHeadersForCurl(headers: Record<string, string> | undefined): string[] { if (!headers) { return []; } const headerArgs: string[] = []; for (const [key, value] of Object.entries(headers)) { headerArgs.push("-H", `${key}: ${value}`); } return headerArgs; } ``` -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- ```markdown # Figma MCP Server Roadmap This roadmap outlines planned improvements and features for the Figma MCP Server project. Items are organized by development phases and effort levels. ## Overview 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. ## Core Feature Enhancements 🚀 _High impact, foundational improvements_ ### Component & Prototype Support (High Priority) - [ ] **Add dedicated tool for component extraction** ([#124](https://github.com/GLips/Figma-Context-MCP/issues/124)) - [ ] Create `get_figma_components` tool for fetching full component/component set design data including variants and properties - [ ] **Improve INSTANCE support** - [ ] Return only overridden values - [ ] Hide children of INSTANCE except for slot type children or if full data is explicitly requested via new tool call parameter - [ ] **Prototype support** - [ ] Extract interactivity data (e.g. actions on hover, click, etc.) - [ ] Return data on animations / transitions - [?] State management hints ### Parsing Logic - [ ] Inline variables that only show up once, and keep global vars only for variables that are reused ### Image & Asset Handling - [ ] **Fix masked / cropped image exports** - [ ] Correctly export cropped images ([#162](https://github.com/GLips/Figma-Context-MCP/issues/162)) - [?] Support complex mask shapes and transformations - [?] Pull image fills/vectors out to top level for better AI visibility - [ ] **Improve SVG handling** - [ ] Better icon identification, e.g. if all components of a frame are VECTOR, download the full frame as an SVG - [?] Add support for raw path data in response—not sure if this is valuable yet ### Layout Improvements - [ ] **Smart wrapped layout detection** - [?] Detect and convert fixed-width children to percentage-based widths - [ ] Better flexbox wrap support - [ ] Grid layout detection for wrapped items - [ ] Support for Figma's new grid layout ### Advanced Styling - [ ] **Enhanced gradient support** - [ ] Make sure gradients are exported correctly in CSS syntax ([#152](https://github.com/GLips/Figma-Context-MCP/issues/152)) - [ ] **Grid system support** - [ ] Support for Figma's new grid autolayout (an addition to the long-existing flex autolayout) - [ ] Legacy "layout guide" grids - [ ] **Named styles extraction** - [ ] Export style names associated with different layouts, colors, text, etc. for easier identification by the LLM (can use `/v1/styles/:key` endpoint) ### Text & Typography - [ ] **Text styling** - [ ] Add support for formatted text in text fields ([#159](https://github.com/GLips/Figma-Context-MCP/issues/159)) - [ ] Add support for mixed text styles (e.g. multiple colors) ([#140](https://github.com/GLips/Figma-Context-MCP/issues/140)) ## Enterprise & Advanced Features 🏢 _Features for scaling and enterprise adoption_ ### Enterprise Support - [ ] **Variable System Enhancements** - [ ] 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?) - [ ] Add `getFigmaVariables` for Enterprise plans - [?] Export design tokens in standard formats ## Developer Experience 🛠️ _Improving usability and integration_ ### Performance & Reliability - [ ] **Better error handling** - [x] Retry logic for API failures - [ ] Detailed error messages which the LLM can expand on for users ### Documentation & Testing - [ ] **Test coverage improvements** - [ ] Unit tests for all transformers - [ ] Integration tests with mock Figma API - [ ] 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 ## Quick Wins 🎪 _Low effort, high impact_ - [ ] Better handling of text overflow (e.g. auto width, auto height, fixed width + truncate text setting) - [ ] Double check to make sure blend modes are forwarded properly in the simplified response ## Technical Debt 🧹 _Code quality and maintenance_ - [ ] Clean up image download code (noted in mcp.ts) - [ ] Refactor `convertAlign` function (layout.ts) - [ ] Standardize error handling across services ## Research & Exploration 🔬 _Investigate feasibility / value_ - [ ] Figma plugin companion 🚀🚀🚀 - [ ] **Design System Integration** - [ ] Token extraction and mapping - [ ] Component dependency graphs - [ ] **Figma File Metadata** - [ ] Investigate how we can use frames that are marked "Ready for Dev" - [ ] Investigate feasibility of pulling in annotations via the Figma API - [ ] Investigate feasibility/value of using—and even modifying—"Dev Resources" links via Figma API ## Contributing 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. --- _This roadmap is subject to change based on community feedback and priorities. Last updated: June 2025_ ``` -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- ```typescript import { config as loadEnv } from "dotenv"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { resolve } from "path"; import type { FigmaAuthOptions } from "./services/figma.js"; interface ServerConfig { auth: FigmaAuthOptions; port: number; outputFormat: "yaml" | "json"; skipImageDownloads?: boolean; configSources: { figmaApiKey: "cli" | "env"; figmaOAuthToken: "cli" | "env" | "none"; port: "cli" | "env" | "default"; outputFormat: "cli" | "env" | "default"; envFile: "cli" | "default"; skipImageDownloads?: "cli" | "env" | "default"; }; } function maskApiKey(key: string): string { if (!key || key.length <= 4) return "****"; return `****${key.slice(-4)}`; } interface CliArgs { "figma-api-key"?: string; "figma-oauth-token"?: string; env?: string; port?: number; json?: boolean; "skip-image-downloads"?: boolean; } export function getServerConfig(isStdioMode: boolean): ServerConfig { // Parse command line arguments const argv = yargs(hideBin(process.argv)) .options({ "figma-api-key": { type: "string", description: "Figma API key (Personal Access Token)", }, "figma-oauth-token": { type: "string", description: "Figma OAuth Bearer token", }, env: { type: "string", description: "Path to custom .env file to load environment variables from", }, port: { type: "number", description: "Port to run the server on", }, json: { type: "boolean", description: "Output data from tools in JSON format instead of YAML", default: false, }, "skip-image-downloads": { type: "boolean", description: "Do not register the download_figma_images tool (skip image downloads)", default: false, }, }) .help() .version(process.env.NPM_PACKAGE_VERSION ?? "unknown") .parseSync() as CliArgs; // Load environment variables ASAP from custom path or default let envFilePath: string; let envFileSource: "cli" | "default"; if (argv["env"]) { envFilePath = resolve(argv["env"]); envFileSource = "cli"; } else { envFilePath = resolve(process.cwd(), ".env"); envFileSource = "default"; } // Override anything auto-loaded from .env if a custom file is provided. loadEnv({ path: envFilePath, override: true }); const auth: FigmaAuthOptions = { figmaApiKey: "", figmaOAuthToken: "", useOAuth: false, }; const config: Omit<ServerConfig, "auth"> = { port: 3333, outputFormat: "yaml", skipImageDownloads: false, configSources: { figmaApiKey: "env", figmaOAuthToken: "none", port: "default", outputFormat: "default", envFile: envFileSource, skipImageDownloads: "default", }, }; // Handle FIGMA_API_KEY if (argv["figma-api-key"]) { auth.figmaApiKey = argv["figma-api-key"]; config.configSources.figmaApiKey = "cli"; } else if (process.env.FIGMA_API_KEY) { auth.figmaApiKey = process.env.FIGMA_API_KEY; config.configSources.figmaApiKey = "env"; } // Handle FIGMA_OAUTH_TOKEN if (argv["figma-oauth-token"]) { auth.figmaOAuthToken = argv["figma-oauth-token"]; config.configSources.figmaOAuthToken = "cli"; auth.useOAuth = true; } else if (process.env.FIGMA_OAUTH_TOKEN) { auth.figmaOAuthToken = process.env.FIGMA_OAUTH_TOKEN; config.configSources.figmaOAuthToken = "env"; auth.useOAuth = true; } // Handle PORT if (argv.port) { config.port = argv.port; config.configSources.port = "cli"; } else if (process.env.PORT) { config.port = parseInt(process.env.PORT, 10); config.configSources.port = "env"; } // Handle JSON output format if (argv.json) { config.outputFormat = "json"; config.configSources.outputFormat = "cli"; } else if (process.env.OUTPUT_FORMAT) { config.outputFormat = process.env.OUTPUT_FORMAT as "yaml" | "json"; config.configSources.outputFormat = "env"; } // Handle skipImageDownloads if (argv["skip-image-downloads"]) { config.skipImageDownloads = true; config.configSources.skipImageDownloads = "cli"; } else if (process.env.SKIP_IMAGE_DOWNLOADS === "true") { config.skipImageDownloads = true; config.configSources.skipImageDownloads = "env"; } // Validate configuration if (!auth.figmaApiKey && !auth.figmaOAuthToken) { console.error( "Either FIGMA_API_KEY or FIGMA_OAUTH_TOKEN is required (via CLI argument or .env file)", ); process.exit(1); } // Log configuration sources if (!isStdioMode) { console.log("\nConfiguration:"); console.log(`- ENV_FILE: ${envFilePath} (source: ${config.configSources.envFile})`); if (auth.useOAuth) { console.log( `- FIGMA_OAUTH_TOKEN: ${maskApiKey(auth.figmaOAuthToken)} (source: ${config.configSources.figmaOAuthToken})`, ); console.log("- Authentication Method: OAuth Bearer Token"); } else { console.log( `- FIGMA_API_KEY: ${maskApiKey(auth.figmaApiKey)} (source: ${config.configSources.figmaApiKey})`, ); console.log("- Authentication Method: Personal Access Token (X-Figma-Token)"); } console.log(`- PORT: ${config.port} (source: ${config.configSources.port})`); console.log( `- OUTPUT_FORMAT: ${config.outputFormat} (source: ${config.configSources.outputFormat})`, ); console.log( `- SKIP_IMAGE_DOWNLOADS: ${config.skipImageDownloads} (source: ${config.configSources.skipImageDownloads})`, ); console.log(); // Empty line for better readability } return { ...config, auth, }; } ``` -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- ```typescript import fs from "fs"; import path from "path"; export type StyleId = `${string}_${string}` & { __brand: "StyleId" }; /** * Download Figma image and save it locally * @param fileName - The filename to save as * @param localPath - The local path to save to * @param imageUrl - Image URL (images[nodeId]) * @returns A Promise that resolves to the full file path where the image was saved * @throws Error if download fails */ export async function downloadFigmaImage( fileName: string, localPath: string, imageUrl: string, ): Promise<string> { try { // Ensure local path exists if (!fs.existsSync(localPath)) { fs.mkdirSync(localPath, { recursive: true }); } // Build the complete file path const fullPath = path.join(localPath, fileName); // Use fetch to download the image const response = await fetch(imageUrl, { method: "GET", }); if (!response.ok) { throw new Error(`Failed to download image: ${response.statusText}`); } // Create write stream const writer = fs.createWriteStream(fullPath); // Get the response as a readable stream and pipe it to the file const reader = response.body?.getReader(); if (!reader) { throw new Error("Failed to get response body"); } return new Promise((resolve, reject) => { // Process stream const processStream = async () => { try { while (true) { const { done, value } = await reader.read(); if (done) { writer.end(); break; } writer.write(value); } } catch (err) { writer.end(); fs.unlink(fullPath, () => {}); reject(err); } }; // Resolve only when the stream is fully written writer.on("finish", () => { resolve(fullPath); }); writer.on("error", (err) => { reader.cancel(); fs.unlink(fullPath, () => {}); reject(new Error(`Failed to write image: ${err.message}`)); }); processStream(); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Error downloading image: ${errorMessage}`); } } /** * Remove keys with empty arrays or empty objects from an object. * @param input - The input object or value. * @returns The processed object or the original value. */ export function removeEmptyKeys<T>(input: T): T { // If not an object type or null, return directly if (typeof input !== "object" || input === null) { return input; } // Handle array type if (Array.isArray(input)) { return input.map((item) => removeEmptyKeys(item)) as T; } // Handle object type const result = {} as T; for (const key in input) { if (Object.prototype.hasOwnProperty.call(input, key)) { const value = input[key]; // Recursively process nested objects const cleanedValue = removeEmptyKeys(value); // Skip empty arrays and empty objects if ( cleanedValue !== undefined && !(Array.isArray(cleanedValue) && cleanedValue.length === 0) && !( typeof cleanedValue === "object" && cleanedValue !== null && Object.keys(cleanedValue).length === 0 ) ) { result[key] = cleanedValue; } } } return result; } /** * Generate a 6-character random variable ID * @param prefix - ID prefix * @returns A 6-character random ID string with prefix */ export function generateVarId(prefix: string = "var"): StyleId { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let result = ""; for (let i = 0; i < 6; i++) { const randomIndex = Math.floor(Math.random() * chars.length); result += chars[randomIndex]; } return `${prefix}_${result}` as StyleId; } /** * Generate a CSS shorthand for values that come with top, right, bottom, and left * * input: { top: 10, right: 10, bottom: 10, left: 10 } * output: "10px" * * input: { top: 10, right: 20, bottom: 10, left: 20 } * output: "10px 20px" * * input: { top: 10, right: 20, bottom: 30, left: 40 } * output: "10px 20px 30px 40px" * * @param values - The values to generate the shorthand for * @returns The generated shorthand */ export function generateCSSShorthand( values: { top: number; right: number; bottom: number; left: number; }, { ignoreZero = true, suffix = "px", }: { /** * If true and all values are 0, return undefined. Defaults to true. */ ignoreZero?: boolean; /** * The suffix to add to the shorthand. Defaults to "px". */ suffix?: string; } = {}, ) { const { top, right, bottom, left } = values; if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) { return undefined; } if (top === right && right === bottom && bottom === left) { return `${top}${suffix}`; } if (right === left) { if (top === bottom) { return `${top}${suffix} ${right}${suffix}`; } return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`; } return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`; } /** * Check if an element is visible * @param element - The item to check * @returns True if the item is visible, false otherwise */ export function isVisible(element: { visible?: boolean }): boolean { return element.visible ?? true; } /** * Rounds a number to two decimal places, suitable for pixel value processing. * @param num The number to be rounded. * @returns The rounded number with two decimal places. * @throws TypeError If the input is not a valid number */ export function pixelRound(num: number): number { if (isNaN(num)) { throw new TypeError(`Input must be a valid number`); } return Number(Number(num).toFixed(2)); } ``` -------------------------------------------------------------------------------- /src/mcp/tools/download-figma-images-tool.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { FigmaService } from "../../services/figma.js"; import { Logger } from "../../utils/logger.js"; const parameters = { fileKey: z .string() .regex(/^[a-zA-Z0-9]+$/, "File key must be alphanumeric") .describe("The key of the Figma file containing the images"), nodes: z .object({ nodeId: z .string() .regex( /^I?\d+[:|-]\d+(?:;\d+[:|-]\d+)*$/, "Node ID must be like '1234:5678' or 'I5666:180910;1:10515;1:10336'", ) .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"), imageRef: z .string() .optional() .describe( "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.", ), fileName: z .string() .regex( /^[a-zA-Z0-9_.-]+\.(png|svg)$/, "File names must contain only letters, numbers, underscores, dots, or hyphens, and end with .png or .svg.", ) .describe( "The local name for saving the fetched file, including extension. Either png or svg.", ), needsCropping: z .boolean() .optional() .describe("Whether this image needs cropping based on its transform matrix"), cropTransform: z .array(z.array(z.number())) .optional() .describe("Figma transform matrix for image cropping"), requiresImageDimensions: z .boolean() .optional() .describe("Whether this image requires dimension information for CSS variables"), filenameSuffix: z .string() .optional() .describe( "Suffix to add to filename for unique cropped images, provided in the Figma data (e.g., 'abc123')", ), }) .array() .describe("The nodes to fetch as images"), pngScale: z .number() .positive() .optional() .default(2) .describe( "Export scale for PNG images. Optional, defaults to 2 if not specified. Affects PNG images only.", ), localPath: z .string() .describe( "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.", ), }; const parametersSchema = z.object(parameters); export type DownloadImagesParams = z.infer<typeof parametersSchema>; // Enhanced handler function with image processing support async function downloadFigmaImages(params: DownloadImagesParams, figmaService: FigmaService) { try { const { fileKey, nodes, localPath, pngScale = 2 } = parametersSchema.parse(params); // Process nodes: collect unique downloads and track which requests they satisfy const downloadItems = []; const downloadToRequests = new Map<number, string[]>(); // download index -> requested filenames const seenDownloads = new Map<string, number>(); // uniqueKey -> download index for (const rawNode of nodes) { const { nodeId: rawNodeId, ...node } = rawNode; // Replace - with : in nodeId for our query—Figma API expects : const nodeId = rawNodeId?.replace(/-/g, ":"); // Apply filename suffix if provided let finalFileName = node.fileName; if (node.filenameSuffix && !finalFileName.includes(node.filenameSuffix)) { const ext = finalFileName.split(".").pop(); const nameWithoutExt = finalFileName.substring(0, finalFileName.lastIndexOf(".")); finalFileName = `${nameWithoutExt}-${node.filenameSuffix}.${ext}`; } const downloadItem = { fileName: finalFileName, needsCropping: node.needsCropping || false, cropTransform: node.cropTransform, requiresImageDimensions: node.requiresImageDimensions || false, }; if (node.imageRef) { // For imageRefs, check if we've already planned to download this const uniqueKey = `${node.imageRef}-${node.filenameSuffix || "none"}`; if (!node.filenameSuffix && seenDownloads.has(uniqueKey)) { // Already planning to download this, just add to the requests list const downloadIndex = seenDownloads.get(uniqueKey)!; const requests = downloadToRequests.get(downloadIndex)!; if (!requests.includes(finalFileName)) { requests.push(finalFileName); } // Update requiresImageDimensions if needed if (downloadItem.requiresImageDimensions) { downloadItems[downloadIndex].requiresImageDimensions = true; } } else { // New unique download const downloadIndex = downloadItems.length; downloadItems.push({ ...downloadItem, imageRef: node.imageRef }); downloadToRequests.set(downloadIndex, [finalFileName]); seenDownloads.set(uniqueKey, downloadIndex); } } else { // Rendered nodes are always unique const downloadIndex = downloadItems.length; downloadItems.push({ ...downloadItem, nodeId }); downloadToRequests.set(downloadIndex, [finalFileName]); } } const allDownloads = await figmaService.downloadImages(fileKey, localPath, downloadItems, { pngScale, }); const successCount = allDownloads.filter(Boolean).length; // Format results with aliases const imagesList = allDownloads .map((result, index) => { const fileName = result.filePath.split("/").pop() || result.filePath; const dimensions = `${result.finalDimensions.width}x${result.finalDimensions.height}`; const cropStatus = result.wasCropped ? " (cropped)" : ""; const dimensionInfo = result.cssVariables ? `${dimensions} | ${result.cssVariables}` : dimensions; // Show all the filenames that were requested for this download const requestedNames = downloadToRequests.get(index) || [fileName]; const aliasText = requestedNames.length > 1 ? ` (also requested as: ${requestedNames.filter((name: string) => name !== fileName).join(", ")})` : ""; return `- ${fileName}: ${dimensionInfo}${cropStatus}${aliasText}`; }) .join("\n"); return { content: [ { type: "text" as const, text: `Downloaded ${successCount} images:\n${imagesList}`, }, ], }; } catch (error) { Logger.error(`Error downloading images from ${params.fileKey}:`, error); return { isError: true, content: [ { type: "text" as const, text: `Failed to download images: ${error instanceof Error ? error.message : String(error)}`, }, ], }; } } // Export tool configuration export const downloadFigmaImagesTool = { name: "download_figma_images", description: "Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes", parameters, handler: downloadFigmaImages, } as const; ``` -------------------------------------------------------------------------------- /src/utils/image-processing.ts: -------------------------------------------------------------------------------- ```typescript import fs from "fs"; import path from "path"; import sharp from "sharp"; import type { Transform } from "@figma/rest-api-spec"; /** * Apply crop transform to an image based on Figma's transformation matrix * @param imagePath - Path to the original image file * @param cropTransform - Figma transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]] * @returns Promise<string> - Path to the cropped image */ export async function applyCropTransform( imagePath: string, cropTransform: Transform, ): Promise<string> { const { Logger } = await import("./logger.js"); try { // Extract transform values const scaleX = cropTransform[0]?.[0] ?? 1; const skewX = cropTransform[0]?.[1] ?? 0; const translateX = cropTransform[0]?.[2] ?? 0; const skewY = cropTransform[1]?.[0] ?? 0; const scaleY = cropTransform[1]?.[1] ?? 1; const translateY = cropTransform[1]?.[2] ?? 0; // Load the image and get metadata const image = sharp(imagePath); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) { throw new Error(`Could not get image dimensions for ${imagePath}`); } const { width, height } = metadata; // Calculate crop region based on transform matrix // Figma's transform matrix represents how the image is positioned within its container // We need to extract the visible portion based on the scaling and translation // The transform matrix defines the visible area as: // - scaleX/scaleY: how much of the original image is visible (0-1) // - translateX/translateY: offset of the visible area (0-1, relative to image size) const cropLeft = Math.max(0, Math.round(translateX * width)); const cropTop = Math.max(0, Math.round(translateY * height)); const cropWidth = Math.min(width - cropLeft, Math.round(scaleX * width)); const cropHeight = Math.min(height - cropTop, Math.round(scaleY * height)); // Validate crop dimensions if (cropWidth <= 0 || cropHeight <= 0) { Logger.log(`Invalid crop dimensions for ${imagePath}, using original image`); return imagePath; } // Overwrite the original file with the cropped version const tempPath = imagePath + ".tmp"; // Apply crop transformation to temporary file first await image .extract({ left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight, }) .toFile(tempPath); // Replace original file with cropped version fs.renameSync(tempPath, imagePath); Logger.log(`Cropped image saved (overwritten): ${imagePath}`); Logger.log( `Crop region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight} from ${width}x${height}`, ); return imagePath; } catch (error) { Logger.error(`Error cropping image ${imagePath}:`, error); // Return original path if cropping fails return imagePath; } } /** * Get image dimensions from a file * @param imagePath - Path to the image file * @returns Promise<{width: number, height: number}> */ export async function getImageDimensions(imagePath: string): Promise<{ width: number; height: number; }> { const { Logger } = await import("./logger.js"); try { const metadata = await sharp(imagePath).metadata(); if (!metadata.width || !metadata.height) { throw new Error(`Could not get image dimensions for ${imagePath}`); } return { width: metadata.width, height: metadata.height, }; } catch (error) { Logger.error(`Error getting image dimensions for ${imagePath}:`, error); // Return default dimensions if reading fails return { width: 1000, height: 1000 }; } } export type ImageProcessingResult = { filePath: string; originalDimensions: { width: number; height: number }; finalDimensions: { width: number; height: number }; wasCropped: boolean; cropRegion?: { left: number; top: number; width: number; height: number }; cssVariables?: string; processingLog: string[]; }; /** * Enhanced image download with post-processing * @param fileName - The filename to save as * @param localPath - The local path to save to * @param imageUrl - Image URL * @param needsCropping - Whether to apply crop transform * @param cropTransform - Transform matrix for cropping * @param requiresImageDimensions - Whether to generate dimension metadata * @returns Promise<ImageProcessingResult> - Detailed processing information */ export async function downloadAndProcessImage( fileName: string, localPath: string, imageUrl: string, needsCropping: boolean = false, cropTransform?: Transform, requiresImageDimensions: boolean = false, ): Promise<ImageProcessingResult> { const { Logger } = await import("./logger.js"); const processingLog: string[] = []; // First download the original image const { downloadFigmaImage } = await import("./common.js"); const originalPath = await downloadFigmaImage(fileName, localPath, imageUrl); Logger.log(`Downloaded original image: ${originalPath}`); // Get original dimensions before any processing const originalDimensions = await getImageDimensions(originalPath); Logger.log(`Original dimensions: ${originalDimensions.width}x${originalDimensions.height}`); let finalPath = originalPath; let wasCropped = false; let cropRegion: { left: number; top: number; width: number; height: number } | undefined; // Apply crop transform if needed if (needsCropping && cropTransform) { Logger.log("Applying crop transform..."); // Extract crop region info before applying transform const scaleX = cropTransform[0]?.[0] ?? 1; const scaleY = cropTransform[1]?.[1] ?? 1; const translateX = cropTransform[0]?.[2] ?? 0; const translateY = cropTransform[1]?.[2] ?? 0; const cropLeft = Math.max(0, Math.round(translateX * originalDimensions.width)); const cropTop = Math.max(0, Math.round(translateY * originalDimensions.height)); const cropWidth = Math.min( originalDimensions.width - cropLeft, Math.round(scaleX * originalDimensions.width), ); const cropHeight = Math.min( originalDimensions.height - cropTop, Math.round(scaleY * originalDimensions.height), ); if (cropWidth > 0 && cropHeight > 0) { cropRegion = { left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight }; finalPath = await applyCropTransform(originalPath, cropTransform); wasCropped = true; Logger.log(`Cropped to region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight}`); } else { Logger.log("Invalid crop dimensions, keeping original image"); } } // Get final dimensions after processing const finalDimensions = await getImageDimensions(finalPath); Logger.log(`Final dimensions: ${finalDimensions.width}x${finalDimensions.height}`); // Generate CSS variables if required (for TILE mode) let cssVariables: string | undefined; if (requiresImageDimensions) { cssVariables = generateImageCSSVariables(finalDimensions); } return { filePath: finalPath, originalDimensions, finalDimensions, wasCropped, cropRegion, cssVariables, processingLog, }; } /** * Create CSS custom properties for image dimensions * @param imagePath - Path to the image file * @returns Promise<string> - CSS custom properties */ export function generateImageCSSVariables({ width, height, }: { width: number; height: number; }): string { return `--original-width: ${width}px; --original-height: ${height}px;`; } ``` -------------------------------------------------------------------------------- /src/extractors/built-in.ts: -------------------------------------------------------------------------------- ```typescript import type { ExtractorFn, GlobalVars, StyleTypes, TraversalContext, SimplifiedNode, } from "./types.js"; import { buildSimplifiedLayout } from "~/transformers/layout.js"; import { buildSimplifiedStrokes, parsePaint } from "~/transformers/style.js"; import { buildSimplifiedEffects } from "~/transformers/effects.js"; import { extractNodeText, extractTextStyle, hasTextStyle, isTextNode, } from "~/transformers/text.js"; import { hasValue, isRectangleCornerRadii } from "~/utils/identity.js"; import { generateVarId } from "~/utils/common.js"; import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; /** * Helper function to find or create a global variable. */ function findOrCreateVar(globalVars: GlobalVars, value: StyleTypes, prefix: string): string { // Check if the same value already exists const [existingVarId] = Object.entries(globalVars.styles).find( ([_, existingValue]) => JSON.stringify(existingValue) === JSON.stringify(value), ) ?? []; if (existingVarId) { return existingVarId; } // Create a new variable if it doesn't exist const varId = generateVarId(prefix); globalVars.styles[varId] = value; return varId; } /** * Extracts layout-related properties from a node. */ export const layoutExtractor: ExtractorFn = (node, result, context) => { const layout = buildSimplifiedLayout(node, context.parent); if (Object.keys(layout).length > 1) { result.layout = findOrCreateVar(context.globalVars, layout, "layout"); } }; /** * Extracts text content and text styling from a node. */ export const textExtractor: ExtractorFn = (node, result, context) => { // Extract text content if (isTextNode(node)) { result.text = extractNodeText(node); } // Extract text style if (hasTextStyle(node)) { const textStyle = extractTextStyle(node); if (textStyle) { // Prefer Figma named style when available const styleName = getStyleName(node, context, ["text", "typography"]); if (styleName) { context.globalVars.styles[styleName] = textStyle; result.textStyle = styleName; } else { result.textStyle = findOrCreateVar(context.globalVars, textStyle, "style"); } } } }; /** * Extracts visual appearance properties (fills, strokes, effects, opacity, border radius). */ export const visualsExtractor: ExtractorFn = (node, result, context) => { // Check if node has children to determine CSS properties const hasChildren = hasValue("children", node) && Array.isArray(node.children) && node.children.length > 0; // fills if (hasValue("fills", node) && Array.isArray(node.fills) && node.fills.length) { const fills = node.fills.map((fill) => parsePaint(fill, hasChildren)).reverse(); const styleName = getStyleName(node, context, ["fill", "fills"]); if (styleName) { context.globalVars.styles[styleName] = fills; result.fills = styleName; } else { result.fills = findOrCreateVar(context.globalVars, fills, "fill"); } } // strokes const strokes = buildSimplifiedStrokes(node, hasChildren); if (strokes.colors.length) { const styleName = getStyleName(node, context, ["stroke", "strokes"]); if (styleName) { // Only colors are stylable; keep other stroke props on the node context.globalVars.styles[styleName] = strokes.colors; result.strokes = styleName; if (strokes.strokeWeight) result.strokeWeight = strokes.strokeWeight; if (strokes.strokeDashes) result.strokeDashes = strokes.strokeDashes; if (strokes.strokeWeights) result.strokeWeights = strokes.strokeWeights; } else { result.strokes = findOrCreateVar(context.globalVars, strokes, "stroke"); } } // effects const effects = buildSimplifiedEffects(node); if (Object.keys(effects).length) { const styleName = getStyleName(node, context, ["effect", "effects"]); if (styleName) { // Effects styles store only the effect values context.globalVars.styles[styleName] = effects; result.effects = styleName; } else { result.effects = findOrCreateVar(context.globalVars, effects, "effect"); } } // opacity if (hasValue("opacity", node) && typeof node.opacity === "number" && node.opacity !== 1) { result.opacity = node.opacity; } // border radius if (hasValue("cornerRadius", node) && typeof node.cornerRadius === "number") { result.borderRadius = `${node.cornerRadius}px`; } if (hasValue("rectangleCornerRadii", node, isRectangleCornerRadii)) { result.borderRadius = `${node.rectangleCornerRadii[0]}px ${node.rectangleCornerRadii[1]}px ${node.rectangleCornerRadii[2]}px ${node.rectangleCornerRadii[3]}px`; } }; /** * Extracts component-related properties from INSTANCE nodes. */ export const componentExtractor: ExtractorFn = (node, result, _context) => { if (node.type === "INSTANCE") { if (hasValue("componentId", node)) { result.componentId = node.componentId; } // Add specific properties for instances of components if (hasValue("componentProperties", node)) { result.componentProperties = Object.entries(node.componentProperties ?? {}).map( ([name, { value, type }]) => ({ name, value: value.toString(), type, }), ); } } }; // Helper to fetch a Figma style name for specific style keys on a node function getStyleName( node: FigmaDocumentNode, context: TraversalContext, keys: string[], ): string | undefined { if (!hasValue("styles", node)) return undefined; const styleMap = node.styles as Record<string, string>; for (const key of keys) { const styleId = styleMap[key]; if (styleId) { const meta = context.globalVars.extraStyles?.[styleId]; if (meta?.name) return meta.name; } } return undefined; } // -------------------- CONVENIENCE COMBINATIONS -------------------- /** * All extractors - replicates the current parseNode behavior. */ export const allExtractors = [layoutExtractor, textExtractor, visualsExtractor, componentExtractor]; /** * Layout and text only - useful for content analysis and layout planning. */ export const layoutAndText = [layoutExtractor, textExtractor]; /** * Text content only - useful for content audits and copy extraction. */ export const contentOnly = [textExtractor]; /** * Visuals only - useful for design system analysis and style extraction. */ export const visualsOnly = [visualsExtractor]; /** * Layout only - useful for structure analysis. */ export const layoutOnly = [layoutExtractor]; // -------------------- AFTER CHILDREN HELPERS -------------------- /** * Node types that can be exported as SVG images. * When a FRAME, GROUP, or INSTANCE contains only these types, we can collapse it to IMAGE-SVG. * Note: FRAME/GROUP/INSTANCE are NOT included here—they're only eligible if collapsed to IMAGE-SVG. */ export const SVG_ELIGIBLE_TYPES = new Set([ "IMAGE-SVG", // VECTOR nodes are converted to IMAGE-SVG, or containers that were collapsed "STAR", "LINE", "ELLIPSE", "REGULAR_POLYGON", "RECTANGLE", ]); /** * afterChildren callback that collapses SVG-heavy containers to IMAGE-SVG. * * If a FRAME, GROUP, or INSTANCE contains only SVG-eligible children, the parent * is marked as IMAGE-SVG and children are omitted, reducing payload size. * * @param node - Original Figma node * @param result - SimplifiedNode being built * @param children - Processed children * @returns Children to include (empty array if collapsed) */ export function collapseSvgContainers( node: FigmaDocumentNode, result: SimplifiedNode, children: SimplifiedNode[], ): SimplifiedNode[] { const allChildrenAreSvgEligible = children.every((child) => SVG_ELIGIBLE_TYPES.has(child.type), ); if ( (node.type === "FRAME" || node.type === "GROUP" || node.type === "INSTANCE") && allChildrenAreSvgEligible ) { // Collapse to IMAGE-SVG and omit children result.type = "IMAGE-SVG"; return []; } // Include all children normally return children; } ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript import { randomUUID } from "node:crypto"; import express, { type Request, type Response } from "express"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { Server } from "http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Logger } from "./utils/logger.js"; import { createServer } from "./mcp/index.js"; import { getServerConfig } from "./config.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; let httpServer: Server | null = null; const transports = { streamable: {} as Record<string, StreamableHTTPServerTransport>, sse: {} as Record<string, SSEServerTransport>, }; /** * Start the MCP server in either stdio or HTTP mode. */ export async function startServer(): Promise<void> { // Check if we're running in stdio mode (e.g., via CLI) const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio"); const config = getServerConfig(isStdioMode); const server = createServer(config.auth, { isHTTP: !isStdioMode, outputFormat: config.outputFormat, skipImageDownloads: config.skipImageDownloads, }); if (isStdioMode) { const transport = new StdioServerTransport(); await server.connect(transport); } else { console.log(`Initializing Figma MCP Server in HTTP mode on port ${config.port}...`); await startHttpServer(config.port, server); } } export async function startHttpServer(port: number, mcpServer: McpServer): Promise<void> { const app = express(); // Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint app.use("/mcp", express.json()); // Modern Streamable HTTP endpoint app.post("/mcp", async (req, res) => { Logger.log("Received StreamableHTTP request"); const sessionId = req.headers["mcp-session-id"] as string | undefined; // Logger.log("Session ID:", sessionId); // Logger.log("Headers:", req.headers); // Logger.log("Body:", req.body); // Logger.log("Is Initialize Request:", isInitializeRequest(req.body)); let transport: StreamableHTTPServerTransport; if (sessionId && transports.streamable[sessionId]) { // Reuse existing transport Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId); transport = transports.streamable[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { Logger.log("New initialization request for StreamableHTTP sessionId", sessionId); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { // Store the transport by session ID transports.streamable[sessionId] = transport; }, }); transport.onclose = () => { if (transport.sessionId) { delete transports.streamable[transport.sessionId]; } }; // 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" await mcpServer.connect(transport); } else { // Invalid request Logger.log("Invalid request:", req.body); res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: No valid session ID provided", }, id: null, }); return; } let progressInterval: NodeJS.Timeout | null = null; const progressToken = req.body.params?._meta?.progressToken; // Logger.log("Progress token:", progressToken); let progress = 0; if (progressToken) { Logger.log( `Setting up progress notifications for token ${progressToken} on session ${sessionId}`, ); progressInterval = setInterval(async () => { Logger.log("Sending progress notification", progress); await mcpServer.server.notification({ method: "notifications/progress", params: { progress, progressToken, }, }); progress++; }, 1000); } Logger.log("Handling StreamableHTTP request"); await transport.handleRequest(req, res, req.body); if (progressInterval) { clearInterval(progressInterval); } Logger.log("StreamableHTTP request handled"); }); // Handle GET requests for SSE streams (using built-in support from StreamableHTTP) const handleSessionRequest = async (req: Request, res: Response) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (!sessionId || !transports.streamable[sessionId]) { res.status(400).send("Invalid or missing session ID"); return; } console.log(`Received session termination request for session ${sessionId}`); try { const transport = transports.streamable[sessionId]; await transport.handleRequest(req, res); } catch (error) { console.error("Error handling session termination:", error); if (!res.headersSent) { res.status(500).send("Error processing session termination"); } } }; // Handle GET requests for server-to-client notifications via SSE app.get("/mcp", handleSessionRequest); // Handle DELETE requests for session termination app.delete("/mcp", handleSessionRequest); app.get("/sse", async (req, res) => { Logger.log("Establishing new SSE connection"); const transport = new SSEServerTransport("/messages", res); Logger.log(`New SSE connection established for sessionId ${transport.sessionId}`); Logger.log("/sse request headers:", req.headers); Logger.log("/sse request body:", req.body); transports.sse[transport.sessionId] = transport; res.on("close", () => { delete transports.sse[transport.sessionId]; }); await mcpServer.connect(transport); }); app.post("/messages", async (req, res) => { const sessionId = req.query.sessionId as string; const transport = transports.sse[sessionId]; if (transport) { Logger.log(`Received SSE message for sessionId ${sessionId}`); Logger.log("/messages request headers:", req.headers); Logger.log("/messages request body:", req.body); await transport.handlePostMessage(req, res); } else { res.status(400).send(`No transport found for sessionId ${sessionId}`); return; } }); httpServer = app.listen(port, "127.0.0.1", () => { Logger.log(`HTTP server listening on port ${port}`); Logger.log(`SSE endpoint available at http://localhost:${port}/sse`); Logger.log(`Message endpoint available at http://localhost:${port}/messages`); Logger.log(`StreamableHTTP endpoint available at http://localhost:${port}/mcp`); }); process.on("SIGINT", async () => { Logger.log("Shutting down server..."); // Close all active transports to properly clean up resources await closeTransports(transports.sse); await closeTransports(transports.streamable); Logger.log("Server shutdown complete"); process.exit(0); }); } async function closeTransports( transports: Record<string, SSEServerTransport | StreamableHTTPServerTransport>, ) { for (const sessionId in transports) { try { await transports[sessionId]?.close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); } } } export async function stopHttpServer(): Promise<void> { if (!httpServer) { throw new Error("HTTP server is not running"); } return new Promise((resolve, reject) => { httpServer!.close((err: Error | undefined) => { if (err) { reject(err); return; } httpServer = null; const closing = Object.values(transports.sse).map((transport) => { return transport.close(); }); Promise.all(closing).then(() => { resolve(); }); }); }); } ``` -------------------------------------------------------------------------------- /src/transformers/layout.ts: -------------------------------------------------------------------------------- ```typescript import { isInAutoLayoutFlow, isFrame, isLayout, isRectangle } from "~/utils/identity.js"; import type { Node as FigmaDocumentNode, HasFramePropertiesTrait, HasLayoutTrait, } from "@figma/rest-api-spec"; import { generateCSSShorthand, pixelRound } from "~/utils/common.js"; export interface SimplifiedLayout { mode: "none" | "row" | "column"; justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch"; alignItems?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch"; alignSelf?: "flex-start" | "flex-end" | "center" | "stretch"; wrap?: boolean; gap?: string; locationRelativeToParent?: { x: number; y: number; }; dimensions?: { width?: number; height?: number; aspectRatio?: number; }; padding?: string; sizing?: { horizontal?: "fixed" | "fill" | "hug"; vertical?: "fixed" | "fill" | "hug"; }; overflowScroll?: ("x" | "y")[]; position?: "absolute"; } // Convert Figma's layout config into a more typical flex-like schema export function buildSimplifiedLayout( n: FigmaDocumentNode, parent?: FigmaDocumentNode, ): SimplifiedLayout { const frameValues = buildSimplifiedFrameValues(n); const layoutValues = buildSimplifiedLayoutValues(n, parent, frameValues.mode) || {}; return { ...frameValues, ...layoutValues }; } // For flex layouts, process alignment and sizing function convertAlign( axisAlign?: | HasFramePropertiesTrait["primaryAxisAlignItems"] | HasFramePropertiesTrait["counterAxisAlignItems"], stretch?: { children: FigmaDocumentNode[]; axis: "primary" | "counter"; mode: "row" | "column" | "none"; }, ) { if (stretch && stretch.mode !== "none") { const { children, mode, axis } = stretch; // Compute whether to check horizontally or vertically based on axis and direction const direction = getDirection(axis, mode); const shouldStretch = children.length > 0 && children.reduce((shouldStretch, c) => { if (!shouldStretch) return false; if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true; if (direction === "horizontal") { return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL"; } else if (direction === "vertical") { return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL"; } return false; }, true); if (shouldStretch) return "stretch"; } switch (axisAlign) { case "MIN": // MIN, AKA flex-start, is the default alignment return undefined; case "MAX": return "flex-end"; case "CENTER": return "center"; case "SPACE_BETWEEN": return "space-between"; case "BASELINE": return "baseline"; default: return undefined; } } function convertSelfAlign(align?: HasLayoutTrait["layoutAlign"]) { switch (align) { case "MIN": // MIN, AKA flex-start, is the default alignment return undefined; case "MAX": return "flex-end"; case "CENTER": return "center"; case "STRETCH": return "stretch"; default: return undefined; } } // interpret sizing function convertSizing( s?: HasLayoutTrait["layoutSizingHorizontal"] | HasLayoutTrait["layoutSizingVertical"], ) { if (s === "FIXED") return "fixed"; if (s === "FILL") return "fill"; if (s === "HUG") return "hug"; return undefined; } function getDirection( axis: "primary" | "counter", mode: "row" | "column", ): "horizontal" | "vertical" { switch (axis) { case "primary": switch (mode) { case "row": return "horizontal"; case "column": return "vertical"; } case "counter": switch (mode) { case "row": return "horizontal"; case "column": return "vertical"; } } } function buildSimplifiedFrameValues(n: FigmaDocumentNode): SimplifiedLayout | { mode: "none" } { if (!isFrame(n)) { return { mode: "none" }; } const frameValues: SimplifiedLayout = { mode: !n.layoutMode || n.layoutMode === "NONE" ? "none" : n.layoutMode === "HORIZONTAL" ? "row" : "column", }; const overflowScroll: SimplifiedLayout["overflowScroll"] = []; if (n.overflowDirection?.includes("HORIZONTAL")) overflowScroll.push("x"); if (n.overflowDirection?.includes("VERTICAL")) overflowScroll.push("y"); if (overflowScroll.length > 0) frameValues.overflowScroll = overflowScroll; if (frameValues.mode === "none") { return frameValues; } // TODO: convertAlign should be two functions, one for justifyContent and one for alignItems frameValues.justifyContent = convertAlign(n.primaryAxisAlignItems ?? "MIN", { children: n.children, axis: "primary", mode: frameValues.mode, }); frameValues.alignItems = convertAlign(n.counterAxisAlignItems ?? "MIN", { children: n.children, axis: "counter", mode: frameValues.mode, }); frameValues.alignSelf = convertSelfAlign(n.layoutAlign); // Only include wrap if it's set to WRAP, since flex layouts don't default to wrapping frameValues.wrap = n.layoutWrap === "WRAP" ? true : undefined; frameValues.gap = n.itemSpacing ? `${n.itemSpacing ?? 0}px` : undefined; // gather padding if (n.paddingTop || n.paddingBottom || n.paddingLeft || n.paddingRight) { frameValues.padding = generateCSSShorthand({ top: n.paddingTop ?? 0, right: n.paddingRight ?? 0, bottom: n.paddingBottom ?? 0, left: n.paddingLeft ?? 0, }); } return frameValues; } function buildSimplifiedLayoutValues( n: FigmaDocumentNode, parent: FigmaDocumentNode | undefined, mode: "row" | "column" | "none", ): SimplifiedLayout | undefined { if (!isLayout(n)) return undefined; const layoutValues: SimplifiedLayout = { mode }; layoutValues.sizing = { horizontal: convertSizing(n.layoutSizingHorizontal), vertical: convertSizing(n.layoutSizingVertical), }; // Only include positioning-related properties if parent layout isn't flex or if the node is absolute if ( // If parent is a frame but not an AutoLayout, or if the node is absolute, include positioning-related properties isFrame(parent) && !isInAutoLayoutFlow(n, parent) ) { if (n.layoutPositioning === "ABSOLUTE") { layoutValues.position = "absolute"; } if (n.absoluteBoundingBox && parent.absoluteBoundingBox) { layoutValues.locationRelativeToParent = { x: pixelRound(n.absoluteBoundingBox.x - parent.absoluteBoundingBox.x), y: pixelRound(n.absoluteBoundingBox.y - parent.absoluteBoundingBox.y), }; } } // Handle dimensions based on layout growth and alignment if (isRectangle("absoluteBoundingBox", n)) { const dimensions: { width?: number; height?: number; aspectRatio?: number } = {}; // Only include dimensions that aren't meant to stretch if (mode === "row") { // AutoLayout row, only include dimensions if the node is not growing if (!n.layoutGrow && n.layoutSizingHorizontal == "FIXED") dimensions.width = n.absoluteBoundingBox.width; if (n.layoutAlign !== "STRETCH" && n.layoutSizingVertical == "FIXED") dimensions.height = n.absoluteBoundingBox.height; } else if (mode === "column") { // AutoLayout column, only include dimensions if the node is not growing if (n.layoutAlign !== "STRETCH" && n.layoutSizingHorizontal == "FIXED") dimensions.width = n.absoluteBoundingBox.width; if (!n.layoutGrow && n.layoutSizingVertical == "FIXED") dimensions.height = n.absoluteBoundingBox.height; if (n.preserveRatio) { dimensions.aspectRatio = n.absoluteBoundingBox?.width / n.absoluteBoundingBox?.height; } } else { // Node is not an AutoLayout. Include dimensions if the node is not growing (which it should never be) if (!n.layoutSizingHorizontal || n.layoutSizingHorizontal === "FIXED") { dimensions.width = n.absoluteBoundingBox.width; } if (!n.layoutSizingVertical || n.layoutSizingVertical === "FIXED") { dimensions.height = n.absoluteBoundingBox.height; } } if (Object.keys(dimensions).length > 0) { if (dimensions.width) { dimensions.width = pixelRound(dimensions.width); } if (dimensions.height) { dimensions.height = pixelRound(dimensions.height); } layoutValues.dimensions = dimensions; } } return layoutValues; } ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown # figma-developer-mcp ## 0.6.4 ### Patch Changes - [#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. ## 0.6.3 ### Patch Changes - [#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 ## 0.6.2 ### Patch Changes - [#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. ## 0.6.1 ### Patch Changes - [#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. - [#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 ## 0.6.0 ### Minor Changes - [#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. ## 0.5.2 ### Patch Changes - [#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. ## 0.5.1 ### Patch Changes - [#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. ## 0.5.0 ### Minor Changes - [#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. - [#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. - [#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. ### Patch Changes - [#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. ## 0.4.3 ### Patch Changes - [#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. ## 0.4.2 ### Patch Changes - [#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. ## 0.4.1 ### Patch Changes - [#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. ## 0.4.0 ### Minor Changes - [#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. ### Patch Changes - [#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. - [#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. - [#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 - [#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. ## 0.3.1 ### Patch Changes - [#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. ## 0.3.0 ### Minor Changes - [#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 - [#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. - [#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. ### Patch Changes - [#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. - [#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. - [#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 ## 0.2.2 ### Patch Changes - fd10a46: - Update HTTP server creation method to no longer subclass McpServer - Change logging behavior on HTTP server - 6e2c8f5: Minor bump, testing fix for hanging CF DOs ## 0.2.2-beta.1 ### Patch Changes - 6e2c8f5: Minor bump, testing fix for hanging CF DOs ## 0.2.2-beta.0 ### Patch Changes - fd10a46: - Update HTTP server creation method to no longer subclass McpServer - Change logging behavior on HTTP server ``` -------------------------------------------------------------------------------- /src/services/figma.ts: -------------------------------------------------------------------------------- ```typescript import path from "path"; import type { GetImagesResponse, GetFileResponse, GetFileNodesResponse, GetImageFillsResponse, } from "@figma/rest-api-spec"; import { downloadFigmaImage } from "~/utils/common.js"; import { downloadAndProcessImage, type ImageProcessingResult } from "~/utils/image-processing.js"; import { Logger, writeLogs } from "~/utils/logger.js"; import { fetchWithRetry } from "~/utils/fetch-with-retry.js"; export type FigmaAuthOptions = { figmaApiKey: string; figmaOAuthToken: string; useOAuth: boolean; }; type SvgOptions = { outlineText: boolean; includeId: boolean; simplifyStroke: boolean; }; export class FigmaService { private readonly apiKey: string; private readonly oauthToken: string; private readonly useOAuth: boolean; private readonly baseUrl = "https://api.figma.com/v1"; constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) { this.apiKey = figmaApiKey || ""; this.oauthToken = figmaOAuthToken || ""; this.useOAuth = !!useOAuth && !!this.oauthToken; } private getAuthHeaders(): Record<string, string> { if (this.useOAuth) { Logger.log("Using OAuth Bearer token for authentication"); return { Authorization: `Bearer ${this.oauthToken}` }; } else { Logger.log("Using Personal Access Token for authentication"); return { "X-Figma-Token": this.apiKey }; } } /** * Filters out null values from Figma image responses. This ensures we only work with valid image URLs. */ private filterValidImages( images: { [key: string]: string | null } | undefined, ): Record<string, string> { if (!images) return {}; return Object.fromEntries(Object.entries(images).filter(([, value]) => !!value)) as Record< string, string >; } private async request<T>(endpoint: string): Promise<T> { try { Logger.log(`Calling ${this.baseUrl}${endpoint}`); const headers = this.getAuthHeaders(); return await fetchWithRetry<T & { status?: number }>(`${this.baseUrl}${endpoint}`, { headers, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error( `Failed to make request to Figma API endpoint '${endpoint}': ${errorMessage}`, ); } } /** * Builds URL query parameters for SVG image requests. */ private buildSvgQueryParams(svgIds: string[], svgOptions: SvgOptions): string { const params = new URLSearchParams({ ids: svgIds.join(","), format: "svg", svg_outline_text: String(svgOptions.outlineText), svg_include_id: String(svgOptions.includeId), svg_simplify_stroke: String(svgOptions.simplifyStroke), }); return params.toString(); } /** * Gets download URLs for image fills without downloading them. * * @returns Map of imageRef to download URL */ async getImageFillUrls(fileKey: string): Promise<Record<string, string>> { const endpoint = `/files/${fileKey}/images`; const response = await this.request<GetImageFillsResponse>(endpoint); return response.meta.images || {}; } /** * Gets download URLs for rendered nodes without downloading them. * * @returns Map of node ID to download URL */ async getNodeRenderUrls( fileKey: string, nodeIds: string[], format: "png" | "svg", options: { pngScale?: number; svgOptions?: SvgOptions } = {}, ): Promise<Record<string, string>> { if (nodeIds.length === 0) return {}; if (format === "png") { const scale = options.pngScale || 2; const endpoint = `/images/${fileKey}?ids=${nodeIds.join(",")}&format=png&scale=${scale}`; const response = await this.request<GetImagesResponse>(endpoint); return this.filterValidImages(response.images); } else { const svgOptions = options.svgOptions || { outlineText: true, includeId: false, simplifyStroke: true, }; const params = this.buildSvgQueryParams(nodeIds, svgOptions); const endpoint = `/images/${fileKey}?${params}`; const response = await this.request<GetImagesResponse>(endpoint); return this.filterValidImages(response.images); } } /** * Download images method with post-processing support for cropping and returning image dimensions. * * Supports: * - Image fills vs rendered nodes (based on imageRef vs nodeId) * - PNG vs SVG format (based on filename extension) * - Image cropping based on transform matrices * - CSS variable generation for image dimensions * * @returns Array of local file paths for successfully downloaded images */ async downloadImages( fileKey: string, localPath: string, items: Array<{ imageRef?: string; nodeId?: string; fileName: string; needsCropping?: boolean; cropTransform?: any; requiresImageDimensions?: boolean; }>, options: { pngScale?: number; svgOptions?: SvgOptions } = {}, ): Promise<ImageProcessingResult[]> { if (items.length === 0) return []; const sanitizedPath = path.normalize(localPath).replace(/^(\.\.(\/|\\|$))+/, ""); const resolvedPath = path.resolve(sanitizedPath); if (!resolvedPath.startsWith(path.resolve(process.cwd()))) { throw new Error("Invalid path specified. Directory traversal is not allowed."); } const { pngScale = 2, svgOptions } = options; const downloadPromises: Promise<ImageProcessingResult[]>[] = []; // Separate items by type const imageFills = items.filter( (item): item is typeof item & { imageRef: string } => !!item.imageRef, ); const renderNodes = items.filter( (item): item is typeof item & { nodeId: string } => !!item.nodeId, ); // Download image fills with processing if (imageFills.length > 0) { const fillUrls = await this.getImageFillUrls(fileKey); const fillDownloads = imageFills .map(({ imageRef, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { const imageUrl = fillUrls[imageRef]; return imageUrl ? downloadAndProcessImage( fileName, resolvedPath, imageUrl, needsCropping, cropTransform, requiresImageDimensions, ) : null; }) .filter((promise): promise is Promise<ImageProcessingResult> => promise !== null); if (fillDownloads.length > 0) { downloadPromises.push(Promise.all(fillDownloads)); } } // Download rendered nodes with processing if (renderNodes.length > 0) { const pngNodes = renderNodes.filter((node) => !node.fileName.toLowerCase().endsWith(".svg")); const svgNodes = renderNodes.filter((node) => node.fileName.toLowerCase().endsWith(".svg")); // Download PNG renders if (pngNodes.length > 0) { const pngUrls = await this.getNodeRenderUrls( fileKey, pngNodes.map((n) => n.nodeId), "png", { pngScale }, ); const pngDownloads = pngNodes .map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { const imageUrl = pngUrls[nodeId]; return imageUrl ? downloadAndProcessImage( fileName, resolvedPath, imageUrl, needsCropping, cropTransform, requiresImageDimensions, ) : null; }) .filter((promise): promise is Promise<ImageProcessingResult> => promise !== null); if (pngDownloads.length > 0) { downloadPromises.push(Promise.all(pngDownloads)); } } // Download SVG renders if (svgNodes.length > 0) { const svgUrls = await this.getNodeRenderUrls( fileKey, svgNodes.map((n) => n.nodeId), "svg", { svgOptions }, ); const svgDownloads = svgNodes .map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { const imageUrl = svgUrls[nodeId]; return imageUrl ? downloadAndProcessImage( fileName, resolvedPath, imageUrl, needsCropping, cropTransform, requiresImageDimensions, ) : null; }) .filter((promise): promise is Promise<ImageProcessingResult> => promise !== null); if (svgDownloads.length > 0) { downloadPromises.push(Promise.all(svgDownloads)); } } } const results = await Promise.all(downloadPromises); return results.flat(); } /** * Get raw Figma API response for a file (for use with flexible extractors) */ async getRawFile(fileKey: string, depth?: number | null): Promise<GetFileResponse> { const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`; Logger.log(`Retrieving raw Figma file: ${fileKey} (depth: ${depth ?? "default"})`); const response = await this.request<GetFileResponse>(endpoint); writeLogs("figma-raw.json", response); return response; } /** * Get raw Figma API response for specific nodes (for use with flexible extractors) */ async getRawNode( fileKey: string, nodeId: string, depth?: number | null, ): Promise<GetFileNodesResponse> { const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; Logger.log( `Retrieving raw Figma node: ${nodeId} from ${fileKey} (depth: ${depth ?? "default"})`, ); const response = await this.request<GetFileNodesResponse>(endpoint); writeLogs("figma-raw.json", response); return response; } } ``` -------------------------------------------------------------------------------- /src/transformers/style.ts: -------------------------------------------------------------------------------- ```typescript import type { Node as FigmaDocumentNode, Paint, Vector, RGBA, Transform, } from "@figma/rest-api-spec"; import { generateCSSShorthand, isVisible } from "~/utils/common.js"; import { hasValue, isStrokeWeights } from "~/utils/identity.js"; export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`; export type CSSHexColor = `#${string}`; export interface ColorValue { hex: CSSHexColor; opacity: number; } /** * Simplified image fill with CSS properties and processing metadata * * This type represents an image fill that can be used as either: * - background-image (when parent node has children) * - <img> tag (when parent node has no children) * * The CSS properties are mutually exclusive based on usage context. */ export type SimplifiedImageFill = { type: "IMAGE"; imageRef: string; scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH"; /** * For TILE mode, the scaling factor relative to original image size */ scalingFactor?: number; // CSS properties for background-image usage (when node has children) backgroundSize?: string; backgroundRepeat?: string; // CSS properties for <img> tag usage (when node has no children) isBackground?: boolean; objectFit?: string; // Image processing metadata (NOT for CSS translation) // Used by download tools to determine post-processing needs imageDownloadArguments?: { /** * Whether image needs cropping based on transform */ needsCropping: boolean; /** * Whether CSS variables for dimensions are needed to calculate the background size for TILE mode * * Figma bases scalingFactor on the image's original size. In CSS, background size (as a percentage) * is calculated based on the size of the container. We need to pass back the original dimensions * after processing to calculate the intended background size when translated to code. */ requiresImageDimensions: boolean; /** * Figma's transform matrix for Sharp processing */ cropTransform?: Transform; /** * Suggested filename suffix to make cropped images unique * When the same imageRef is used multiple times with different crops, * this helps avoid overwriting conflicts */ filenameSuffix?: string; }; }; export type SimplifiedGradientFill = { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND"; gradient: string; }; export type SimplifiedPatternFill = { type: "PATTERN"; patternSource: { /** * Hardcode to expect PNG for now, consider SVG detection in the future. * * SVG detection is a bit challenging because the nodeId in question isn't * guaranteed to be included in the response we're working with. No guaranteed * way to look into it and see if it's only composed of vector shapes. */ type: "IMAGE-PNG"; nodeId: string; }; backgroundRepeat: string; backgroundSize: string; backgroundPosition: string; }; export type SimplifiedFill = | SimplifiedImageFill | SimplifiedGradientFill | SimplifiedPatternFill | CSSRGBAColor | CSSHexColor; export type SimplifiedStroke = { colors: SimplifiedFill[]; strokeWeight?: string; strokeDashes?: number[]; strokeWeights?: string; }; /** * Translate Figma scale modes to CSS properties based on usage context * * @param scaleMode - The Figma scale mode (FILL, FIT, TILE, STRETCH) * @param isBackground - Whether this image will be used as background-image (true) or <img> tag (false) * @param scalingFactor - For TILE mode, the scaling factor relative to original image size * @returns Object containing CSS properties and processing metadata */ function translateScaleMode( scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH", hasChildren: boolean, scalingFactor?: number, ): { css: Partial<SimplifiedImageFill>; processing: NonNullable<SimplifiedImageFill["imageDownloadArguments"]>; } { const isBackground = hasChildren; switch (scaleMode) { case "FILL": // Image covers entire container, may be cropped return { css: isBackground ? { backgroundSize: "cover", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "cover", isBackground: false }, processing: { needsCropping: false, requiresImageDimensions: false }, }; case "FIT": // Image fits entirely within container, may have empty space return { css: isBackground ? { backgroundSize: "contain", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "contain", isBackground: false }, processing: { needsCropping: false, requiresImageDimensions: false }, }; case "TILE": // Image repeats to fill container at specified scale // Always treat as background image (can't tile an <img> tag) return { css: { backgroundRepeat: "repeat", backgroundSize: scalingFactor ? `calc(var(--original-width) * ${scalingFactor}) calc(var(--original-height) * ${scalingFactor})` : "auto", isBackground: true, }, processing: { needsCropping: false, requiresImageDimensions: true }, }; case "STRETCH": // Figma calls crop "STRETCH" in its API. return { css: isBackground ? { backgroundSize: "100% 100%", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "fill", isBackground: false }, processing: { needsCropping: false, requiresImageDimensions: false }, }; default: return { css: {}, processing: { needsCropping: false, requiresImageDimensions: false }, }; } } /** * Generate a short hash from a transform matrix to create unique filenames * @param transform - The transform matrix to hash * @returns Short hash string for filename suffix */ function generateTransformHash(transform: Transform): string { const values = transform.flat(); const hash = values.reduce((acc, val) => { // Simple hash function - convert to string and create checksum const str = val.toString(); for (let i = 0; i < str.length; i++) { acc = ((acc << 5) - acc + str.charCodeAt(i)) & 0xffffffff; } return acc; }, 0); // Convert to positive hex string, take first 6 chars return Math.abs(hash).toString(16).substring(0, 6); } /** * Handle imageTransform for post-processing (not CSS translation) * * When Figma includes an imageTransform matrix, it means the image is cropped/transformed. * This function converts the transform into processing instructions for Sharp. * * @param imageTransform - Figma's 2x3 transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]] * @returns Processing metadata for image cropping */ function handleImageTransform( imageTransform: Transform, ): NonNullable<SimplifiedImageFill["imageDownloadArguments"]> { const transformHash = generateTransformHash(imageTransform); return { needsCropping: true, requiresImageDimensions: false, cropTransform: imageTransform, filenameSuffix: `${transformHash}`, }; } /** * Build simplified stroke information from a Figma node * * @param n - The Figma node to extract stroke information from * @param hasChildren - Whether the node has children (affects paint processing) * @returns Simplified stroke object with colors and properties */ export function buildSimplifiedStrokes( n: FigmaDocumentNode, hasChildren: boolean = false, ): SimplifiedStroke { let strokes: SimplifiedStroke = { colors: [] }; if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) { strokes.colors = n.strokes.filter(isVisible).map((stroke) => parsePaint(stroke, hasChildren)); } if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) { strokes.strokeWeight = `${n.strokeWeight}px`; } if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) { strokes.strokeDashes = n.strokeDashes; } if (hasValue("individualStrokeWeights", n, isStrokeWeights)) { strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights); } return strokes; } /** * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill * @param raw - The Figma paint to convert * @param hasChildren - Whether the node has children (determines CSS properties) * @returns The converted SimplifiedFill */ export function parsePaint(raw: Paint, hasChildren: boolean = false): SimplifiedFill { if (raw.type === "IMAGE") { const baseImageFill: SimplifiedImageFill = { type: "IMAGE", imageRef: raw.imageRef, scaleMode: raw.scaleMode as "FILL" | "FIT" | "TILE" | "STRETCH", scalingFactor: raw.scalingFactor, }; // Get CSS properties and processing metadata from scale mode // TILE mode always needs to be treated as background image (can't tile an <img> tag) const isBackground = hasChildren || baseImageFill.scaleMode === "TILE"; const { css, processing } = translateScaleMode( baseImageFill.scaleMode, isBackground, raw.scalingFactor, ); // Combine scale mode processing with transform processing if needed // Transform processing (cropping) takes precedence over scale mode processing let finalProcessing = processing; if (raw.imageTransform) { const transformProcessing = handleImageTransform(raw.imageTransform); finalProcessing = { ...processing, ...transformProcessing, // Keep requiresImageDimensions from scale mode (needed for TILE) requiresImageDimensions: processing.requiresImageDimensions || transformProcessing.requiresImageDimensions, }; } return { ...baseImageFill, ...css, imageDownloadArguments: finalProcessing, }; } else if (raw.type === "SOLID") { // treat as SOLID const { hex, opacity } = convertColor(raw.color!, raw.opacity); if (opacity === 1) { return hex; } else { return formatRGBAColor(raw.color!, opacity); } } else if (raw.type === "PATTERN") { return parsePatternPaint(raw); } else if ( ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes( raw.type, ) ) { return { type: raw.type as | "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND", gradient: convertGradientToCss(raw), }; } else { throw new Error(`Unknown paint type: ${raw.type}`); } } /** * Convert a Figma PatternPaint to a CSS-like pattern fill. * * Ignores `tileType` and `spacing` from the Figma API currently as there's * no great way to translate them to CSS. * * @param raw - The Figma PatternPaint to convert * @returns The converted pattern SimplifiedFill */ function parsePatternPaint( raw: Extract<Paint, { type: "PATTERN" }>, ): Extract<SimplifiedFill, { type: "PATTERN" }> { /** * The only CSS-like repeat value supported by Figma is repeat. * * They also have hexagonal horizontal and vertical repeats, but * those aren't easy to pull off in CSS, so we just use repeat. */ let backgroundRepeat = "repeat"; let horizontal = "left"; switch (raw.horizontalAlignment) { case "START": horizontal = "left"; break; case "CENTER": horizontal = "center"; break; case "END": horizontal = "right"; break; } let vertical = "top"; switch (raw.verticalAlignment) { case "START": vertical = "top"; break; case "CENTER": vertical = "center"; break; case "END": vertical = "bottom"; break; } return { type: raw.type, patternSource: { type: "IMAGE-PNG", nodeId: raw.sourceNodeId, }, backgroundRepeat, backgroundSize: `${Math.round(raw.scalingFactor * 100)}%`, backgroundPosition: `${horizontal} ${vertical}`, }; } /** * Convert hex color value and opacity to rgba format * @param hex - Hexadecimal color value (e.g., "#FF0000" or "#F00") * @param opacity - Opacity value (0-1) * @returns Color string in rgba format */ export function hexToRgba(hex: string, opacity: number = 1): string { // Remove possible # prefix hex = hex.replace("#", ""); // Handle shorthand hex values (e.g., #FFF) if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } // Convert hex to RGB values const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); // Ensure opacity is in the 0-1 range const validOpacity = Math.min(Math.max(opacity, 0), 1); return `rgba(${r}, ${g}, ${b}, ${validOpacity})`; } /** * Convert color from RGBA to { hex, opacity } * * @param color - The color to convert, including alpha channel * @param opacity - The opacity of the color, if not included in alpha channel * @returns The converted color **/ export function convertColor(color: RGBA, opacity = 1): ColorValue { const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative const a = Math.round(opacity * color.a * 100) / 100; const hex = ("#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor; return { hex, opacity: a }; } /** * Convert color from Figma RGBA to rgba(#, #, #, #) CSS format * * @param color - The color to convert, including alpha channel * @param opacity - The opacity of the color, if not included in alpha channel * @returns The converted color **/ export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor { const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative const a = Math.round(opacity * color.a * 100) / 100; return `rgba(${r}, ${g}, ${b}, ${a})`; } /** * Map gradient stops from Figma's handle-based coordinate system to CSS percentages */ function mapGradientStops( gradient: Extract< Paint, { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" } >, elementBounds: { width: number; height: number } = { width: 1, height: 1 }, ): { stops: string; cssGeometry: string } { const handles = gradient.gradientHandlePositions; if (!handles || handles.length < 2) { const stops = gradient.gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: "0deg" }; } const [handle1, handle2, handle3] = handles; switch (gradient.type) { case "GRADIENT_LINEAR": { return mapLinearGradient(gradient.gradientStops, handle1, handle2, elementBounds); } case "GRADIENT_RADIAL": { return mapRadialGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds); } case "GRADIENT_ANGULAR": { return mapAngularGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds); } case "GRADIENT_DIAMOND": { return mapDiamondGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds); } default: { const stops = gradient.gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: "0deg" }; } } } /** * Map linear gradient from Figma handles to CSS */ function mapLinearGradient( gradientStops: { position: number; color: RGBA }[], start: Vector, end: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { // Calculate the gradient line in element space const dx = end.x - start.x; const dy = end.y - start.y; const gradientLength = Math.sqrt(dx * dx + dy * dy); // Handle degenerate case if (gradientLength === 0) { const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: "0deg" }; } // Calculate angle for CSS const angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; // Find where the extended gradient line intersects the element boundaries const extendedIntersections = findExtendedLineIntersections(start, end); if (extendedIntersections.length >= 2) { // The gradient line extended to fill the element const fullLineStart = Math.min(extendedIntersections[0], extendedIntersections[1]); const fullLineEnd = Math.max(extendedIntersections[0], extendedIntersections[1]); const fullLineLength = fullLineEnd - fullLineStart; // Map gradient stops from the Figma line segment to the full CSS line const mappedStops = gradientStops.map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); // Position along the Figma gradient line (0 = start handle, 1 = end handle) const figmaLinePosition = position; // The Figma line spans from t=0 to t=1 // The full extended line spans from fullLineStart to fullLineEnd // Map the figma position to the extended line const tOnExtendedLine = figmaLinePosition * (1 - 0) + 0; // This is just figmaLinePosition const extendedPosition = (tOnExtendedLine - fullLineStart) / (fullLineEnd - fullLineStart); const clampedPosition = Math.max(0, Math.min(1, extendedPosition)); return `${cssColor} ${Math.round(clampedPosition * 100)}%`; }); return { stops: mappedStops.join(", "), cssGeometry: `${Math.round(angle)}deg`, }; } // Fallback to simple gradient if intersection calculation fails const mappedStops = gradientStops.map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }); return { stops: mappedStops.join(", "), cssGeometry: `${Math.round(angle)}deg`, }; } /** * Find where the extended gradient line intersects with the element boundaries */ function findExtendedLineIntersections(start: Vector, end: Vector): number[] { const dx = end.x - start.x; const dy = end.y - start.y; // Handle degenerate case if (Math.abs(dx) < 1e-10 && Math.abs(dy) < 1e-10) { return []; } const intersections: number[] = []; // Check intersection with each edge of the unit square [0,1] x [0,1] // Top edge (y = 0) if (Math.abs(dy) > 1e-10) { const t = -start.y / dy; const x = start.x + t * dx; if (x >= 0 && x <= 1) { intersections.push(t); } } // Bottom edge (y = 1) if (Math.abs(dy) > 1e-10) { const t = (1 - start.y) / dy; const x = start.x + t * dx; if (x >= 0 && x <= 1) { intersections.push(t); } } // Left edge (x = 0) if (Math.abs(dx) > 1e-10) { const t = -start.x / dx; const y = start.y + t * dy; if (y >= 0 && y <= 1) { intersections.push(t); } } // Right edge (x = 1) if (Math.abs(dx) > 1e-10) { const t = (1 - start.x) / dx; const y = start.y + t * dy; if (y >= 0 && y <= 1) { intersections.push(t); } } // Remove duplicates and sort const uniqueIntersections = [ ...new Set(intersections.map((t) => Math.round(t * 1000000) / 1000000)), ]; return uniqueIntersections.sort((a, b) => a - b); } /** * Find where a line intersects with the unit square (0,0) to (1,1) */ function findLineIntersections(start: Vector, end: Vector): number[] { const dx = end.x - start.x; const dy = end.y - start.y; const intersections: number[] = []; // Check intersection with each edge of the unit square const edges = [ { x: 0, y: 0, dx: 1, dy: 0 }, // top edge { x: 1, y: 0, dx: 0, dy: 1 }, // right edge { x: 1, y: 1, dx: -1, dy: 0 }, // bottom edge { x: 0, y: 1, dx: 0, dy: -1 }, // left edge ]; for (const edge of edges) { const t = lineIntersection(start, { x: dx, y: dy }, edge, { x: edge.dx, y: edge.dy }); if (t !== null && t >= 0 && t <= 1) { intersections.push(t); } } return intersections.sort((a, b) => a - b); } /** * Calculate line intersection parameter */ function lineIntersection( p1: Vector, d1: Vector, p2: { x: number; y: number }, d2: Vector, ): number | null { const denominator = d1.x * d2.y - d1.y * d2.x; if (Math.abs(denominator) < 1e-10) return null; // Lines are parallel const dx = p2.x - p1.x; const dy = p2.y - p1.y; const t = (dx * d2.y - dy * d2.x) / denominator; return t; } /** * Map radial gradient from Figma handles to CSS */ function mapRadialGradient( gradientStops: { position: number; color: RGBA }[], center: Vector, edge: Vector, widthHandle: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { const centerX = Math.round(center.x * 100); const centerY = Math.round(center.y * 100); const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: `circle at ${centerX}% ${centerY}%`, }; } /** * Map angular gradient from Figma handles to CSS */ function mapAngularGradient( gradientStops: { position: number; color: RGBA }[], center: Vector, angleHandle: Vector, widthHandle: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { const centerX = Math.round(center.x * 100); const centerY = Math.round(center.y * 100); const angle = Math.atan2(angleHandle.y - center.y, angleHandle.x - center.x) * (180 / Math.PI) + 90; const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: `from ${Math.round(angle)}deg at ${centerX}% ${centerY}%`, }; } /** * Map diamond gradient from Figma handles to CSS (approximate with ellipse) */ function mapDiamondGradient( gradientStops: { position: number; color: RGBA }[], center: Vector, edge: Vector, widthHandle: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { const centerX = Math.round(center.x * 100); const centerY = Math.round(center.y * 100); const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: `ellipse at ${centerX}% ${centerY}%`, }; } /** * Convert a Figma gradient to CSS gradient syntax */ function convertGradientToCss( gradient: Extract< Paint, { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" } >, ): string { // Sort stops by position to ensure proper order const sortedGradient = { ...gradient, gradientStops: [...gradient.gradientStops].sort((a, b) => a.position - b.position), }; // Map gradient stops using handle-based geometry const { stops, cssGeometry } = mapGradientStops(sortedGradient); switch (gradient.type) { case "GRADIENT_LINEAR": { return `linear-gradient(${cssGeometry}, ${stops})`; } case "GRADIENT_RADIAL": { return `radial-gradient(${cssGeometry}, ${stops})`; } case "GRADIENT_ANGULAR": { return `conic-gradient(${cssGeometry}, ${stops})`; } case "GRADIENT_DIAMOND": { return `radial-gradient(${cssGeometry}, ${stops})`; } default: return `linear-gradient(0deg, ${stops})`; } } ```