open-pencil-headless-mcp
by salvadorsru
README.md
# OpenPencil headless MCP
Inspect and export OpenPencil `.fig` and `.pen` files from any MCP client
without opening OpenPencil Desktop and without installing the `openpencil` CLI.
The server embeds the engine from
[salvadorsru/open-pencil](https://github.com/salvadorsru/open-pencil) in
`dist/engine.mjs`. `npx` only installs Node dependencies from the public npm
registry (`@modelcontextprotocol/server`, `zod`, `canvaskit-wasm`, `css-tree`).
No npm login. No GitHub Packages.
This is not [`@open-pencil/mcp`](https://www.npmjs.com/package/@open-pencil/mcp).
That package is a bridge to a running desktop app. This one reads files on disk.
## Requirements
- Node.js 20+ (`npx` comes with npm)
Raster export (`png`, `jpg`, `webp`, `pdf`) downloads `canvaskit-wasm` the first
time `npx` runs. Nothing else is required on `PATH`.
## Install
From the project root:
```sh
npx -y --prefer-online github:salvadorsru/open-pencil-headless-mcp#main -- install
```
`--` keeps `install` as an argument to this package, not to npm. The command
detects the MCP client and writes the right file in that client's JSON shape:
| Detects | Writes | Shape |
| --- | --- | --- |
| Cursor (env or `.cursor/`) | `.cursor/mcp.json` | `mcpServers` |
| VS Code (env or `.vscode/`) | `.vscode/mcp.json` | `servers` + `type: stdio` |
| Claude Code (env or `.mcp.json`) | `.mcp.json` | `mcpServers` |
| Claude Desktop | OS user config | `mcpServers` |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
If nothing matches, it prints the snippet and does not write. If several
project clients are present and the host is unclear, it also prints instead of
guessing. Override with `--client cursor|vscode|claude|claude-code|windsurf`
or `--out FILE`.
Point the sandbox at another folder with `--root`:
```sh
npx -y --prefer-online github:salvadorsru/open-pencil-headless-mcp#main -- install --root /absolute/path/to/your/designs
```
Give each project its own root. A single user-level config can only see one
designs folder. Restart the MCP server in your client afterwards.
The server key is `pencil`. MCP `instructions` tell the client to use these
tools when the user asks to consult Figma or a `.fig`, before a Figma
cloud/API/desktop MCP, unless they paste a `figma.com` URL.
The client starts the server with `npx`. `-y` skips the npm prompt.
`--prefer-online` refreshes `main` without deleting the npx cache. Progress
goes to stderr (`starting`, `engine loaded`, `ready`, plus the package
version).
### Local clone
Skip `npx` and run the repo you already have:
```json
{
"mcpServers": {
"pencil": {
"command": "node",
"args": ["/absolute/path/to/open-pencil-headless-mcp/server.mjs"],
"env": {
"OPENPENCIL_MCP_ROOT": "/absolute/path/to/your/designs"
}
}
}
}
```
## Paths
`OPENPENCIL_MCP_ROOT` is the only directory the tools can read or write. Point it
at the folder that contains your documents. Tool arguments are **relative** to
that root:
| Root | `file` argument |
| --- | --- |
| `/absolute/path/to/your/designs` | `project/file.fig` |
| `/absolute/path/to/your/designs/project` | `file.fig` |
Absolute paths or `../` that escape the root are rejected.
## Inspect cache
The first inspect of a `.fig` still parses the file (~2 s for a large document).
After that, the server writes an index under the designs root:
```
your-designs/.cache/pencil/project/file.fig.json
```
That folder is **your designs root** (`OPENPENCIL_MCP_ROOT`), not the `npx` cache.
Anyone using `npx` with the same root reuses it.
The index (v3) is for **navigation**: `TEXT` plus named frames, components, instances, groups, and sections. Generic `Frame 2147…` / `Container` / vectors are omitted from find/tree.
- `pencil_find`, `pencil_tree`, `pencil_pages`, `pencil_info` read the index.
- `pencil_node` and `pencil_section` use the live graph for **style**. After `ready`, the server warms the graph in the background so the first style query is usually already in memory. If you query before that finishes, only the requested page is populated.
- Changing the `.fig` (new mtime) or an older index version rebuilds the cache.
- Export, lint, convert, XPath, and analyze always parse the `.fig`.
Delete `.cache/pencil` to force a rebuild.
## Tools
Responses are JSON text. File-scoped tools take `file` relative to the root.
These match the headless CLI. App-only commands (`documents`, `selection`,
`eval`) are omitted: they need OpenPencil Desktop.
### `pencil_info`
Document metadata: page count, node counts by type, fonts.
```
file: project/file.fig
```
### `pencil_pages`
Page list with node counts.
```
file: project/file.fig
```
### `pencil_tree`
Node tree. Optional `page` (name) and `depth`.
```
file: project/file.fig
page: Home
depth: 2
```
Omitting `page` uses the first page. Large `.fig` files can be heavy without
`depth`.
### `pencil_query`
XPath over the document. Optional `page` and `limit` (max 10000). Returns id,
name, type, and box. Use `pencil_node` for text and styles.
```
file: project/file.fig
selector: //SECTION[@name='Hero']
page: Home
```
Useful selectors:
```
//FRAME[@name='Header']
//COMPONENT[contains(@name,'Button')]
//TEXT[contains(@name,'Title')]
//*[@name='Hero']
```
Layer names must match the document exactly.
### `pencil_find`
Find nodes by partial `name` and/or `type` (`FRAME`, `TEXT`, `COMPONENT`, …).
Optional `page` and `limit`.
```
file: project/file.fig
name: Hero
type: FRAME
```
### `pencil_section`
One-shot inspect. Resolves a named block and returns path, descendant copy,
child names, and **style** (fills, padding, gap, layout) from the live graph.
Prefer this over `find` + `node` + `tree`.
```
file: project/file.fig
name: Hero
page: Mobile
within: Homepage
```
`page` and `within` are substrings. If several layers share the name, frames and
components win over text.
### `pencil_node`
Full properties for one node from the live graph: fills, strokes, padding,
gap (`itemSpacing`), layout, type, parent. Works for any id in the document,
including layers omitted from the content index.
```
file: project/file.fig
id: 12:34
```
### `pencil_variables`
Design variables and collections. Optional `collection` and `type`
(`COLOR`, `FLOAT`, `STRING`, `BOOLEAN`).
### `pencil_fonts`
Fonts used in the document and whether they resolve.
### `pencil_analyze`
`kind`: `colors`, `typography`, `spacing`, `clusters`, `overlaps`.
```
file: project/file.fig
kind: colors
similar: true
```
### `pencil_formats`
Supported read / write / export formats. No `file`.
### `pencil_lint`
Quality and accessibility rules. Optional `preset`: `recommended` (default),
`strict`, `accessibility`.
```
file: project/file.fig
preset: recommended
```
### `pencil_export`
Write a derived file **inside the root**.
| Argument | Notes |
| --- | --- |
| `file` | Source `.fig` / `.pen` |
| `format` | `png`, `jpg`, `webp`, `svg`, `pdf`, `pptx`, `jsx`, `html`, `fig` |
| `output` | Relative destination, e.g. `project/exports/hero.png` |
| `page` | Optional page name |
| `scale` | Optional, raster only |
```
file: project/file.fig
format: png
output: project/exports/hero.png
page: Home
scale: 2
```
Missing fonts on raster/PDF are reported as a warning in the JSON (`warn`
policy). HTML writes a fragment plus any sidecar assets next to `output`.
### `pencil_convert`
Write a `.fig` copy.
```
file: project/file.fig
output: project/exports/file.fig
```
## Environment
| Variable | Default | When it applies |
| --- | --- | --- |
| `OPENPENCIL_MCP_ROOT` | process working directory | Runtime. Sandbox for every tool. |
| `OPENPENCIL_SRC` | `../open-pencil` | `bun run bundle` only. Path to the fork checkout. |
| `OPENPENCIL_SMOKE_FILE` | first existing candidate | `bun run test` only. |
There is no `OPENPENCIL_CLI`.
## Develop
The published `npx` payload is `server.mjs` + `dist/engine.mjs`. The source of
the engine is `engine.mjs`; it is bundled from a sibling clone of the fork.
```sh
git clone https://github.com/salvadorsru/open-pencil-headless-mcp.git
git clone https://github.com/salvadorsru/open-pencil.git # sibling directory
cd open-pencil && bun install
cd ../open-pencil-headless-mcp && bun install
bun run bundle
bun run test
```
`bun run test` runs `info` on `OPENPENCIL_SMOKE_FILE` if set, otherwise the
first existing local `.fig` or `.pen` fixture it finds.
After changing the fork, regenerate and commit `dist/engine.mjs` so GitHub `npx`
picks it up:
```sh
bun run bundle
bun run test
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues