penpot-headless
The penpot-headless server provides MCP tools for managing Penpot design files programmatically via Penpot's RPC API — no browser or plugin session required.
Team & Project Management
List all accessible Penpot teams
List, create, rename, and delete projects within a team
File Management
List, create, rename, and delete files within a project
Get a full file snapshot including pages, shape tree, and revision info
Page & Shape Management
Create new empty pages within a file
Add
rect,frame, ortextshapes to a page with support for:Position, size, fill colors/opacity, stroke (color, style, width, alignment)
Corner radii, rotation, auto-layout settings, and parent/frame nesting
Update existing shapes' geometry, fill, stroke, rotation, and text content (partial updates preserve other properties)
Component & Variant Management
Create components from shape trees and add component instances to pages
Create variant groups from multiple components with shared property axes (e.g., a Button with Primary/Secondary variants)
Export
Render shapes or entire pages as PNG or SVG via Penpot's server-side exporter
Design Token Support
Load and validate a project's design token file to resolve color/font references
Color fields throughout the API accept either a literal hex string (e.g.
"#7AA2FF") or a token reference (e.g.{ "token": "accent" })
Provides tools for managing Penpot files, projects, and teams, including CRUD operations for teams, projects, files, and pages, as well as creating shapes (rect, frame, text) and retrieving file snapshots, all via Penpot's RPC API without a browser session.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@penpot-headlesscreate a new file called 'Landing Page' in the Design team"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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), andimage— viapenpot_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 optionalrotationfield (degrees, clockwise, about the shape's center).Shape editing:
penpot_update_shapeschanges 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/clearShadowsremove strokes/shadows entirely. Geometry changes automatically recompute the shape's selection box and transform, so a partial edit (e.g. justwidth) can't desync from the rest of the shape's geometry. Layout/layoutItem aren't editable this way — only settable at creation time viapenpot_add_shapes.Shape deletion:
penpot_delete_shapesremoves 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_shapeswraps 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-awareselrects.penpot_ungroup_shapesdissolves 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_shapesduplicates 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. Optionaldx/dyoffset each clone from its source (defaults to no offset, stacked directly on top of the original); optionalparentId/frameIdreparent every cloned root instead of leaving it alongside its source.Shape stacking order:
penpot_reorder_shapeschanges a shape's z-order among its siblings, matching Penpot's own "Bring to front" / "Send to back" / "Forward" / "Backward" UI actions.actionisfront/back(top/bottom of the stack),forward/backward(swap with the next/ previous sibling — a no-op if already at that end), orbefore/after(place immediately relative to another sibling given astargetId). Only reorders among existing siblings — it doesn't reparent.Align & distribute:
penpot_align_shapeslines up two or more shapes on a common edge or center (left/right/top/bottom/center-h/center-v), andpenpot_distribute_shapesspreads 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 (itsselrect), 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_batchapplies an ordered list of create/update/ delete/reorder operations as a singleupdate-filechange-set — onerevn/vernround trip no matter how many shapes are touched, instead of one RPC call per shape (and the races onrevnthat come with that). Each op takes the same fields as the matching standalone tool (penpot_add_shapesforcreate,penpot_update_shapesforupdate, etc.); acreateop may set an explicitidand be referenced as a later op'sparentId/frameIdor targeted by a laterupdate/delete/reorderin the same call, so a whole form or card grid can be built in one round trip.Undo point:
penpot_checkpointsnapshots shapes and returns acheckpointId;penpot_restore_checkpointundoes everything since — including a wrongpenpot_delete_shapescall, which otherwise has no undo path short of Penpot's own UI. SupplypageIdto scope the snapshot to a single page; omitpageIdfor a whole-file checkpoint that covers every page. Works by diffing current state against the snapshot and replaying corrective changes as a singleupdate-filecall (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 viapenpot_discard_checkpointor the server process restarts.Shape lookup:
penpot_get_shapefetches a single shape by id, without pulling the whole page viapenpot_get_file_snapshot. By default nests the shape's full descendant subtree undershapes;includeDescendantsandmaxDepthcontrol how much of the subtree comes back. Always includes acomponentInfofield:linkState("linked"/"detached"/"not-an-instance"/"main-component-root"), and — for same-file linked instances — adriftedFieldsarray listing the camelCase field names that differ from the main component's current definition (empty = fully in sync).Shape search:
penpot_find_shapessearches 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/orisRoot— all given filters must match (AND). Omit every filter to list the whole page. Returns each match's id/type/name/position/size pluslinkStateand (for linked same-file instances)driftedFields; usepenpot_get_shapeon a match's id for the full subtree and detailedcomponentInfo, optionally capped vialimit.Text measurement:
penpot_measure_textcomputes the real rendered width/height of a string for a given font (family/size/weight), without creating or touching any shape — removing the guesswork aroundwidth/heightwhen callingpenpot_add_shapes/penpot_update_shapesfor 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; passmaxWidthto also get word-wrapped line breaks for a fixed-width box.Auto-layout: a
framemay declare flex or grid layout via an optionallayoutfield (direction, gap, padding, alignment; grid also takes row/column track definitions). Any shape may setlayoutItemto 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_componentregisters a shape tree (same specs aspenpot_add_shapes) as a component's main instance; give shapes explicitids to nest them, and the one shape not parented to a sibling becomes the root.penpot_add_component_instancethen places a full copy of that tree elsewhere on a page, linked back to the main instance via Penpot'sshape-refso it's recognized as a proper component copy — reusable any number of times.Variants:
penpot_create_variant_groupgroups two or more components (built the same way aspenpot_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, includinginstance.switchVariant(pos, value)correctly swapping a placed instance between variants.penpot_add_variantadds a new variant to an already-existing variant group container: accepts the same shape specs as one entry inpenpot_create_variant_group'svariantsarray and thecontainerIdreturned by that tool (or found viapenpot_find_shapes), then appends the new variant's main instance to the container'sshapesarray and registers a new component — all in a singleupdate-filecall.penpot_list_componentsenumerates a file's existing components (from itsdata.componentsmap), instead of requiring the caller to have created them itself in the same session or parsepenpot_get_file_snapshot's output by hand. Each entry includes thecomponentId(usable withpenpot_add_component_instance),name,mainInstanceId/mainInstancePage, and — for variant components —variantId/variantProperties.Media upload:
penpot_upload_mediauploads 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 returnedidis used asmediaIdwhen creating animageshape.Comments:
penpot_list_comment_threadslists all threads for a file;penpot_get_commentsfetches replies inside a thread;penpot_create_comment_threadpins a new thread to a canvas position (x/y, optionalframeId);penpot_create_commentadds a reply;penpot_update_commentedits a comment's text;penpot_resolve_comment_threadmarks a thread resolved or reopens it;penpot_delete_commentremoves a single reply;penpot_delete_comment_threadremoves a whole thread.Version history:
penpot_list_file_snapshotslists all named snapshots for a file;penpot_create_file_snapshotsaves the current state as a named version (equivalent to "Save version" in the Penpot UI, with an optionallabel);penpot_restore_file_snapshotrolls 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_snapshotmanage user-created versions;penpot_lock_file_snapshot/penpot_unlock_file_snapshotprotect a version from accidental deletion;penpot_get_file_snapshot_datareturns 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) andpenpot_export_batch(multiple shapes in one call, returned in the same order, across any number of pages — use theshapesarray for multi-page exports; theshapeIdsshorthand is still available when all shapes share the same page) — requiresPENPOT_LOGIN_EMAIL/PENPOT_LOGIN_PASSWORD,PENPOT_OIDC_USERNAME/PENPOT_OIDC_PASSWORD, orPENPOT_AUTH_TOKEN_COOKIE, see below — no browser tab or Penpot plugin session needed on your end.
Related MCP server: Local Figma MCP Bridge
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:
Open your Penpot instance in a browser and complete the SSO/OIDC login.
Open DevTools → Application → Cookies → your Penpot domain.
Copy the value of the
auth-tokencookie (not the wholeCookie:header — just the token value afterauth-token=).Set
PENPOT_AUTH_TOKEN_COOKIEto that value.
If none of the options is configured, penpot_export_shape is not registered and
every other tool works exactly as before.
Setup
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_FLAGSincludingenable-access-tokens.)Create a token file for your project (see
design-tokens/*.tokens.jsonin the consuming repo for an example — or write your own matching the schema below).If you want
penpot_export_shape, also configure one of:Password login: set
PENPOT_LOGIN_EMAIL/PENPOT_LOGIN_PASSWORDto the same account the access token above belongs to.Headless OIDC login: set
PENPOT_OIDC_USERNAME/PENPOT_OIDC_PASSWORDto your IdP credentials. Works for form-based IdPs (Keycloak, Authentik, Dex). SetPENPOT_OIDC_PROVIDERif your provider name differs from the default"oidc".SSO/OIDC cookie: set
PENPOT_AUTH_TOKEN_COOKIEto the rawauth-tokencookie 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.
(Optional) To persist checkpoints across server restarts, set
PENPOT_CHECKPOINTS_PATHto a local directory path. When set, eachpenpot_checkpointcall 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).Register this server in your MCP client config, e.g. (password-login variant):
{ "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):
{ "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:
{ "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:
{ "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
{
"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 againstspacing(gaps/padding/margins) orradii(corner radii).shadowson 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 againstshadows.styleis"drop-shadow"(default) or"inner-shadow".
Testing
Two suites, split because they need very different things:
Unit (
npm test) — pure functions inshape-builders.ts(rotation matrix math, layout attribute mapping, the camelCase-in/kebab-case-out shape extractionpenpot_update_shapesrelies on) andfont-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 viaPENPOT_BASE_URL/PENPOT_ACCESS_TOKEN(reads.env, same as the server itself). Each test creates its own scratch project and deletes it in afinallyblock, even on failure — seetest/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.tsalso 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 needingPENPOT_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_textdoesn'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 (seemeasureLineWidthinfont-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_textmatches 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:
penpot_update_shapes— edit an existing shape's geometry/fill/stroke/ text in place. (Done — see above.)penpot_delete_shapes— wrapsdel-obj. (Done — see above.)penpot_get_shape— look up one shape (or a small subtree) by id, instead of pulling the whole page viapenpot_get_file_snapshotand parsing a potentially large JSON blob client-side to find it. (Done — see above.)penpot_measure_text— textwidth/heightinpenpot_add_shapesare 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 andfont-metrics.tsfor why it sums per-glyph advances instead of using opentype.js's built-ingetAdvanceWidth.)penpot_clone_shapes— plain-shape duplication (not the component-instance path already covered bypenpot_add_component_instance). (Done — see above.)penpot_reorder_shapes— bring-to-front/send-to-back/forward/backward/ before/after, by id. (Done — see above. Note: does not wrapreorder-childrenas originally planned — that RPC change type was never actually verified live. Instead it round-trips the parent shape throughadd-objwith a recomputedshapesarray, the same mechanismpenpot_update_shapesalready uses;reorder-childrenmay still work but wasn't worth the extra risk once theadd-objapproach was confirmed live. One live-only pitfall found along the way: fields liketransformInverse/hideFillOnExporton the object returned byget-fileare real required fields, not stale camelCase duplicates — they must be renamed to their kebab-case form when round-tripped, not dropped, orupdate-filerejects the change with a malli validation error ontransform-inverse.)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'scomponentsmap by hand. (Done — see above.)penpot_find_shapes— predicate/name-based search over a page (mirrors the plugin API'sfindShapes), instead of the caller walking the tree itself for "every text shape" or "the shape named X." (Done — see above.)penpot_batch— apply an ordered list of create/update/delete/reorder ops as a singleupdate-filechange-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 howupdate-fileapplies itschangesarray sequentially server-side. This is also what lets acreateop's caller-chosenidbe referenced by a later op'sparentId/frameId, or be the target of a laterupdate/delete/reorder, without an extra RPC round trip in between.)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 correctiveadd-obj/del-objchanges as a singleupdate-filecall, reusing the same camelCase-in/kebab-case-out normalization already verified live forpenpot_update_shapes/penpot_reorder_shapes— seerestoreShapeAsAddObjinshape-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_checkpointnow accepts an optionalpageId: 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 oneupdate-filecall, so a multi-step edit that touched multiple pages is fully undone in a single round trip.)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 frompenpot_get_shaperesults — exactly the kind of arithmetic LLMs get wrong. (Done — see above. Both operate on each shape'sselrect(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-outadd-objupdate path already verified live forpenpot_update_shapes, applied as a singleupdate-filechange-set.)Token file support for spacing/radii/shadows, not just
colorsandfonts. (Done — see above/Token file schema. Added ashadowswire field torect/frame/textthat didn't exist at all before this — verified live against a real instance'sadd-obj/get-file: the field isshadows(array, back-to-front likefills/strokes),styleis"drop-shadow"|"inner-shadow"(notshadowTypeas the Penpot plugin API'sShadowtype would suggest), offsets are kebab-caseoffset-x/offset-yon write and camelCaseoffsetX/offsetYon read back, andcoloris a nested{ color, opacity }object rather than flattenedshadow-color/shadow-opacitykeys — see the comment onShadowinshape-builders.ts. Every numeric field that previously took a raw number (layoutrowGap/columnGap/padding,layoutItemmargins/min/max sizes, corner radiir1-r4) now accepts a{ token: "name" }reference resolved against the token file's newspacing/radiitables, resolved incontent.tsbefore reachingshape-builders.ts— which stays token-agnostic by design, same seamresolveColoralready used for fills/strokes.)Path/ellipse shapes and boolean ops (union/subtract/intersect) in
penpot_add_shapes. (Done —circleis an ellipse bounded by x/y/width/height;pathaccepts an array of{ command, params }segments and derives its bounding box automatically from tight cubic-Bézier extrema;boolwraps children shapes (added withparentId= the bool's id, same pattern as frames) with aboolTypeofunion,difference,intersection, orexclusion— 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.)Widen
penpot_update_shapesto cover path/group/circle/svg-raw/image/bool (and any other shape type). (Done —circleandboolround-trip through their dedicated builders (preservingbool-typeandshapes);pathrebuilds geometry from the existing path content unless a newcontentarray is supplied in the patch (x/y/width/height are always derived from content and cannot be set independently);grouprebuilds geometry andshapeswithout touching children; image/svg-raw and any other type get a generic geometry-only rebuild, with all type-specific fields carried forward fromexistingvia the{ ...existing, ...obj }merge. Also addedlayout(frame shapes only) andlayoutItem(any shape) to the patch schema so auto-layout can be set or replaced after creation — same schema aspenpot_add_shapes. A newboolTypepatch field replaces a bool's operation type in place.computeShapeGeometryandgroupare now exported fromshape-builders.tsandexpandTranslateChangesincontent.tsnow correctly handles groups and paths in align/distribute subtrees.)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 — seefont-metrics.ts:fetchPenpotFontBytes;PenpotFontClientinterface keeps the module decoupled fromrpc-client.tsso unit tests can supply a plain mock.)Add a variant to an already-existing component/group.
penpot_add_variantappends a new variant to an existing variant group container by re-adding the container frame viaadd-objwith the new variant's root id appended to itsshapesarray (the same delete-and-recreate-through-add-objtrick used bypenpot_reorder_shapes, avoiding themov-objectssilent-no-op pitfall noted below). The new component is registered in the sameupdate-filecall.penpot_list_pages/penpot_rename_page/penpot_delete_page—penpot_create_pageexists and now so does the rest of the page CRUD:penpot_list_pagesreturns every page's id and name (in order) without pulling the full shape tree;penpot_rename_pagerenames a page in place;penpot_delete_pageremoves it (guards against deleting the last page of a file). Both mutations go throughupdate-filewithrename-page/del-pagechange types, the same single-round-trip path every other content tool uses.SSO/OIDC-only instance support for
penpot_export_shapeviaPENPOT_AUTH_TOKEN_COOKIE. (Done — in addition to the existingPENPOT_LOGIN_EMAIL/PENPOT_LOGIN_PASSWORDpassword-login path, you can now setPENPOT_AUTH_TOKEN_COOKIEto a rawauth-tokencookie value obtained by completing the OIDC/SSO flow in a real browser. The profile-id is fetched viaget-profileon first use; if the cookie expires a clear error message explains how to renew it.PENPOT_LOGIN_EMAIL/PENPOT_LOGIN_PASSWORDtakes 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.)Unit coverage for
rpc-client.ts,transit.ts, andtools/project-files.ts— currently zero unit tests, only reachable via the integration suite that's excluded from CI. Mockingfetchfor status-code branching (204, 401/403 retry, non-2xx →PenpotRpcError) would catch regressions in CI instead of only on a manual integration run.Gradient and image fills —
Fill/Strokeinshape-builders.tsonly carry a flatcolor/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 —fillsarray on every non-text shape (rect,frame,circle,path,bool) inpenpot_add_shapes/penpot_update_shapes/penpot_batchaccepts entries oftype"solid","linear-gradient","radial-gradient", or"image". Gradient stops may use{ token: "name" }references against the token file'scolorstable. Image fills reference an already-uploaded Penpot media object bymediaIdUUID — the caller must upload the image to Penpot first (via the Penpot UI or a separate tool). Whenfillsis provided it overrides the legacyfillColor/fillOpacityshorthand entirely; backward compatibility is preserved for all existing callers usingfillColor/fillOpacity. TheFilltype inshape-builders.tsis now a union ofSolidFill,GradientFill, andImageFill;extractEditableFieldsround-trips all three types fromget-file's camelCase response back to the kebab-case formadd-objexpects.)Image shapes / asset upload — there's no
imageshape builder and no upload-media RPC call anywhere;image/svg-rawshapes are only handled via a generic geometry-only fallback inpenpot_update_shapes. Blocks any workflow that needs to bring in photos, icons, or existing SVG art. (Done —penpot_upload_mediauploads 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, usescreate-file-media-object-from-url), ordataBase64(base64-encoded bytes, requiresmtype). The returnedidis then passed asmediaIdwhen callingpenpot_add_shapeswithtype: "image";mediaWidth/mediaHeight/mtypecome from the same response. Theimageshape builder setstype: "image"with ametadata: { id, width, height, mtype }block (Penpot's wire format, verified fromschema:image-attrsinshape.cljc) plus the standard geometry and selrect/transform fields.penpot_update_shapesnow handles image shapes with a dedicated case (instead of the previous generic fallback) and acceptsmediaId/mediaWidth/mediaHeight/mtypepatch fields to swap the displayed image in place. MIME type is auto-detected from file extension forfilePathuploads (.png→image/png,.jpg/.jpeg→image/jpeg,.svg→image/svg+xml,.webp,.gif,.avif).)Opacity/hidden/locked/blend-mode flags (Done — all four fields now available in shape builders and
penpot_update_shapesschema: opacity (0-1 numeric), hidden (boolean), blocked (boolean), blendMode (string). Extraction viaextractEditableFieldsconverts wire-format "blend-mode" to camelCase "blendMode" for consistent builder interface. Unit and integration tests verify round-trip through Penpot's RPC API.)Rich per-range text formatting —
text()inshape-builders.tsalways 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_shapesandpenpot_update_shapesboth accept aparagraphsarray on text shapes. Each paragraph setstextAlignplus typography defaults (fontFamily,fontSize,fontWeight,fontStyle,lineHeight,letterSpacing,textDecoration,textTransform,fills/fillColor); itsrangesarray 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") andverticalAlign("top"|"center"|"bottom") are also now settable on text shapes. The legacycharacters/fontFamily/fontSize/fontWeight/fillColorshorthand is fully preserved for callers that don't need per-range control.extractEditableFieldsnow returns a fullparagraphsarray for text shapes so a geometry-only update patch (x/y/width/heightonly) no longer collapses a rich-text shape back to single-paragraph. Verified field names against Penpot'scommon/src/app/common/types/text.cljctext-node-attrs/paragraph-attrs/root-attrs.)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_shapestakes ashapeIdsarray 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'sselrects (rotation-aware bounds).penpot_ungroup_shapestakes agroupIdand 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 singleupdate-filechange-sets — group creation sendsadd-objfor the new group, oneadd-objper child (to updateparent-id) and anadd-objfor the updated parent'sshapesarray; ungroup does the same in reverse then appends adel-obj. Requires siblings only — shapes with different parents must be moved to a common parent first.)Resize constraints — Penpot's
constraints-h/constraints-v(how a shape behaves when its parent resizes) are never set or exposed inEditableShapeFieldsor the update patch schema. (Done —constraintsH(left/right/leftright/center/scale) andconstraintsV(top/bottom/topbottom/center/scale) are now accepted by every shape builder (rect,frame,text,circle,path,bool,group,image), emitted asconstraints-h/constraints-vin the wire format. Both fields are settable at creation time viapenpot_add_shapesand patchable viapenpot_update_shapes/penpot_batch;extractEditableFieldsreads them back fromget-file's camelCase response so a partial update patch preserves the existing constraints when neither field is included in the patch.)Batch/whole-page export —
penpot_export_shapetakes exactly oneshapeIdper call, with no multi-shape or multi-page batch export, and onlypng/svg(no PDF). (Done —penpot_export_batchaccepts either ashapeIdsarray (samepageId, any mix offormat/scale) or ashapesarray where each entry may carry its ownpageId,format,scale, andname— enabling whole-file exports across multiple pages in a singlePOST /api/exportcall. Parsing each result's~:uri/~:mtype/~:filenameviamatchAllon the transit+json response; asset downloads are parallelised. Both tools also acceptpdfas a format value. Multi-page export was previously gated on the MCP schema, not the exporter-client, which already sent per-specpageIds.)Shared-library components —
penpot_list_componentsandpenpot_add_component_instanceonly look at the current file's owncomponentsmap; there's no support for pulling components from a separate connected shared-library file. (Done —penpot_list_componentsnow acceptsincludeLibraries: true, which callsget-file-librariesto enumerate all linked library files (direct and transitive) and then callsget-fileon each to read itscomponentsmap; each library component entry carries alibraryFileIdandlibraryFileNamefield in the response.penpot_add_component_instancenow accepts an optionallibraryFileId; 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 rootcomponent-filefield is set to the library file's id (matching what Penpot's own editor writes for cross-file component instances). Note:get-file-librariesreturns file metadata only — it does not include shape/component data — so a separateget-filecall per library is necessary to enumerate components; this is the same two-step pattern Penpot's own frontend uses.)Comments API —
penpot_list_comment_threadslists all threads for a file;penpot_get_commentsfetches the replies inside a thread;penpot_create_comment_threadpins a new thread to a canvas position (x/y + optionalframeId);penpot_create_commentadds a reply;penpot_update_commentedits an existing comment's text;penpot_resolve_comment_threadmarks a thread resolved or reopens it;penpot_delete_commentremoves a single reply; andpenpot_delete_comment_threadremoves a whole thread and all its replies. All eight tools go throughPenpotRpcClientmethods that send kebab-case JSON params to theget-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):positionis a nested{ x, y }JSON object;frame-idis optional on creation (omit to place on the page root);is-resolvedcontrols thread resolution state.Text search-and-replace across a file —
penpot_replace_textfinds every text shape on a page whose runs contain a literal search string and rewrites all occurrences in a singleupdate-filecall. Matching is case-insensitive by default (caseSensitive: trueto override); an optionallimitcaps the number of shapes touched; emptyreplacementdeletes 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.)Cross-page/whole-file undo —
penpot_checkpointnow accepts an optionalpageId; omit it to snapshot every page in the file in one call.penpot_restore_checkpointrestores all snapshotted pages in a singleupdate-filecall. Checkpoints still live in server memory and don't survive a restart; persistence across restarts is still not implemented (callers needing that should snapshot viapenpot_get_file_snapshotthemselves). (Done —Checkpointincheckpoints.tsnow holds apagesmap (pageId → objects) instead of a singlepageId/objectspair; the restore loop iterates every entry and accumulates all changes before sending oneupdate-filecall — the same single-round-trip pattern used bypenpot_batch.)Fully headless OIDC/SSO login — drive the redirect flow without a browser, instead of requiring
PENPOT_AUTH_TOKEN_COOKIEto be obtained manually beforehand. (Done —PENPOT_OIDC_USERNAME+PENPOT_OIDC_PASSWORDtrigger 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.jshttp/httpsmodules, 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 resultingauth-tokencookie. 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_PROVIDERcontrols the provider name (default"oidc"). Does not work for JavaScript-driven IdP login pages — usePENPOT_AUTH_TOKEN_COOKIEfor those. Unit-tested via injected mock fetcher; integration test requires a live OIDC provider and is not in CI.)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 — setPENPOT_CHECKPOINTS_PATHto a local directory; the server writes each checkpoint as<uuid>.jsoninto that directory and reloads all files from it at startup.initCheckpointStore(dir)is called once inserver.tsmain;saveCheckpointis now async and awaits thewriteFileso the caller knows immediately if the write failed;deleteCheckpointis also async and removes the corresponding file from disk. No change to the in-memory behaviour when the env var is unset.)Single-call multi-page batch export —
penpot_export_batchexports multiple shapes on one page in a single call; exporting across pages still requires one call perpageId. Worth revisiting if batch export becomes a bottleneck for whole-file exports. (Done —penpot_export_batchnow accepts eithershapeIds(single-page shorthand, backward-compatible) or ashapesarray where each entry carries its ownpageId,format,scale, and optionalname. The exporter-client already sent per-specpageIds in a singlePOST /api/export; the only change was widening the MCP tool's Zod schema to expose this capability. Cross-field validation (at least one ofshapeIds/shapes; not both;pageIdrequired forshapeIds; everyshapesentry has apageIdeither per-entry or via the top-level default) is done with.refine()on a separateexportBatchInputschema whileexportBatchBaseSchema(the rawZodObject) is passed to the MCP SDK'sinputSchemato preserve.shape.)Stale-write detection via
revn— every write tool (penpot_update_shapes/penpot_batch/penpot_restore_checkpoint/etc.) currently sendsupdate-fileblind, without checking the file's currentrevn(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 ownupdate-fileRPC takes arevnand is expected to reject a change-set built against a stale one; today nothing here reads or checks it beforehand. Fix is to fetch currentrevnbefore 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 —PenpotStaleWriteErroris thrown byupdateFileinrpc-client.tswhenever theupdate-fileresponse includes a non-emptylaggedarray, 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 newrevnand tells the caller to re-fetch viapenpot_get_file_snapshotbefore 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 throughupdateFile.PenpotStaleWriteErrorcarrieslaggedCount(number of concurrent change-sets) andresult(the fullupdate-fileresponse, including the newrevn) so callers that need the new revision can read it from the error rather than requiring an additional round trip.)Version history / branching support —
penpot_list_file_snapshotslists all named snapshots for a file (both user-created versions and system auto-backups);penpot_create_file_snapshotsaves the current state as a named version (optionallabel; Penpot generates a timestamp label when omitted);penpot_restore_file_snapshotrolls 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_snapshotrenames a user-created snapshot;penpot_delete_file_snapshotremoves one;penpot_lock_file_snapshot/penpot_unlock_file_snapshotpin/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_datareturns the full file content at a specific snapshot for read-only inspection or comparison without touching the live file. Wire format verified againstbackend/src/app/rpc/commands/files_snapshot.cljandbackend/src/app/features/file_snapshots.clj.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'sshape-reflink to its main component (and whether it has drifted — fields overridden since being placed, or fully detached) is invisible: an agent editing an instance viapenpot_update_shapescan'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: extendpenpot_get_shape/penpot_find_shapesto 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 (checkingpenpot_list_componentsbefore creating shapes that duplicate an existing component) are possible natural follow-ons once drift is at least visible. (Done — bothpenpot_get_shapeandpenpot_find_shapesnow include component link state in their output.penpot_get_shapeadds a top-levelcomponentInfofield withlinkState("linked"/"detached"/"not-an-instance"/"main-component-root"), thecomponentIdandcomponentFileIdwhen present,mainInstanceId/mainInstancePagefor same-file linked instances, and adriftedFieldsarray (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_shapesaddslinkStateandcomponentIdto each match entry, plusdriftedFieldsfor same-file linked instances. Drift is compared over:name,fills,strokes,shadows,opacity,hidden,blendMode,width,height,constraintsH,constraintsV, andcontent(text shapes); positionx/yis excluded since every placed instance lives at a different canvas location by design. Library components (componentFile≠ current file) reportlinkState: "linked"butdriftedFieldsis omitted since the library file's pages would require an extra RPC call — usepenpot_get_shapeon a library instance root, then follow itsmainInstanceId/mainInstancePagemanually if cross-file drift is needed.)
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- Alicense-qualityCmaintenanceA hybrid MCP server that proxies Pencil's native design tools and adds utilities for .pen file management, enabling design token extraction, layer trees, diffs, and batch export.Last updatedMIT
- Alicense-qualityCmaintenanceUnofficial local MCP bridge for reading the currently open Figma file through a Figma plugin. Provides tools to inspect selection, file info, and export selected nodes as SVG/PNG.Last updatedMIT
- Flicense-qualityDmaintenanceMCP server for the Figma API. Lets AI agents fetch designs, nodes, and rendered images from Figma.Last updated3,326
- Flicense-qualityCmaintenanceMCP server that fetches Figma data (file trees, nodes, images, components, styles, variables) and serves as a foundation for Flutter code generation.Last updated
Related MCP Connectors
MCP (Model Context Protocol) server for Appwrite
MCP server for interacting with the Supabase platform
An MCP server that let you interact with Cycloid.io Internal Development Portal and Platform
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/tvolodi/penpot-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server