{
  "markdown": "# Figsnap MCP\n\n[![CI](https://github.com/arimunandar/figsnap-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/arimunandar/figsnap-mcp/actions/workflows/ci.yml)\n[![npm](https://img.shields.io/npm/v/figsnap-mcp)](https://www.npmjs.com/package/figsnap-mcp)\n[![node](https://img.shields.io/node/v/figsnap-mcp)](https://www.npmjs.com/package/figsnap-mcp)\n[![licence](https://img.shields.io/npm/l/figsnap-mcp)](./LICENSE)\n\nThe Figma file you have open, as MCP tools — and nothing else.\n\n[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_figsnap--mcp-0098FF?logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=figsnap-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22figsnap-mcp%22%5D%7D)\n[![Install in Cursor](https://img.shields.io/badge/Cursor-Install_figsnap--mcp-000000?logo=cursor&logoColor=white)](cursor://anysphere.cursor-deeplink/mcp/install?name=figsnap-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImZpZ3NuYXAtbWNwIl19)\n\n```bash\nclaude mcp add figsnap-mcp -s user -- npx -y figsnap-mcp\n```\n\nThose install the MCP server. It answers nothing until the daemon is running and\nthe Figma plugin is open — see [Getting it running](#getting-it-running), which\nis five steps and takes about two minutes.\n\nA Figma plugin, a local daemon, and an MCP server. Your coding agent reads the\ndesign, extracts a node as PNG, React, HTML or CSS, edits the canvas when you\nallow it, and shares a saved set of components with you. No relay, no account, no\nnetwork beyond loopback.\n\n```\n  MCP client              figsnap-mcp              daemon                 the plugin\n  (Claude Code)   stdio    (stateless      HTTP :3058       ws         (open in Figma)\n       ─────────────────►   proxy)          ──────────►   /panel   ──────────►   figma.*\n                                            127.0.0.1 only\n```\n\nThree processes and one rule: `figma.*` exists only while the plugin is open, so\nthe plugin holds the connection and everything else asks questions down it.\n\n## What this is not\n\nThis repo is a derivative of [Figsnap](../Figsnap), cut down to one job. Figsnap\nships four products from one `package.json` — the plugin, a Cloudflare Worker\nrelay with accounts, an ACP chat client, and this MCP server. If all you want is\nMCP tools and a saved set, you had to clone all of it, build a 4,200-line panel\nyou would never open, and load a plugin whose manifest whitelists a Worker you\nwould never sign in to.\n\nSo: **no ACP client, no harness discovery, no session store, no relay, no\naccounts, no chat, no API browser, no code-export UI.** What is left is the\nextraction engine, the 41 tools, and a panel with three panes.\n\nFigsnap is not modified by any of this, and the two can run side by side — this\ndaemon is on port **3058** with its token in `~/.figsnap-mcp/agent-token`, where\nFigsnap's is on 3056 with its token in `~/.figsnap/agent-token`.\n\nOne caution on that manifest: Figma validates `allowedDomains` and refuses an IP\nliteral — `Invalid value for allowedDomains. 'ws://127.0.0.1:3058' must be a\nvalid URL` — and a manifest it refuses is a manifest it does not load, so the\nplugin quietly keeps running the last good one. Name `localhost`, and check the\nconsole after any change here, because nothing else reports it.\n\nThey are kept apart in Figma too. `clientStorage` is keyed by plugin id, so the\nmanifest carries an id of its own rather than Figsnap's `REPLACE_ON_PUBLISH`, and\nevery key this plugin writes is prefixed `figsnap-mcp:` on top of that. Sharing\neither one means sharing the stored daemon address, and a panel that inherits\n`ws://localhost:3056` is then blocked by its own manifest's CSP — with a console\nerror the designer never sees. The panel also refuses any stored or typed address\noutside the port its manifest allows, and drops the token that came with it.\n\n## Install\n\n```bash\nnpm install -g figsnap-mcp\n```\n\nTwo commands come with it:\n\n| | |\n|---|---|\n| `figsnap-mcp-daemon` | the bridge; leave it running while you work |\n| `figsnap-mcp` | the MCP server, spawned by your client — not run by hand |\n\nYou can skip the install and let `npx` fetch it, which is what an MCP client\nconfig usually does. The Figma plugin ships in the package too: after a global\ninstall its manifest is at\n\n```bash\nnpm root -g   # …/lib/node_modules — the manifest is figsnap-mcp/manifest.json\n```\n\nOr clone the repository and `npm install && npm run build`, which is the same\nthing with the sources beside it.\n\n## Getting it running\n\nFive steps, once.\n\n**1. Build it.**\n\n```bash\nnpm install -g figsnap-mcp\n```\n\nFrom a clone instead: `npm install && npm run build`.\n\n**2. Start the daemon.** Leave it running; it is the only thing that talks to\nFigma.\n\n```bash\nfigsnap-mcp-daemon                # --allow-edits opens the writing tools at boot\n```\n\nFrom a clone: `npm run daemon`. `figsnap-mcp-daemon --help` lists the rest.\n\nIt prints its address and its token:\n\n```\nfigsnap-mcp-daemon 0.1.0\n  panel socket   ws://127.0.0.1:3058/panel\n  http           http://127.0.0.1:3058\n  token          8fT2qN4vRk1pXwLzYc7BhJ0mAeUdSg9T\n  edits          off — turn them on in the plugin, or start with --allow-edits\n```\n\n**3. Load the plugin.** In Figma desktop: **Plugins → Development → Import\nplugin from manifest**, pick the package's `manifest.json` (`npm root -g` finds\nit, or it is in the repository root), then run **Figsnap MCP**.\n\n**4. Pair the panel.** Copy the `token` line from step 2 into the panel's\n**Connect** pane and press **Connect**. The dot turns green and the Address\nreads `ws://localhost:3058/panel`.\n\nThis is the only place a token is ever typed. It is stored in Figma's own\nper-user storage, so the panel reconnects itself every time from now on.\n\n**5. Register the MCP server.**\n\n```bash\nclaude mcp add figsnap-mcp -s user -- npx -y figsnap-mcp\n```\n\nRestart the client, then `claude mcp list` shows `figsnap-mcp · ✔ Connected`.\nFrom a clone, name the file instead: `-- node /path/to/FigsnapMCP/agent/mcp-stdio.mjs`.\n\nCheck the whole chain:\n\n```bash\ncurl -s http://127.0.0.1:3058/health\n# { \"ok\": true, \"panelConnected\": true, \"editsAllowed\": false, ... }\n```\n\n`panelConnected: true` is the line that matters — it means Figma is on the\nother end.\n\n## The token\n\n**An MCP client never needs it.** This is the part that surprises people:\n`claude mcp add` takes no token, no environment variable, no config. The MCP\nserver reads the daemon's own file on the way past.\n\nOnly two things use the token, and only one of them is you:\n\n| Who | How it gets it |\n|---|---|\n| The Figma panel | You paste it, once, in **Connect** |\n| `agent/mcp-stdio.mjs` | Reads `~/.figsnap-mcp/agent-token` by itself |\n\n**Where it comes from.** The first time the daemon starts it makes one — 24\nrandom bytes, base64url — and writes it to `~/.figsnap-mcp/agent-token` with\nmode `600`. Every later start reads that same file back, so the token is stable\nand the panel is not re-paired every morning.\n\n**How to see it again** without restarting anything:\n\n```bash\ncat ~/.figsnap-mcp/agent-token\n```\n\n**Why there is one at all.** The daemon listens on a loopback port, and any web\npage you happen to visit can open a socket to `localhost`. Two things stop it:\nthe `Origin` header, checked on upgrade, which a browser cannot forge; and this\ntoken, because a browser WebSocket cannot set headers. Only `GET /health` is\nreachable without it — so the panel can tell you the daemon is running before it\nhas been paired.\n\n**If it leaks**, rotate it:\n\n```bash\nnpm run daemon -- --new-token\n```\n\nThat writes a fresh one and invalidates the old. Re-paste it in **Connect**; MCP\nclients pick the new one up on their own, because they read the file.\n\n**To use one of your own** — a fixed token in a script, say — set\n`FIGSNAP_MCP_TOKEN` and the daemon uses it instead of the file. Set the same\nvariable for the MCP client if it cannot read your home directory.\n`FIGSNAP_MCP_URL` moves the address the client dials.\n\n## Wiring up an MCP client\n\n```bash\nclaude mcp add figsnap-mcp -s user -- npx -y figsnap-mcp\n```\n\nFor a client that takes JSON:\n\n```json\n{ \"mcpServers\": { \"figsnap-mcp\": { \"command\": \"npx\", \"args\": [\"-y\", \"figsnap-mcp\"] } } }\n```\n\n`figsnap-mcp-daemon --mcp` prints both, with paths already filled in.\n\nThen `claude mcp list` should show `figsnap-mcp · ✔ Connected`, and in a session\n`figma_get_selection` answers about whatever is selected on the canvas.\n\n### When it does not answer\n\nThree things can be wrong, and each says so differently:\n\n| What the tool says | What to do |\n|---|---|\n| `No figsnap-mcp daemon at http://127.0.0.1:3058` | `npm run daemon` |\n| `The Figsnap MCP plugin is not open in Figma` | Open the file and run the plugin |\n| `The figsnap-mcp daemon rejected the token` | `cat ~/.figsnap-mcp/agent-token`, or set `FIGSNAP_MCP_TOKEN` |\n| `Editing the file is switched off` | Turn on **Allow edits** in the plugin's Tools pane |\n\n### Resources\n\nThree things a question about a Figma file almost always needs are addressable\nrather than called for, so a client can `@`-mention them:\n\n| URI | What it is |\n|---|---|\n| `figma://selection` | Everything selected, extracted |\n| `figma://page` | The layer tree, three levels deep |\n| `figma://library` | Components, styles and variables, with ids |\n| `figma://node/{nodeId}` | One layer — `figma://node/21:10314` |\n\n## The panel\n\nFour panes, and the one it opens on is **Selection**.\n\n**Selection** is a preview of whatever you have picked on the canvas — the\npicture, its name, type, size, child count and node id — with a folder picker\nand a **Save** button beside it. Saving from here is the short way round: no\nswitching panes, no hunting for the layer again in a list.\n\nThe preview costs one PNG export. The panel used to run a full extraction on\nevery selection change — HTML, TSX, two stylesheets and a 2× image — for a\npicture nobody was looking at; the code outputs are what MCP asks for, on\nrequest, not what a click costs. A small layer is magnified at most 4×, so an\nicon and a screen do not both fill the stage.\n\n**Minimise** (the ▼ at the top right) drops the window to a 44px strip and the\npreview under it, so the canvas is clear while the plugin keeps running — the\ndaemon needs the panel open, but you do not need to look at it. The strip still\nnames what is selected and still has **Save**, because those are the two things\nworth having while you work. ▲ puts it back to whatever size you had dragged it\nto.\n\n**Connect** and **Tools** are pairing and the tool list; **Saved** is the folder\nmanager. Both are described below.\n\n## The tools\n\n41 of them: 14 read Figma, 26 write to it, and one is answered in the daemon.\nThe **Tools** pane lists them all with the writing ones marked; `GET /tools` is\nthe same list as JSON.\n\nReading is always allowed. **Writing is not, until you say so** — every mutating\ntool is refused until *Allow edits* is on, which is a switch the designer holds\nrather than a prompt the agent can talk past. A harness running with permission\nprompts disabled still cannot get past it. `--allow-edits` opens the same gate\nfrom the terminal, for when the work is happening there.\n\nTwo tools fold thirteen plugin commands into one argument each, because 41 tool\ndescriptions already cost real context on every request:\n\n- `figma_extract` takes `nodeId`, or `nodeIds`, `urls`, `selection: true`,\n  `saved: true` for a batch.\n- `figma_saved` takes an `action`: `list`, `folders`, `save`, `unsave`, `clear`,\n  `move`, `newFolder`, `renameFolder`, `deleteFolder`.\n\nA picture comes back as a real image block, never as base64 in a text field —\nthat is what `figma_export_png` is for, one node at a time.\n\n`figma_ios_spec` answers with the properties Figma actually holds rather than\nwith CSS, in points: per-axis sizing, auto layout and its padding, stroke weight\nand alignment, corner smoothing, effects, the text block — and every fill and\nstroke marked `painted` or not. Reach for it when the answer becomes code.\n`figmaCss` describes what a node would look like on the web, which drops what an\nimplementation needs and also reports paint that is never painted: a stroke\nswitched off still comes back as `border: 1px solid`. A stroke here carries both\n`layoutFrame` and `visualFrame`, because a CENTER stroke puts half its weight\noutside the bounds while UIKit draws borders inside them, and an effect carries\n`uikitShadowRadius`, which is half the blur.\n\nOne limit: a gradient paint reports its type, opacity and `painted` flag but not\nits stops, so you learn that a gradient is there without being able to reproduce\nit. Read those colours off `figma_export_png` for now.\n\n`figsnap_skill` is the odd one out: the plugin has no part in it, so it answers\nwith Figma closed. `check` says whether this project has the skill this package\nships — `missing`, `current`, or `modified` if you have edited your copy, which\nis yours and so is reported rather than replaced. `install` hands back the file\nand where it goes instead of writing it, because whatever is calling already\nasks you before it writes into your repository. Which project it means comes\nfrom the MCP client's own directory, which `mcp-stdio.mjs` names on every call —\nthe daemon's is wherever you started it, and usually not where you are working.\n\n## The saved set\n\nThe **Saved** pane is a place to keep the components you keep coming back to:\nfolders, *Save selection*, jump-to-node, move, remove. It is reachable over MCP\nas `figma_list_saved` and `figma_saved`, so you and the agent are looking at the\nsame list.\n\nIt lives in `figma.clientStorage`, keyed by document id. That means: **per user,\nper file, per machine.** Up to 100 entries and 30 folders, one level deep.\n\n**One deliberate loss.** Figsnap also mirrors this set to its relay, so it\nfollows you to a second machine. Without the Worker, these sets are local only.\nThat is the right trade for this repo — no account, no network, and always\nwritable even in a file you can only view — but it is a real difference. If\ncross-device sync is wanted later it is an additive change: the plugin would gain\na sync target, not a new owner of the data.\n\n## Layout\n\n```\nindex.mjs              the library entry point; importing it starts nothing\nindex.d.mts            hand-written types for it\nmanifest.json          the Figma plugin manifest; localhost:3058 only\nbuild.mjs              esbuild → dist/code.js + a self-contained dist/ui.html\nshared/                nodes.mjs (findable types), shape.mjs (what a body means)\nagent/\n  index.mjs            the daemon: WS server, HTTP server, the Edits gate\n  mcp-stdio.mjs        the MCP server; a stateless proxy to the daemon\n  lib/tools.mjs        the 41 tools — one command each, no logic in between\n  lib/skill.mjs        the skill on disk: the one tool answered without Figma\n  lib/plugin-socket.mjs  the panel socket: origin check, token, request/response\n  lib/http.mjs         /health, /tools, /tool\n  lib/gate.mjs         the Edits switch\n  lib/paths.mjs        the port, the host and the token file, defined once\nsrc/\n  code.ts              the main thread: 51 commands, extraction and codegen\n  figma-css.ts         Figma's own CSS, rendered\n  daemon.ts            the one address the plugin dials\n  ui/                  the panel: bridge.ts, main.ts, index.html, style.css\ntest/                  run.mjs and five suites; see Tests below\n.github/workflows/     CI on Node 20, 22 and 24; publish on a version tag\n```\n\n## Using it as a library\n\nMost people want the two commands. If you are building your own bridge, the\npackage exports the catalogue and the pieces the daemon is assembled from:\n\n```js\nimport { toolManifest, createGate, createPluginSocket, createHttpHandler } from 'figsnap-mcp'\n\nconsole.log(toolManifest().length)  // 39\n```\n\nImporting it starts no server and opens no socket — `agent/mcp-stdio.mjs`\nconnects an MCP server to stdio the moment it loads, so it is deliberately not\nre-exported, and the constants that used to live on it are in\n`agent/lib/paths.mjs`. `index.d.mts` is hand-written and the test suite checks it\nagainst the runtime in both directions.\n\nSemver applies from 1.0.0. While this is 0.x, the factories are the part most\nlikely to move; the catalogue and the constants are the stable half.\n\n## Security\n\nThe daemon binds `127.0.0.1` only, and two things guard the socket, because a\nlocal port is reachable by any page you happen to visit:\n\n- **Origin**, checked on upgrade. A plugin iframe is a sandboxed document and\n  sends `null`; the editor sends figma.com. Anything else is closed with 4001.\n  A browser cannot forge this header, and CORS does not apply to an upgrade, so\n  it is the check that matters.\n- **A token** in the query string, because a browser WebSocket cannot set\n  headers. It is the same one HTTP callers send as `x-figsnap-token`, and only\n  `/health` is reachable without it — so the panel can probe before it is paired.\n\n`--new-token` rotates it if one ever leaks.\n\n## Tests\n\n```bash\nnpm test          # four suites: no wrangler, no network, no Figma\nnpm run typecheck\n```\n\n- `e2e-plugin.mjs` runs the shipped `dist/code.js` against a fake `figma`, wired\n  to a real daemon, and drives it through `POST /tool` — extraction fidelity,\n  `figma_find_nodes`, the saved set including a reload, and a write with the gate\n  both shut and open.\n- `e2e-mcp.mjs` spawns the daemon, fakes the panel as a WebSocket client, and\n  drives a real MCP client over stdio: the guards, the tool list, the batch and\n  image rules, all ten saved-set commands, the resources, and the three ways a\n  call can fail before it reaches Figma.\n- `e2e-panel.mjs` loads the shipped `dist/ui.html` into jsdom with the main\n  thread and the daemon replaced, and drives the designer's side: the panes\n  render what they are sent, the clicks mean what they say, and a destructive\n  folder action arms before it fires.\n- `contract.mjs` is the drift guard. `shared/`, `agent/lib/tools.mjs` and\n  `src/code.ts` exist in both this repo and Figsnap, and the protocol between\n  them has no shared type. So it asserts what a one-sided edit would break: every\n  command a tool can name is a case in `src/code.ts` and every case is reachable\n  from a tool, `MAX_BATCH` agrees, the caps the panel prints are the caps the\n  plugin enforces, the `find_nodes` schema offers exactly `FINDABLE_TYPES`, all\n  three files agree on 3058, and nothing has quietly imported the relay, the\n  accounts or the ACP client back in.\n\n## Publishing the plugin to your organisation\n\nThe plugin can stay a development install — import the manifest, done — but\npublishing it to your Figma **organisation** puts it in everyone's plugin list\nand updates them automatically, without it appearing in Community.\n\nThat option needs a Figma **Organization or Enterprise** plan. On Professional\nthe only published option is Community, which is public; a development install\nis the private route there.\n\n1. Build first: `npm run build`. Figma publishes what `dist/` holds, not what\n   `src/` says.\n2. Figma desktop → **Plugins → Development → Figsnap MCP → Publish**.\n3. Choose **Only <your organisation>** rather than Community.\n4. Fill in the listing: a 128×128 icon, a description, and a cover image. An\n   org-only publish skips Community review, so it is live once you submit.\n\n**Figma writes a plugin id into `manifest.json` on that first publish**,\nreplacing `REPLACE_ON_PUBLISH_FIGSNAP_MCP`. Commit that change — it is what\nidentifies later versions as updates rather than a new plugin, and it is also\nwhat keeps this plugin's `clientStorage` separate from Figsnap's. To release an\nupdate, build again and publish again from the same menu.\n\nOne thing worth being clear about: this repository and the npm package are\npublic, and both carry the built plugin. Org-only publishing controls *listing\nand distribution inside Figma*, not who can obtain the code — it is MIT either\nway.\n\n## Releasing\n\nCI runs the suites on Node 20, 22 and 24 for every push.\n\n**The first release is manual**, because npm configures a trusted publisher on a\npackage's own settings page and there is no page until the package exists:\n\n```bash\nnpm login\nnpm publish --access public\n```\n\n**Then turn on trusted publishing**, once, at\n`npmjs.com/package/figsnap-mcp/access` → Trusted Publisher → GitHub Actions:\n\n| Field | Value |\n|---|---|\n| Organization or user | `arimunandar` |\n| Repository | `figsnap-mcp` |\n| Workflow filename | `publish.yml` |\n| Environment name | leave empty |\n\n**Every release after that is a tag:**\n\n```bash\nnpm version patch          # writes package.json and the v0.1.1 tag\ngit push --follow-tags\n```\n\n`npm version` also runs `scripts/sync-server-json.mjs`, which copies the new\nversion into `server.json`'s two version fields and stages it, so all three end\nup in the same commit. The workflow refuses a release where they disagree —\nwhich is how 0.2.0 was caught before it reached the registry.\n\n`.github/workflows/publish.yml` picks the tag up, refuses it if it disagrees with\n`package.json`, and publishes with provenance over OIDC — no token exists to\nleak, which matters because npm is restricting tokens that bypass 2FA (account\nchanges August 2026, direct publishing January 2027).\n\n`prepublishOnly` typechecks, builds and runs every suite first, so a release that\nwould not have worked cannot reach the registry. `npm pack --dry-run` shows\nexactly what would be sent; `test/e2e-package.mjs` asserts those contents.\n\n## Licence\n\nMIT.\n",
  "bytes": 21493,
  "sha": "c97fb3c4662a8000cb1ba06ff42b73401afe64043060b70c6a242385e83bbde4",
  "repo_slug": "arimunandar/figsnap-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_arimunandar_figsnap_mcp_2ac73f1e/readme"
}