Skip to main content
Glama
tvolodi

penpot-headless

by tvolodi
README.md
# penpot-headless

A portable MCP server for headless Penpot project/file/content management,
using Penpot's RPC API directly. No browser, no Penpot plugin session.

This package has **zero project-specific knowledge** — no hardcoded colors,
fonts, or Penpot instance URL. Every consuming project supplies its own
config and token file, so it can be installed from npm or copied as-is into
another project.

## What this does

- Team/project/file/page CRUD (`penpot_list_teams`, `penpot_create_file`, etc.)
- Shape creation — `rect`, `frame`, `text`, `circle` (ellipse), `path`, `bool` (boolean
  operations), and `image` — via `penpot_add_shapes`, with
  colors/fonts resolved either from a literal value or a
  `{ "token": "name" }` reference against your project's token file. The
  same token-or-literal pattern applies to spacing (layout gaps/padding,
  layoutItem margins/min/max sizes), corner radii (`r1`-`r4`), and shadows
  (`shadows`, either an inline `{ style, color, offsetX, offsetY, blur,
  spread, opacity }` object or `{ "token": "name" }`). Shapes may be rotated
  via an optional `rotation` field (degrees, clockwise, about the shape's
  center).
- Shape editing: `penpot_update_shapes` changes an existing shape in place
  by id — position, size, rotation, name, fill, stroke, shadows, corner
  radii, or (for text) content/font. Only the fields you pass are touched;
  everything else (children, component/variant tags, layout) is left as-is.
  `clearStroke`/`clearShadows` remove strokes/shadows entirely. Geometry
  changes automatically recompute the shape's selection box and transform,
  so a partial edit (e.g. just `width`) can't desync from the rest of the
  shape's geometry. Layout/layoutItem aren't editable this way — only
  settable at creation time via `penpot_add_shapes`.
- Shape deletion: `penpot_delete_shapes` removes one or more existing
  shapes from a page by id. Deleting a frame or group also removes its
  children, matching Penpot's own delete behavior.
- Shape grouping: `penpot_group_shapes` wraps one or more sibling shapes into
  a new group (Ctrl+G equivalent) — all shapes must share the same parent.
  The group is inserted at the topmost selected shape's z-order position and
  its bounding box is computed from the children's rotation-aware `selrect`s.
  `penpot_ungroup_shapes` dissolves a group (Ctrl+Shift+G equivalent):
  children are reparented to the group's former parent at the group's
  z-order slot, then the group is deleted.
- Shape duplication: `penpot_clone_shapes` duplicates one or more existing
  shapes (and, for frames/groups, their full descendant subtree), each with
  fresh ids — plain shape duplication, like Penpot's own Ctrl+D, not a
  component instance. Optional `dx`/`dy` offset each clone from its source
  (defaults to no offset, stacked directly on top of the original); optional
  `parentId`/`frameId` reparent every cloned root instead of leaving it
  alongside its source.
- Shape stacking order: `penpot_reorder_shapes` changes a shape's z-order
  among its siblings, matching Penpot's own "Bring to front" / "Send to
  back" / "Forward" / "Backward" UI actions. `action` is `front`/`back`
  (top/bottom of the stack), `forward`/`backward` (swap with the next/
  previous sibling — a no-op if already at that end), or `before`/`after`
  (place immediately relative to another sibling given as `targetId`).
  Only reorders among existing siblings — it doesn't reparent.
- Align & distribute: `penpot_align_shapes` lines up two or more shapes on a
  common edge or center (`left`/`right`/`top`/`bottom`/`center-h`/`center-v`),
  and `penpot_distribute_shapes` spreads three or more shapes so the gaps
  between them are equal (`horizontal`/`vertical`) — matching Penpot's own
  one-click align/distribute actions, instead of the agent computing pixel
  positions itself. Both work on each shape's visible bounding box (its
  `selrect`), so rotated shapes line up by their rendered bounds; align never
  moves the group as a whole, and distribute leaves the two outermost shapes
  fixed. Aligning or distributing a frame/group carries its whole child
  subtree along by the same offset.
- Batched edits: `penpot_batch` applies an ordered list of create/update/
  delete/reorder operations as a single `update-file` change-set — one
  `revn`/`vern` round trip no matter how many shapes are touched, instead of
  one RPC call per shape (and the races on `revn` that come with that). Each
  op takes the same fields as the matching standalone tool (`penpot_add_shapes`
  for `create`, `penpot_update_shapes` for `update`, etc.); a `create` op may
  set an explicit `id` and be referenced as a later op's `parentId`/`frameId`
  or targeted by a later `update`/`delete`/`reorder` in the same call, so a
  whole form or card grid can be built in one round trip.
- Undo point: `penpot_checkpoint` snapshots shapes and returns a `checkpointId`;
  `penpot_restore_checkpoint` undoes everything since — including a wrong
  `penpot_delete_shapes` call, which otherwise has no undo path short of
  Penpot's own UI. Supply `pageId` to scope the snapshot to a single page; omit
  `pageId` for a whole-file checkpoint that covers every page. Works by diffing
  current state against the snapshot and replaying corrective changes as a single
  `update-file` call (recreate what's missing, delete what's new, overwrite what
  changed back to its snapshotted fields), since Penpot's RPC has no "revert to
  revn X" primitive. Checkpoints live in the MCP server's memory (not the Penpot
  file, not disk) and are reusable — restore to the same point as many times as
  you like — until explicitly freed via `penpot_discard_checkpoint` or the server
  process restarts.
- Shape lookup: `penpot_get_shape` fetches a single shape by id, without
  pulling the whole page via `penpot_get_file_snapshot`. By default nests
  the shape's full descendant subtree under `shapes`; `includeDescendants`
  and `maxDepth` control how much of the subtree comes back. Always includes
  a `componentInfo` field: `linkState` (`"linked"` / `"detached"` /
  `"not-an-instance"` / `"main-component-root"`), and — for same-file linked
  instances — a `driftedFields` array listing the camelCase field names that
  differ from the main component's current definition (empty = fully in sync).
- Shape search: `penpot_find_shapes` searches every shape on a page against
  one or more predicates — `type`, `name` (exact match), `nameContains`
  (case-insensitive substring), `textContains` (case-insensitive substring
  against a text shape's rendered characters), `isComponentInstance`, and/or
  `isRoot` — all given filters must match (AND). Omit every filter to list
  the whole page. Returns each match's id/type/name/position/size plus
  `linkState` and (for linked same-file instances) `driftedFields`; use
  `penpot_get_shape` on a match's id for the full subtree and detailed
  `componentInfo`, optionally capped via `limit`.
- Text measurement: `penpot_measure_text` computes the real rendered
  width/height of a string for a given font (family/size/weight), without
  creating or touching any shape — removing the guesswork around `width`/
  `height` when calling `penpot_add_shapes`/`penpot_update_shapes` for text.
  Tries Google Fonts first (by family name, no API key needed); if the family
  isn't there, searches all Penpot teams accessible to the configured token
  for a matching custom/team font, so fonts uploaded directly to your Penpot
  instance are also measurable. Measures real glyph advance widths (plus
  kerning) so the numbers match what Penpot renders. Splits on explicit `\n`;
  pass `maxWidth` to also get word-wrapped line breaks for a fixed-width box.
- Auto-layout: a `frame` may declare flex or grid layout via an optional
  `layout` field (direction, gap, padding, alignment; grid also takes
  row/column track definitions). Any shape may set `layoutItem` to control
  its own placement within an auto-layout parent (sizing, alignment,
  margins, min/max size, and — for grid parents — row/column/span).
- Components: `penpot_create_component` registers a shape tree (same specs
  as `penpot_add_shapes`) as a component's main instance; give shapes
  explicit `id`s to nest them, and the one shape not parented to a sibling
  becomes the root. `penpot_add_component_instance` then places a full
  copy of that tree elsewhere on a page, linked back to the main instance
  via Penpot's `shape-ref` so it's recognized as a proper component copy —
  reusable any number of times.
- Variants: `penpot_create_variant_group` groups two or more components
  (built the same way as `penpot_create_component`, one per variant) into a
  single container tagged with shared property axes (e.g. a "Button" with
  Type=Primary/Secondary). This wires up Penpot's actual variant-swap
  system — confirmed live via the Penpot plugin API, including
  `instance.switchVariant(pos, value)` correctly swapping a placed
  instance between variants. `penpot_add_variant` adds a new variant to
  an already-existing variant group container: accepts the same shape specs
  as one entry in `penpot_create_variant_group`'s `variants` array and the
  `containerId` returned by that tool (or found via `penpot_find_shapes`),
  then appends the new variant's main instance to the container's `shapes`
  array and registers a new component — all in a single `update-file` call.
- `penpot_list_components` enumerates a file's existing components (from its
  `data.components` map), instead of requiring the caller to have created
  them itself in the same session or parse `penpot_get_file_snapshot`'s
  output by hand. Each entry includes the `componentId` (usable with
  `penpot_add_component_instance`), `name`, `mainInstanceId`/
  `mainInstancePage`, and — for variant components — `variantId`/
  `variantProperties`.
- Media upload: `penpot_upload_media` uploads an image or other asset to a Penpot
  file and returns the media object metadata (`id`, `width`, `height`, `mtype`).
  Accepts a local file path (`filePath`), a remote URL (`url` — Penpot's server
  fetches it directly), or base64-encoded bytes (`dataBase64` + `mtype`). The
  returned `id` is used as `mediaId` when creating an `image` shape.
- Comments: `penpot_list_comment_threads` lists all threads for a file;
  `penpot_get_comments` fetches replies inside a thread;
  `penpot_create_comment_thread` pins a new thread to a canvas position
  (x/y, optional `frameId`); `penpot_create_comment` adds a reply;
  `penpot_update_comment` edits a comment's text;
  `penpot_resolve_comment_thread` marks a thread resolved or reopens it;
  `penpot_delete_comment` removes a single reply;
  `penpot_delete_comment_thread` removes a whole thread.
- Version history: `penpot_list_file_snapshots` lists all named snapshots for a
  file; `penpot_create_file_snapshot` saves the current state as a named version
  (equivalent to "Save version" in the Penpot UI, with an optional `label`);
  `penpot_restore_file_snapshot` rolls the live file back to a snapshot (Penpot
  automatically saves a system backup before restoring, so restores are themselves
  undoable); `penpot_rename_file_snapshot` / `penpot_delete_file_snapshot` manage
  user-created versions; `penpot_lock_file_snapshot` / `penpot_unlock_file_snapshot`
  protect a version from accidental deletion; `penpot_get_file_snapshot_data` returns
  the full file content at a snapshot for read-only inspection.
- Reading a file's current shape tree via `penpot_get_file_snapshot`.
- Rendering shapes or pages to PNG/SVG/PDF via `penpot_export_shape` (single shape) and
  `penpot_export_batch` (multiple shapes in one call, returned in the same order, across any
  number of pages — use the `shapes` array for multi-page exports; the `shapeIds` shorthand
  is still available when all shapes share the same page) — requires
  `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD`, `PENPOT_OIDC_USERNAME`/`PENPOT_OIDC_PASSWORD`,
  or `PENPOT_AUTH_TOKEN_COOKIE`, see below — no browser tab
  or Penpot plugin session needed on your end.

## Render/export capability

`penpot_export_shape` renders a single shape (or an entire page, by passing its
root frame's id) to PNG, SVG, or PDF, using Penpot's own server-side exporter —
the same headless-Chromium-backed service Penpot's self-hosted stack
already runs (the `penpotapp/exporter` container). `penpot_export_batch`
accepts either a `shapeIds` array (all shapes on the same `pageId`) or a `shapes`
array where each entry can specify its own `pageId`, `format`, `scale`, and `name`
— enabling whole-file exports across multiple pages in a single API call. All
formats (PNG/SVG/PDF) are supported. No browser automation
happens in this package; it's two HTTP calls (auth, then export) against
your Penpot instance.

This needs a **second, separate auth mode** from the rest of this package:
Penpot's export pipeline (`POST /api/export`) is a distinct microservice
from the `/api/rpc/command/*` surface everything else here uses, and it
authenticates purely via a session cookie — a personal access token in an
`Authorization` header is never read on this path (confirmed by reading
Penpot's exporter source: its `wrap-auth` looks for a literal `auth-token`
cookie and nothing else), independently of `PENPOT_ACCESS_TOKEN`.

Three ways to supply this cookie — configure exactly one:

**Option A — password login** (`PENPOT_LOGIN_EMAIL` + `PENPOT_LOGIN_PASSWORD`):
The server logs in with email/password to obtain the `auth-token` cookie and
caches it for the process lifetime, re-logging-in automatically on expiry.
Best for instances that expose password login. `PENPOT_ACCESS_TOKEN` and
these credentials must belong to **the same Penpot account** (or at least
accounts with access to the same team); mismatched accounts cause a silent
10-second render timeout because the exporter can't distinguish "access
denied" from "shape not visible."

**Option B — headless OIDC/SSO login** (`PENPOT_OIDC_USERNAME` + `PENPOT_OIDC_PASSWORD`):
The server drives the OIDC redirect flow over plain HTTP: it follows
Penpot's OAuth endpoint redirect chain to the identity provider's login page,
parses the HTML login form, submits the credentials, and captures the
resulting `auth-token` cookie — no browser or external tooling needed.
Re-logs in automatically on expiry. Works for form-based IdPs that render a
standard HTML login form (Keycloak, Authentik, Dex, and similar). Does **not**
work for JavaScript-driven login pages (Google, Microsoft, Okta hosted login)
because their login UIs are SPA shells with no server-rendered form.

Set `PENPOT_OIDC_PROVIDER` to the OAuth provider name configured on your
Penpot instance (default: `"oidc"`). Most self-hosted setups use this
default; check your Penpot server flags if login doesn't start.

**Option C — pre-obtained SSO/OIDC cookie** (`PENPOT_AUTH_TOKEN_COOKIE`):
Paste the raw value of the `auth-token` cookie from a browser session in
which you already completed the OIDC/SSO login. The server uses it as-is
and emits a clear error if it expires (since it has no credentials to
re-login with). Works for instances where password login is disabled and
the IdP uses a JavaScript-driven login page (where Option B can't reach).
To obtain the cookie value:
1. Open your Penpot instance in a browser and complete the SSO/OIDC login.
2. Open DevTools → Application → Cookies → your Penpot domain.
3. Copy the value of the `auth-token` cookie (not the whole `Cookie:` header
   — just the token value after `auth-token=`).
4. Set `PENPOT_AUTH_TOKEN_COOKIE` to that value.

If none of the options is configured, `penpot_export_shape` is not registered and
every other tool works exactly as before.

## Setup

1. Generate a Penpot access token: your Penpot instance → Account settings
   → Access tokens. (Self-hosted instances: this section is hidden unless
   the backend/frontend are started with `PENPOT_FLAGS` including
   `enable-access-tokens`.)
2. Create a token file for your project (see `design-tokens/*.tokens.json`
   in the consuming repo for an example — or write your own matching the
   schema below).
3. If you want `penpot_export_shape`, also configure one of:
   - **Password login:** set `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD` to the
     **same account** the access token above belongs to.
   - **Headless OIDC login:** set `PENPOT_OIDC_USERNAME`/`PENPOT_OIDC_PASSWORD` to
     your IdP credentials. Works for form-based IdPs (Keycloak, Authentik, Dex).
     Set `PENPOT_OIDC_PROVIDER` if your provider name differs from the default `"oidc"`.
   - **SSO/OIDC cookie:** set `PENPOT_AUTH_TOKEN_COOKIE` to the raw `auth-token`
     cookie value from a browser session in which you completed the SSO login
     (see "Render/export capability" above). Use this for JavaScript-driven IdP
     login pages where headless OIDC can't reach.

   Skip this step entirely if you don't need rendering.
4. *(Optional)* To persist checkpoints across server restarts, set `PENPOT_CHECKPOINTS_PATH`
   to a local directory path. When set, each `penpot_checkpoint` call writes a JSON file
   to that directory; on startup the server reloads any files already there, so a
   checkpoint survives the MCP server being restarted mid-way through a long edit.
   The directory is created automatically. Without this setting, checkpoints live only
   in process memory (the previous behaviour).
5. Register this server in your MCP client config, e.g. (password-login variant):
   ```json
   {
     "mcpServers": {
       "penpot-headless": {
         "command": "npx",
         "args": ["-y", "@ai-dala/penpot-headless"],
         "env": {
           "PENPOT_BASE_URL": "https://your-penpot-instance.example.com",
           "PENPOT_ACCESS_TOKEN": "your-token-here",
           "PENPOT_TOKENS_PATH": "/path/to/your-project/design-tokens/tokens.json",
           "PENPOT_LOGIN_EMAIL": "same-account-as-the-token-above@example.com",
           "PENPOT_LOGIN_PASSWORD": "that-account's-password"
         }
       }
     }
   }
   ```

   Or, for an SSO/OIDC-only instance with a form-based IdP (headless OIDC login):
   ```json
   {
     "mcpServers": {
       "penpot-headless": {
         "command": "npx",
         "args": ["-y", "@ai-dala/penpot-headless"],
         "env": {
           "PENPOT_BASE_URL": "https://your-penpot-instance.example.com",
           "PENPOT_ACCESS_TOKEN": "your-token-here",
           "PENPOT_TOKENS_PATH": "/path/to/your-project/design-tokens/tokens.json",
           "PENPOT_OIDC_USERNAME": "your-idp-username-or-email",
           "PENPOT_OIDC_PASSWORD": "your-idp-password"
         }
       }
     }
   }
   ```

   Or, for an SSO/OIDC-only instance with a JavaScript-driven IdP login page:
   ```json
   {
     "mcpServers": {
       "penpot-headless": {
         "command": "npx",
         "args": ["-y", "@ai-dala/penpot-headless"],
         "env": {
           "PENPOT_BASE_URL": "https://your-penpot-instance.example.com",
           "PENPOT_ACCESS_TOKEN": "your-token-here",
           "PENPOT_TOKENS_PATH": "/path/to/your-project/design-tokens/tokens.json",
           "PENPOT_AUTH_TOKEN_COOKIE": "the-auth-token-cookie-value-from-your-browser"
         }
       }
     }
   }
   ```

   Or, if working from a local clone instead of the published package:
   ```json
   {
     "mcpServers": {
       "penpot-headless": {
         "command": "npx",
         "args": ["tsx", "/path/to/penpot-mcp/src/server.ts"],
         "env": {
           "PENPOT_BASE_URL": "https://your-penpot-instance.example.com",
           "PENPOT_ACCESS_TOKEN": "your-token-here",
           "PENPOT_TOKENS_PATH": "/path/to/your-project/design-tokens/tokens.json",
           "PENPOT_LOGIN_EMAIL": "same-account-as-the-token-above@example.com",
           "PENPOT_LOGIN_PASSWORD": "that-account's-password"
         }
       }
     }
   }
   ```

## Token file schema

```json
{
  "colors": {
    "accent": "#7AA2FF",
    "bg-surface": "#111726"
  },
  "fonts": {
    "sans": { "family": "Inter", "weights": ["400", "500", "600"] }
  },
  "spacing": {
    "sm": 8,
    "md": 16,
    "lg": 24
  },
  "radii": {
    "sm": 4,
    "md": 8,
    "pill": 999
  },
  "shadows": {
    "card": {
      "style": "drop-shadow",
      "color": "#000000",
      "opacity": 0.3,
      "offsetX": 4,
      "offsetY": 4,
      "blur": 8,
      "spread": 2
    }
  }
}
```

All four token tables are optional except `colors`. Any color-typed field
in `penpot_add_shapes`/`penpot_update_shapes` accepts either a literal hex
string or `{ "token": "accent" }`, resolved against this file at call time.
The same applies to:

- **Numeric fields** (layout gaps/padding, layoutItem margins/min/max
  sizes, and corner radii `r1`-`r4`) — either a literal number or
  `{ "token": "sm" }`, resolved against `spacing` (gaps/padding/margins) or
  `radii` (corner radii).
- **`shadows`** on a shape — an array where each entry is either an inline
  `{ style, color, opacity, offsetX, offsetY, blur, spread }` object (color
  itself may also be a `{ "token": "name" }` reference) or
  `{ "token": "card" }`, resolved against `shadows`. `style` is
  `"drop-shadow"` (default) or `"inner-shadow"`.

## Testing

Two suites, split because they need very different things:

- **Unit** (`npm test`) — pure functions in `shape-builders.ts` (rotation
  matrix math, layout attribute mapping, the camelCase-in/kebab-case-out
  shape extraction `penpot_update_shapes` relies on) and `font-metrics.ts`
  (line-splitting/word-wrap logic, measured against a hand-built fake font
  so no network call is needed). No network, no Penpot account, runs
  anywhere. This is the suite CI runs on every push/PR.
- **Integration** (`npm run test:integration`) — exercises the actual MCP
  tool handlers against a real Penpot instance via `PENPOT_BASE_URL`/
  `PENPOT_ACCESS_TOKEN` (reads `.env`, same as the server itself). Each
  test creates its own scratch project and deletes it in a `finally` block,
  even on failure — see `test/integration/helpers/scratch-project.ts`.
  Skips itself (doesn't fail) if credentials aren't configured. **Local/
  manual only — deliberately not run in CI**, since it mutates a real
  Penpot account and CI credentials for that are out of scope for this
  project. Run it yourself before trusting a change to Penpot's wire
  schema (rotation, layout, components, variants). `font-metrics.test.ts`
  also lives in this suite despite not needing Penpot credentials — it
  makes a real call to the public Google Fonts CDN, which the unit suite's
  "no network" rule excludes; it skips itself if that network call fails
  rather than needing `PENPOT_ACCESS_TOKEN`.

This split matters because Penpot's RPC schema accepting a change is not
the same as Penpot's editor correctly rendering or recognizing it — the
variant-id bug documented in `shape-builders.ts` (`variantContainerAttrs`)
was only caught by live testing, not by the schema validating successfully.
`npm run test:all` runs both.

## Copying to another project

This package is published to npm as `@ai-dala/penpot-headless`, so most
consuming projects can just reference it via `npx` (see above) without a
local copy.

If you'd rather vendor it, this directory has its own
`package.json`/`tsconfig.json` and depends only on
`@modelcontextprotocol/sdk`, `zod`, and `opentype.js` (used by
`penpot_measure_text` to parse real font files) — no framework
dependencies. Copy the whole `penpot-headless/` directory into the new
project, run `npm install`, write a token file for that project, and
register the server with a `PENPOT_TOKENS_PATH` pointing at it.

## Known limitations

- `penpot_measure_text` doesn't apply ligature substitution (e.g. rendering
  "fi" as one glyph) — it sums individual glyph advances (with real GPOS
  kerning) instead of using opentype.js's built-in shaping, because that
  shaping path throws on GSUB tables present in most current-generation
  Google Fonts, including Inter and Roboto (see `measureLineWidth` in
  `font-metrics.ts`). This is a deliberate tradeoff, not an open gap: the
  width difference from skipping ligatures is a few percent at most for
  typical UI text, next to a hard crash on most real-world fonts if the
  built-in shaping were used instead.
- `penpot_measure_text` matches Penpot custom/team fonts by family name
  (case-insensitive) and weight. If a font variant exists in multiple styles
  (normal/italic) for the same weight, the "normal" style is preferred; if
  your use-case requires a specific non-normal style for measurement, this
  is not yet controllable via the tool's input parameters.

## Possible future additions

Ideas for further reducing agent friction, roughly in priority order:

- [x] `penpot_update_shapes` — edit an existing shape's geometry/fill/stroke/
      text in place. (Done — see above.)
- [x] `penpot_delete_shapes` — wraps `del-obj`. (Done — see above.)
- [x] `penpot_get_shape` — look up one shape (or a small subtree) by id,
      instead of pulling the whole page via `penpot_get_file_snapshot` and
      parsing a potentially large JSON blob client-side to find it. (Done —
      see above.)
- [x] `penpot_measure_text` — text `width`/`height` in `penpot_add_shapes`
      are just numbers the caller has to guess; Penpot only knows real
      rendered bounds after layout. (Done — see above. Measures against a
      real fetched Google Font via opentype.js rather than an approximation;
      see the "Text measurement" note below and `font-metrics.ts` for why it
      sums per-glyph advances instead of using opentype.js's built-in
      `getAdvanceWidth`.)
- [x] `penpot_clone_shapes` — plain-shape duplication (not the
      component-instance path already covered by
      `penpot_add_component_instance`). (Done — see above.)
- [x] `penpot_reorder_shapes` — bring-to-front/send-to-back/forward/backward/
      before/after, by id. (Done — see above. Note: does *not* wrap
      `reorder-children` as originally planned — that RPC change type was
      never actually verified live. Instead it round-trips the parent shape
      through `add-obj` with a recomputed `shapes` array, the same mechanism
      `penpot_update_shapes` already uses; `reorder-children` may still work
      but wasn't worth the extra risk once the `add-obj` approach was
      confirmed live. One live-only pitfall found along the way: fields like
      `transformInverse`/`hideFillOnExport` on the object returned by
      `get-file` are real required fields, not stale camelCase duplicates —
      they must be renamed to their kebab-case form when round-tripped, not
      dropped, or `update-file` rejects the change with a malli validation
      error on `transform-inverse`.)
- [x] `penpot_list_components` — enumerate a file's existing components,
      instead of requiring the caller to have created them itself in the
      same session or parse the snapshot's `components` map by hand. (Done —
      see above.)
- [x] `penpot_find_shapes` — predicate/name-based search over a page
      (mirrors the plugin API's `findShapes`), instead of the caller
      walking the tree itself for "every text shape" or "the shape named X."
      (Done — see above.)
- [x] `penpot_batch` — apply an ordered list of create/update/delete/reorder
      ops as a single `update-file` change-set. (Done — see above. Ops are
      applied against an in-memory shadow of the page's shapes, mutated as
      each op is processed, so a later op sees the effect of every earlier
      op in the same call — mirroring how `update-file` applies its
      `changes` array sequentially server-side. This is also what lets a
      `create` op's caller-chosen `id` be referenced by a later op's
      `parentId`/`frameId`, or be the target of a later `update`/`delete`/
      `reorder`, without an extra RPC round trip in between.)
- [x] `penpot_checkpoint` / `penpot_restore_checkpoint` /
      `penpot_discard_checkpoint` — snapshot shapes before a risky multi-step
      edit, and restore them after. (Done — see above. Penpot's RPC has no
      "revert to revn X" primitive, so restore works by diffing current state
      against the snapshot and replaying corrective `add-obj`/`del-obj` changes
      as a single `update-file` call, reusing the same camelCase-in/kebab-case-out
      normalization already verified live for `penpot_update_shapes`/
      `penpot_reorder_shapes` — see `restoreShapeAsAddObj` in `shape-builders.ts`.
      Snapshots are held in the MCP server's own memory, not written to the Penpot
      file or disk, so a checkpoint doesn't survive a server restart.
      `penpot_checkpoint` now accepts an optional `pageId`: supply it to scope the
      snapshot to a single page; omit it for a whole-file checkpoint that covers
      every page. Restore applies all pages' corrective changes in one `update-file`
      call, so a multi-step edit that touched multiple pages is fully undone in a
      single round trip.)
- [x] `penpot_align_shapes` / `penpot_distribute_shapes` — the one-click
      align-left/center/distribute-evenly actions Penpot's own UI has, that
      this package doesn't. Without them, an agent has to compute pixel
      positions itself from `penpot_get_shape` results — exactly the kind
      of arithmetic LLMs get wrong. (Done — see above. Both operate on each
      shape's `selrect` (its rotation-aware visible bounding box), so rotated
      shapes line up by what's rendered; align never moves the group as a
      whole (its reference line comes from the shapes' own extent) and
      distribute leaves the two endpoints fixed. Aligning/distributing a
      frame or group moves its whole descendant subtree by the same delta —
      a container's children have absolute positions, so shifting only the
      container's own x/y would leave them behind. Reuses the same
      camelCase-in/kebab-case-out `add-obj` update path already verified live
      for `penpot_update_shapes`, applied as a single `update-file`
      change-set.)
- [x] Token file support for spacing/radii/shadows, not just `colors` and
      `fonts`. (Done — see above/Token file schema. Added a `shadows` wire
      field to `rect`/`frame`/`text` that didn't exist at all before this —
      verified live against a real instance's `add-obj`/`get-file`: the
      field is `shadows` (array, back-to-front like `fills`/`strokes`),
      `style` is `"drop-shadow"` | `"inner-shadow"` (not `shadowType` as the
      Penpot plugin API's `Shadow` type would suggest), offsets are
      kebab-case `offset-x`/`offset-y` on write and camelCase `offsetX`/
      `offsetY` on read back, and `color` is a nested `{ color, opacity }`
      object rather than flattened `shadow-color`/`shadow-opacity` keys —
      see the comment on `Shadow` in `shape-builders.ts`. Every numeric
      field that previously took a raw number (layout `rowGap`/`columnGap`/
      `padding`, `layoutItem` margins/min/max sizes, corner radii `r1`-`r4`)
      now accepts a `{ token: "name" }` reference resolved against the token
      file's new `spacing`/`radii` tables, resolved in `content.ts` before
      reaching `shape-builders.ts` — which stays token-agnostic by design,
      same seam `resolveColor` already used for fills/strokes.)
- [x] Path/ellipse shapes and boolean ops (union/subtract/intersect) in
      `penpot_add_shapes`. (Done — `circle` is an ellipse bounded by x/y/width/height;
      `path` accepts an array of `{ command, params }` segments and derives its bounding
      box automatically from tight cubic-Bézier extrema; `bool` wraps children shapes
      (added with `parentId` = the bool's id, same pattern as frames) with a `boolType`
      of `union`, `difference`, `intersection`, or `exclusion` — the visual result path
      is computed by Penpot's editor when the file is opened, since the boolean geometry
      engine lives in Penpot's browser-side WASM module rather than on the server.)
- [x] Widen `penpot_update_shapes` to cover path/group/circle/svg-raw/image/bool
      (and any other shape type). (Done — `circle` and `bool` round-trip through their
      dedicated builders (preserving `bool-type` and `shapes`); `path` rebuilds geometry
      from the existing path content unless a new `content` array is supplied in the patch
      (x/y/width/height are always derived from content and cannot be set independently);
      `group` rebuilds geometry and `shapes` without touching children; image/svg-raw and
      any other type get a generic geometry-only rebuild, with all type-specific fields
      carried forward from `existing` via the `{ ...existing, ...obj }` merge. Also added
      `layout` (frame shapes only) and `layoutItem` (any shape) to the patch schema so
      auto-layout can be set or replaced after creation — same schema as `penpot_add_shapes`.
      A new `boolType` patch field replaces a bool's operation type in place. `computeShapeGeometry`
      and `group` are now exported from `shape-builders.ts` and `expandTranslateChanges` in
      `content.ts` now correctly handles groups and paths in align/distribute subtrees.)
- [x] Penpot team/custom font lookup for `penpot_measure_text` — if the
      requested font family is not found on Google Fonts, all Penpot teams
      accessible to the configured access token are searched for a matching
      custom/team font variant (`get-font-variants` → `download-font`). The
      "normal" style is preferred when multiple variants exist for the same
      family+weight; if none match anywhere, a combined error message names
      both sources. (Done — see `font-metrics.ts`:`fetchPenpotFontBytes`;
      `PenpotFontClient` interface keeps the module decoupled from
      `rpc-client.ts` so unit tests can supply a plain mock.)
- [x] Add a variant to an already-existing component/group.
      `penpot_add_variant` appends a new variant to an existing variant group
      container by re-adding the container frame via `add-obj` with the new
      variant's root id appended to its `shapes` array (the same
      delete-and-recreate-through-`add-obj` trick used by
      `penpot_reorder_shapes`, avoiding the `mov-objects` silent-no-op
      pitfall noted below). The new component is registered in the same
      `update-file` call.
- [x] `penpot_list_pages` / `penpot_rename_page` / `penpot_delete_page` —
      `penpot_create_page` exists and now so does the rest of the page CRUD:
      `penpot_list_pages` returns every page's id and name (in order) without
      pulling the full shape tree; `penpot_rename_page` renames a page in place;
      `penpot_delete_page` removes it (guards against deleting the last page of
      a file). Both mutations go through `update-file` with `rename-page` /
      `del-page` change types, the same single-round-trip path every other
      content tool uses.
- [x] SSO/OIDC-only instance support for `penpot_export_shape` via
      `PENPOT_AUTH_TOKEN_COOKIE`. (Done — in addition to the existing
      `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD` password-login path,
      you can now set `PENPOT_AUTH_TOKEN_COOKIE` to a raw `auth-token`
      cookie value obtained by completing the OIDC/SSO flow in a real
      browser. The profile-id is fetched via `get-profile` on first use;
      if the cookie expires a clear error message explains how to renew
      it. `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD` takes precedence
      when both are set. Fully headless OIDC automation — driving the
      redirect flow without a browser — is still not implemented, as
      the cookie-passthrough approach covers the SSO gap without adding
      a Playwright dependency.)
- [x] Unit coverage for `rpc-client.ts`, `transit.ts`, and
      `tools/project-files.ts` — currently zero unit tests, only reachable
      via the integration suite that's excluded from CI. Mocking `fetch`
      for status-code branching (204, 401/403 retry, non-2xx →
      `PenpotRpcError`) would catch regressions in CI instead of only on a
      manual integration run.
- [x] Gradient and image fills — `Fill`/`Stroke` in `shape-builders.ts`
      only carry a flat `color`/`opacity`; Penpot supports linear/radial
      gradient fills and image fills, neither reachable from any tool
      today. Likely the highest-value gap, since most real designs use at
      least one gradient or image fill somewhere. (Done — `fills` array on
      every non-text shape (`rect`, `frame`, `circle`, `path`, `bool`) in
      `penpot_add_shapes`/`penpot_update_shapes`/`penpot_batch` accepts
      entries of `type` `"solid"`, `"linear-gradient"`, `"radial-gradient"`,
      or `"image"`. Gradient stops may use `{ token: "name" }` references
      against the token file's `colors` table. Image fills reference an
      already-uploaded Penpot media object by `mediaId` UUID — the caller
      must upload the image to Penpot first (via the Penpot UI or a
      separate tool). When `fills` is provided it overrides the legacy
      `fillColor`/`fillOpacity` shorthand entirely; backward compatibility
      is preserved for all existing callers using `fillColor`/`fillOpacity`.
      The `Fill` type in `shape-builders.ts` is now a union of `SolidFill`,
      `GradientFill`, and `ImageFill`; `extractEditableFields` round-trips
      all three types from `get-file`'s camelCase response back to the
      kebab-case form `add-obj` expects.)
- [x] Image shapes / asset upload — there's no `image` shape builder and
      no upload-media RPC call anywhere; `image`/`svg-raw` shapes are only
      handled via a generic geometry-only fallback in
      `penpot_update_shapes`. Blocks any workflow that needs to bring in
      photos, icons, or existing SVG art. (Done — `penpot_upload_media`
      uploads a media asset to a Penpot file and returns `{ id, name,
      width, height, mtype }`. Three source modes: `filePath` (the MCP
      server reads a local file and POSTs it as multipart), `url` (Penpot's
      server fetches the image directly — nothing passes through the MCP
      server, uses `create-file-media-object-from-url`), or `dataBase64`
      (base64-encoded bytes, requires `mtype`). The returned `id` is then
      passed as `mediaId` when calling `penpot_add_shapes` with
      `type: "image"`; `mediaWidth`/`mediaHeight`/`mtype` come from the
      same response. The `image` shape builder sets `type: "image"` with a
      `metadata: { id, width, height, mtype }` block (Penpot's wire format,
      verified from `schema:image-attrs` in `shape.cljc`) plus the standard
      geometry and selrect/transform fields. `penpot_update_shapes` now
      handles image shapes with a dedicated case (instead of the previous
      generic fallback) and accepts `mediaId`/`mediaWidth`/`mediaHeight`/
      `mtype` patch fields to swap the displayed image in place. MIME type
      is auto-detected from file extension for `filePath` uploads
      (`.png`→`image/png`, `.jpg`/`.jpeg`→`image/jpeg`, `.svg`→
      `image/svg+xml`, `.webp`, `.gif`, `.avif`).)
- [x] Opacity/hidden/locked/blend-mode flags (Done — all four fields now available in shape builders and `penpot_update_shapes` schema: opacity (0-1 numeric), hidden (boolean), blocked (boolean), blendMode (string). Extraction via `extractEditableFields` converts wire-format "blend-mode" to camelCase "blendMode" for consistent builder interface. Unit and integration tests verify round-trip through Penpot's RPC API.)
- [x] Rich per-range text formatting — `text()` in `shape-builders.ts`
      always emits a single paragraph with one style run; no per-
      character-range styling, text-align, line-height, letter-spacing,
      or text-decoration. (Done — `penpot_add_shapes` and
      `penpot_update_shapes` both accept a `paragraphs` array on text
      shapes. Each paragraph sets `textAlign` plus typography defaults
      (`fontFamily`, `fontSize`, `fontWeight`, `fontStyle`, `lineHeight`,
      `letterSpacing`, `textDecoration`, `textTransform`, `fills`/`fillColor`);
      its `ranges` array holds one or more text runs that may individually
      override any of those fields. Multiple paragraphs produce separate
      paragraph nodes (line-break boundaries). `growType`
      (`"auto-width"` | `"auto-height"` | `"fixed"`) and `verticalAlign`
      (`"top"` | `"center"` | `"bottom"`) are also now settable on text
      shapes. The legacy `characters`/`fontFamily`/`fontSize`/`fontWeight`/
      `fillColor` shorthand is fully preserved for callers that don't need
      per-range control. `extractEditableFields` now returns a full
      `paragraphs` array for text shapes so a geometry-only update patch
      (`x`/`y`/`width`/`height` only) no longer collapses a rich-text shape
      back to single-paragraph. Verified field names against Penpot's
      `common/src/app/common/types/text.cljc` `text-node-attrs` /
      `paragraph-attrs` / `root-attrs`.)
- [x] Group/ungroup as first-class tools — `group()` already exists as an
      internal builder, but nothing exposes grouping existing shapes or
      ungrouping a group via an MCP tool. (Done — `penpot_group_shapes` takes
      a `shapeIds` array of sibling shapes (all must share the same parent)
      and wraps them in a new group at the topmost selected shape's z-order
      position; the group's bounding box is the union of the children's
      `selrect`s (rotation-aware bounds). `penpot_ungroup_shapes` takes a
      `groupId` and dissolves it: each child is reparented to the group's
      former parent at the group's z-order position (preserving relative
      child order) and the group shape is deleted. Both are implemented as
      single `update-file` change-sets — group creation sends `add-obj` for
      the new group, one `add-obj` per child (to update `parent-id`) and an
      `add-obj` for the updated parent's `shapes` array; ungroup does the
      same in reverse then appends a `del-obj`. Requires siblings only —
      shapes with different parents must be moved to a common parent first.)
- [x] Resize constraints — Penpot's `constraints-h`/`constraints-v`
      (how a shape behaves when its parent resizes) are never set or
      exposed in `EditableShapeFields` or the update patch schema.
      (Done — `constraintsH` (`left`/`right`/`leftright`/`center`/`scale`) and
      `constraintsV` (`top`/`bottom`/`topbottom`/`center`/`scale`) are now
      accepted by every shape builder (`rect`, `frame`, `text`, `circle`,
      `path`, `bool`, `group`, `image`), emitted as `constraints-h`/
      `constraints-v` in the wire format. Both fields are settable at creation
      time via `penpot_add_shapes` and patchable via `penpot_update_shapes`/
      `penpot_batch`; `extractEditableFields` reads them back from `get-file`'s
      camelCase response so a partial update patch preserves the existing
      constraints when neither field is included in the patch.)
- [x] Batch/whole-page export — `penpot_export_shape` takes exactly one
      `shapeId` per call, with no multi-shape or multi-page batch export,
      and only `png`/`svg` (no PDF). (Done — `penpot_export_batch` accepts
      either a `shapeIds` array (same `pageId`, any mix of `format`/`scale`)
      or a `shapes` array where each entry may carry its own `pageId`, `format`,
      `scale`, and `name` — enabling whole-file exports across multiple pages in
      a single `POST /api/export` call. Parsing each result's `~:uri`/
      `~:mtype`/`~:filename` via `matchAll` on the transit+json response; asset
      downloads are parallelised. Both tools also accept `pdf` as a format value.
      Multi-page export was previously gated on the MCP schema, not the
      exporter-client, which already sent per-spec `pageId`s.)
- [x] Shared-library components — `penpot_list_components` and
      `penpot_add_component_instance` only look at the current file's own
      `components` map; there's no support for pulling components from a
      separate connected shared-library file. (Done — `penpot_list_components`
      now accepts `includeLibraries: true`, which calls `get-file-libraries` to
      enumerate all linked library files (direct and transitive) and then calls
      `get-file` on each to read its `components` map; each library component
      entry carries a `libraryFileId` and `libraryFileName` field in the
      response. `penpot_add_component_instance` now accepts an optional
      `libraryFileId`; when supplied, the component's main-instance tree is
      looked up in that library file instead of the current file, and the
      cloned instance's root `component-file` field is set to the library
      file's id (matching what Penpot's own editor writes for cross-file
      component instances). Note: `get-file-libraries` returns file metadata
      only — it does not include shape/component data — so a separate
      `get-file` call per library is necessary to enumerate components; this
      is the same two-step pattern Penpot's own frontend uses.)
- [x] Comments API — `penpot_list_comment_threads` lists all threads for a file;
      `penpot_get_comments` fetches the replies inside a thread;
      `penpot_create_comment_thread` pins a new thread to a canvas position
      (x/y + optional `frameId`); `penpot_create_comment` adds a reply;
      `penpot_update_comment` edits an existing comment's text;
      `penpot_resolve_comment_thread` marks a thread resolved or reopens it;
      `penpot_delete_comment` removes a single reply; and
      `penpot_delete_comment_thread` removes a whole thread and all its replies.
      All eight tools go through `PenpotRpcClient` methods that send
      kebab-case JSON params to the `get-comment-threads` /
      `create-comment-thread` / `update-comment-thread` / `delete-comment` /
      etc. RPC endpoints (same call pattern as every other tool). Wire format
      verified against Penpot's frontend source (`data/comments.cljs`):
      `position` is a nested `{ x, y }` JSON object; `frame-id` is optional
      on creation (omit to place on the page root); `is-resolved` controls
      thread resolution state.
- [x] Text search-and-replace across a file — `penpot_replace_text` finds
      every text shape on a page whose runs contain a literal search string and
      rewrites all occurrences in a single `update-file` call. Matching is
      case-insensitive by default (`caseSensitive: true` to override); an
      optional `limit` caps the number of shapes touched; empty `replacement`
      deletes matches. Replacement is per text-run (leaf node): a search string
      that spans two adjacent runs within a paragraph is not matched — this
      covers the common case where all text is in a single run per paragraph.
      (Done — regex metacharacters in the search string are escaped so `.` /
      `*` / `(` etc. are always matched literally, not as pattern operators.)
- [x] Cross-page/whole-file undo — `penpot_checkpoint` now accepts an optional
      `pageId`; omit it to snapshot every page in the file in one call.
      `penpot_restore_checkpoint` restores all snapshotted pages in a single
      `update-file` call. Checkpoints still live in server memory and don't
      survive a restart; persistence across restarts is still not implemented
      (callers needing that should snapshot via `penpot_get_file_snapshot`
      themselves). (Done — `Checkpoint` in `checkpoints.ts` now holds a
      `pages` map (pageId → objects) instead of a single `pageId`/`objects`
      pair; the restore loop iterates every entry and accumulates all changes
      before sending one `update-file` call — the same single-round-trip
      pattern used by `penpot_batch`.)
- [x] Fully headless OIDC/SSO login — drive the redirect flow without a
      browser, instead of requiring `PENPOT_AUTH_TOKEN_COOKIE` to be
      obtained manually beforehand. (Done — `PENPOT_OIDC_USERNAME` +
      `PENPOT_OIDC_PASSWORD` trigger a new `'oidc'` auth mode in the
      exporter client. The server follows Penpot's `/api/auth/oauth/{provider}`
      redirect chain to the identity provider's login page via plain HTTP
      (Node.js `http`/`https` modules, no browser dependency), parses the
      HTML login form heuristically to identify username/email and password
      fields (including hidden CSRF/state fields, which are preserved and
      re-submitted verbatim), and captures the resulting `auth-token`
      cookie. Multi-step flows — where the IdP shows username on page 1
      and password on page 2 (Authentik-style) — are handled by a
      MAX_FORM_STEPS loop that re-parses and re-submits until the cookie
      appears. 307/308 redirects preserve POST method+body; 301/302/303
      switch to GET per RFC 7231. The cookie jar correctly captures
      Penpot's own state/CSRF cookies set on the initial redirect so they
      are forwarded to the IdP's callback. Re-logs in automatically on
      401/403 (same as password mode). `PENPOT_OIDC_PROVIDER` controls
      the provider name (default `"oidc"`). Does not work for
      JavaScript-driven IdP login pages — use `PENPOT_AUTH_TOKEN_COOKIE`
      for those. Unit-tested via injected mock fetcher; integration test
      requires a live OIDC provider and is not in CI.)
- [x] Checkpoint persistence across server restarts — checkpoints
      (`penpot_checkpoint`/`penpot_restore_checkpoint`) currently live only
      in the MCP server's own memory and are lost on restart. Persisting
      them to disk (or letting the caller supply a checkpoint id to
      save/load) would let a long-running multi-step edit survive a server
      restart mid-way through. (Done — set `PENPOT_CHECKPOINTS_PATH` to a
      local directory; the server writes each checkpoint as `<uuid>.json`
      into that directory and reloads all files from it at startup.
      `initCheckpointStore(dir)` is called once in `server.ts` main;
      `saveCheckpoint` is now async and awaits the `writeFile` so the
      caller knows immediately if the write failed; `deleteCheckpoint` is
      also async and removes the corresponding file from disk. No change
      to the in-memory behaviour when the env var is unset.)
- [x] Single-call multi-page batch export — `penpot_export_batch` exports
      multiple shapes on one page in a single call; exporting across pages
      still requires one call per `pageId`. Worth revisiting if batch
      export becomes a bottleneck for whole-file exports. (Done — `penpot_export_batch`
      now accepts either `shapeIds` (single-page shorthand, backward-compatible) or a
      `shapes` array where each entry carries its own `pageId`, `format`, `scale`, and
      optional `name`. The exporter-client already sent per-spec `pageId`s in a single
      `POST /api/export`; the only change was widening the MCP tool's Zod schema to
      expose this capability. Cross-field validation (at least one of `shapeIds`/`shapes`;
      not both; `pageId` required for `shapeIds`; every `shapes` entry has a `pageId`
      either per-entry or via the top-level default) is done with `.refine()` on a
      separate `exportBatchInput` schema while `exportBatchBaseSchema` (the raw
      `ZodObject`) is passed to the MCP SDK's `inputSchema` to preserve `.shape`.)
- [x] Stale-write detection via `revn` — every write tool
      (`penpot_update_shapes`/`penpot_batch`/`penpot_restore_checkpoint`/etc.)
      currently sends `update-file` blind, without checking the file's
      current `revn` (revision number) first. If a human has the file open
      in the Penpot editor and edits concurrently with an agent session,
      whichever write lands second silently overwrites the other with no
      error. Penpot's own `update-file` RPC takes a `revn` and is expected
      to reject a change-set built against a stale one; today nothing here
      reads or checks it beforehand. Fix is to fetch current `revn` before
      writing and surface a clear "file changed underneath you, re-fetch
      and retry" error on rejection, rather than actually merging concurrent
      edits (which would need Penpot's own operational-transform logic).
      (Done — `PenpotStaleWriteError` is thrown by `updateFile` in
      `rpc-client.ts` whenever the `update-file` response includes a
      non-empty `lagged` array, meaning Penpot applied change-sets from
      another session before ours. Penpot's collaborative engine still
      applies our write on top, so the write succeeds at the HTTP level,
      but the error surfaces the concurrent-edit situation with a clear
      message that names the new `revn` and tells the caller to re-fetch
      via `penpot_get_file_snapshot` before making further edits. Applies
      to all write tools automatically — `penpot_add_shapes`,
      `penpot_update_shapes`, `penpot_delete_shapes`, `penpot_batch`,
      `penpot_restore_checkpoint`, `penpot_group_shapes`,
      `penpot_ungroup_shapes`, `penpot_clone_shapes`, `penpot_reorder_shapes`,
      `penpot_align_shapes`, `penpot_distribute_shapes`, page CRUD, etc. —
      since they all go through `updateFile`. `PenpotStaleWriteError`
      carries `laggedCount` (number of concurrent change-sets) and `result`
      (the full `update-file` response, including the new `revn`) so
      callers that need the new revision can read it from the error rather
      than requiring an additional round trip.)
- [x] Version history / branching support — `penpot_list_file_snapshots` lists all named
      snapshots for a file (both user-created versions and system auto-backups);
      `penpot_create_file_snapshot` saves the current state as a named version (optional
      `label`; Penpot generates a timestamp label when omitted);
      `penpot_restore_file_snapshot` rolls the live file back to any listed snapshot
      (Penpot automatically creates a system backup of the current state before applying
      the restore, so a restore is itself undoable by restoring the most-recent system
      entry); `penpot_rename_file_snapshot` renames a user-created snapshot;
      `penpot_delete_file_snapshot` removes one; `penpot_lock_file_snapshot` /
      `penpot_unlock_file_snapshot` pin/unpin a snapshot against accidental deletion
      (only the snapshot's creator can lock/unlock, and only user-created snapshots
      support locking — system backups expire automatically). `penpot_get_file_snapshot_data`
      returns the full file content at a specific snapshot for read-only inspection or
      comparison without touching the live file. Wire format verified against
      `backend/src/app/rpc/commands/files_snapshot.clj` and
      `backend/src/app/features/file_snapshots.clj`.
- [x] Component-instance drift/override visibility — Penpot's composition
      model is tokens → elements → components (with variants) → screens
      built from component instances, and today the tools that read shape
      state (`penpot_get_shape`, `penpot_find_shapes`) don't surface where a
      shape sits in that hierarchy. A component instance's `shape-ref` link
      to its main component (and whether it has drifted — fields overridden
      since being placed, or fully detached) is invisible: an agent editing
      an instance via `penpot_update_shapes` can't currently tell "this edit
      creates a per-instance override" from "this edit changes a raw shape,"
      which matters for keeping instances honestly in sync with their
      component rather than silently diverging the way ad-hoc UI edits can.
      Scope: extend `penpot_get_shape`/`penpot_find_shapes` to report
      link state (`linked` / `detached` / not-an-instance) and, when linked,
      which fields differ from the main component's current definition —
      read-only visibility first; explicit detach/relink tools and
      discovery-first tool guidance (checking `penpot_list_components`
      before creating shapes that duplicate an existing component) are
      possible natural follow-ons once drift is at least visible.
      (Done — both `penpot_get_shape` and `penpot_find_shapes` now include
      component link state in their output. `penpot_get_shape` adds a
      top-level `componentInfo` field with `linkState` (`"linked"` /
      `"detached"` / `"not-an-instance"` / `"main-component-root"`), the
      `componentId` and `componentFileId` when present, `mainInstanceId` /
      `mainInstancePage` for same-file linked instances, and a `driftedFields`
      array (camelCase field names) listing which visual properties on this
      instance differ from the main component's current definition — empty
      array means fully in sync. `penpot_find_shapes` adds `linkState` and
      `componentId` to each match entry, plus `driftedFields` for same-file
      linked instances. Drift is compared over: `name`, `fills`, `strokes`,
      `shadows`, `opacity`, `hidden`, `blendMode`, `width`, `height`,
      `constraintsH`, `constraintsV`, and `content` (text shapes); position
      `x`/`y` is excluded since every placed instance lives at a different
      canvas location by design. Library components (`componentFile` ≠ current
      file) report `linkState: "linked"` but `driftedFields` is omitted since
      the library file's pages would require an extra RPC call — use
      `penpot_get_shape` on a library instance root, then follow its
      `mainInstanceId`/`mainInstancePage` manually if cross-file drift is
      needed.)

TDQS

A3.5/5.0

Scored across 54 tools

Disambiguation5/5

Each tool targets a distinct operation or entity (file, page, shape, component, comment, checkpoint, media, etc.), with no overlapping purposes. Even similar tools like penpot_add_shapes and penpot_add_component_instance are clearly differentiated by their descriptions.

Naming Consistency5/5

All tools follow the penpot_{verb}_{noun(s)} pattern with imperative verbs (add, create, delete, get, list, update, etc.) and descriptive nouns. The naming is uniform and predictable across the entire set.

Tool Count2/5

With 54 tools, the set is significantly larger than the typical well-scoped range of 3-15. While the breadth of Penpot's feature set may justify many tools, the sheer number makes it unwieldy for an agent and suggests the server could benefit from consolidation or higher-level operations.

Completeness5/5

The tool surface covers the full lifecycle for files, pages, shapes, components, variants, comments, and media, plus supportive utilities like checkpoints, text measurement, and batching. There are no obvious gaps for the domain of headless Penpot operations.

Maintenance

ActivityStale
ResponsivenessNo issues