Showing preview only (203K chars total). Download the full file or copy to clipboard to get everything.
Repository: GLips/Figma-Context-MCP
Branch: main
Commit: c68a01a57d56
Files: 57
Total size: 189.1 KB
Directory structure:
gitextract_gy0gq7mg/
├── .claude/
│ └── commands/
│ └── release.md
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.md
│ ├── actions/
│ │ └── setup/
│ │ └── action.yml
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .nvmrc
├── .prettierrc
├── .release-please-manifest.json
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── ROADMAP.md
├── eslint.config.js
├── lefthook.yml
├── package.json
├── release-please-config.json
├── server.json
├── src/
│ ├── bin.ts
│ ├── config.ts
│ ├── extractors/
│ │ ├── README.md
│ │ ├── built-in.ts
│ │ ├── design-extractor.ts
│ │ ├── index.ts
│ │ ├── node-walker.ts
│ │ └── 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
│ │ ├── image-processing.test.ts
│ │ ├── integration.test.ts
│ │ ├── layout-alignment.test.ts
│ │ ├── path-validation.test.ts
│ │ ├── server.test.ts
│ │ └── stdio.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
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .claude/commands/release.md
================================================
# Release
Review and publish a new release.
## Steps
1. **Check for a release-please PR:**
Run `gh pr list --repo GLips/Figma-Context-MCP --label "autorelease: pending" --json number,title,url` to find the open release PR.
If no release PR exists, inform the user: "No pending release PR. Release-please creates one automatically when conventional commits (`fix:`, `feat:`) land on `main`."
2. **Show what's in the release:**
Run `gh pr view <number> --json body` to display the pending changelog and version bump. Summarize:
- New version number
- Number of features, fixes, and other changes
- List of included commits
3. **Ask for confirmation:**
Use AskUserQuestion: "Merge this release PR to publish v<version> to npm?"
- **Merge and publish** — Proceed with merge
- **Review diff first** — Show `gh pr diff <number>`
- **Cancel** — Stop without merging
4. **Merge the release PR:**
Run `gh pr merge <number> --squash --repo GLips/Figma-Context-MCP`
5. **Verify:**
Run `gh run list --repo GLips/Figma-Context-MCP --limit 1` to confirm the Release workflow triggered.
Report the workflow run URL so the user can monitor npm publish.
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: GLips
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
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.
================================================
FILE: .github/actions/setup/action.yml
================================================
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
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
pull_request:
branches:
- main
jobs:
ci:
name: Lint, Type Check, Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: Check formatting
run: pnpm prettier --check "src/**/*.ts"
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm type-check
- name: Test
run: pnpm test
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
id-token: write # Required for npm OIDC trusted publishing
jobs:
release-please:
if: ${{ github.repository_owner == 'GLips' }}
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: googleapis/release-please-action@v4
id: release
- uses: actions/checkout@v4
if: ${{ steps.release.outputs.release_created }}
- uses: pnpm/action-setup@v4
with:
version: 10.10.0
if: ${{ steps.release.outputs.release_created }}
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
if: ${{ steps.release.outputs.release_created }}
- name: Install dependencies
run: pnpm install
if: ${{ steps.release.outputs.release_created }}
- name: Type check
run: pnpm type-check
if: ${{ steps.release.outputs.release_created }}
- name: Build
run: pnpm build
if: ${{ steps.release.outputs.release_created }}
- name: Publish to npm
run: pnpm publish --no-git-checks
env:
NODE_ENV: production
if: ${{ steps.release.outputs.release_created }}
- name: Update server.json version
run: |
VERSION="${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}"
jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp && mv server.tmp server.json
if: ${{ steps.release.outputs.release_created }}
- name: Install mcp-publisher
run: |
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher
if: ${{ steps.release.outputs.release_created }}
- name: Publish to MCP Registry
run: |
./mcp-publisher login github-oidc
./mcp-publisher publish
if: ${{ steps.release.outputs.release_created }}
================================================
FILE: .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*
================================================
FILE: .nvmrc
================================================
v24.14.0
================================================
FILE: .prettierrc
================================================
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
================================================
FILE: .release-please-manifest.json
================================================
{
".": "0.7.0"
}
================================================
FILE: CHANGELOG.md
================================================
# figma-developer-mcp
## [0.7.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.6.6...v0.7.0) (2026-03-19)
### ⚠ BREAKING CHANGES
* getServerConfig() no longer takes an isStdioMode parameter. It now detects stdio mode internally and returns it as part of ServerConfig.
### Features
* add --image-dir config for image download path control ([#297](https://github.com/GLips/Figma-Context-MCP/issues/297)) ([0417766](https://github.com/GLips/Figma-Context-MCP/commit/0417766eb5fc1e0b76e55da497961f9aee2f62f7))
* replace yargs with cleye for CLI flag parsing ([#285](https://github.com/GLips/Figma-Context-MCP/issues/285)) ([0092ee7](https://github.com/GLips/Figma-Context-MCP/commit/0092ee789fce01b9ef1dab5e8f32c52e71107dbb))
* support gifRef for downloading animated GIF embeds ([#286](https://github.com/GLips/Figma-Context-MCP/issues/286)) ([f1ec913](https://github.com/GLips/Figma-Context-MCP/commit/f1ec9133c31a351b55651126c20ea2f842c0a9ee))
### Bug Fixes
* remove inline release-type so release-please reads config file ([a03cd68](https://github.com/GLips/Figma-Context-MCP/commit/a03cd68826da1c1596273a223a612eb919832397))
* replace sharp dependency with js-native jimp for image manipulation ([#289](https://github.com/GLips/Figma-Context-MCP/issues/289)) ([62b9f94](https://github.com/GLips/Figma-Context-MCP/commit/62b9f94b1607dd08daeaa90e8ace0a896fe6eb50))
* skip jimp processing for SVGs and prevent image-fill collapse ([#298](https://github.com/GLips/Figma-Context-MCP/issues/298)) ([a4a4b13](https://github.com/GLips/Figma-Context-MCP/commit/a4a4b13ec7cae5d603022b1c8719cc717749195b))
## [0.6.6](https://github.com/GLips/Figma-Context-MCP/compare/v0.6.5...v0.6.6) (2026-03-04)
### Bug Fixes
* use Node 24 in release workflow for npm OIDC support ([11ba7c6](https://github.com/GLips/Figma-Context-MCP/commit/11ba7c6a2e22910c483592ba7cdc1966fcdc9166))
## [0.6.5](https://github.com/GLips/Figma-Context-MCP/compare/v0.6.4...v0.6.5) (2026-03-04)
### Bug Fixes
* upgrade MCP SDK to 1.27.1 and modernize tool registration ([#282](https://github.com/GLips/Figma-Context-MCP/issues/282)) ([4153e5f](https://github.com/GLips/Figma-Context-MCP/commit/4153e5f857aa708ee9ee10156e553c1289f03cf7))
## 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
================================================
FILE: CLAUDE.md
================================================
# Framelink MCP for Figma
Framelink MCP for Figma is a Model Context Protocol (MCP) server that gives AI coding tools (Cursor, etc.) access to Figma design data. It fetches Figma files/nodes via the Figma API, simplifies the response to include only relevant layout and styling information, and serves it to AI clients.
## Build & Development Commands
```bash
pnpm install # Install dependencies
pnpm build # Build with tsup (outputs to dist/)
pnpm dev # Development mode with watch + auto-restart (HTTP)
pnpm dev:cli # Development mode (stdio)
pnpm test # Run Vitest tests
pnpm type-check # TypeScript type checking only
pnpm lint # ESLint
pnpm format # Prettier formatting
pnpm inspect # Run MCP inspector for debugging
```
### Running the Server
```bash
pnpm start # HTTP mode (default port 3333)
pnpm start:cli # stdio mode for MCP clients
```
### Running a Single Test
```bash
pnpm test -- path/to/test.ts
pnpm test -- --testNamePattern="pattern"
```
### Releasing
Releases are automated via [release-please](https://github.com/googleapis/release-please). On merge to `main`, release-please reads conventional commit prefixes (`fix:`, `feat:`, `feat!:`) and maintains a release PR. Merging the release PR publishes to npm via OIDC trusted publishing.
### PR Title Convention
PRs are squash-merged, so the PR title becomes the commit message that release-please parses. Always use [Conventional Commit](https://www.conventionalcommits.org/) prefixes in PR titles.
## Architecture
### Entry Points
- `src/bin.ts` — CLI entry point, calls `startServer()`
- `src/server.ts` — Server initialization, handles stdio vs HTTP mode selection
- `src/mcp-server.ts` — Library re-exports for external consumers (`createServer`, `startServer`, etc.)
- `src/index.ts` — Library exports (extractors, types)
### Transport Modes
The server supports three transports (all configured in `src/server.ts`):
- **stdio** — For direct MCP client integration (activated with `--stdio` flag or `NODE_ENV=cli`)
- **StreamableHTTP** — Modern HTTP transport at `/mcp`
- **SSE** — Legacy HTTP transport at `/sse` + `/messages`
### Core Data Flow
1. **MCP Tools** (`src/mcp/tools/`) — Define tool schemas and handlers
- `get_figma_data` — Fetches and simplifies Figma design data
- `download_figma_images` — Downloads images from Figma
2. **Figma Service** (`src/services/figma.ts`) — API client for Figma REST API
- Handles auth (Personal Access Token or OAuth)
- Methods: `getRawFile()`, `getRawNode()`, `downloadImages()`
3. **Extractor System** (`src/extractors/`) — Transforms raw Figma API responses
- `design-extractor.ts` — Entry point, parses API response and calls extractors
- `node-walker.ts` — Recursive traversal applying extractors to each node
- `built-in.ts` — Built-in extractors: `layoutExtractor`, `textExtractor`, `visualsExtractor`, `componentExtractor`
- Extractors are composable; `allExtractors` combines all built-ins
4. **Transformers** (`src/transformers/`) — Convert specific Figma properties
- `layout.ts` — Layout/positioning transforms
- `style.ts` — Visual styling (fills, strokes)
- `effects.ts` — Effects (shadows, blurs)
- `text.ts` — Text content and styling
- `component.ts` — Component metadata
### Configuration
`src/config.ts` handles CLI args and environment variables:
- `FIGMA_API_KEY` or `--figma-api-key` — Personal Access Token
- `FIGMA_OAUTH_TOKEN` or `--figma-oauth-token` — OAuth Bearer token
- `PORT` or `--port` — HTTP server port (default: 3333)
- `--json` — Output JSON instead of YAML
- `--skip-image-downloads` — Disable image download tool
### Path Alias
The codebase uses `~/` as an alias for `src/` (configured in tsconfig.json and vitest.config.ts).
## Philosophy
From CONTRIBUTING.md — important context for development:
1. **Unix Philosophy** — Tools should have one job and few arguments. Keep tools simple to avoid confusing LLMs.
2. **Focused Scope** — The server only handles "ingesting designs for AI consumption." Out of scope: image manipulation, CMS syncing, code generation, third-party integrations.
3. **Project-level Config** — Options unlikely to change between requests should be CLI arguments, not tool parameters.
## Quality
This codebase will outlive you. Every shortcut becomes someone else's burden. Every hack compounds into technical debt that slows the whole team down.
For each proposed change, examine the existing system and redesign it into the most elegant solution that would have emerged if the change had been a foundational assumption from the start.
You are not just writing code. You are shaping the future of this project. The patterns you establish will be copied. The corners you cut will be cut again.
Fight entropy. Leave the codebase better than you found it.
## Comment Policy
### Unacceptable Comments
- Comments that repeat what code does
- Commented-out code (delete it)
- Obvious comments ("increment counter")
- Comments instead of good naming
### Great Comments
- **Why this exists** — what problem does this solve, why is it valuable
- **Why it works this way** — important design decisions and their rationale
- **Why NOT** — approaches you considered and rejected, to prevent re-attempting failed ideas
- **Warnings** — non-obvious gotchas, ordering dependencies, "this must happen before X"
- **Domain bridges** — when code implements complex domain logic (finance calculations, protocol specs, algorithms) that can't fully express the underlying concept
- **Looks wrong** — when code appears unused, redundant, or incorrect but exists for a non-obvious reason (e.g., interface contracts for test implementations, load-bearing side effects)
- **Negative space** — when code deliberately doesn't handle something and that absence is intentional (e.g., "Does not retry—caller handles backoff" prevents someone from "helpfully" adding retry logic that breaks upstream assumptions)
## Testing Philosophy
Write tests. Not too many. Mostly integration.
- Every test has a cost: maintenance, false positives, slower CI. Tests must earn their place.
- Most features need 2-5 tests. Some need zero.
- Zero tests is valid for: simple CRUD, styling, config changes, framework-convention code, etc.
- Design for testability using "functional core, imperative shell": keep pure business logic separate from code that does IO.
### Principles
- **Test behavior, not implementation.** Tests should verify what the code does, not how it does it. Only use methods available on the public interface to verify behavior.
- **Don't test what the type system guarantees.** If TypeScript enforces it at compile time, a runtime test adds no value.
- **Don't test the framework.** Don't verify that Express routes, React renders, or ORM queries work — test _your_ logic.
- **Prefer real implementations over mocks.** Mocks couple tests to implementation details and hide real bugs. Only mock at system boundaries (network, filesystem, time).
### Only test behavior where:
- A failure would frustrate or block real users
- The behavior is non-obvious and could regress silently
- It's a critical integration point or state transition
### Skip testing:
- Implementation details, private methods, trivial code
- Edge cases that won't occur in practice
- Variations that test the same underlying behavior
## Error Handling
Trust internal code and framework guarantees. Only validate at system boundaries — user input, external APIs, file I/O. Don't add try/catch, fallbacks, or defensive checks for scenarios that can't happen in practice. Let errors propagate naturally; the caller that knows how to handle them should be the one catching them.
## External Libraries
Use the context7 MCP first to gather information on unfamiliar libraries or APIs. If that fails, you may search the code directly or search the web for more detail.
## Communication Style
When reviewing plans, providing feedback, or analyzing approaches, be genuinely critical. Flag real risks, tradeoffs, and things that will break rather than being agreeable. Grounded, opinionated analysis is more valuable than polite agreement.
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Framelink MCP for Figma
Thank you for your interest in contributing to the Framelink MCP for Figma! 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 MCP for Figma - 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
This project uses [Conventional Commits](https://www.conventionalcommits.org/) to automate versioning and changelog generation. The maintainer applies the correct prefix when squash-merging your PR — you don't need to worry about this in your individual commits.
For reference, these prefixes determine version bumps:
- `fix: <description>` — patch release (0.6.4 → 0.6.5)
- `feat: <description>` — minor release (0.6.4 → 0.7.0)
- `feat!: <description>` or `BREAKING CHANGE:` footer — major release (0.6.4 → 1.0.0)
- `chore:`, `docs:`, `test:`, `refactor:` — no release triggered
### 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.
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Graham Lipsman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<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 MCP for Figma</h1>
<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 MCP for Figma": {
"command": "npx",
"args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
}
}
}
```
### Windows
```json
{
"mcpServers": {
"Framelink MCP for Figma": {
"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 MCP for Figma, 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 MCP for Figma 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.
================================================
FILE: ROADMAP.md
================================================
# 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_
================================================
FILE: eslint.config.js
================================================
import js from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import prettier from "eslint-config-prettier";
import globals from "globals";
export default [
js.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
globals: {
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
"no-undef": "off", // TypeScript handles this; no-undef doesn't understand TS types like NodeJS
"@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,
},
{
files: ["**/*.test.ts", "**/*.test.tsx", "**/tests/**/*.ts"],
languageOptions: {
globals: {
...globals.jest, // vitest globals are the same names
},
},
},
{
ignores: ["dist/**", "node_modules/**"],
},
];
================================================
FILE: lefthook.yml
================================================
pre-commit:
parallel: true
commands:
format:
glob: "*.{ts,js,json,md}"
run: pnpm prettier --write {staged_files} && git add {staged_files}
lint:
glob: "*.{ts,js}"
run: pnpm eslint {staged_files}
type-check:
glob: "*.{ts,js}"
run: pnpm type-check
================================================
FILE: package.json
================================================
{
"name": "figma-developer-mcp",
"version": "0.7.0",
"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": "vitest run",
"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"
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "pnpm@10.10.0",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lefthook"
]
},
"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.27.1",
"cleye": "^2.2.1",
"cross-env": "^7.0.3",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"js-yaml": "^4.1.1",
"remeda": "^2.20.1",
"jimp": "^1.6.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/express": "^5.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.3",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.0.1",
"globals": "^17.3.0",
"lefthook": "^2.0.15",
"prettier": "^3.5.0",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}
================================================
FILE: release-please-config.json
================================================
{
"packages": {
".": {
"release-type": "node",
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"include-component-in-tag": false
}
}
}
================================================
FILE: server.json
================================================
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/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.",
"repository": {
"url": "https://github.com/GLips/Figma-Context-MCP",
"source": "github"
},
"version": "0.6.4",
"packages": [
{
"registryType": "npm",
"identifier": "figma-developer-mcp",
"version": "0.6.4",
"transport": {
"type": "stdio"
},
"packageArguments": [
{
"type": "positional",
"value": "--stdio"
}
],
"environmentVariables": [
{
"description": "Your Figma Personal Access Token, learn more here: https://www.figma.com/developers/api#access-tokens",
"isRequired": true,
"format": "string",
"isSecret": true,
"name": "FIGMA_API_KEY"
}
]
}
]
}
================================================
FILE: src/bin.ts
================================================
#!/usr/bin/env node
import { startServer } from "./server.js";
startServer().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
================================================
FILE: src/config.ts
================================================
import { cli } from "cleye";
import { config as loadEnv } from "dotenv";
import { resolve as resolvePath } from "path";
import type { FigmaAuthOptions } from "./services/figma.js";
type Source = "cli" | "env" | "default";
interface Resolved<T> {
value: T;
source: Source;
}
interface ServerConfig {
auth: FigmaAuthOptions;
port: number;
host: string;
outputFormat: "yaml" | "json";
skipImageDownloads: boolean;
imageDir: string;
isStdioMode: boolean;
configSources: Record<string, Source>;
}
/** Resolve a config value through the priority chain: CLI flag → env var → default. */
function resolve<T>(flag: T | undefined, env: T | undefined, fallback: T): Resolved<T> {
if (flag !== undefined) return { value: flag, source: "cli" };
if (env !== undefined) return { value: env, source: "env" };
return { value: fallback, source: "default" };
}
function envStr(name: string): string | undefined {
return process.env[name] || undefined;
}
function envInt(...names: string[]): number | undefined {
for (const name of names) {
const val = process.env[name];
if (val) return parseInt(val, 10);
}
return undefined;
}
function envBool(name: string): boolean | undefined {
const val = process.env[name];
if (val === "true") return true;
if (val === "false") return false;
return undefined;
}
function maskApiKey(key: string): string {
if (!key || key.length <= 4) return "****";
return `****${key.slice(-4)}`;
}
export function getServerConfig(): ServerConfig {
const argv = cli({
name: "figma-developer-mcp",
version: process.env.NPM_PACKAGE_VERSION ?? "unknown",
flags: {
figmaApiKey: {
type: String,
description: "Figma API key (Personal Access Token)",
},
figmaOauthToken: {
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",
},
host: {
type: String,
description: "Host to run the server on",
},
json: {
type: Boolean,
description: "Output data from tools in JSON format instead of YAML",
},
skipImageDownloads: {
type: Boolean,
description: "Do not register the download_figma_images tool (skip image downloads)",
},
imageDir: {
type: String,
description:
"Base directory for image downloads. The download tool will only write files within this directory. Defaults to the current working directory.",
},
stdio: {
type: Boolean,
description: "Run in stdio transport mode for MCP clients",
},
},
});
// Load .env before resolving env-backed values
const envFilePath = argv.flags.env
? resolvePath(argv.flags.env)
: resolvePath(process.cwd(), ".env");
const envFileSource: Source = argv.flags.env ? "cli" : "default";
loadEnv({ path: envFilePath, override: true });
// Resolve config values: CLI flag → env var → default
const figmaApiKey = resolve(argv.flags.figmaApiKey, envStr("FIGMA_API_KEY"), "");
const figmaOauthToken = resolve(argv.flags.figmaOauthToken, envStr("FIGMA_OAUTH_TOKEN"), "");
const port = resolve(argv.flags.port, envInt("FRAMELINK_PORT", "PORT"), 3333);
const host = resolve(argv.flags.host, envStr("FRAMELINK_HOST"), "127.0.0.1");
const skipImageDownloads = resolve(
argv.flags.skipImageDownloads,
envBool("SKIP_IMAGE_DOWNLOADS"),
false,
);
const envImageDir = envStr("IMAGE_DIR");
const imageDir = resolve(
argv.flags.imageDir ? resolvePath(argv.flags.imageDir) : undefined,
envImageDir ? resolvePath(envImageDir) : undefined,
process.cwd(),
);
// These two don't fit the simple pattern: --json maps to a string enum,
// and --stdio has a NODE_ENV backdoor.
const outputFormat = resolve<"yaml" | "json">(
argv.flags.json ? "json" : undefined,
envStr("OUTPUT_FORMAT") as "yaml" | "json" | undefined,
"yaml",
);
const isStdioMode = argv.flags.stdio === true || process.env.NODE_ENV === "cli";
// Auth
const useOAuth = Boolean(figmaOauthToken.value);
const auth: FigmaAuthOptions = {
figmaApiKey: figmaApiKey.value,
figmaOAuthToken: figmaOauthToken.value,
useOAuth,
};
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);
}
const configSources: Record<string, Source> = {
envFile: envFileSource,
figmaApiKey: figmaApiKey.source,
figmaOauthToken: figmaOauthToken.source,
port: port.source,
host: host.source,
outputFormat: outputFormat.source,
skipImageDownloads: skipImageDownloads.source,
imageDir: imageDir.source,
};
if (!isStdioMode) {
console.log("\nConfiguration:");
console.log(`- ENV_FILE: ${envFilePath} (source: ${configSources.envFile})`);
if (useOAuth) {
console.log(
`- FIGMA_OAUTH_TOKEN: ${maskApiKey(auth.figmaOAuthToken)} (source: ${configSources.figmaOauthToken})`,
);
console.log("- Authentication Method: OAuth Bearer Token");
} else {
console.log(
`- FIGMA_API_KEY: ${maskApiKey(auth.figmaApiKey)} (source: ${configSources.figmaApiKey})`,
);
console.log("- Authentication Method: Personal Access Token (X-Figma-Token)");
}
console.log(`- FRAMELINK_PORT: ${port.value} (source: ${configSources.port})`);
console.log(`- FRAMELINK_HOST: ${host.value} (source: ${configSources.host})`);
console.log(`- OUTPUT_FORMAT: ${outputFormat.value} (source: ${configSources.outputFormat})`);
console.log(
`- SKIP_IMAGE_DOWNLOADS: ${skipImageDownloads.value} (source: ${configSources.skipImageDownloads})`,
);
console.log(`- IMAGE_DIR: ${imageDir.value} (source: ${configSources.imageDir})`);
console.log();
}
return {
auth,
port: port.value,
host: host.value,
outputFormat: outputFormat.value,
skipImageDownloads: skipImageDownloads.value,
imageDir: imageDir.value,
isStdioMode,
configSources,
};
}
================================================
FILE: src/extractors/README.md
================================================
# 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.
================================================
FILE: src/extractors/built-in.ts
================================================
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 &&
!hasImageFillInChildren(node)
) {
// Collapse to IMAGE-SVG and omit children
result.type = "IMAGE-SVG";
return [];
}
// Include all children normally
return children;
}
/**
* Check whether a node or its direct children have image fills.
*
* Only direct children need checking because afterChildren runs bottom-up:
* if a deeper descendant has image fills, its parent won't collapse (stays FRAME),
* and FRAME isn't SVG-eligible, so the chain breaks naturally at each level.
*/
function hasImageFillInChildren(node: FigmaDocumentNode): boolean {
if (hasValue("fills", node) && node.fills.some((fill) => fill.type === "IMAGE")) {
return true;
}
if (hasValue("children", node)) {
return node.children.some(
(child) => hasValue("fills", child) && child.fills.some((fill) => fill.type === "IMAGE"),
);
}
return false;
}
================================================
FILE: src/extractors/design-extractor.ts
================================================
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,
};
}
================================================
FILE: src/extractors/index.ts
================================================
// 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";
================================================
FILE: src/extractors/node-walker.ts
================================================
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;
}
================================================
FILE: src/extractors/types.ts
================================================
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;
}
================================================
FILE: src/index.ts
================================================
// 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,
collapseSvgContainers,
} from "./extractors/index.js";
================================================
FILE: src/mcp/index.ts
================================================
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",
description:
"Gives AI coding agents access to Figma design data, providing layout, styling, and content information for implementing designs.",
};
type CreateServerOptions = {
isHTTP?: boolean;
outputFormat?: "yaml" | "json";
skipImageDownloads?: boolean;
imageDir?: string;
};
function createServer(
authOptions: FigmaAuthOptions,
{
isHTTP = false,
outputFormat = "yaml",
skipImageDownloads = false,
imageDir,
}: CreateServerOptions = {},
) {
const server = new McpServer(serverInfo);
const figmaService = new FigmaService(authOptions);
registerTools(server, figmaService, { outputFormat, skipImageDownloads, imageDir });
Logger.isHTTP = isHTTP;
return server;
}
function registerTools(
server: McpServer,
figmaService: FigmaService,
options: {
outputFormat: "yaml" | "json";
skipImageDownloads: boolean;
imageDir?: string;
},
): void {
server.registerTool(
getFigmaDataTool.name,
{
title: "Get Figma Data",
description: getFigmaDataTool.description,
inputSchema: getFigmaDataTool.parametersSchema,
annotations: { readOnlyHint: true },
},
(params: GetFigmaDataParams) =>
getFigmaDataTool.handler(params, figmaService, options.outputFormat),
);
if (!options.skipImageDownloads) {
server.registerTool(
downloadFigmaImagesTool.name,
{
title: "Download Figma Images",
description: downloadFigmaImagesTool.getDescription(options.imageDir),
inputSchema: downloadFigmaImagesTool.parametersSchema,
annotations: { openWorldHint: true },
},
(params: DownloadImagesParams) =>
downloadFigmaImagesTool.handler(params, figmaService, options.imageDir),
);
}
}
export { createServer };
================================================
FILE: src/mcp/tools/download-figma-images-tool.ts
================================================
import path from "path";
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 or animated GIFs (use gifRef instead).",
),
gifRef: z
.string()
.optional()
.describe(
"If a node has a gifRef fill (animated GIF), you must include this variable to download the animated GIF. When gifRef is present in the Figma data, use it instead of imageRef to get the animated file rather than a static snapshot.",
),
fileName: z
.string()
.regex(
/^[a-zA-Z0-9_.-]+\.(png|svg|gif)$/,
"File names must contain only letters, numbers, underscores, dots, or hyphens, and end with .png, .svg, or .gif.",
)
.describe(
"The local name for saving the fetched file, including extension. png, svg, or gif.",
),
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()
.regex(
/^[a-zA-Z0-9_-]+$/,
"Suffix must contain only letters, numbers, underscores, or hyphens",
)
.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 path to the directory where images should be saved, relative to the project root. If the directory does not exist, it will be created. Use forward slashes for path separators (e.g., 'public/images' or 'assets/icons').",
),
};
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,
imageDir?: string,
) {
try {
const { fileKey, nodes, localPath, pngScale = 2 } = parametersSchema.parse(params);
// Resolve localPath relative to the configured image directory.
// path.join (not path.resolve) so a leading "/" is treated as relative, not absolute —
// LLMs frequently produce paths like "/public/images" when they mean "public/images".
const baseDir = imageDir ?? process.cwd();
const resolvedPath = path.resolve(path.join(baseDir, localPath));
if (resolvedPath !== baseDir && !resolvedPath.startsWith(baseDir + path.sep)) {
return {
isError: true,
content: [
{
type: "text" as const,
text: `Invalid path: "${localPath}" resolves outside the allowed image directory. The server's image directory is "${baseDir}". Provide a path relative to this directory (e.g., "public/images" or "assets/icons").`,
},
],
};
}
// 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.gifRef) {
// GIF fills are always unique downloads (animated, no dedup needed)
const downloadIndex = downloadItems.length;
downloadItems.push({ ...downloadItem, gifRef: node.gifRef });
downloadToRequests.set(downloadIndex, [finalFileName]);
} else 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, resolvedPath, 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)}`,
},
],
};
}
}
function getDescription(imageDir?: string) {
const baseDir = imageDir ?? process.cwd();
return `Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes. Images will be saved relative to the server's image directory: ${baseDir}`;
}
// Export tool configuration
export const downloadFigmaImagesTool = {
name: "download_figma_images",
getDescription,
parametersSchema,
handler: downloadFigmaImages,
} as const;
================================================
FILE: src/mcp/tools/get-figma-data-tool.ts
================================================
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",
parametersSchema,
handler: getFigmaData,
} as const;
================================================
FILE: src/mcp/tools/index.ts
================================================
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";
================================================
FILE: src/mcp-server.ts
================================================
// 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";
================================================
FILE: src/server.ts
================================================
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;
type Session = {
transport: StreamableHTTPServerTransport | SSEServerTransport;
server: McpServer;
};
const sessions: Record<string, Session> = {};
/**
* Start the MCP server in either stdio or HTTP mode.
*/
export async function startServer(): Promise<void> {
const config = getServerConfig();
const serverOptions = {
isHTTP: !config.isStdioMode,
outputFormat: config.outputFormat as "yaml" | "json",
skipImageDownloads: config.skipImageDownloads,
imageDir: config.imageDir,
};
if (config.isStdioMode) {
const server = createServer(config.auth, serverOptions);
const transport = new StdioServerTransport();
await server.connect(transport);
} else {
const createMcpServer = () => createServer(config.auth, serverOptions);
console.log(`Initializing Figma MCP Server in HTTP mode on ${config.host}:${config.port}...`);
await startHttpServer(config.host, config.port, createMcpServer);
process.on("SIGINT", async () => {
Logger.log("Shutting down server...");
await stopHttpServer();
Logger.log("Server shutdown complete");
process.exit(0);
});
}
}
export async function startHttpServer(
host: string,
port: number,
createMcpServer: () => McpServer,
): Promise<Server> {
if (httpServer) {
throw new Error("HTTP server is already running");
}
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;
let transport: StreamableHTTPServerTransport;
if (sessionId && sessions[sessionId]) {
// Reuse existing transport
Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId);
transport = sessions[sessionId].transport as StreamableHTTPServerTransport;
} else if (!sessionId && isInitializeRequest(req.body)) {
Logger.log("New initialization request for StreamableHTTP sessionId", sessionId);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
sessions[newSessionId] = { transport, server: mcpServer };
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete sessions[transport.sessionId];
}
};
const mcpServer = createMcpServer();
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;
let progress = 0;
if (progressToken && sessionId && sessions[sessionId]) {
Logger.log(
`Setting up progress notifications for token ${progressToken} on session ${sessionId}`,
);
progressInterval = setInterval(async () => {
Logger.log("Sending progress notification", progress);
await sessions[sessionId].server.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 || !sessions[sessionId]) {
res.status(400).send("Invalid or missing session ID");
return;
}
console.log(`Received session termination request for session ${sessionId}`);
try {
const transport = sessions[sessionId].transport as StreamableHTTPServerTransport;
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);
const mcpServer = createMcpServer();
sessions[transport.sessionId] = { transport, server: mcpServer };
res.on("close", () => {
delete sessions[transport.sessionId];
});
await mcpServer.connect(transport);
});
app.post("/messages", async (req, res) => {
const sessionId = req.query.sessionId as string;
const session = sessions[sessionId];
if (session) {
Logger.log(`Received SSE message for sessionId ${sessionId}`);
Logger.log("/messages request headers:", req.headers);
Logger.log("/messages request body:", req.body);
await (session.transport as SSEServerTransport).handlePostMessage(req, res);
} else {
res.status(400).send(`No transport found for sessionId ${sessionId}`);
return;
}
});
return new Promise((resolve, reject) => {
const server = app.listen(port, host, () => {
Logger.log(`HTTP server listening on port ${port}`);
Logger.log(`SSE endpoint available at http://${host}:${port}/sse`);
Logger.log(`Message endpoint available at http://${host}:${port}/messages`);
Logger.log(`StreamableHTTP endpoint available at http://${host}:${port}/mcp`);
resolve(server);
});
server.once("error", (err) => {
httpServer = null;
reject(err);
});
httpServer = server;
});
}
export async function stopHttpServer(): Promise<void> {
if (!httpServer) {
throw new Error("HTTP server is not running");
}
// Close all sessions FIRST so connections drain
for (const sessionId in sessions) {
try {
await sessions[sessionId].transport.close();
delete sessions[sessionId];
} catch (error) {
console.error(`Error closing session ${sessionId}:`, error);
}
}
// Then close the HTTP server
return new Promise((resolve, reject) => {
httpServer!.close((err) => {
httpServer = null;
if (err) reject(err);
else resolve();
});
});
}
================================================
FILE: src/services/figma.ts
================================================
import type {
GetImagesResponse,
GetFileResponse,
GetFileNodesResponse,
GetImageFillsResponse,
Transform,
} from "@figma/rest-api-spec";
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;
gifRef?: string;
nodeId?: string;
fileName: string;
needsCropping?: boolean;
cropTransform?: Transform;
requiresImageDimensions?: boolean;
}>,
options: { pngScale?: number; svgOptions?: SvgOptions } = {},
): Promise<ImageProcessingResult[]> {
if (items.length === 0) return [];
const resolvedPath = localPath;
const { pngScale = 2, svgOptions } = options;
const downloadPromises: Promise<ImageProcessingResult[]>[] = [];
// Separate items by type: image/gif fills vs rendered nodes
const imageFills = items.filter(
(item): item is typeof item & ({ imageRef: string } | { gifRef: string }) =>
!!item.imageRef || !!item.gifRef,
);
const renderNodes = items.filter(
(item): item is typeof item & { nodeId: string } => !!item.nodeId,
);
// Download image fills (static images and animated GIFs) with processing
if (imageFills.length > 0) {
const fillUrls = await this.getImageFillUrls(fileKey);
const fillDownloads = imageFills
.map(
({
imageRef,
gifRef,
fileName,
needsCropping,
cropTransform,
requiresImageDimensions,
}) => {
// gifRef takes priority when present — it points to the animated GIF file.
// imageRef only points to a static snapshot frame for GIF nodes.
const fillRef = gifRef ?? imageRef;
const imageUrl = fillRef ? fillUrls[fillRef] : undefined;
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;
}
}
================================================
FILE: src/tests/benchmark.test.ts
================================================
import yaml from "js-yaml";
describe("Benchmarks", () => {
const data = {
name: "John Doe",
age: 30,
email: "john.doe@example.com",
};
it("YAML should be token efficient", () => {
const yamlResult = yaml.dump(data);
const jsonResult = JSON.stringify(data);
expect(yamlResult.length).toBeLessThan(jsonResult.length);
});
});
================================================
FILE: src/tests/image-processing.test.ts
================================================
import path from "path";
import os from "os";
import fs from "fs";
import { Jimp } from "jimp";
import { getImageDimensions, applyCropTransform } from "../utils/image-processing.js";
import type { Transform } from "@figma/rest-api-spec";
describe("image processing", () => {
let tmpDir: string;
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "image-processing-test-"));
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true });
});
async function createTemp(name: string, width: number, height: number): Promise<string> {
const filePath = path.join(tmpDir, name);
const image = new Jimp({ width, height, color: 0xff0000ff });
await image.write(filePath as `${string}.${string}`);
return filePath;
}
describe("getImageDimensions", () => {
it("reads correct dimensions from a PNG", async () => {
const filePath = await createTemp("test-200x100.png", 200, 100);
const dims = await getImageDimensions(filePath);
expect(dims).toEqual({ width: 200, height: 100 });
});
it("reads correct dimensions from a JPEG", async () => {
const filePath = await createTemp("test-300x150.jpg", 300, 150);
const dims = await getImageDimensions(filePath);
expect(dims).toEqual({ width: 300, height: 150 });
});
});
describe("applyCropTransform", () => {
it("crops to the correct dimensions", async () => {
const filePath = await createTemp("test-crop-400x400.png", 400, 400);
// Crop to the top-left quarter: scale 0.5 in both axes, no translation
const transform: Transform = [
[0.5, 0, 0],
[0, 0.5, 0],
];
await applyCropTransform(filePath, transform);
const dims = await getImageDimensions(filePath);
expect(dims).toEqual({ width: 200, height: 200 });
});
it("returns original image unchanged for invalid crop dimensions", async () => {
const filePath = await createTemp("test-crop-invalid.png", 100, 100);
// Zero scale produces invalid (0-width) crop region
const transform: Transform = [
[0, 0, 0],
[0, 0, 0],
];
const result = await applyCropTransform(filePath, transform);
expect(result).toBe(filePath);
const dims = await getImageDimensions(filePath);
expect(dims).toEqual({ width: 100, height: 100 });
});
});
});
================================================
FILE: src/tests/integration.test.ts
================================================
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();
const describeOrSkip = process.env.RUN_FIGMA_INTEGRATION === "1" ? describe : describe.skip;
describeOrSkip("Figma MCP Server Tests", () => {
let server: McpServer;
let client: Client;
let figmaApiKey: string;
let figmaFileKey: string;
beforeAll(async () => {
figmaApiKey = process.env.FIGMA_API_KEY || "";
figmaFileKey = process.env.FIGMA_FILE_KEY || "";
server = createServer({
figmaApiKey,
figmaOAuthToken: "",
useOAuth: false,
});
client = new Client({
name: "figma-test-client",
version: "1.0.0",
});
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 = {
fileKey: figmaFileKey,
};
const result = await client.request(
{
method: "tools/call",
params: {
name: "get_figma_data",
arguments: args,
},
},
CallToolResultSchema,
);
const firstContent = result.content[0];
const content = firstContent.type === "text" ? firstContent.text : "";
const parsed = yaml.load(content);
expect(parsed).toBeDefined();
}, 60000);
});
});
================================================
FILE: src/tests/layout-alignment.test.ts
================================================
import { describe, test, expect } from "vitest";
import { buildSimplifiedLayout } from "~/transformers/layout.js";
import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec";
function makeFrame(overrides: Record<string, unknown> = {}) {
return {
clipsContent: true,
layoutMode: "HORIZONTAL",
children: [],
primaryAxisAlignItems: "MIN",
counterAxisAlignItems: "MIN",
...overrides,
} as unknown as FigmaDocumentNode;
}
function makeChild(overrides: Record<string, unknown> = {}) {
return {
layoutSizingHorizontal: "FIXED",
layoutSizingVertical: "FIXED",
...overrides,
};
}
describe("layout alignment", () => {
describe("justifyContent (primary axis)", () => {
const cases: [string, string | undefined][] = [
["MIN", undefined],
["MAX", "flex-end"],
["CENTER", "center"],
["SPACE_BETWEEN", "space-between"],
];
test.each(cases)("row: %s → %s", (figmaValue, expected) => {
const node = makeFrame({
layoutMode: "HORIZONTAL",
primaryAxisAlignItems: figmaValue,
});
expect(buildSimplifiedLayout(node).justifyContent).toBe(expected);
});
test.each(cases)("column: %s → %s", (figmaValue, expected) => {
const node = makeFrame({
layoutMode: "VERTICAL",
primaryAxisAlignItems: figmaValue,
});
expect(buildSimplifiedLayout(node).justifyContent).toBe(expected);
});
});
describe("alignItems (counter axis)", () => {
const cases: [string, string | undefined][] = [
["MIN", undefined],
["MAX", "flex-end"],
["CENTER", "center"],
["BASELINE", "baseline"],
];
test.each(cases)("row: %s → %s", (figmaValue, expected) => {
const node = makeFrame({
layoutMode: "HORIZONTAL",
counterAxisAlignItems: figmaValue,
});
expect(buildSimplifiedLayout(node).alignItems).toBe(expected);
});
test.each(cases)("column: %s → %s", (figmaValue, expected) => {
const node = makeFrame({
layoutMode: "VERTICAL",
counterAxisAlignItems: figmaValue,
});
expect(buildSimplifiedLayout(node).alignItems).toBe(expected);
});
});
describe("alignItems stretch detection", () => {
test("row: all children fill cross axis → stretch", () => {
const node = makeFrame({
layoutMode: "HORIZONTAL",
children: [
makeChild({ layoutSizingVertical: "FILL" }),
makeChild({ layoutSizingVertical: "FILL" }),
],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("stretch");
});
test("column: all children fill cross axis → stretch", () => {
const node = makeFrame({
layoutMode: "VERTICAL",
children: [
makeChild({ layoutSizingHorizontal: "FILL" }),
makeChild({ layoutSizingHorizontal: "FILL" }),
],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("stretch");
});
test("row: mixed children → falls back to enum value", () => {
const node = makeFrame({
layoutMode: "HORIZONTAL",
counterAxisAlignItems: "CENTER",
children: [
makeChild({ layoutSizingVertical: "FILL" }),
makeChild({ layoutSizingVertical: "FIXED" }),
],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("center");
});
test("column: mixed children → falls back to enum value", () => {
const node = makeFrame({
layoutMode: "VERTICAL",
counterAxisAlignItems: "MAX",
children: [
makeChild({ layoutSizingHorizontal: "FILL" }),
makeChild({ layoutSizingHorizontal: "FIXED" }),
],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("flex-end");
});
test("absolute children are excluded from stretch check", () => {
const node = makeFrame({
layoutMode: "HORIZONTAL",
children: [
makeChild({ layoutSizingVertical: "FILL" }),
makeChild({ layoutPositioning: "ABSOLUTE", layoutSizingVertical: "FIXED" }),
],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("stretch");
});
test("no children → no stretch, uses enum value", () => {
const node = makeFrame({
layoutMode: "HORIZONTAL",
counterAxisAlignItems: "CENTER",
children: [],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("center");
});
// These two tests verify correct cross-axis detection — the bug PR #232 addressed.
// With the old bug, row mode checked layoutSizingHorizontal (main axis) instead of
// layoutSizingVertical (cross axis), so children filling main-only would false-positive.
test("row: children fill main axis only → no stretch", () => {
const node = makeFrame({
layoutMode: "HORIZONTAL",
counterAxisAlignItems: "CENTER",
children: [
makeChild({ layoutSizingHorizontal: "FILL", layoutSizingVertical: "FIXED" }),
makeChild({ layoutSizingHorizontal: "FILL", layoutSizingVertical: "FIXED" }),
],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("center");
});
test("column: children fill main axis only → no stretch", () => {
const node = makeFrame({
layoutMode: "VERTICAL",
counterAxisAlignItems: "CENTER",
children: [
makeChild({ layoutSizingVertical: "FILL", layoutSizingHorizontal: "FIXED" }),
makeChild({ layoutSizingVertical: "FILL", layoutSizingHorizontal: "FIXED" }),
],
});
expect(buildSimplifiedLayout(node).alignItems).toBe("center");
});
});
});
================================================
FILE: src/tests/path-validation.test.ts
================================================
import path from "path";
import { describe, expect, it } from "vitest";
import { downloadFigmaImagesTool } from "~/mcp/tools/download-figma-images-tool.js";
import { downloadFigmaImage } from "~/utils/common.js";
const stubFigmaService = {} as Parameters<typeof downloadFigmaImagesTool.handler>[1];
const validParams = {
fileKey: "abc123",
nodes: [{ nodeId: "1:2", fileName: "test.png" }],
pngScale: 2,
};
describe("download path validation", () => {
const imageDir = "/project/root";
it("rejects localPath that traverses outside imageDir", async () => {
const result = await downloadFigmaImagesTool.handler(
{ ...validParams, localPath: "../../etc" },
stubFigmaService,
imageDir,
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("resolves outside the allowed image directory");
expect(result.content[0].text).toContain(imageDir);
});
it("rejects traversal with leading slash", async () => {
const result = await downloadFigmaImagesTool.handler(
{ ...validParams, localPath: "/../../etc" },
stubFigmaService,
imageDir,
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("resolves outside the allowed image directory");
});
it("accepts valid relative path within imageDir", async () => {
// Will fail on the Figma API call — we only care that it doesn't
// return the path validation error.
const result = await downloadFigmaImagesTool.handler(
{ ...validParams, localPath: "public/images" },
stubFigmaService,
imageDir,
);
if (result.isError) {
expect(result.content[0].text).not.toContain("resolves outside the allowed image directory");
}
});
it("accepts path with leading slash as relative", async () => {
// LLMs frequently produce paths like "/public/images" when they mean "public/images"
const result = await downloadFigmaImagesTool.handler(
{ ...validParams, localPath: "/public/images" },
stubFigmaService,
imageDir,
);
if (result.isError) {
expect(result.content[0].text).not.toContain("resolves outside the allowed image directory");
}
});
});
describe("downloadFigmaImage filename validation", () => {
it("rejects fileName with directory traversal", async () => {
const localPath = path.join(process.cwd(), "test-images");
await expect(
downloadFigmaImage("../../../etc/evil.png", localPath, "https://example.com/img.png"),
).rejects.toThrow("File path escapes target directory");
});
});
================================================
FILE: src/tests/server.test.ts
================================================
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { createServer } from "../mcp/index.js";
import { startHttpServer, stopHttpServer } from "../server.js";
import type { AddressInfo } from "net";
import type { FigmaAuthOptions } from "../services/figma.js";
const dummyAuth: FigmaAuthOptions = {
figmaApiKey: "test-key-not-used",
figmaOAuthToken: "",
useOAuth: false,
};
describe("StreamableHTTP transport", () => {
let port: number;
beforeAll(async () => {
const mcpServer = createServer(dummyAuth, { isHTTP: true });
const httpServer = await startHttpServer("127.0.0.1", 0, () => mcpServer);
port = (httpServer.address() as AddressInfo).port;
}, 15_000);
afterAll(async () => {
try {
await stopHttpServer();
} catch {
// Server may not have started
}
});
it("connects, initializes, and lists tools", async () => {
const client = new Client({ name: "test-streamable", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
await client.connect(transport);
const { tools } = await client.listTools();
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("get_figma_data");
expect(toolNames).toContain("download_figma_images");
await transport.terminateSession();
await client.close();
}, 15_000);
});
describe("SSE transport", () => {
let port: number;
beforeAll(async () => {
const mcpServer = createServer(dummyAuth, { isHTTP: true });
const httpServer = await startHttpServer("127.0.0.1", 0, () => mcpServer);
port = (httpServer.address() as AddressInfo).port;
}, 15_000);
afterAll(async () => {
try {
await stopHttpServer();
} catch {
// Server may not have started
}
});
it("connects, initializes, and lists tools", async () => {
const client = new Client({ name: "test-sse", version: "1.0.0" });
const transport = new SSEClientTransport(new URL(`http://127.0.0.1:${port}/sse`));
await client.connect(transport);
const { tools } = await client.listTools();
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("get_figma_data");
await client.close();
}, 15_000);
});
describe("Negative protocol tests", () => {
let port: number;
beforeAll(async () => {
const mcpServer = createServer(dummyAuth, { isHTTP: true });
const httpServer = await startHttpServer("127.0.0.1", 0, () => mcpServer);
port = (httpServer.address() as AddressInfo).port;
}, 15_000);
afterAll(async () => {
try {
await stopHttpServer();
} catch {
// Server may not have started
}
});
it("POST /mcp without session ID and non-initialize body returns 400", async () => {
const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "tools/list",
id: 1,
}),
});
expect(res.status).toBe(400);
});
it("GET /mcp with invalid session ID returns 400", async () => {
const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "GET",
headers: { "mcp-session-id": "nonexistent-session" },
});
expect(res.status).toBe(400);
});
it("DELETE /mcp with invalid session ID returns 400", async () => {
const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "DELETE",
headers: { "mcp-session-id": "nonexistent-session" },
});
expect(res.status).toBe(400);
});
it("POST /messages with unknown sessionId returns 400", async () => {
const res = await fetch(`http://127.0.0.1:${port}/messages?sessionId=nonexistent`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "tools/list",
id: 1,
}),
});
expect(res.status).toBe(400);
});
});
describe("Multi-client test", () => {
let port: number;
beforeAll(async () => {
const httpServer = await startHttpServer("127.0.0.1", 0, () =>
createServer(dummyAuth, { isHTTP: true }),
);
port = (httpServer.address() as AddressInfo).port;
}, 15_000);
afterAll(async () => {
try {
await stopHttpServer();
} catch {
// Server may not have started
}
});
it("StreamableHTTP and SSE clients work concurrently", async () => {
const streamableClient = new Client({ name: "test-streamable", version: "1.0.0" });
const streamableTransport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp`),
);
const sseClient = new Client({ name: "test-sse", version: "1.0.0" });
const sseTransport = new SSEClientTransport(new URL(`http://127.0.0.1:${port}/sse`));
// Connect both concurrently
await Promise.all([
streamableClient.connect(streamableTransport),
sseClient.connect(sseTransport),
]);
// Both should be able to list tools
const [streamableTools, sseTools] = await Promise.all([
streamableClient.listTools(),
sseClient.listTools(),
]);
expect(streamableTools.tools.map((t) => t.name)).toContain("get_figma_data");
expect(sseTools.tools.map((t) => t.name)).toContain("get_figma_data");
// Clean up
await streamableTransport.terminateSession();
await Promise.all([streamableClient.close(), sseClient.close()]);
}, 15_000);
});
describe("Session reconnection", () => {
let port: number;
beforeAll(async () => {
const httpServer = await startHttpServer("127.0.0.1", 0, () =>
createServer(dummyAuth, { isHTTP: true }),
);
port = (httpServer.address() as AddressInfo).port;
}, 15_000);
afterAll(async () => {
try {
await stopHttpServer();
} catch {
// Server may not have started
}
});
it("connects, terminates, and reconnects successfully", async () => {
// First session
const client1 = new Client({ name: "test-reconnect-1", version: "1.0.0" });
const transport1 = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
await client1.connect(transport1);
const { tools: tools1 } = await client1.listTools();
expect(tools1.map((t) => t.name)).toContain("get_figma_data");
await transport1.terminateSession();
await client1.close();
// Second session after termination
const client2 = new Client({ name: "test-reconnect-2", version: "1.0.0" });
const transport2 = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
await client2.connect(transport2);
const { tools: tools2 } = await client2.listTools();
expect(tools2.map((t) => t.name)).toContain("get_figma_data");
await transport2.terminateSession();
await client2.close();
}, 15_000);
it("reconnects after client drops without clean termination", async () => {
// First session — close abruptly without terminateSession()
const client1 = new Client({ name: "test-dirty-close-1", version: "1.0.0" });
const transport1 = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
await client1.connect(transport1);
await client1.listTools();
// Simulate unclean disconnect (just close, no terminate)
await client1.close();
// Second session should still work
const client2 = new Client({ name: "test-dirty-close-2", version: "1.0.0" });
const transport2 = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
await client2.connect(transport2);
const { tools } = await client2.listTools();
expect(tools.map((t) => t.name)).toContain("get_figma_data");
await transport2.terminateSession();
await client2.close();
}, 15_000);
});
describe("Server lifecycle", () => {
it("starts and listens on assigned port", async () => {
const mcpServer = createServer(dummyAuth, { isHTTP: true });
const httpServer = await startHttpServer("127.0.0.1", 0, () => mcpServer);
const port = (httpServer.address() as AddressInfo).port;
expect(port).toBeGreaterThan(0);
await stopHttpServer();
}, 15_000);
it("stopHttpServer shuts down cleanly without hanging", async () => {
const mcpServer = createServer(dummyAuth, { isHTTP: true });
await startHttpServer("127.0.0.1", 0, () => mcpServer);
// Race stopHttpServer against a deadline
const timeout = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), 5_000).unref(),
);
const result = await Promise.race([stopHttpServer().then(() => "stopped" as const), timeout]);
expect(result).toBe("stopped");
}, 15_000);
});
================================================
FILE: src/tests/stdio.test.ts
================================================
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
describe("stdio transport", () => {
let client: Client;
let transport: StdioClientTransport;
afterEach(async () => {
try {
await client?.close();
} catch {
// Best-effort cleanup
}
});
it("starts, completes MCP handshake, and lists tools", async () => {
transport = new StdioClientTransport({
command: "tsx",
args: ["src/bin.ts", "--stdio", "--figma-api-key=test-key"],
});
client = new Client({ name: "stdio-test", version: "1.0.0" });
await client.connect(transport);
const { tools } = await client.listTools();
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("get_figma_data");
expect(toolNames).toContain("download_figma_images");
}, 30_000);
});
================================================
FILE: src/transformers/component.ts
================================================
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,
},
]),
);
}
================================================
FILE: src/transformers/effects.ts
================================================
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)`;
}
================================================
FILE: src/transformers/layout.ts
================================================
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 };
}
function convertJustifyContent(align?: HasFramePropertiesTrait["primaryAxisAlignItems"]) {
switch (align) {
case "MIN":
return undefined;
case "MAX":
return "flex-end";
case "CENTER":
return "center";
case "SPACE_BETWEEN":
return "space-between";
default:
return undefined;
}
}
function convertAlignItems(
align: HasFramePropertiesTrait["counterAxisAlignItems"] | undefined,
children: FigmaDocumentNode[],
mode: "row" | "column",
) {
// Row cross-axis is vertical; column cross-axis is horizontal
const crossSizing = mode === "row" ? "layoutSizingVertical" : "layoutSizingHorizontal";
const allStretch =
children.length > 0 &&
children.every(
(c) =>
("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") ||
(crossSizing in c && (c as Record<string, unknown>)[crossSizing] === "FILL"),
);
if (allStretch) return "stretch";
switch (align) {
case "MIN":
return undefined;
case "MAX":
return "flex-end";
case "CENTER":
return "center";
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 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;
}
frameValues.justifyContent = convertJustifyContent(n.primaryAxisAlignItems ?? "MIN");
frameValues.alignItems = convertAlignItems(
n.counterAxisAlignItems ?? "MIN",
n.children,
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;
}
================================================
FILE: src/transformers/style.ts
================================================
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;
/**
* Present when the fill is an animated GIF. Use this ref (instead of imageRef) when calling
* download_figma_images to retrieve the animated GIF file; imageRef only points to a static
* snapshot frame.
*/
gifRef?: 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,
...(raw.gifRef ? { gifRef: raw.gifRef } : {}),
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]);
// 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);
}
/**
* 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})`;
}
}
================================================
FILE: src/transformers/text.ts
================================================
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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- `any` needed to extract the style variant from the union
): 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;
}
}
================================================
FILE: src/utils/common.ts
================================================
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 and verify it stays within localPath
const fullPath = path.resolve(path.join(localPath, fileName));
const resolvedLocalPath = path.resolve(localPath);
if (!fullPath.startsWith(resolvedLocalPath + path.sep)) {
throw new Error(`File path escapes target directory: ${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));
}
================================================
FILE: src/utils/fetch-with-retry.ts
================================================
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: unknown) {
const fetchMessage = fetchError instanceof Error ? fetchError.message : String(fetchError);
Logger.log(
`[fetchWithRetry] Initial fetch failed for ${url}: ${fetchMessage}. 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: unknown) {
const curlMessage = curlError instanceof Error ? curlError.message : String(curlError);
Logger.error(`[fetchWithRetry] Curl fallback also failed for ${url}: ${curlMessage}`);
// 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;
}
================================================
FILE: src/utils/identity.ts
================================================
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"));
}
================================================
FILE: src/utils/image-processing.ts
================================================
import { Jimp } from "jimp";
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 (skew values intentionally unused for now)
const scaleX = cropTransform[0]?.[0] ?? 1;
const translateX = cropTransform[0]?.[2] ?? 0;
const scaleY = cropTransform[1]?.[1] ?? 1;
const translateY = cropTransform[1]?.[2] ?? 0;
const image = await Jimp.read(imagePath);
const { width, height } = image;
// 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));
if (cropWidth <= 0 || cropHeight <= 0) {
Logger.log(`Invalid crop dimensions for ${imagePath}, using original image`);
return imagePath;
}
image.crop({ x: cropLeft, y: cropTop, w: cropWidth, h: cropHeight });
await image.write(imagePath as `${string}.${string}`);
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 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 image = await Jimp.read(imagePath);
return { width: image.width, height: image.height };
}
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}`);
// SVGs are vector — jimp can't read them and cropping/dimensions don't apply
const isSvg = fileName.toLowerCase().endsWith(".svg");
if (isSvg) {
return {
filePath: originalPath,
originalDimensions: { width: 0, height: 0 },
finalDimensions: { width: 0, height: 0 },
wasCropped: false,
processingLog,
};
}
// 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 (skip for GIFs — cropping destroys animation frames)
if (needsCropping && cropTransform && !fileName.toLowerCase().endsWith(".gif")) {
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;`;
}
================================================
FILE: src/utils/logger.ts
================================================
import fs from "fs";
/* eslint-disable @typescript-eslint/no-explicit-any -- logging accepts arbitrary values */
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);
},
};
/* eslint-enable @typescript-eslint/no-explicit-any */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- writes arbitrary debug data
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}`);
}
}
================================================
FILE: tsconfig.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,
"types": ["vitest/globals"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
=================================
gitextract_gy0gq7mg/ ├── .claude/ │ └── commands/ │ └── release.md ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ ├── actions/ │ │ └── setup/ │ │ └── action.yml │ └── workflows/ │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .release-please-manifest.json ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── ROADMAP.md ├── eslint.config.js ├── lefthook.yml ├── package.json ├── release-please-config.json ├── server.json ├── src/ │ ├── bin.ts │ ├── config.ts │ ├── extractors/ │ │ ├── README.md │ │ ├── built-in.ts │ │ ├── design-extractor.ts │ │ ├── index.ts │ │ ├── node-walker.ts │ │ └── 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 │ │ ├── image-processing.test.ts │ │ ├── integration.test.ts │ │ ├── layout-alignment.test.ts │ │ ├── path-validation.test.ts │ │ ├── server.test.ts │ │ └── stdio.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 └── vitest.config.ts
SYMBOL INDEX (127 symbols across 22 files)
FILE: src/config.ts
type Source (line 6) | type Source = "cli" | "env" | "default";
type Resolved (line 8) | interface Resolved<T> {
type ServerConfig (line 13) | interface ServerConfig {
function resolve (line 25) | function resolve<T>(flag: T | undefined, env: T | undefined, fallback: T...
function envStr (line 31) | function envStr(name: string): string | undefined {
function envInt (line 35) | function envInt(...names: string[]): number | undefined {
function envBool (line 43) | function envBool(name: string): boolean | undefined {
function maskApiKey (line 50) | function maskApiKey(key: string): string {
function getServerConfig (line 55) | function getServerConfig(): ServerConfig {
FILE: src/extractors/built-in.ts
function findOrCreateVar (line 24) | function findOrCreateVar(globalVars: GlobalVars, value: StyleTypes, pref...
function getStyleName (line 162) | function getStyleName(
constant SVG_ELIGIBLE_TYPES (line 213) | const SVG_ELIGIBLE_TYPES = new Set([
function collapseSvgContainers (line 233) | function collapseSvgContainers(
function hasImageFillInChildren (line 261) | function hasImageFillInChildren(node: FigmaDocumentNode): boolean {
FILE: src/extractors/design-extractor.ts
function simplifyRawFigmaObject (line 17) | function simplifyRawFigmaObject(
function parseAPIResponse (line 48) | function parseAPIResponse(data: GetFileResponse | GetFileNodesResponse) {
FILE: src/extractors/node-walker.ts
function extractFromDesign (line 21) | function extractFromDesign(
function processNodeWithExtractors (line 46) | function processNodeWithExtractors(
function shouldProcessNode (line 102) | function shouldProcessNode(node: FigmaDocumentNode, options: TraversalOp...
function shouldTraverseChildren (line 119) | function shouldTraverseChildren(
FILE: src/extractors/types.ts
type StyleTypes (line 12) | type StyleTypes =
type GlobalVars (line 20) | type GlobalVars = {
type TraversalContext (line 24) | interface TraversalContext {
type TraversalOptions (line 30) | interface TraversalOptions {
type ExtractorFn (line 56) | type ExtractorFn = (
type SimplifiedDesign (line 62) | interface SimplifiedDesign {
type SimplifiedNode (line 70) | interface SimplifiedNode {
type BoundingBox (line 97) | interface BoundingBox {
FILE: src/mcp/index.ts
type CreateServerOptions (line 18) | type CreateServerOptions = {
function createServer (line 25) | function createServer(
function registerTools (line 43) | function registerTools(
FILE: src/mcp/tools/download-figma-images-tool.ts
type DownloadImagesParams (line 82) | type DownloadImagesParams = z.infer<typeof parametersSchema>;
function downloadFigmaImages (line 85) | async function downloadFigmaImages(
function getDescription (line 222) | function getDescription(imageDir?: string) {
FILE: src/mcp/tools/get-figma-data-tool.ts
type GetFigmaDataParams (line 38) | type GetFigmaDataParams = z.infer<typeof parametersSchema>;
function getFigmaData (line 41) | async function getFigmaData(
FILE: src/server.ts
type Session (line 15) | type Session = {
function startServer (line 24) | async function startServer(): Promise<void> {
function startHttpServer (line 52) | async function startHttpServer(
function stopHttpServer (line 207) | async function stopHttpServer(): Promise<void> {
FILE: src/services/figma.ts
type FigmaAuthOptions (line 12) | type FigmaAuthOptions = {
type SvgOptions (line 18) | type SvgOptions = {
class FigmaService (line 24) | class FigmaService {
method constructor (line 30) | constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptio...
method getAuthHeaders (line 36) | private getAuthHeaders(): Record<string, string> {
method filterValidImages (line 49) | private filterValidImages(
method request (line 59) | private async request<T>(endpoint: string): Promise<T> {
method buildSvgQueryParams (line 78) | private buildSvgQueryParams(svgIds: string[], svgOptions: SvgOptions):...
method getImageFillUrls (line 94) | async getImageFillUrls(fileKey: string): Promise<Record<string, string...
method getNodeRenderUrls (line 105) | async getNodeRenderUrls(
method downloadImages (line 142) | async downloadImages(
method getRawFile (line 278) | async getRawFile(fileKey: string, depth?: number | null): Promise<GetF...
method getRawNode (line 291) | async getRawNode(
FILE: src/tests/image-processing.test.ts
function createTemp (line 19) | async function createTemp(name: string, width: number, height: number): ...
FILE: src/tests/layout-alignment.test.ts
function makeFrame (line 5) | function makeFrame(overrides: Record<string, unknown> = {}) {
function makeChild (line 16) | function makeChild(overrides: Record<string, unknown> = {}) {
FILE: src/transformers/component.ts
type ComponentProperties (line 3) | interface ComponentProperties {
type SimplifiedComponentDefinition (line 9) | interface SimplifiedComponentDefinition {
type SimplifiedComponentSetDefinition (line 16) | interface SimplifiedComponentSetDefinition {
function simplifyComponents (line 26) | function simplifyComponents(
function simplifyComponentSets (line 45) | function simplifyComponentSets(
FILE: src/transformers/effects.ts
type SimplifiedEffects (line 10) | type SimplifiedEffects = {
function buildSimplifiedEffects (line 17) | function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects {
function simplifyDropShadow (line 60) | function simplifyDropShadow(effect: DropShadowEffect) {
function simplifyInnerShadow (line 64) | function simplifyInnerShadow(effect: InnerShadowEffect) {
function simplifyBlur (line 68) | function simplifyBlur(effect: BlurEffect) {
FILE: src/transformers/layout.ts
type SimplifiedLayout (line 9) | interface SimplifiedLayout {
function buildSimplifiedLayout (line 35) | function buildSimplifiedLayout(
function convertJustifyContent (line 45) | function convertJustifyContent(align?: HasFramePropertiesTrait["primaryA...
function convertAlignItems (line 60) | function convertAlignItems(
function convertSelfAlign (line 90) | function convertSelfAlign(align?: HasLayoutTrait["layoutAlign"]) {
function convertSizing (line 107) | function convertSizing(
function buildSimplifiedFrameValues (line 116) | function buildSimplifiedFrameValues(n: FigmaDocumentNode): SimplifiedLay...
function buildSimplifiedLayoutValues (line 163) | function buildSimplifiedLayoutValues(
FILE: src/transformers/style.ts
type CSSRGBAColor (line 11) | type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
type CSSHexColor (line 12) | type CSSHexColor = `#${string}`;
type ColorValue (line 13) | interface ColorValue {
type SimplifiedImageFill (line 27) | type SimplifiedImageFill = {
type SimplifiedGradientFill (line 78) | type SimplifiedGradientFill = {
type SimplifiedPatternFill (line 83) | type SimplifiedPatternFill = {
type SimplifiedFill (line 101) | type SimplifiedFill =
type SimplifiedStroke (line 108) | type SimplifiedStroke = {
function translateScaleMode (line 123) | function translateScaleMode(
function generateTransformHash (line 188) | function generateTransformHash(transform: Transform): string {
function handleImageTransform (line 212) | function handleImageTransform(
function buildSimplifiedStrokes (line 231) | function buildSimplifiedStrokes(
function parsePaint (line 261) | function parsePaint(raw: Paint, hasChildren: boolean = false): Simplifie...
function parsePatternPaint (line 336) | function parsePatternPaint(
function hexToRgba (line 391) | function hexToRgba(hex: string, opacity: number = 1): string {
function convertColor (line 418) | function convertColor(color: RGBA, opacity = 1): ColorValue {
function formatRGBAColor (line 439) | function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor {
function mapGradientStops (line 452) | function mapGradientStops(
function mapLinearGradient (line 500) | function mapLinearGradient(
function findExtendedLineIntersections (line 570) | function findExtendedLineIntersections(start: Vector, end: Vector): numb...
function mapRadialGradient (line 628) | function mapRadialGradient(
function mapAngularGradient (line 654) | function mapAngularGradient(
function mapDiamondGradient (line 683) | function mapDiamondGradient(
function convertGradientToCss (line 709) | function convertGradientToCss(
FILE: src/transformers/text.ts
type SimplifiedTextStyle (line 4) | type SimplifiedTextStyle = Partial<{
function isTextNode (line 15) | function isTextNode(
function hasTextStyle (line 21) | function hasTextStyle(
function extractNodeText (line 29) | function extractNodeText(n: FigmaDocumentNode) {
function extractTextStyle (line 35) | function extractTextStyle(n: FigmaDocumentNode) {
FILE: src/utils/common.ts
type StyleId (line 4) | type StyleId = `${string}_${string}` & { __brand: "StyleId" };
function downloadFigmaImage (line 14) | async function downloadFigmaImage(
function removeEmptyKeys (line 93) | function removeEmptyKeys<T>(input: T): T {
function generateVarId (line 136) | function generateVarId(prefix: string = "var"): StyleId {
function generateCSSShorthand (line 163) | function generateCSSShorthand(
function isVisible (line 205) | function isVisible(element: { visible?: boolean }): boolean {
function pixelRound (line 215) | function pixelRound(num: number): number {
FILE: src/utils/fetch-with-retry.ts
type RequestOptions (line 7) | type RequestOptions = RequestInit & {
function fetchWithRetry (line 16) | async function fetchWithRetry<T extends { status?: number }>(
function formatHeadersForCurl (line 90) | function formatHeadersForCurl(headers: Record<string, string> | undefine...
FILE: src/utils/identity.ts
function hasValue (line 12) | function hasValue<K extends PropertyKey, T>(
function isFrame (line 23) | function isFrame(val: unknown): val is HasFramePropertiesTrait {
function isLayout (line 32) | function isLayout(val: unknown): val is HasLayoutTrait {
function isInAutoLayoutFlow (line 55) | function isInAutoLayoutFlow(node: unknown, parent: unknown): boolean {
function isStrokeWeights (line 65) | function isStrokeWeights(val: unknown): val is StrokeWeights {
function isRectangle (line 76) | function isRectangle<T, K extends string>(
function isRectangleCornerRadii (line 94) | function isRectangleCornerRadii(val: unknown): val is number[] {
function isCSSColorValue (line 98) | function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor {
FILE: src/utils/image-processing.ts
function applyCropTransform (line 10) | async function applyCropTransform(
function getImageDimensions (line 64) | async function getImageDimensions(imagePath: string): Promise<{
type ImageProcessingResult (line 72) | type ImageProcessingResult = {
function downloadAndProcessImage (line 92) | async function downloadAndProcessImage(
function generateImageCSSVariables (line 185) | function generateImageCSSVariables({
FILE: src/utils/logger.ts
function writeLogs (line 20) | function writeLogs(name: string, value: any): void {
Condensed preview — 57 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (205K chars).
[
{
"path": ".claude/commands/release.md",
"chars": 1186,
"preview": "# Release\n\nReview and publish a new release.\n\n## Steps\n\n1. **Check for a release-please PR:**\n Run `gh pr list --repo "
},
{
"path": ".github/FUNDING.yml",
"chars": 61,
"preview": "# These are supported funding model platforms\n\ngithub: GLips\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 3275,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"\"\nlabels: bug\nassignees: \"\"\n---\n\n**Describe the b"
},
{
"path": ".github/actions/setup/action.yml",
"chars": 413,
"preview": "name: \"Setup and install\"\ndescription: \"Common setup steps for Actions\"\n\nruns:\n using: composite\n steps:\n - name: I"
},
{
"path": ".github/workflows/ci.yml",
"chars": 435,
"preview": "name: CI\n\non:\n pull_request:\n branches:\n - main\n\njobs:\n ci:\n name: Lint, Type Check, Test\n runs-on: ubun"
},
{
"path": ".github/workflows/release.yml",
"chars": 2146,
"preview": "name: Release\n\non:\n push:\n branches:\n - main\n\npermissions:\n contents: write\n pull-requests: write\n id-token:"
},
{
"path": ".gitignore",
"chars": 425,
"preview": "# Dependencies\nnode_modules\n.pnpm-store\npackage-lock.json\n\n# Build output\ndist\n\n# Environment variables\n.env\n.env.local\n"
},
{
"path": ".nvmrc",
"chars": 9,
"preview": "v24.14.0\n"
},
{
"path": ".prettierrc",
"chars": 127,
"preview": "{\n \"semi\": true,\n \"trailingComma\": \"all\",\n \"singleQuote\": false,\n \"printWidth\": 100,\n \"tabWidth\": 2,\n \"useTabs\": f"
},
{
"path": ".release-please-manifest.json",
"chars": 19,
"preview": "{\n \".\": \"0.7.0\"\n}\n"
},
{
"path": "CHANGELOG.md",
"chars": 11128,
"preview": "# figma-developer-mcp\n\n## [0.7.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.6.6...v0.7.0) (2026-03-19)\n\n\n###"
},
{
"path": "CLAUDE.md",
"chars": 8262,
"preview": "# Framelink MCP for Figma\n\nFramelink MCP for Figma is a Model Context Protocol (MCP) server that gives AI coding tools ("
},
{
"path": "CONTRIBUTING.md",
"chars": 5883,
"preview": "# Contributing to Framelink MCP for Figma\n\nThank you for your interest in contributing to the Framelink MCP for Figma! T"
},
{
"path": "LICENSE",
"chars": 1071,
"preview": "MIT License\n\nCopyright (c) 2025 Graham Lipsman\n\nPermission is hereby granted, free of charge, to any person obtaining a "
},
{
"path": "README.md",
"chars": 4182,
"preview": "<a href=\"https://www.framelink.ai/?utm_source=github&utm_medium=referral&utm_campaign=readme\" target=\"_blank\" rel=\"noope"
},
{
"path": "ROADMAP.md",
"chars": 5324,
"preview": "# Figma MCP Server Roadmap\n\nThis roadmap outlines planned improvements and features for the Figma MCP Server project. It"
},
{
"path": "eslint.config.js",
"chars": 1246,
"preview": "import js from \"@eslint/js\";\nimport tseslint from \"@typescript-eslint/eslint-plugin\";\nimport tsparser from \"@typescript-"
},
{
"path": "lefthook.yml",
"chars": 298,
"preview": "pre-commit:\n parallel: true\n commands:\n format:\n glob: \"*.{ts,js,json,md}\"\n run: pnpm prettier --write {s"
},
{
"path": "package.json",
"chars": 2105,
"preview": "{\n \"name\": \"figma-developer-mcp\",\n \"version\": \"0.7.0\",\n \"mcpName\": \"io.github.GLips/Figma-Context-MCP\",\n \"descriptio"
},
{
"path": "release-please-config.json",
"chars": 187,
"preview": "{\n \"packages\": {\n \".\": {\n \"release-type\": \"node\",\n \"changelog-path\": \"CHANGELOG.md\",\n \"bump-minor-pre"
},
{
"path": "server.json",
"chars": 1001,
"preview": "{\n \"$schema\": \"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json\",\n \"name\": \"io.github.GLip"
},
{
"path": "src/bin.ts",
"chars": 172,
"preview": "#!/usr/bin/env node\n\nimport { startServer } from \"./server.js\";\n\nstartServer().catch((error) => {\n console.error(\"Faile"
},
{
"path": "src/config.ts",
"chars": 6269,
"preview": "import { cli } from \"cleye\";\nimport { config as loadEnv } from \"dotenv\";\nimport { resolve as resolvePath } from \"path\";\n"
},
{
"path": "src/extractors/README.md",
"chars": 4048,
"preview": "# Flexible Figma Data Extractors\n\nThis module provides a flexible, single-pass system for extracting data from Figma des"
},
{
"path": "src/extractors/built-in.ts",
"chars": 8803,
"preview": "import type {\n ExtractorFn,\n GlobalVars,\n StyleTypes,\n TraversalContext,\n SimplifiedNode,\n} from \"./types.js\";\nimpo"
},
{
"path": "src/extractors/design-extractor.ts",
"chars": 2891,
"preview": "import type {\n GetFileResponse,\n GetFileNodesResponse,\n Node as FigmaDocumentNode,\n Component,\n ComponentSet,\n Sty"
},
{
"path": "src/extractors/index.ts",
"chars": 673,
"preview": "// Types\nexport type {\n ExtractorFn,\n TraversalContext,\n TraversalOptions,\n GlobalVars,\n StyleTypes,\n} from \"./type"
},
{
"path": "src/extractors/node-walker.ts",
"chars": 3688,
"preview": "import type { Node as FigmaDocumentNode } from \"@figma/rest-api-spec\";\nimport { isVisible } from \"~/utils/common.js\";\nim"
},
{
"path": "src/extractors/types.ts",
"chars": 3007,
"preview": "import type { Node as FigmaDocumentNode, Style } from \"@figma/rest-api-spec\";\nimport type { SimplifiedTextStyle } from \""
},
{
"path": "src/index.ts",
"chars": 526,
"preview": "// Re-export extractor types only\nexport type { SimplifiedDesign } from \"./extractors/types.js\";\n\n// Flexible extractor "
},
{
"path": "src/mcp/index.ts",
"chars": 2207,
"preview": "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { FigmaService, type FigmaAuthOptions } from"
},
{
"path": "src/mcp/tools/download-figma-images-tool.ts",
"chars": 8938,
"preview": "import path from \"path\";\nimport { z } from \"zod\";\nimport { FigmaService } from \"../../services/figma.js\";\nimport { Logge"
},
{
"path": "src/mcp/tools/get-figma-data-tool.ts",
"chars": 3649,
"preview": "import { z } from \"zod\";\nimport type { GetFileResponse, GetFileNodesResponse } from \"@figma/rest-api-spec\";\nimport { Fig"
},
{
"path": "src/mcp/tools/index.ts",
"chars": 281,
"preview": "export { getFigmaDataTool } from \"./get-figma-data-tool.js\";\nexport { downloadFigmaImagesTool } from \"./download-figma-i"
},
{
"path": "src/mcp-server.ts",
"chars": 312,
"preview": "// Re-export server-related functionality for users who want MCP server capabilities\nexport { createServer } from \"./mcp"
},
{
"path": "src/server.ts",
"chars": 7857,
"preview": "import { randomUUID } from \"node:crypto\";\nimport express, { type Request, type Response } from \"express\";\nimport { SSESe"
},
{
"path": "src/services/figma.ts",
"chars": 10097,
"preview": "import type {\n GetImagesResponse,\n GetFileResponse,\n GetFileNodesResponse,\n GetImageFillsResponse,\n Transform,\n} fr"
},
{
"path": "src/tests/benchmark.test.ts",
"chars": 359,
"preview": "import yaml from \"js-yaml\";\n\ndescribe(\"Benchmarks\", () => {\n const data = {\n name: \"John Doe\",\n age: 30,\n emai"
},
{
"path": "src/tests/image-processing.test.ts",
"chars": 2373,
"preview": "import path from \"path\";\nimport os from \"os\";\nimport fs from \"fs\";\nimport { Jimp } from \"jimp\";\nimport { getImageDimensi"
},
{
"path": "src/tests/integration.test.ts",
"chars": 1851,
"preview": "import { createServer } from \"../mcp/index.js\";\nimport { config } from \"dotenv\";\nimport { InMemoryTransport } from \"@mod"
},
{
"path": "src/tests/layout-alignment.test.ts",
"chars": 5663,
"preview": "import { describe, test, expect } from \"vitest\";\nimport { buildSimplifiedLayout } from \"~/transformers/layout.js\";\nimpor"
},
{
"path": "src/tests/path-validation.test.ts",
"chars": 2582,
"preview": "import path from \"path\";\nimport { describe, expect, it } from \"vitest\";\nimport { downloadFigmaImagesTool } from \"~/mcp/t"
},
{
"path": "src/tests/server.test.ts",
"chars": 8930,
"preview": "import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@mode"
},
{
"path": "src/tests/stdio.test.ts",
"chars": 911,
"preview": "import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StdioClientTransport } from \"@modelcontextp"
},
{
"path": "src/transformers/component.ts",
"chars": 1381,
"preview": "import type { Component, ComponentPropertyType, ComponentSet } from \"@figma/rest-api-spec\";\n\nexport interface ComponentP"
},
{
"path": "src/transformers/effects.ts",
"chars": 2187,
"preview": "import type {\n DropShadowEffect,\n InnerShadowEffect,\n BlurEffect,\n Node as FigmaDocumentNode,\n} from \"@figma/rest-ap"
},
{
"path": "src/transformers/layout.ts",
"chars": 7605,
"preview": "import { isInAutoLayoutFlow, isFrame, isLayout, isRectangle } from \"~/utils/identity.js\";\nimport type {\n Node as FigmaD"
},
{
"path": "src/transformers/style.ts",
"chars": 23295,
"preview": "import type {\n Node as FigmaDocumentNode,\n Paint,\n Vector,\n RGBA,\n Transform,\n} from \"@figma/rest-api-spec\";\nimport"
},
{
"path": "src/transformers/text.ts",
"chars": 1802,
"preview": "import type { Node as FigmaDocumentNode } from \"@figma/rest-api-spec\";\nimport { hasValue, isTruthy } from \"~/utils/ident"
},
{
"path": "src/utils/common.ts",
"chars": 6118,
"preview": "import fs from \"fs\";\nimport path from \"path\";\n\nexport type StyleId = `${string}_${string}` & { __brand: \"StyleId\" };\n\n/*"
},
{
"path": "src/utils/fetch-with-retry.ts",
"chars": 3928,
"preview": "import { execFile } from \"child_process\";\nimport { promisify } from \"util\";\nimport { Logger } from \"./logger.js\";\n\nconst"
},
{
"path": "src/utils/identity.ts",
"chars": 2827,
"preview": "import type {\n Rectangle,\n HasLayoutTrait,\n StrokeWeights,\n HasFramePropertiesTrait,\n} from \"@figma/rest-api-spec\";\n"
},
{
"path": "src/utils/image-processing.ts",
"chars": 6931,
"preview": "import { Jimp } from \"jimp\";\nimport type { Transform } from \"@figma/rest-api-spec\";\n\n/**\n * Apply crop transform to an i"
},
{
"path": "src/utils/logger.ts",
"chars": 1272,
"preview": "import fs from \"fs\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- logging accepts arbitrary values */\nexport"
},
{
"path": "tsconfig.json",
"chars": 677,
"preview": "{\n \"compilerOptions\": {\n \"baseUrl\": \"./\",\n \"rootDir\": \"src\",\n \"paths\": {\n \"~/*\": [\"./src/*\"]\n },\n\n "
},
{
"path": "tsup.config.ts",
"chars": 544,
"preview": "import { defineConfig } from \"tsup\";\n\nconst isDev = process.env.npm_lifecycle_event === \"dev\";\nconst packageVersion = pr"
},
{
"path": "vitest.config.ts",
"chars": 246,
"preview": "import { defineConfig } from \"vitest/config\";\nimport path from \"path\";\n\nexport default defineConfig({\n resolve: {\n a"
}
]
About this extraction
This page contains the full source code of the GLips/Figma-Context-MCP GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 57 files (189.1 KB), approximately 50.3k tokens, and a symbol index with 127 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.