{"_id":"@ai-dala/penpot-headless","_rev":"3-1785b3b2f97f1654f415d8b6c258ab12","name":"@ai-dala/penpot-headless","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@ai-dala/penpot-headless","version":"0.1.0","keywords":["penpot","mcp","headless"],"author":{"name":"Volodymyr Tytenko"},"license":"MIT","_id":"@ai-dala/penpot-headless@0.1.0","maintainers":[{"name":"tvolodi","email":"tvolodi@gmail.com"}],"homepage":"https://github.com/tvolodi/penpot-mcp#readme","bugs":{"url":"https://github.com/tvolodi/penpot-mcp/issues"},"bin":{"penpot-headless":"dist/server.js"},"dist":{"shasum":"9aabc9d1385ac44774ba0551351f035c965c4f95","tarball":"https://registry.npmjs.org/@ai-dala/penpot-headless/-/penpot-headless-0.1.0.tgz","fileCount":17,"integrity":"sha512-zkk+AB3Iyc5x/uEKw2lMVEPrcDuynTdnmk9lDkXKm97Eq1cK+Gee3S6lv4FJYN7loHlpfQHFTqNbNhyj9WeuHw==","signatures":[{"sig":"MEYCIQCgbc+4/Q2gPV9TFeksC2IeQrBUnNJdTEGWJS4+d+y5uQIhANbkhiP6r4sYNKR2605bjHgabQhq9qA5O08WIuFgWUIY","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":43459},"main":"dist/server.js","type":"module","types":"dist/server.d.ts","gitHead":"4c34f7d592332ce854e663450fd8a04ecf57955b","scripts":{"build":"tsc","start":"tsx src/server.ts","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"tvolodi","email":"tvolodi@gmail.com"},"repository":{"url":"git+https://github.com/tvolodi/penpot-mcp.git","type":"git"},"_npmVersion":"11.4.1","description":"Portable MCP server for headless Penpot project/file/content management via Penpot's RPC API. No browser, no Penpot plugin session required. Copy this directory to any project to reuse.","directories":{},"_nodeVersion":"24.13.1","dependencies":{"zod":"^4.4.3","@modelcontextprotocol/sdk":"^1.29.0"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.19.2","typescript":"^5.8.3","@types/node":"^22.10.0"},"_npmOperationalInternal":{"tmp":"tmp/penpot-headless_0.1.0_1783921181838_0.7171566579609376","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@ai-dala/penpot-headless","version":"0.2.0","keywords":["penpot","mcp","headless"],"author":{"name":"Volodymyr Tytenko"},"license":"MIT","_id":"@ai-dala/penpot-headless@0.2.0","maintainers":[{"name":"tvolodi","email":"tvolodi@gmail.com"}],"homepage":"https://github.com/tvolodi/penpot-mcp#readme","bugs":{"url":"https://github.com/tvolodi/penpot-mcp/issues"},"bin":{"penpot-headless":"dist/server.js"},"dist":{"shasum":"4f5597eb8ef8ec6e1150abdca3331d8aa35f23b4","tarball":"https://registry.npmjs.org/@ai-dala/penpot-headless/-/penpot-headless-0.2.0.tgz","fileCount":23,"integrity":"sha512-q5mMuhOUIrc2Wng+rBojK2kRlzdoSDCoDStwkPs6uO8+WmMSi+eO2kIOFl3PpZncv/2XT8Hq6g9uElU3MGZJMQ==","signatures":[{"sig":"MEUCICz0dtmQvOTQoynRjD4q2oPLIhzhbRUOt+9cEXEhOIv/AiEA5q1j7Xd+262WRpZ/+1ohjYmkMdOXkX7a91FT58Bpl1k=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@ai-dala%2fpenpot-headless@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":58188},"main":"dist/server.js","type":"module","types":"dist/server.d.ts","gitHead":"8fee398cb4737d69d4f69c09f40d4d9d60656f2f","scripts":{"build":"tsc","start":"tsx src/server.ts","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1821b158-95ea-4706-8a2c-0bebe7db9942"}},"repository":{"url":"git+https://github.com/tvolodi/penpot-mcp.git","type":"git"},"_npmVersion":"12.0.1","description":"Portable MCP server for headless Penpot project/file/content management via Penpot's RPC API. No browser, no Penpot plugin session required. Copy this directory to any project to reuse.","directories":{},"_nodeVersion":"22.23.1","dependencies":{"zod":"^4.4.3","@modelcontextprotocol/sdk":"^1.29.0"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.19.2","typescript":"^5.8.3","@types/node":"^22.10.0"},"_npmOperationalInternal":{"tmp":"tmp/penpot-headless_0.2.0_1783945046807_0.11151536966380426","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@ai-dala/penpot-headless","version":"0.3.0","description":"Portable MCP server for headless Penpot project/file/content management via Penpot's RPC API. No browser, no Penpot plugin session required. Copy this directory to any project to reuse.","type":"module","main":"dist/server.js","types":"dist/server.d.ts","bin":{"penpot-headless":"dist/server.js"},"scripts":{"start":"tsx src/server.ts","typecheck":"tsc --noEmit","typecheck:test":"tsc --noEmit -p tsconfig.test.json","build":"tsc","prepublishOnly":"npm run build","test":"vitest run --project unit","test:watch":"vitest --project unit","test:integration":"vitest run --project integration","test:all":"vitest run"},"keywords":["penpot","mcp","headless"],"author":{"name":"Volodymyr Tytenko"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/tvolodi/penpot-mcp.git"},"homepage":"https://github.com/tvolodi/penpot-mcp#readme","bugs":{"url":"https://github.com/tvolodi/penpot-mcp/issues"},"dependencies":{"@modelcontextprotocol/sdk":"^1.29.0","opentype.js":"^2.0.0","zod":"^4.4.3"},"devDependencies":{"@types/node":"^22.10.0","@types/opentype.js":"^1.3.10","tsx":"^4.19.2","typescript":"^5.8.3","vitest":"^4.1.10"},"gitHead":"3ad63a70295d2a8b52d1a16ba091a2b9bb2d158b","_id":"@ai-dala/penpot-headless@0.3.0","_nodeVersion":"22.23.1","_npmVersion":"12.0.1","dist":{"integrity":"sha512-M1oUK96vr3+6auaMPSjGsoY7ptiSZPZljOznYoPtsKA/ydSoAKaITlWY6ca/rOHsovydoztk2nPNl9hOyOclLA==","shasum":"9e24b0f2026dac15c0631cc8a3cf0154e9600a9f","tarball":"https://registry.npmjs.org/@ai-dala/penpot-headless/-/penpot-headless-0.3.0.tgz","fileCount":33,"unpackedSize":686354,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@ai-dala%2fpenpot-headless@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIGr87Tzk5/9zyCe5rC03XxSjziYyZ8D5IOQNSfl2LiA+AiBXGPL8f734f98BCACxHwBntuJo0qMd3L9yAQd2A9XDrw=="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1821b158-95ea-4706-8a2c-0bebe7db9942"}},"directories":{},"maintainers":[{"name":"tvolodi","email":"tvolodi@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/penpot-headless_0.3.0_1784218524415_0.303215598670187"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-13T05:39:41.681Z","modified":"2026-07-16T16:15:24.876Z","0.1.0":"2026-07-13T05:39:41.972Z","0.2.0":"2026-07-13T12:17:26.980Z","0.3.0":"2026-07-16T16:15:24.558Z"},"bugs":{"url":"https://github.com/tvolodi/penpot-mcp/issues"},"author":{"name":"Volodymyr Tytenko"},"license":"MIT","homepage":"https://github.com/tvolodi/penpot-mcp#readme","keywords":["penpot","mcp","headless"],"repository":{"type":"git","url":"git+https://github.com/tvolodi/penpot-mcp.git"},"description":"Portable MCP server for headless Penpot project/file/content management via Penpot's RPC API. No browser, no Penpot plugin session required. Copy this directory to any project to reuse.","maintainers":[{"name":"tvolodi","email":"tvolodi@gmail.com"}],"readme":"# penpot-headless\n\nA portable MCP server for headless Penpot project/file/content management,\nusing Penpot's RPC API directly. No browser, no Penpot plugin session.\n\nThis package has **zero project-specific knowledge** — no hardcoded colors,\nfonts, or Penpot instance URL. Every consuming project supplies its own\nconfig and token file, so it can be installed from npm or copied as-is into\nanother project.\n\n## What this does\n\n- Team/project/file/page CRUD (`penpot_list_teams`, `penpot_create_file`, etc.)\n- Shape creation — `rect`, `frame`, `text`, `circle` (ellipse), `path`, `bool` (boolean\n  operations), and `image` — via `penpot_add_shapes`, with\n  colors/fonts resolved either from a literal value or a\n  `{ \"token\": \"name\" }` reference against your project's token file. The\n  same token-or-literal pattern applies to spacing (layout gaps/padding,\n  layoutItem margins/min/max sizes), corner radii (`r1`-`r4`), and shadows\n  (`shadows`, either an inline `{ style, color, offsetX, offsetY, blur,\n  spread, opacity }` object or `{ \"token\": \"name\" }`). Shapes may be rotated\n  via an optional `rotation` field (degrees, clockwise, about the shape's\n  center).\n- Shape editing: `penpot_update_shapes` changes an existing shape in place\n  by id — position, size, rotation, name, fill, stroke, shadows, corner\n  radii, or (for text) content/font. Only the fields you pass are touched;\n  everything else (children, component/variant tags, layout) is left as-is.\n  `clearStroke`/`clearShadows` remove strokes/shadows entirely. Geometry\n  changes automatically recompute the shape's selection box and transform,\n  so a partial edit (e.g. just `width`) can't desync from the rest of the\n  shape's geometry. Layout/layoutItem aren't editable this way — only\n  settable at creation time via `penpot_add_shapes`.\n- Shape deletion: `penpot_delete_shapes` removes one or more existing\n  shapes from a page by id. Deleting a frame or group also removes its\n  children, matching Penpot's own delete behavior.\n- Shape grouping: `penpot_group_shapes` wraps one or more sibling shapes into\n  a new group (Ctrl+G equivalent) — all shapes must share the same parent.\n  The group is inserted at the topmost selected shape's z-order position and\n  its bounding box is computed from the children's rotation-aware `selrect`s.\n  `penpot_ungroup_shapes` dissolves a group (Ctrl+Shift+G equivalent):\n  children are reparented to the group's former parent at the group's\n  z-order slot, then the group is deleted.\n- Shape duplication: `penpot_clone_shapes` duplicates one or more existing\n  shapes (and, for frames/groups, their full descendant subtree), each with\n  fresh ids — plain shape duplication, like Penpot's own Ctrl+D, not a\n  component instance. Optional `dx`/`dy` offset each clone from its source\n  (defaults to no offset, stacked directly on top of the original); optional\n  `parentId`/`frameId` reparent every cloned root instead of leaving it\n  alongside its source.\n- Shape stacking order: `penpot_reorder_shapes` changes a shape's z-order\n  among its siblings, matching Penpot's own \"Bring to front\" / \"Send to\n  back\" / \"Forward\" / \"Backward\" UI actions. `action` is `front`/`back`\n  (top/bottom of the stack), `forward`/`backward` (swap with the next/\n  previous sibling — a no-op if already at that end), or `before`/`after`\n  (place immediately relative to another sibling given as `targetId`).\n  Only reorders among existing siblings — it doesn't reparent.\n- Align & distribute: `penpot_align_shapes` lines up two or more shapes on a\n  common edge or center (`left`/`right`/`top`/`bottom`/`center-h`/`center-v`),\n  and `penpot_distribute_shapes` spreads three or more shapes so the gaps\n  between them are equal (`horizontal`/`vertical`) — matching Penpot's own\n  one-click align/distribute actions, instead of the agent computing pixel\n  positions itself. Both work on each shape's visible bounding box (its\n  `selrect`), so rotated shapes line up by their rendered bounds; align never\n  moves the group as a whole, and distribute leaves the two outermost shapes\n  fixed. Aligning or distributing a frame/group carries its whole child\n  subtree along by the same offset.\n- Batched edits: `penpot_batch` applies an ordered list of create/update/\n  delete/reorder operations as a single `update-file` change-set — one\n  `revn`/`vern` round trip no matter how many shapes are touched, instead of\n  one RPC call per shape (and the races on `revn` that come with that). Each\n  op takes the same fields as the matching standalone tool (`penpot_add_shapes`\n  for `create`, `penpot_update_shapes` for `update`, etc.); a `create` op may\n  set an explicit `id` and be referenced as a later op's `parentId`/`frameId`\n  or targeted by a later `update`/`delete`/`reorder` in the same call, so a\n  whole form or card grid can be built in one round trip.\n- Undo point: `penpot_checkpoint` snapshots shapes and returns a `checkpointId`;\n  `penpot_restore_checkpoint` undoes everything since — including a wrong\n  `penpot_delete_shapes` call, which otherwise has no undo path short of\n  Penpot's own UI. Supply `pageId` to scope the snapshot to a single page; omit\n  `pageId` for a whole-file checkpoint that covers every page. Works by diffing\n  current state against the snapshot and replaying corrective changes as a single\n  `update-file` call (recreate what's missing, delete what's new, overwrite what\n  changed back to its snapshotted fields), since Penpot's RPC has no \"revert to\n  revn X\" primitive. Checkpoints live in the MCP server's memory (not the Penpot\n  file, not disk) and are reusable — restore to the same point as many times as\n  you like — until explicitly freed via `penpot_discard_checkpoint` or the server\n  process restarts.\n- Shape lookup: `penpot_get_shape` fetches a single shape by id, without\n  pulling the whole page via `penpot_get_file_snapshot`. By default nests\n  the shape's full descendant subtree under `shapes`; `includeDescendants`\n  and `maxDepth` control how much of the subtree comes back. Always includes\n  a `componentInfo` field: `linkState` (`\"linked\"` / `\"detached\"` /\n  `\"not-an-instance\"` / `\"main-component-root\"`), and — for same-file linked\n  instances — a `driftedFields` array listing the camelCase field names that\n  differ from the main component's current definition (empty = fully in sync).\n- Shape search: `penpot_find_shapes` searches every shape on a page against\n  one or more predicates — `type`, `name` (exact match), `nameContains`\n  (case-insensitive substring), `textContains` (case-insensitive substring\n  against a text shape's rendered characters), `isComponentInstance`, and/or\n  `isRoot` — all given filters must match (AND). Omit every filter to list\n  the whole page. Returns each match's id/type/name/position/size plus\n  `linkState` and (for linked same-file instances) `driftedFields`; use\n  `penpot_get_shape` on a match's id for the full subtree and detailed\n  `componentInfo`, optionally capped via `limit`.\n- Text measurement: `penpot_measure_text` computes the real rendered\n  width/height of a string for a given font (family/size/weight), without\n  creating or touching any shape — removing the guesswork around `width`/\n  `height` when calling `penpot_add_shapes`/`penpot_update_shapes` for text.\n  Tries Google Fonts first (by family name, no API key needed); if the family\n  isn't there, searches all Penpot teams accessible to the configured token\n  for a matching custom/team font, so fonts uploaded directly to your Penpot\n  instance are also measurable. Measures real glyph advance widths (plus\n  kerning) so the numbers match what Penpot renders. Splits on explicit `\\n`;\n  pass `maxWidth` to also get word-wrapped line breaks for a fixed-width box.\n- Auto-layout: a `frame` may declare flex or grid layout via an optional\n  `layout` field (direction, gap, padding, alignment; grid also takes\n  row/column track definitions). Any shape may set `layoutItem` to control\n  its own placement within an auto-layout parent (sizing, alignment,\n  margins, min/max size, and — for grid parents — row/column/span).\n- Components: `penpot_create_component` registers a shape tree (same specs\n  as `penpot_add_shapes`) as a component's main instance; give shapes\n  explicit `id`s to nest them, and the one shape not parented to a sibling\n  becomes the root. `penpot_add_component_instance` then places a full\n  copy of that tree elsewhere on a page, linked back to the main instance\n  via Penpot's `shape-ref` so it's recognized as a proper component copy —\n  reusable any number of times.\n- Variants: `penpot_create_variant_group` groups two or more components\n  (built the same way as `penpot_create_component`, one per variant) into a\n  single container tagged with shared property axes (e.g. a \"Button\" with\n  Type=Primary/Secondary). This wires up Penpot's actual variant-swap\n  system — confirmed live via the Penpot plugin API, including\n  `instance.switchVariant(pos, value)` correctly swapping a placed\n  instance between variants. `penpot_add_variant` adds a new variant to\n  an already-existing variant group container: accepts the same shape specs\n  as one entry in `penpot_create_variant_group`'s `variants` array and the\n  `containerId` returned by that tool (or found via `penpot_find_shapes`),\n  then appends the new variant's main instance to the container's `shapes`\n  array and registers a new component — all in a single `update-file` call.\n- `penpot_list_components` enumerates a file's existing components (from its\n  `data.components` map), instead of requiring the caller to have created\n  them itself in the same session or parse `penpot_get_file_snapshot`'s\n  output by hand. Each entry includes the `componentId` (usable with\n  `penpot_add_component_instance`), `name`, `mainInstanceId`/\n  `mainInstancePage`, and — for variant components — `variantId`/\n  `variantProperties`.\n- Media upload: `penpot_upload_media` uploads an image or other asset to a Penpot\n  file and returns the media object metadata (`id`, `width`, `height`, `mtype`).\n  Accepts a local file path (`filePath`), a remote URL (`url` — Penpot's server\n  fetches it directly), or base64-encoded bytes (`dataBase64` + `mtype`). The\n  returned `id` is used as `mediaId` when creating an `image` shape.\n- Comments: `penpot_list_comment_threads` lists all threads for a file;\n  `penpot_get_comments` fetches replies inside a thread;\n  `penpot_create_comment_thread` pins a new thread to a canvas position\n  (x/y, optional `frameId`); `penpot_create_comment` adds a reply;\n  `penpot_update_comment` edits a comment's text;\n  `penpot_resolve_comment_thread` marks a thread resolved or reopens it;\n  `penpot_delete_comment` removes a single reply;\n  `penpot_delete_comment_thread` removes a whole thread.\n- Version history: `penpot_list_file_snapshots` lists all named snapshots for a\n  file; `penpot_create_file_snapshot` saves the current state as a named version\n  (equivalent to \"Save version\" in the Penpot UI, with an optional `label`);\n  `penpot_restore_file_snapshot` rolls the live file back to a snapshot (Penpot\n  automatically saves a system backup before restoring, so restores are themselves\n  undoable); `penpot_rename_file_snapshot` / `penpot_delete_file_snapshot` manage\n  user-created versions; `penpot_lock_file_snapshot` / `penpot_unlock_file_snapshot`\n  protect a version from accidental deletion; `penpot_get_file_snapshot_data` returns\n  the full file content at a snapshot for read-only inspection.\n- Reading a file's current shape tree via `penpot_get_file_snapshot`.\n- Rendering shapes or pages to PNG/SVG/PDF via `penpot_export_shape` (single shape) and\n  `penpot_export_batch` (multiple shapes in one call, returned in the same order, across any\n  number of pages — use the `shapes` array for multi-page exports; the `shapeIds` shorthand\n  is still available when all shapes share the same page) — requires\n  `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD`, `PENPOT_OIDC_USERNAME`/`PENPOT_OIDC_PASSWORD`,\n  or `PENPOT_AUTH_TOKEN_COOKIE`, see below — no browser tab\n  or Penpot plugin session needed on your end.\n\n## Render/export capability\n\n`penpot_export_shape` renders a single shape (or an entire page, by passing its\nroot frame's id) to PNG, SVG, or PDF, using Penpot's own server-side exporter —\nthe same headless-Chromium-backed service Penpot's self-hosted stack\nalready runs (the `penpotapp/exporter` container). `penpot_export_batch`\naccepts either a `shapeIds` array (all shapes on the same `pageId`) or a `shapes`\narray where each entry can specify its own `pageId`, `format`, `scale`, and `name`\n— enabling whole-file exports across multiple pages in a single API call. All\nformats (PNG/SVG/PDF) are supported. No browser automation\nhappens in this package; it's two HTTP calls (auth, then export) against\nyour Penpot instance.\n\nThis needs a **second, separate auth mode** from the rest of this package:\nPenpot's export pipeline (`POST /api/export`) is a distinct microservice\nfrom the `/api/rpc/command/*` surface everything else here uses, and it\nauthenticates purely via a session cookie — a personal access token in an\n`Authorization` header is never read on this path (confirmed by reading\nPenpot's exporter source: its `wrap-auth` looks for a literal `auth-token`\ncookie and nothing else), independently of `PENPOT_ACCESS_TOKEN`.\n\nThree ways to supply this cookie — configure exactly one:\n\n**Option A — password login** (`PENPOT_LOGIN_EMAIL` + `PENPOT_LOGIN_PASSWORD`):\nThe server logs in with email/password to obtain the `auth-token` cookie and\ncaches it for the process lifetime, re-logging-in automatically on expiry.\nBest for instances that expose password login. `PENPOT_ACCESS_TOKEN` and\nthese credentials must belong to **the same Penpot account** (or at least\naccounts with access to the same team); mismatched accounts cause a silent\n10-second render timeout because the exporter can't distinguish \"access\ndenied\" from \"shape not visible.\"\n\n**Option B — headless OIDC/SSO login** (`PENPOT_OIDC_USERNAME` + `PENPOT_OIDC_PASSWORD`):\nThe server drives the OIDC redirect flow over plain HTTP: it follows\nPenpot's OAuth endpoint redirect chain to the identity provider's login page,\nparses the HTML login form, submits the credentials, and captures the\nresulting `auth-token` cookie — no browser or external tooling needed.\nRe-logs in automatically on expiry. Works for form-based IdPs that render a\nstandard HTML login form (Keycloak, Authentik, Dex, and similar). Does **not**\nwork for JavaScript-driven login pages (Google, Microsoft, Okta hosted login)\nbecause their login UIs are SPA shells with no server-rendered form.\n\nSet `PENPOT_OIDC_PROVIDER` to the OAuth provider name configured on your\nPenpot instance (default: `\"oidc\"`). Most self-hosted setups use this\ndefault; check your Penpot server flags if login doesn't start.\n\n**Option C — pre-obtained SSO/OIDC cookie** (`PENPOT_AUTH_TOKEN_COOKIE`):\nPaste the raw value of the `auth-token` cookie from a browser session in\nwhich you already completed the OIDC/SSO login. The server uses it as-is\nand emits a clear error if it expires (since it has no credentials to\nre-login with). Works for instances where password login is disabled and\nthe IdP uses a JavaScript-driven login page (where Option B can't reach).\nTo obtain the cookie value:\n1. Open your Penpot instance in a browser and complete the SSO/OIDC login.\n2. Open DevTools → Application → Cookies → your Penpot domain.\n3. Copy the value of the `auth-token` cookie (not the whole `Cookie:` header\n   — just the token value after `auth-token=`).\n4. Set `PENPOT_AUTH_TOKEN_COOKIE` to that value.\n\nIf none of the options is configured, `penpot_export_shape` is not registered and\nevery other tool works exactly as before.\n\n## Setup\n\n1. Generate a Penpot access token: your Penpot instance → Account settings\n   → Access tokens. (Self-hosted instances: this section is hidden unless\n   the backend/frontend are started with `PENPOT_FLAGS` including\n   `enable-access-tokens`.)\n2. Create a token file for your project (see `design-tokens/*.tokens.json`\n   in the consuming repo for an example — or write your own matching the\n   schema below).\n3. If you want `penpot_export_shape`, also configure one of:\n   - **Password login:** set `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD` to the\n     **same account** the access token above belongs to.\n   - **Headless OIDC login:** set `PENPOT_OIDC_USERNAME`/`PENPOT_OIDC_PASSWORD` to\n     your IdP credentials. Works for form-based IdPs (Keycloak, Authentik, Dex).\n     Set `PENPOT_OIDC_PROVIDER` if your provider name differs from the default `\"oidc\"`.\n   - **SSO/OIDC cookie:** set `PENPOT_AUTH_TOKEN_COOKIE` to the raw `auth-token`\n     cookie value from a browser session in which you completed the SSO login\n     (see \"Render/export capability\" above). Use this for JavaScript-driven IdP\n     login pages where headless OIDC can't reach.\n\n   Skip this step entirely if you don't need rendering.\n4. *(Optional)* To persist checkpoints across server restarts, set `PENPOT_CHECKPOINTS_PATH`\n   to a local directory path. When set, each `penpot_checkpoint` call writes a JSON file\n   to that directory; on startup the server reloads any files already there, so a\n   checkpoint survives the MCP server being restarted mid-way through a long edit.\n   The directory is created automatically. Without this setting, checkpoints live only\n   in process memory (the previous behaviour).\n5. Register this server in your MCP client config, e.g. (password-login variant):\n   ```json\n   {\n     \"mcpServers\": {\n       \"penpot-headless\": {\n         \"command\": \"npx\",\n         \"args\": [\"-y\", \"@ai-dala/penpot-headless\"],\n         \"env\": {\n           \"PENPOT_BASE_URL\": \"https://your-penpot-instance.example.com\",\n           \"PENPOT_ACCESS_TOKEN\": \"your-token-here\",\n           \"PENPOT_TOKENS_PATH\": \"/path/to/your-project/design-tokens/tokens.json\",\n           \"PENPOT_LOGIN_EMAIL\": \"same-account-as-the-token-above@example.com\",\n           \"PENPOT_LOGIN_PASSWORD\": \"that-account's-password\"\n         }\n       }\n     }\n   }\n   ```\n\n   Or, for an SSO/OIDC-only instance with a form-based IdP (headless OIDC login):\n   ```json\n   {\n     \"mcpServers\": {\n       \"penpot-headless\": {\n         \"command\": \"npx\",\n         \"args\": [\"-y\", \"@ai-dala/penpot-headless\"],\n         \"env\": {\n           \"PENPOT_BASE_URL\": \"https://your-penpot-instance.example.com\",\n           \"PENPOT_ACCESS_TOKEN\": \"your-token-here\",\n           \"PENPOT_TOKENS_PATH\": \"/path/to/your-project/design-tokens/tokens.json\",\n           \"PENPOT_OIDC_USERNAME\": \"your-idp-username-or-email\",\n           \"PENPOT_OIDC_PASSWORD\": \"your-idp-password\"\n         }\n       }\n     }\n   }\n   ```\n\n   Or, for an SSO/OIDC-only instance with a JavaScript-driven IdP login page:\n   ```json\n   {\n     \"mcpServers\": {\n       \"penpot-headless\": {\n         \"command\": \"npx\",\n         \"args\": [\"-y\", \"@ai-dala/penpot-headless\"],\n         \"env\": {\n           \"PENPOT_BASE_URL\": \"https://your-penpot-instance.example.com\",\n           \"PENPOT_ACCESS_TOKEN\": \"your-token-here\",\n           \"PENPOT_TOKENS_PATH\": \"/path/to/your-project/design-tokens/tokens.json\",\n           \"PENPOT_AUTH_TOKEN_COOKIE\": \"the-auth-token-cookie-value-from-your-browser\"\n         }\n       }\n     }\n   }\n   ```\n\n   Or, if working from a local clone instead of the published package:\n   ```json\n   {\n     \"mcpServers\": {\n       \"penpot-headless\": {\n         \"command\": \"npx\",\n         \"args\": [\"tsx\", \"/path/to/penpot-mcp/src/server.ts\"],\n         \"env\": {\n           \"PENPOT_BASE_URL\": \"https://your-penpot-instance.example.com\",\n           \"PENPOT_ACCESS_TOKEN\": \"your-token-here\",\n           \"PENPOT_TOKENS_PATH\": \"/path/to/your-project/design-tokens/tokens.json\",\n           \"PENPOT_LOGIN_EMAIL\": \"same-account-as-the-token-above@example.com\",\n           \"PENPOT_LOGIN_PASSWORD\": \"that-account's-password\"\n         }\n       }\n     }\n   }\n   ```\n\n## Token file schema\n\n```json\n{\n  \"colors\": {\n    \"accent\": \"#7AA2FF\",\n    \"bg-surface\": \"#111726\"\n  },\n  \"fonts\": {\n    \"sans\": { \"family\": \"Inter\", \"weights\": [\"400\", \"500\", \"600\"] }\n  },\n  \"spacing\": {\n    \"sm\": 8,\n    \"md\": 16,\n    \"lg\": 24\n  },\n  \"radii\": {\n    \"sm\": 4,\n    \"md\": 8,\n    \"pill\": 999\n  },\n  \"shadows\": {\n    \"card\": {\n      \"style\": \"drop-shadow\",\n      \"color\": \"#000000\",\n      \"opacity\": 0.3,\n      \"offsetX\": 4,\n      \"offsetY\": 4,\n      \"blur\": 8,\n      \"spread\": 2\n    }\n  }\n}\n```\n\nAll four token tables are optional except `colors`. Any color-typed field\nin `penpot_add_shapes`/`penpot_update_shapes` accepts either a literal hex\nstring or `{ \"token\": \"accent\" }`, resolved against this file at call time.\nThe same applies to:\n\n- **Numeric fields** (layout gaps/padding, layoutItem margins/min/max\n  sizes, and corner radii `r1`-`r4`) — either a literal number or\n  `{ \"token\": \"sm\" }`, resolved against `spacing` (gaps/padding/margins) or\n  `radii` (corner radii).\n- **`shadows`** on a shape — an array where each entry is either an inline\n  `{ style, color, opacity, offsetX, offsetY, blur, spread }` object (color\n  itself may also be a `{ \"token\": \"name\" }` reference) or\n  `{ \"token\": \"card\" }`, resolved against `shadows`. `style` is\n  `\"drop-shadow\"` (default) or `\"inner-shadow\"`.\n\n## Testing\n\nTwo suites, split because they need very different things:\n\n- **Unit** (`npm test`) — pure functions in `shape-builders.ts` (rotation\n  matrix math, layout attribute mapping, the camelCase-in/kebab-case-out\n  shape extraction `penpot_update_shapes` relies on) and `font-metrics.ts`\n  (line-splitting/word-wrap logic, measured against a hand-built fake font\n  so no network call is needed). No network, no Penpot account, runs\n  anywhere. This is the suite CI runs on every push/PR.\n- **Integration** (`npm run test:integration`) — exercises the actual MCP\n  tool handlers against a real Penpot instance via `PENPOT_BASE_URL`/\n  `PENPOT_ACCESS_TOKEN` (reads `.env`, same as the server itself). Each\n  test creates its own scratch project and deletes it in a `finally` block,\n  even on failure — see `test/integration/helpers/scratch-project.ts`.\n  Skips itself (doesn't fail) if credentials aren't configured. **Local/\n  manual only — deliberately not run in CI**, since it mutates a real\n  Penpot account and CI credentials for that are out of scope for this\n  project. Run it yourself before trusting a change to Penpot's wire\n  schema (rotation, layout, components, variants). `font-metrics.test.ts`\n  also lives in this suite despite not needing Penpot credentials — it\n  makes a real call to the public Google Fonts CDN, which the unit suite's\n  \"no network\" rule excludes; it skips itself if that network call fails\n  rather than needing `PENPOT_ACCESS_TOKEN`.\n\nThis split matters because Penpot's RPC schema accepting a change is not\nthe same as Penpot's editor correctly rendering or recognizing it — the\nvariant-id bug documented in `shape-builders.ts` (`variantContainerAttrs`)\nwas only caught by live testing, not by the schema validating successfully.\n`npm run test:all` runs both.\n\n## Copying to another project\n\nThis package is published to npm as `@ai-dala/penpot-headless`, so most\nconsuming projects can just reference it via `npx` (see above) without a\nlocal copy.\n\nIf you'd rather vendor it, this directory has its own\n`package.json`/`tsconfig.json` and depends only on\n`@modelcontextprotocol/sdk`, `zod`, and `opentype.js` (used by\n`penpot_measure_text` to parse real font files) — no framework\ndependencies. Copy the whole `penpot-headless/` directory into the new\nproject, run `npm install`, write a token file for that project, and\nregister the server with a `PENPOT_TOKENS_PATH` pointing at it.\n\n## Known limitations\n\n- `penpot_measure_text` doesn't apply ligature substitution (e.g. rendering\n  \"fi\" as one glyph) — it sums individual glyph advances (with real GPOS\n  kerning) instead of using opentype.js's built-in shaping, because that\n  shaping path throws on GSUB tables present in most current-generation\n  Google Fonts, including Inter and Roboto (see `measureLineWidth` in\n  `font-metrics.ts`). This is a deliberate tradeoff, not an open gap: the\n  width difference from skipping ligatures is a few percent at most for\n  typical UI text, next to a hard crash on most real-world fonts if the\n  built-in shaping were used instead.\n- `penpot_measure_text` matches Penpot custom/team fonts by family name\n  (case-insensitive) and weight. If a font variant exists in multiple styles\n  (normal/italic) for the same weight, the \"normal\" style is preferred; if\n  your use-case requires a specific non-normal style for measurement, this\n  is not yet controllable via the tool's input parameters.\n\n## Possible future additions\n\nIdeas for further reducing agent friction, roughly in priority order:\n\n- [x] `penpot_update_shapes` — edit an existing shape's geometry/fill/stroke/\n      text in place. (Done — see above.)\n- [x] `penpot_delete_shapes` — wraps `del-obj`. (Done — see above.)\n- [x] `penpot_get_shape` — look up one shape (or a small subtree) by id,\n      instead of pulling the whole page via `penpot_get_file_snapshot` and\n      parsing a potentially large JSON blob client-side to find it. (Done —\n      see above.)\n- [x] `penpot_measure_text` — text `width`/`height` in `penpot_add_shapes`\n      are just numbers the caller has to guess; Penpot only knows real\n      rendered bounds after layout. (Done — see above. Measures against a\n      real fetched Google Font via opentype.js rather than an approximation;\n      see the \"Text measurement\" note below and `font-metrics.ts` for why it\n      sums per-glyph advances instead of using opentype.js's built-in\n      `getAdvanceWidth`.)\n- [x] `penpot_clone_shapes` — plain-shape duplication (not the\n      component-instance path already covered by\n      `penpot_add_component_instance`). (Done — see above.)\n- [x] `penpot_reorder_shapes` — bring-to-front/send-to-back/forward/backward/\n      before/after, by id. (Done — see above. Note: does *not* wrap\n      `reorder-children` as originally planned — that RPC change type was\n      never actually verified live. Instead it round-trips the parent shape\n      through `add-obj` with a recomputed `shapes` array, the same mechanism\n      `penpot_update_shapes` already uses; `reorder-children` may still work\n      but wasn't worth the extra risk once the `add-obj` approach was\n      confirmed live. One live-only pitfall found along the way: fields like\n      `transformInverse`/`hideFillOnExport` on the object returned by\n      `get-file` are real required fields, not stale camelCase duplicates —\n      they must be renamed to their kebab-case form when round-tripped, not\n      dropped, or `update-file` rejects the change with a malli validation\n      error on `transform-inverse`.)\n- [x] `penpot_list_components` — enumerate a file's existing components,\n      instead of requiring the caller to have created them itself in the\n      same session or parse the snapshot's `components` map by hand. (Done —\n      see above.)\n- [x] `penpot_find_shapes` — predicate/name-based search over a page\n      (mirrors the plugin API's `findShapes`), instead of the caller\n      walking the tree itself for \"every text shape\" or \"the shape named X.\"\n      (Done — see above.)\n- [x] `penpot_batch` — apply an ordered list of create/update/delete/reorder\n      ops as a single `update-file` change-set. (Done — see above. Ops are\n      applied against an in-memory shadow of the page's shapes, mutated as\n      each op is processed, so a later op sees the effect of every earlier\n      op in the same call — mirroring how `update-file` applies its\n      `changes` array sequentially server-side. This is also what lets a\n      `create` op's caller-chosen `id` be referenced by a later op's\n      `parentId`/`frameId`, or be the target of a later `update`/`delete`/\n      `reorder`, without an extra RPC round trip in between.)\n- [x] `penpot_checkpoint` / `penpot_restore_checkpoint` /\n      `penpot_discard_checkpoint` — snapshot shapes before a risky multi-step\n      edit, and restore them after. (Done — see above. Penpot's RPC has no\n      \"revert to revn X\" primitive, so restore works by diffing current state\n      against the snapshot and replaying corrective `add-obj`/`del-obj` changes\n      as a single `update-file` call, reusing the same camelCase-in/kebab-case-out\n      normalization already verified live for `penpot_update_shapes`/\n      `penpot_reorder_shapes` — see `restoreShapeAsAddObj` in `shape-builders.ts`.\n      Snapshots are held in the MCP server's own memory, not written to the Penpot\n      file or disk, so a checkpoint doesn't survive a server restart.\n      `penpot_checkpoint` now accepts an optional `pageId`: supply it to scope the\n      snapshot to a single page; omit it for a whole-file checkpoint that covers\n      every page. Restore applies all pages' corrective changes in one `update-file`\n      call, so a multi-step edit that touched multiple pages is fully undone in a\n      single round trip.)\n- [x] `penpot_align_shapes` / `penpot_distribute_shapes` — the one-click\n      align-left/center/distribute-evenly actions Penpot's own UI has, that\n      this package doesn't. Without them, an agent has to compute pixel\n      positions itself from `penpot_get_shape` results — exactly the kind\n      of arithmetic LLMs get wrong. (Done — see above. Both operate on each\n      shape's `selrect` (its rotation-aware visible bounding box), so rotated\n      shapes line up by what's rendered; align never moves the group as a\n      whole (its reference line comes from the shapes' own extent) and\n      distribute leaves the two endpoints fixed. Aligning/distributing a\n      frame or group moves its whole descendant subtree by the same delta —\n      a container's children have absolute positions, so shifting only the\n      container's own x/y would leave them behind. Reuses the same\n      camelCase-in/kebab-case-out `add-obj` update path already verified live\n      for `penpot_update_shapes`, applied as a single `update-file`\n      change-set.)\n- [x] Token file support for spacing/radii/shadows, not just `colors` and\n      `fonts`. (Done — see above/Token file schema. Added a `shadows` wire\n      field to `rect`/`frame`/`text` that didn't exist at all before this —\n      verified live against a real instance's `add-obj`/`get-file`: the\n      field is `shadows` (array, back-to-front like `fills`/`strokes`),\n      `style` is `\"drop-shadow\"` | `\"inner-shadow\"` (not `shadowType` as the\n      Penpot plugin API's `Shadow` type would suggest), offsets are\n      kebab-case `offset-x`/`offset-y` on write and camelCase `offsetX`/\n      `offsetY` on read back, and `color` is a nested `{ color, opacity }`\n      object rather than flattened `shadow-color`/`shadow-opacity` keys —\n      see the comment on `Shadow` in `shape-builders.ts`. Every numeric\n      field that previously took a raw number (layout `rowGap`/`columnGap`/\n      `padding`, `layoutItem` margins/min/max sizes, corner radii `r1`-`r4`)\n      now accepts a `{ token: \"name\" }` reference resolved against the token\n      file's new `spacing`/`radii` tables, resolved in `content.ts` before\n      reaching `shape-builders.ts` — which stays token-agnostic by design,\n      same seam `resolveColor` already used for fills/strokes.)\n- [x] Path/ellipse shapes and boolean ops (union/subtract/intersect) in\n      `penpot_add_shapes`. (Done — `circle` is an ellipse bounded by x/y/width/height;\n      `path` accepts an array of `{ command, params }` segments and derives its bounding\n      box automatically from tight cubic-Bézier extrema; `bool` wraps children shapes\n      (added with `parentId` = the bool's id, same pattern as frames) with a `boolType`\n      of `union`, `difference`, `intersection`, or `exclusion` — the visual result path\n      is computed by Penpot's editor when the file is opened, since the boolean geometry\n      engine lives in Penpot's browser-side WASM module rather than on the server.)\n- [x] Widen `penpot_update_shapes` to cover path/group/circle/svg-raw/image/bool\n      (and any other shape type). (Done — `circle` and `bool` round-trip through their\n      dedicated builders (preserving `bool-type` and `shapes`); `path` rebuilds geometry\n      from the existing path content unless a new `content` array is supplied in the patch\n      (x/y/width/height are always derived from content and cannot be set independently);\n      `group` rebuilds geometry and `shapes` without touching children; image/svg-raw and\n      any other type get a generic geometry-only rebuild, with all type-specific fields\n      carried forward from `existing` via the `{ ...existing, ...obj }` merge. Also added\n      `layout` (frame shapes only) and `layoutItem` (any shape) to the patch schema so\n      auto-layout can be set or replaced after creation — same schema as `penpot_add_shapes`.\n      A new `boolType` patch field replaces a bool's operation type in place. `computeShapeGeometry`\n      and `group` are now exported from `shape-builders.ts` and `expandTranslateChanges` in\n      `content.ts` now correctly handles groups and paths in align/distribute subtrees.)\n- [x] Penpot team/custom font lookup for `penpot_measure_text` — if the\n      requested font family is not found on Google Fonts, all Penpot teams\n      accessible to the configured access token are searched for a matching\n      custom/team font variant (`get-font-variants` → `download-font`). The\n      \"normal\" style is preferred when multiple variants exist for the same\n      family+weight; if none match anywhere, a combined error message names\n      both sources. (Done — see `font-metrics.ts`:`fetchPenpotFontBytes`;\n      `PenpotFontClient` interface keeps the module decoupled from\n      `rpc-client.ts` so unit tests can supply a plain mock.)\n- [x] Add a variant to an already-existing component/group.\n      `penpot_add_variant` appends a new variant to an existing variant group\n      container by re-adding the container frame via `add-obj` with the new\n      variant's root id appended to its `shapes` array (the same\n      delete-and-recreate-through-`add-obj` trick used by\n      `penpot_reorder_shapes`, avoiding the `mov-objects` silent-no-op\n      pitfall noted below). The new component is registered in the same\n      `update-file` call.\n- [x] `penpot_list_pages` / `penpot_rename_page` / `penpot_delete_page` —\n      `penpot_create_page` exists and now so does the rest of the page CRUD:\n      `penpot_list_pages` returns every page's id and name (in order) without\n      pulling the full shape tree; `penpot_rename_page` renames a page in place;\n      `penpot_delete_page` removes it (guards against deleting the last page of\n      a file). Both mutations go through `update-file` with `rename-page` /\n      `del-page` change types, the same single-round-trip path every other\n      content tool uses.\n- [x] SSO/OIDC-only instance support for `penpot_export_shape` via\n      `PENPOT_AUTH_TOKEN_COOKIE`. (Done — in addition to the existing\n      `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD` password-login path,\n      you can now set `PENPOT_AUTH_TOKEN_COOKIE` to a raw `auth-token`\n      cookie value obtained by completing the OIDC/SSO flow in a real\n      browser. The profile-id is fetched via `get-profile` on first use;\n      if the cookie expires a clear error message explains how to renew\n      it. `PENPOT_LOGIN_EMAIL`/`PENPOT_LOGIN_PASSWORD` takes precedence\n      when both are set. Fully headless OIDC automation — driving the\n      redirect flow without a browser — is still not implemented, as\n      the cookie-passthrough approach covers the SSO gap without adding\n      a Playwright dependency.)\n- [x] Unit coverage for `rpc-client.ts`, `transit.ts`, and\n      `tools/project-files.ts` — currently zero unit tests, only reachable\n      via the integration suite that's excluded from CI. Mocking `fetch`\n      for status-code branching (204, 401/403 retry, non-2xx →\n      `PenpotRpcError`) would catch regressions in CI instead of only on a\n      manual integration run.\n- [x] Gradient and image fills — `Fill`/`Stroke` in `shape-builders.ts`\n      only carry a flat `color`/`opacity`; Penpot supports linear/radial\n      gradient fills and image fills, neither reachable from any tool\n      today. Likely the highest-value gap, since most real designs use at\n      least one gradient or image fill somewhere. (Done — `fills` array on\n      every non-text shape (`rect`, `frame`, `circle`, `path`, `bool`) in\n      `penpot_add_shapes`/`penpot_update_shapes`/`penpot_batch` accepts\n      entries of `type` `\"solid\"`, `\"linear-gradient\"`, `\"radial-gradient\"`,\n      or `\"image\"`. Gradient stops may use `{ token: \"name\" }` references\n      against the token file's `colors` table. Image fills reference an\n      already-uploaded Penpot media object by `mediaId` UUID — the caller\n      must upload the image to Penpot first (via the Penpot UI or a\n      separate tool). When `fills` is provided it overrides the legacy\n      `fillColor`/`fillOpacity` shorthand entirely; backward compatibility\n      is preserved for all existing callers using `fillColor`/`fillOpacity`.\n      The `Fill` type in `shape-builders.ts` is now a union of `SolidFill`,\n      `GradientFill`, and `ImageFill`; `extractEditableFields` round-trips\n      all three types from `get-file`'s camelCase response back to the\n      kebab-case form `add-obj` expects.)\n- [x] Image shapes / asset upload — there's no `image` shape builder and\n      no upload-media RPC call anywhere; `image`/`svg-raw` shapes are only\n      handled via a generic geometry-only fallback in\n      `penpot_update_shapes`. Blocks any workflow that needs to bring in\n      photos, icons, or existing SVG art. (Done — `penpot_upload_media`\n      uploads a media asset to a Penpot file and returns `{ id, name,\n      width, height, mtype }`. Three source modes: `filePath` (the MCP\n      server reads a local file and POSTs it as multipart), `url` (Penpot's\n      server fetches the image directly — nothing passes through the MCP\n      server, uses `create-file-media-object-from-url`), or `dataBase64`\n      (base64-encoded bytes, requires `mtype`). The returned `id` is then\n      passed as `mediaId` when calling `penpot_add_shapes` with\n      `type: \"image\"`; `mediaWidth`/`mediaHeight`/`mtype` come from the\n      same response. The `image` shape builder sets `type: \"image\"` with a\n      `metadata: { id, width, height, mtype }` block (Penpot's wire format,\n      verified from `schema:image-attrs` in `shape.cljc`) plus the standard\n      geometry and selrect/transform fields. `penpot_update_shapes` now\n      handles image shapes with a dedicated case (instead of the previous\n      generic fallback) and accepts `mediaId`/`mediaWidth`/`mediaHeight`/\n      `mtype` patch fields to swap the displayed image in place. MIME type\n      is auto-detected from file extension for `filePath` uploads\n      (`.png`→`image/png`, `.jpg`/`.jpeg`→`image/jpeg`, `.svg`→\n      `image/svg+xml`, `.webp`, `.gif`, `.avif`).)\n- [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.)\n- [x] Rich per-range text formatting — `text()` in `shape-builders.ts`\n      always emits a single paragraph with one style run; no per-\n      character-range styling, text-align, line-height, letter-spacing,\n      or text-decoration. (Done — `penpot_add_shapes` and\n      `penpot_update_shapes` both accept a `paragraphs` array on text\n      shapes. Each paragraph sets `textAlign` plus typography defaults\n      (`fontFamily`, `fontSize`, `fontWeight`, `fontStyle`, `lineHeight`,\n      `letterSpacing`, `textDecoration`, `textTransform`, `fills`/`fillColor`);\n      its `ranges` array holds one or more text runs that may individually\n      override any of those fields. Multiple paragraphs produce separate\n      paragraph nodes (line-break boundaries). `growType`\n      (`\"auto-width\"` | `\"auto-height\"` | `\"fixed\"`) and `verticalAlign`\n      (`\"top\"` | `\"center\"` | `\"bottom\"`) are also now settable on text\n      shapes. The legacy `characters`/`fontFamily`/`fontSize`/`fontWeight`/\n      `fillColor` shorthand is fully preserved for callers that don't need\n      per-range control. `extractEditableFields` now returns a full\n      `paragraphs` array for text shapes so a geometry-only update patch\n      (`x`/`y`/`width`/`height` only) no longer collapses a rich-text shape\n      back to single-paragraph. Verified field names against Penpot's\n      `common/src/app/common/types/text.cljc` `text-node-attrs` /\n      `paragraph-attrs` / `root-attrs`.)\n- [x] Group/ungroup as first-class tools — `group()` already exists as an\n      internal builder, but nothing exposes grouping existing shapes or\n      ungrouping a group via an MCP tool. (Done — `penpot_group_shapes` takes\n      a `shapeIds` array of sibling shapes (all must share the same parent)\n      and wraps them in a new group at the topmost selected shape's z-order\n      position; the group's bounding box is the union of the children's\n      `selrect`s (rotation-aware bounds). `penpot_ungroup_shapes` takes a\n      `groupId` and dissolves it: each child is reparented to the group's\n      former parent at the group's z-order position (preserving relative\n      child order) and the group shape is deleted. Both are implemented as\n      single `update-file` change-sets — group creation sends `add-obj` for\n      the new group, one `add-obj` per child (to update `parent-id`) and an\n      `add-obj` for the updated parent's `shapes` array; ungroup does the\n      same in reverse then appends a `del-obj`. Requires siblings only —\n      shapes with different parents must be moved to a common parent first.)\n- [x] Resize constraints — Penpot's `constraints-h`/`constraints-v`\n      (how a shape behaves when its parent resizes) are never set or\n      exposed in `EditableShapeFields` or the update patch schema.\n      (Done — `constraintsH` (`left`/`right`/`leftright`/`center`/`scale`) and\n      `constraintsV` (`top`/`bottom`/`topbottom`/`center`/`scale`) are now\n      accepted by every shape builder (`rect`, `frame`, `text`, `circle`,\n      `path`, `bool`, `group`, `image`), emitted as `constraints-h`/\n      `constraints-v` in the wire format. Both fields are settable at creation\n      time via `penpot_add_shapes` and patchable via `penpot_update_shapes`/\n      `penpot_batch`; `extractEditableFields` reads them back from `get-file`'s\n      camelCase response so a partial update patch preserves the existing\n      constraints when neither field is included in the patch.)\n- [x] Batch/whole-page export — `penpot_export_shape` takes exactly one\n      `shapeId` per call, with no multi-shape or multi-page batch export,\n      and only `png`/`svg` (no PDF). (Done — `penpot_export_batch` accepts\n      either a `shapeIds` array (same `pageId`, any mix of `format`/`scale`)\n      or a `shapes` array where each entry may carry its own `pageId`, `format`,\n      `scale`, and `name` — enabling whole-file exports across multiple pages in\n      a single `POST /api/export` call. Parsing each result's `~:uri`/\n      `~:mtype`/`~:filename` via `matchAll` on the transit+json response; asset\n      downloads are parallelised. Both tools also accept `pdf` as a format value.\n      Multi-page export was previously gated on the MCP schema, not the\n      exporter-client, which already sent per-spec `pageId`s.)\n- [x] Shared-library components — `penpot_list_components` and\n      `penpot_add_component_instance` only look at the current file's own\n      `components` map; there's no support for pulling components from a\n      separate connected shared-library file. (Done — `penpot_list_components`\n      now accepts `includeLibraries: true`, which calls `get-file-libraries` to\n      enumerate all linked library files (direct and transitive) and then calls\n      `get-file` on each to read its `components` map; each library component\n      entry carries a `libraryFileId` and `libraryFileName` field in the\n      response. `penpot_add_component_instance` now accepts an optional\n      `libraryFileId`; when supplied, the component's main-instance tree is\n      looked up in that library file instead of the current file, and the\n      cloned instance's root `component-file` field is set to the library\n      file's id (matching what Penpot's own editor writes for cross-file\n      component instances). Note: `get-file-libraries` returns file metadata\n      only — it does not include shape/component data — so a separate\n      `get-file` call per library is necessary to enumerate components; this\n      is the same two-step pattern Penpot's own frontend uses.)\n- [x] Comments API — `penpot_list_comment_threads` lists all threads for a file;\n      `penpot_get_comments` fetches the replies inside a thread;\n      `penpot_create_comment_thread` pins a new thread to a canvas position\n      (x/y + optional `frameId`); `penpot_create_comment` adds a reply;\n      `penpot_update_comment` edits an existing comment's text;\n      `penpot_resolve_comment_thread` marks a thread resolved or reopens it;\n      `penpot_delete_comment` removes a single reply; and\n      `penpot_delete_comment_thread` removes a whole thread and all its replies.\n      All eight tools go through `PenpotRpcClient` methods that send\n      kebab-case JSON params to the `get-comment-threads` /\n      `create-comment-thread` / `update-comment-thread` / `delete-comment` /\n      etc. RPC endpoints (same call pattern as every other tool). Wire format\n      verified against Penpot's frontend source (`data/comments.cljs`):\n      `position` is a nested `{ x, y }` JSON object; `frame-id` is optional\n      on creation (omit to place on the page root); `is-resolved` controls\n      thread resolution state.\n- [x] Text search-and-replace across a file — `penpot_replace_text` finds\n      every text shape on a page whose runs contain a literal search string and\n      rewrites all occurrences in a single `update-file` call. Matching is\n      case-insensitive by default (`caseSensitive: true` to override); an\n      optional `limit` caps the number of shapes touched; empty `replacement`\n      deletes matches. Replacement is per text-run (leaf node): a search string\n      that spans two adjacent runs within a paragraph is not matched — this\n      covers the common case where all text is in a single run per paragraph.\n      (Done — regex metacharacters in the search string are escaped so `.` /\n      `*` / `(` etc. are always matched literally, not as pattern operators.)\n- [x] Cross-page/whole-file undo — `penpot_checkpoint` now accepts an optional\n      `pageId`; omit it to snapshot every page in the file in one call.\n      `penpot_restore_checkpoint` restores all snapshotted pages in a single\n      `update-file` call. Checkpoints still live in server memory and don't\n      survive a restart; persistence across restarts is still not implemented\n      (callers needing that should snapshot via `penpot_get_file_snapshot`\n      themselves). (Done — `Checkpoint` in `checkpoints.ts` now holds a\n      `pages` map (pageId → objects) instead of a single `pageId`/`objects`\n      pair; the restore loop iterates every entry and accumulates all changes\n      before sending one `update-file` call — the same single-round-trip\n      pattern used by `penpot_batch`.)\n- [x] Fully headless OIDC/SSO login — drive the redirect flow without a\n      browser, instead of requiring `PENPOT_AUTH_TOKEN_COOKIE` to be\n      obtained manually beforehand. (Done — `PENPOT_OIDC_USERNAME` +\n      `PENPOT_OIDC_PASSWORD` trigger a new `'oidc'` auth mode in the\n      exporter client. The server follows Penpot's `/api/auth/oauth/{provider}`\n      redirect chain to the identity provider's login page via plain HTTP\n      (Node.js `http`/`https` modules, no browser dependency), parses the\n      HTML login form heuristically to identify username/email and password\n      fields (including hidden CSRF/state fields, which are preserved and\n      re-submitted verbatim), and captures the resulting `auth-token`\n      cookie. Multi-step flows — where the IdP shows username on page 1\n      and password on page 2 (Authentik-style) — are handled by a\n      MAX_FORM_STEPS loop that re-parses and re-submits until the cookie\n      appears. 307/308 redirects preserve POST method+body; 301/302/303\n      switch to GET per RFC 7231. The cookie jar correctly captures\n      Penpot's own state/CSRF cookies set on the initial redirect so they\n      are forwarded to the IdP's callback. Re-logs in automatically on\n      401/403 (same as password mode). `PENPOT_OIDC_PROVIDER` controls\n      the provider name (default `\"oidc\"`). Does not work for\n      JavaScript-driven IdP login pages — use `PENPOT_AUTH_TOKEN_COOKIE`\n      for those. Unit-tested via injected mock fetcher; integration test\n      requires a live OIDC provider and is not in CI.)\n- [x] Checkpoint persistence across server restarts — checkpoints\n      (`penpot_checkpoint`/`penpot_restore_checkpoint`) currently live only\n      in the MCP server's own memory and are lost on restart. Persisting\n      them to disk (or letting the caller supply a checkpoint id to\n      save/load) would let a long-running multi-step edit survive a server\n      restart mid-way through. (Done — set `PENPOT_CHECKPOINTS_PATH` to a\n      local directory; the server writes each checkpoint as `<uuid>.json`\n      into that directory and reloads all files from it at startup.\n      `initCheckpointStore(dir)` is called once in `server.ts` main;\n      `saveCheckpoint` is now async and awaits the `writeFile` so the\n      caller knows immediately if the write failed; `deleteCheckpoint` is\n      also async and removes the corresponding file from disk. No change\n      to the in-memory behaviour when the env var is unset.)\n- [x] Single-call multi-page batch export — `penpot_export_batch` exports\n      multiple shapes on one page in a single call; exporting across pages\n      still requires one call per `pageId`. Worth revisiting if batch\n      export becomes a bottleneck for whole-file exports. (Done — `penpot_export_batch`\n      now accepts either `shapeIds` (single-page shorthand, backward-compatible) or a\n      `shapes` array where each entry carries its own `pageId`, `format`, `scale`, and\n      optional `name`. The exporter-client already sent per-spec `pageId`s in a single\n      `POST /api/export`; the only change was widening the MCP tool's Zod schema to\n      expose this capability. Cross-field validation (at least one of `shapeIds`/`shapes`;\n      not both; `pageId` required for `shapeIds`; every `shapes` entry has a `pageId`\n      either per-entry or via the top-level default) is done with `.refine()` on a\n      separate `exportBatchInput` schema while `exportBatchBaseSchema` (the raw\n      `ZodObject`) is passed to the MCP SDK's `inputSchema` to preserve `.shape`.)\n- [x] Stale-write detection via `revn` — every write tool\n      (`penpot_update_shapes`/`penpot_batch`/`penpot_restore_checkpoint`/etc.)\n      currently sends `update-file` blind, without checking the file's\n      current `revn` (revision number) first. If a human has the file open\n      in the Penpot editor and edits concurrently with an agent session,\n      whichever write lands second silently overwrites the other with no\n      error. Penpot's own `update-file` RPC takes a `revn` and is expected\n      to reject a change-set built against a stale one; today nothing here\n      reads or checks it beforehand. Fix is to fetch current `revn` before\n      writing and surface a clear \"file changed underneath you, re-fetch\n      and retry\" error on rejection, rather than actually merging concurrent\n      edits (which would need Penpot's own operational-transform logic).\n      (Done — `PenpotStaleWriteError` is thrown by `updateFile` in\n      `rpc-client.ts` whenever the `update-file` response includes a\n      non-empty `lagged` array, meaning Penpot applied change-sets from\n      another session before ours. Penpot's collaborative engine still\n      applies our write on top, so the write succeeds at the HTTP level,\n      but the error surfaces the concurrent-edit situation with a clear\n      message that names the new `revn` and tells the caller to re-fetch\n      via `penpot_get_file_snapshot` before making further edits. Applies\n      to all write tools automatically — `penpot_add_shapes`,\n      `penpot_update_shapes`, `penpot_delete_shapes`, `penpot_batch`,\n      `penpot_restore_checkpoint`, `penpot_group_shapes`,\n      `penpot_ungroup_shapes`, `penpot_clone_shapes`, `penpot_reorder_shapes`,\n      `penpot_align_shapes`, `penpot_distribute_shapes`, page CRUD, etc. —\n      since they all go through `updateFile`. `PenpotStaleWriteError`\n      carries `laggedCount` (number of concurrent change-sets) and `result`\n      (the full `update-file` response, including the new `revn`) so\n      callers that need the new revision can read it from the error rather\n      than requiring an additional round trip.)\n- [x] Version history / branching support — `penpot_list_file_snapshots` lists all named\n      snapshots for a file (both user-created versions and system auto-backups);\n      `penpot_create_file_snapshot` saves the current state as a named version (optional\n      `label`; Penpot generates a timestamp label when omitted);\n      `penpot_restore_file_snapshot` rolls the live file back to any listed snapshot\n      (Penpot automatically creates a system backup of the current state before applying\n      the restore, so a restore is itself undoable by restoring the most-recent system\n      entry); `penpot_rename_file_snapshot` renames a user-created snapshot;\n      `penpot_delete_file_snapshot` removes one; `penpot_lock_file_snapshot` /\n      `penpot_unlock_file_snapshot` pin/unpin a snapshot against accidental deletion\n      (only the snapshot's creator can lock/unlock, and only user-created snapshots\n      support locking — system backups expire automatically). `penpot_get_file_snapshot_data`\n      returns the full file content at a specific snapshot for read-only inspection or\n      comparison without touching the live file. Wire format verified against\n      `backend/src/app/rpc/commands/files_snapshot.clj` and\n      `backend/src/app/features/file_snapshots.clj`.\n- [x] Component-instance drift/override visibility — Penpot's composition\n      model is tokens → elements → components (with variants) → screens\n      built from component instances, and today the tools that read shape\n      state (`penpot_get_shape`, `penpot_find_shapes`) don't surface where a\n      shape sits in that hierarchy. A component instance's `shape-ref` link\n      to its main component (and whether it has drifted — fields overridden\n      since being placed, or fully detached) is invisible: an agent editing\n      an instance via `penpot_update_shapes` can't currently tell \"this edit\n      creates a per-instance override\" from \"this edit changes a raw shape,\"\n      which matters for keeping instances honestly in sync with their\n      component rather than silently diverging the way ad-hoc UI edits can.\n      Scope: extend `penpot_get_shape`/`penpot_find_shapes` to report\n      link state (`linked` / `detached` / not-an-instance) and, when linked,\n      which fields differ from the main component's current definition —\n      read-only visibility first; explicit detach/relink tools and\n      discovery-first tool guidance (checking `penpot_list_components`\n      before creating shapes that duplicate an existing component) are\n      possible natural follow-ons once drift is at least visible.\n      (Done — both `penpot_get_shape` and `penpot_find_shapes` now include\n      component link state in their output. `penpot_get_shape` adds a\n      top-level `componentInfo` field with `linkState` (`\"linked\"` /\n      `\"detached\"` / `\"not-an-instance\"` / `\"main-component-root\"`), the\n      `componentId` and `componentFileId` when present, `mainInstanceId` /\n      `mainInstancePage` for same-file linked instances, and a `driftedFields`\n      array (camelCase field names) listing which visual properties on this\n      instance differ from the main component's current definition — empty\n      array means fully in sync. `penpot_find_shapes` adds `linkState` and\n      `componentId` to each match entry, plus `driftedFields` for same-file\n      linked instances. Drift is compared over: `name`, `fills`, `strokes`,\n      `shadows`, `opacity`, `hidden`, `blendMode`, `width`, `height`,\n      `constraintsH`, `constraintsV`, and `content` (text shapes); position\n      `x`/`y` is excluded since every placed instance lives at a different\n      canvas location by design. Library components (`componentFile` ≠ current\n      file) report `linkState: \"linked\"` but `driftedFields` is omitted since\n      the library file's pages would require an extra RPC call — use\n      `penpot_get_shape` on a library instance root, then follow its\n      `mainInstanceId`/`mainInstancePage` manually if cross-file drift is\n      needed.)\n\n","readmeFilename":"README.md"}