feat(penpot-mcp): add export_frame_png wrapping Penpot's exporter RPC directly #185

Closed
opened 2026-04-20 20:28:41 +00:00 by claude-desktop · 0 comments
Collaborator

Goal

Give the designer / design-reviewer agents a working mcp__penpot__export_frame_png tool so they can export Penpot frames as PNG (and SVG via the same path) and commit them to design/mockups/*. Implementers on downstream stories can then Read the PNG directly (Claude Code's multimodal Read handles images) and build UI against a locked visual reference.

Today our fork (penpot-mcp-server) does not implement export at allcanvas.py:27 explicitly defers it with the note that "recent Penpot builds render PNGs client-side via wasm; there's no stable server-side export RPC on the instance we target". That note is now stale: the self-hosted Penpot exporter (CT 245) was brought online in proxmox-iac@9dc800b + @94a0ffa (2026-04-20). Logs confirm the exporter renders PNGs successfully:

INF [app.renderer.bitmap] uri=.../render.html?file-id=…&page-id=…&object-id=…&route=objects
<Buffer 89 50 4e 47 0d 0a 1a 0a … 1804 more bytes>
INF [app.browser] action=destroy browser-id=3

The remaining gap is an MCP wrapper in our fork. The upstream Penpot MCP does expose export_frame_png, but it uses a stale /assets/by-id/<id> URL pattern that 404s on Penpot 2.14. Implementing our own wrapper lets us call Penpot's exporter RPC directly over the documented interface, bypass the stale URL-rewrite bug, and keep the feature in our fork's MIT-licensed control.

Acceptance criteria

New MCP tool

  • penpot-mcp-server gains a new tool export_frame_png(file_id, page_id, object_id, scale=1.0) in src/penpot_mcp/tools/canvas.py (or a new export.py if cleaner)
  • Tool calls Penpot's exporter endpoint directly at ${PENPOT_BASE_URL}/api/export — posts the export shape spec, waits for the rendered resource, downloads bytes, returns {"content_base64": str, "mime_type": "image/png", "width": int, "height": int}
  • Parallel export_frame_svg tool for vector export (same shape, mime_type: "image/svg+xml", content as a string not base64)
  • Both tools share auth + HTTP plumbing with services/api.py — reuse the existing Authorization: Token <PAT> header

URL / routing resilience

  • Implementation avoids the /assets/by-id/<id> fetch pattern that upstream MCP uses — that path 404s on our Penpot build. Either poll the exporter's response directly for bytes, OR use the documented /api/rpc/command/<command> / /api/export-resource paths that match what the Penpot frontend uses internally
  • Handles the two error paths explicitly:
    • exporter-unavailable (502 from /api/export) — surface clearly in the tool response so the agent doesn't keep retrying
    • unable-to-upload-resource — surface the backend assets-permission hint

Allowlist + integration

  • apps/server/src/agent-runner.ts::FORGEJO_TOOLS_ALLOWLIST-analogue for Penpot — wait, that's forgejo. The Penpot MCP doesn't have a per-tool allowlist on our side; the fork registers every defined tool unconditionally. Just make sure the new tools register cleanly.
  • scripts/smoke-creds.sh (the container smoke probe) adds a check that mcp__penpot__export_frame_png returns successfully against a seeded test frame. Runs only on designer / design-reviewer containers (the ones with penpot_mcp: true)

Tests

  • penpot-mcp-server/tests/tools/test_export.py: mock the Penpot HTTP API, fire export_frame_png, assert bytes match a fixture PNG
  • Error-path tests: exporter 502 → tool returns structured error; missing file → 404 surfaced cleanly
  • Manual smoke after merge: run against the seeded test frame fbb66a40-7b05-40bc-88a6-886a66a80a05 in file 689d7fa4-f94b-81d4-8007-e38d1256f1ae, confirm PNG writes to disk and visually matches the Penpot UI rendering

Docs

  • Update penpot-mcp-server/src/penpot_mcp/tools/canvas.py:27 comment block — remove the "export deferred" note, point at the new tool
  • Bump penpot-mcp-server/CHANGELOG.md with an entry describing the new tool
  • CLAUDE.md "Penpot MCP auth" section gains a one-line mention of export_frame_png / _svg availability on design agents
  • Version bump in pyproject.toml to 0.7.0+claude-hooks.<n>

Downstream work (separate stories)

  • Update #181 M19-0 handoff AC to require designer commits exported PNG + SVG to design/mockups/m19/<frame-slug>.{png,svg} after this tool ships
  • Bump claude-hooks' penpot-mcp-server Docker build stage to pick up the new version; rebuild designer + design-reviewer images

Out of scope

  • Solving upstream MCP's /assets/by-id/<id> bug — not our problem, and we route around it by using Penpot's internal RPCs directly.
  • export_frame_pdf / other formats — PNG + SVG are sufficient for the dashboard/frame handoff flow.
  • Batch export (multiple frames per call) — can add later if needed; for now one-frame-per-call matches how the designer skill iterates.

References

  • Penpot exporter boot fix: proxmox-iac@9dc800b — adds PENPOT_SECRET_KEY
  • CT memory bump: proxmox-iac@94a0ffa — 2 GB → 4 GB + 1 GB swap (chromium pool OOM'd at 2 GB)
  • Host-side chown (done live, not yet committed to ansible): /var/lib/penpot/assets → UID 1001:1001. Should be added as an ansible task in a follow-up IaC commit.
  • Upstream MCP's stale URL pattern proves why we need our own wrapper: /assets/by-id/968c1582-… returns 404 on Penpot 2.14.
  • Stale note to remove: penpot-mcp-server/src/penpot_mcp/tools/canvas.py:27-30 ("recent Penpot builds render PNGs client-side via wasm; there's no stable server-side export RPC…")
## Goal Give the `designer` / `design-reviewer` agents a working `mcp__penpot__export_frame_png` tool so they can export Penpot frames as PNG (and SVG via the same path) and commit them to `design/mockups/*`. Implementers on downstream stories can then `Read` the PNG directly (Claude Code's multimodal Read handles images) and build UI against a locked visual reference. Today our fork (`penpot-mcp-server`) **does not implement export at all** — `canvas.py:27` explicitly defers it with the note that "recent Penpot builds render PNGs client-side via wasm; there's no stable server-side export RPC on the instance we target". That note is now **stale**: the self-hosted Penpot exporter (CT 245) was brought online in `proxmox-iac@9dc800b` + `@94a0ffa` (2026-04-20). Logs confirm the exporter renders PNGs successfully: ``` INF [app.renderer.bitmap] uri=.../render.html?file-id=…&page-id=…&object-id=…&route=objects <Buffer 89 50 4e 47 0d 0a 1a 0a … 1804 more bytes> INF [app.browser] action=destroy browser-id=3 ``` The remaining gap is an MCP wrapper in our fork. The upstream Penpot MCP *does* expose `export_frame_png`, but it uses a stale `/assets/by-id/<id>` URL pattern that 404s on Penpot 2.14. Implementing our own wrapper lets us call Penpot's exporter RPC directly over the documented interface, bypass the stale URL-rewrite bug, and keep the feature in our fork's MIT-licensed control. ## Acceptance criteria ### New MCP tool - [ ] `penpot-mcp-server` gains a new tool `export_frame_png(file_id, page_id, object_id, scale=1.0)` in `src/penpot_mcp/tools/canvas.py` (or a new `export.py` if cleaner) - [ ] Tool calls Penpot's exporter endpoint directly at `${PENPOT_BASE_URL}/api/export` — posts the export shape spec, waits for the rendered resource, downloads bytes, returns `{"content_base64": str, "mime_type": "image/png", "width": int, "height": int}` - [ ] Parallel `export_frame_svg` tool for vector export (same shape, `mime_type: "image/svg+xml"`, `content` as a string not base64) - [ ] Both tools share auth + HTTP plumbing with `services/api.py` — reuse the existing `Authorization: Token <PAT>` header ### URL / routing resilience - [ ] Implementation **avoids the `/assets/by-id/<id>` fetch pattern** that upstream MCP uses — that path 404s on our Penpot build. Either poll the exporter's response directly for bytes, OR use the documented `/api/rpc/command/<command>` / `/api/export-resource` paths that match what the Penpot frontend uses internally - [ ] Handles the two error paths explicitly: - `exporter-unavailable` (502 from `/api/export`) — surface clearly in the tool response so the agent doesn't keep retrying - `unable-to-upload-resource` — surface the backend assets-permission hint ### Allowlist + integration - [ ] `apps/server/src/agent-runner.ts::FORGEJO_TOOLS_ALLOWLIST`-analogue for Penpot — wait, that's forgejo. The Penpot MCP **doesn't** have a per-tool allowlist on our side; the fork registers every defined tool unconditionally. Just make sure the new tools register cleanly. - [ ] `scripts/smoke-creds.sh` (the container smoke probe) adds a check that `mcp__penpot__export_frame_png` returns successfully against a seeded test frame. Runs only on `designer` / `design-reviewer` containers (the ones with `penpot_mcp: true`) ### Tests - [ ] `penpot-mcp-server/tests/tools/test_export.py`: mock the Penpot HTTP API, fire `export_frame_png`, assert bytes match a fixture PNG - [ ] Error-path tests: exporter 502 → tool returns structured error; missing file → 404 surfaced cleanly - [ ] Manual smoke after merge: run against the seeded test frame `fbb66a40-7b05-40bc-88a6-886a66a80a05` in file `689d7fa4-f94b-81d4-8007-e38d1256f1ae`, confirm PNG writes to disk and visually matches the Penpot UI rendering ### Docs - [ ] Update `penpot-mcp-server/src/penpot_mcp/tools/canvas.py:27` comment block — remove the "export deferred" note, point at the new tool - [ ] Bump `penpot-mcp-server/CHANGELOG.md` with an entry describing the new tool - [ ] CLAUDE.md "Penpot MCP auth" section gains a one-line mention of `export_frame_png` / `_svg` availability on design agents - [ ] Version bump in `pyproject.toml` to `0.7.0+claude-hooks.<n>` ## Downstream work (separate stories) - [ ] Update **#181 M19-0 handoff AC** to require designer commits exported PNG + SVG to `design/mockups/m19/<frame-slug>.{png,svg}` after this tool ships - [ ] Bump claude-hooks' `penpot-mcp-server` Docker build stage to pick up the new version; rebuild designer + design-reviewer images ## Out of scope - Solving upstream MCP's `/assets/by-id/<id>` bug — not our problem, and we route around it by using Penpot's internal RPCs directly. - `export_frame_pdf` / other formats — PNG + SVG are sufficient for the dashboard/frame handoff flow. - Batch export (multiple frames per call) — can add later if needed; for now one-frame-per-call matches how the designer skill iterates. ## References - Penpot exporter boot fix: [proxmox-iac@9dc800b](https://forge.jacquin.app/forge_admin/proxmox-iac/commit/9dc800b) — adds PENPOT_SECRET_KEY - CT memory bump: [proxmox-iac@94a0ffa](https://forge.jacquin.app/forge_admin/proxmox-iac/commit/94a0ffa) — 2 GB → 4 GB + 1 GB swap (chromium pool OOM'd at 2 GB) - Host-side chown (done live, not yet committed to ansible): `/var/lib/penpot/assets` → UID 1001:1001. Should be added as an ansible task in a follow-up IaC commit. - Upstream MCP's stale URL pattern proves why we need our own wrapper: `/assets/by-id/968c1582-…` returns 404 on Penpot 2.14. - Stale note to remove: `penpot-mcp-server/src/penpot_mcp/tools/canvas.py:27-30` ("recent Penpot builds render PNGs client-side via wasm; there's no stable server-side export RPC…")
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks#185
No description provided.