Patch Penpot MCP: add export_frame_png(file_id, page_id, frame_id, scale?) #74

Closed
opened 2026-04-19 00:04:51 +00:00 by claude-desktop · 2 comments
Collaborator

User story

As the designer agent, I want an export_frame_png MCP tool so
that the design-implement.md skill's sanity-check step (render the
frame, check for overflow / frame-fill bugs before the handoff) is
actually callable, and so that design-reviewer can consume PNGs
for multimodal visual review.

Carved out of #69 as a follow-up; PR #71 lands every canvas write
primitive but explicitly defers this because recent Penpot builds
render PNGs client-side via wasm — there is no stable server-side
export RPC on our instance that would give us raster output in a
single call.

Context / uncertainty

Probed against our live instance on 2026-04-19 and none of the
obvious endpoints work:

POST /api/rpc/command/get-file-object-thumbnail → {"type":"not-found"}
POST /api/rpc/command/render-wasm              → {"type":"not-found"}
POST /api/rpc/command/get-file-thumbnail       → {"type":"not-found"}
POST /api/rpc/command/export-binfile           → exists, but returns
                                                  binary Penpot file format
                                                  (not PNG)

So the first job of this ticket is figuring out the right path,
not writing the wrapper. Three plausible paths, pick whichever lands
first:

  1. Screenshot via headless Chromium. Bake
    chromium-headless into the claude-hooks:dev image. Tool
    opens https://design.jacquin.app/#/render-object?file-id=…&object-id=…
    (Penpot's single-frame share URL), waits for load, screenshots,
    returns base64 PNG. Reuses Penpot's own wasm renderer — fidelity
    matches the UI exactly. Cost: ~150 MB in the image, a puppeteer /
    playwright dep.
  2. Call Penpot's exporter service directly. Penpot ships an
    internal exporter microservice that the UI uses for SVG/PNG
    export. On our self-host it's the exporter container — reachable
    only on the internal Docker network today. Would need a network
    alias or a public route. Gives PNG-per-frame natively but adds
    deployment surface.
  3. SVG export + server-side rasterise. export-shapes-svg RPC
    (if present — needs probing) returns SVG string; the MCP tool
    rasterises via Pillow / cairosvg. Avoids the Chromium dep but
    font fidelity can drift from Penpot's wasm renderer.

Need to pick one during implementation. Probably (1) — matches UI
fidelity, simplest mental model for the designer / design-reviewer.

Acceptance criteria

MCP — new tool

  • export_frame_png(file_id, page_id, frame_id, scale?=1) in
    penpot-mcp-server/src/penpot_mcp/tools/canvas.py.
    Returns {png_bytes_base64, width, height, mimetype}.
  • scale ≥ 1, ≤ 4. Retina defaults sane.
  • Errors surface as PenpotAPIError with a distinguishable code
    (export-failed) rather than silently returning empty bytes.

Implementation plumbing (option-1 path)

  • claude-hooks:dev image installs chromium +
    playwright (Python binding, because the MCP is Python).
    Add a just containers-rebuild note about the image size
    delta.
  • scripts/smoke-creds.sh extends the Penpot probe: assert
    playwright importable + chromium --version succeeds inside
    the designer / design-reviewer containers. Fails loudly if
    either is missing rather than crashing mid-dispatch.

Tests

  • Unit: patch the Playwright browser, assert the MCP tool
    calls page.goto(expected_url) and page.screenshot(…, scale).
  • Live (pytest -m live): create a temp page + frame on the
    claude-hooks — dashboard file, export PNG, assert
    width > 0 and png_bytes_base64 decodes to valid PNG
    magic bytes (\x89PNG…). Cleanup deletes the page.

Skill update

  • skills/design-implement.md already mentions the export step
    as "sanity check — crashes and frame-fill bugs are easier to
    catch before the reviewer sees them". Currently treated as
    optional because the tool didn't exist. Re-mark it as required
    on this ticket's landing.

Out of scope

  • Multi-frame / whole-page export. Single frame per call.
    Reviewer can call the tool multiple times.
  • SVG / PDF output. Separate tools if ever needed.
  • Animation preview. Static render only.

References

  • Parent: #69 (canvas primitives) — PR #71 landing.
  • Probed RPCs that don't exist (record of the negative results):
    get-file-object-thumbnail, render-wasm, get-file-thumbnail.
  • Penpot source for the exporter path (reference, not part of our
    fork): upstream Penpot repo, backend/src/app/rpc/commands/ +
    the exporter service.
  • Milestone: Agent pool + customization (#16).

Dependencies

  • Blocked by: nothing urgent — PR #71 is a prerequisite and is
    ready to merge.
  • Blocks: the design-implement sanity-check step and
    design-reviewer's multimodal image input path. Design-reviewer
    could manually screenshot today, but the automation needs this.
  • Branch off: main after #71 lands.
## User story As the **designer agent**, I want an `export_frame_png` MCP tool so that the `design-implement.md` skill's sanity-check step (render the frame, check for overflow / frame-fill bugs before the handoff) is actually callable, and so that `design-reviewer` can consume PNGs for multimodal visual review. Carved out of #69 as a follow-up; PR #71 lands every canvas write primitive but explicitly defers this because recent Penpot builds render PNGs client-side via wasm — there is no stable server-side export RPC on our instance that would give us raster output in a single call. ## Context / uncertainty Probed against our live instance on 2026-04-19 and none of the obvious endpoints work: ```text POST /api/rpc/command/get-file-object-thumbnail → {"type":"not-found"} POST /api/rpc/command/render-wasm → {"type":"not-found"} POST /api/rpc/command/get-file-thumbnail → {"type":"not-found"} POST /api/rpc/command/export-binfile → exists, but returns binary Penpot file format (not PNG) ``` So the first job of this ticket is **figuring out the right path**, not writing the wrapper. Three plausible paths, pick whichever lands first: 1. **Screenshot via headless Chromium.** Bake `chromium-headless` into the `claude-hooks:dev` image. Tool opens `https://design.jacquin.app/#/render-object?file-id=…&object-id=…` (Penpot's single-frame share URL), waits for load, screenshots, returns base64 PNG. Reuses Penpot's own wasm renderer — fidelity matches the UI exactly. Cost: ~150 MB in the image, a puppeteer / playwright dep. 2. **Call Penpot's exporter service directly.** Penpot ships an internal exporter microservice that the UI uses for SVG/PNG export. On our self-host it's the `exporter` container — reachable only on the internal Docker network today. Would need a network alias or a public route. Gives PNG-per-frame natively but adds deployment surface. 3. **SVG export + server-side rasterise.** `export-shapes-svg` RPC (if present — needs probing) returns SVG string; the MCP tool rasterises via Pillow / cairosvg. Avoids the Chromium dep but font fidelity can drift from Penpot's wasm renderer. Need to pick one during implementation. Probably (1) — matches UI fidelity, simplest mental model for the designer / design-reviewer. ## Acceptance criteria ### MCP — new tool - [ ] `export_frame_png(file_id, page_id, frame_id, scale?=1)` in `penpot-mcp-server/src/penpot_mcp/tools/canvas.py`. Returns `{png_bytes_base64, width, height, mimetype}`. - [ ] `scale` ≥ 1, ≤ 4. Retina defaults sane. - [ ] Errors surface as `PenpotAPIError` with a distinguishable code (`export-failed`) rather than silently returning empty bytes. ### Implementation plumbing (option-1 path) - [ ] `claude-hooks:dev` image installs `chromium` + `playwright` (Python binding, because the MCP is Python). Add a `just containers-rebuild` note about the image size delta. - [ ] `scripts/smoke-creds.sh` extends the Penpot probe: assert `playwright` importable + `chromium --version` succeeds inside the `designer` / `design-reviewer` containers. Fails loudly if either is missing rather than crashing mid-dispatch. ### Tests - [ ] Unit: patch the Playwright browser, assert the MCP tool calls `page.goto(expected_url)` and `page.screenshot(…, scale)`. - [ ] Live (`pytest -m live`): create a temp page + frame on the `claude-hooks — dashboard` file, export PNG, assert `width > 0` and `png_bytes_base64` decodes to valid PNG magic bytes (`\x89PNG…`). Cleanup deletes the page. ### Skill update - [ ] `skills/design-implement.md` already mentions the export step as "sanity check — crashes and frame-fill bugs are easier to catch before the reviewer sees them". Currently treated as optional because the tool didn't exist. Re-mark it as required on this ticket's landing. ## Out of scope - **Multi-frame / whole-page export.** Single frame per call. Reviewer can call the tool multiple times. - **SVG / PDF output.** Separate tools if ever needed. - **Animation preview.** Static render only. ## References - Parent: #69 (canvas primitives) — PR #71 landing. - Probed RPCs that don't exist (record of the negative results): `get-file-object-thumbnail`, `render-wasm`, `get-file-thumbnail`. - Penpot source for the exporter path (reference, not part of our fork): upstream Penpot repo, `backend/src/app/rpc/commands/` + the `exporter` service. - Milestone: **Agent pool + customization** (#16). ## Dependencies - **Blocked by:** nothing urgent — PR #71 is a prerequisite and is ready to merge. - **Blocks:** the `design-implement` sanity-check step and `design-reviewer`'s multimodal image input path. Design-reviewer could manually screenshot today, but the automation needs this. - **Branch off:** `main` after #71 lands.
Author
Collaborator

Probe findings (2026-04-20)

Hit the live Penpot instance at design.jacquin.app with Authorization: Token <PENPOT_ACCESS_TOKEN>. Full matrix:

RPC commands — all {"type":"not-found"}

POST /api/rpc/command/export-shapes-svg        → not-found
POST /api/rpc/command/export-frame-as-image    → not-found
POST /api/rpc/command/get-object-thumbnails    → not-found
POST /api/rpc/command/export                   → not-found

Plus the three from the ticket body (get-file-object-thumbnail, render-wasm, get-file-thumbnail) that were already known dead. Option 3 (SVG rasterize) is dead — there's no SVG-export RPC on this instance.

HTTP routes — more interesting

POST /export/                 → 301 → /404      (no-op redirect)
POST /api/export              → 502 Bad Gateway (nginx, upstream unreachable)
POST /export-file             → 405 Not Allowed (exists, POST not supported)
POST /api/renderer            → 404
POST /exporter/render-object  → 301 → /404

The 502 on /api/export is the signal. nginx is configured to proxy that path to an upstream exporter microservice, but the upstream isn't responding. Concretely: our self-host has the route wired — the exporter container just isn't running or isn't reachable from the nginx host.

Decision

This reframes the three options from the ticket:

Option State Cost
1. Headless Chromium in claude-hooks:dev Viable fallback +150 MB image, playwright dep
2. Penpot exporter microservice Already routed, just needs the exporter container up Ops-side fix on design.jacquin.app host; zero cost to our image
3. SVG export + rasterize Dead — no export-shapes-svg RPC exists

Recommend Option 2. If the operator can bring the exporter container online on the Penpot host (likely a one-line docker compose or missing service in the self-host stack), we get PNG export via Penpot's own wasm renderer, matching the UI exactly, with no image bloat on our side. export_frame_png(file_id, page_id, frame_id, scale?) then becomes a thin POST to /api/export with the right payload shape.

Fallback to Option 1 only if the exporter service is hard to revive for ops reasons (e.g. upstream removed it, dependencies changed).

Next step

Operator decision: bring up the Penpot exporter service on design.jacquin.app, or greenlight the Chromium route.

## Probe findings (2026-04-20) Hit the live Penpot instance at `design.jacquin.app` with `Authorization: Token <PENPOT_ACCESS_TOKEN>`. Full matrix: ### RPC commands — all `{"type":"not-found"}` ``` POST /api/rpc/command/export-shapes-svg → not-found POST /api/rpc/command/export-frame-as-image → not-found POST /api/rpc/command/get-object-thumbnails → not-found POST /api/rpc/command/export → not-found ``` Plus the three from the ticket body (`get-file-object-thumbnail`, `render-wasm`, `get-file-thumbnail`) that were already known dead. **Option 3 (SVG rasterize) is dead** — there's no SVG-export RPC on this instance. ### HTTP routes — more interesting ``` POST /export/ → 301 → /404 (no-op redirect) POST /api/export → 502 Bad Gateway (nginx, upstream unreachable) POST /export-file → 405 Not Allowed (exists, POST not supported) POST /api/renderer → 404 POST /exporter/render-object → 301 → /404 ``` **The 502 on `/api/export` is the signal.** nginx is configured to proxy that path to an upstream exporter microservice, but the upstream isn't responding. Concretely: our self-host has the route *wired* — the `exporter` container just isn't running or isn't reachable from the nginx host. ## Decision This reframes the three options from the ticket: | Option | State | Cost | |---|---|---| | 1. Headless Chromium in `claude-hooks:dev` | Viable fallback | +150 MB image, playwright dep | | 2. Penpot exporter microservice | **Already routed, just needs the exporter container up** | Ops-side fix on `design.jacquin.app` host; zero cost to our image | | 3. SVG export + rasterize | **Dead** — no `export-shapes-svg` RPC exists | — | **Recommend Option 2.** If the operator can bring the `exporter` container online on the Penpot host (likely a one-line `docker compose` or missing service in the self-host stack), we get PNG export via Penpot's own wasm renderer, matching the UI exactly, with no image bloat on our side. `export_frame_png(file_id, page_id, frame_id, scale?)` then becomes a thin POST to `/api/export` with the right payload shape. Fallback to Option 1 only if the exporter service is hard to revive for ops reasons (e.g. upstream removed it, dependencies changed). ## Next step Operator decision: bring up the Penpot exporter service on `design.jacquin.app`, or greenlight the Chromium route.
Author
Collaborator

Operator decision: closing as deferred.

Option 1 (Chromium in claude-hooks:dev) is too heavy — +150MB per image rebuild, Playwright dep, Chromium auto-update maintenance, container-wide for only 2 agents.
Option 2 (exporter microservice) requires Penpot host ops work the operator isn't planning to invest in now.
Option 3 (SVG rasterize) is dead — no RPC exists.

Design-reviewer's current spec-cross-check flow (validated on #62 and #70) produces useful reviews without visual PNG inspection. The quality delta doesn't justify the cost today.

Reopen when the cost/benefit flips (e.g. a design change needs pixel-level review, or Penpot upstream ships an RPC, or the Penpot host gets an exporter container on a non-claude-hooks maintenance pass).

Operator decision: **closing as deferred**. Option 1 (Chromium in `claude-hooks:dev`) is too heavy — +150MB per image rebuild, Playwright dep, Chromium auto-update maintenance, container-wide for only 2 agents. Option 2 (exporter microservice) requires Penpot host ops work the operator isn't planning to invest in now. Option 3 (SVG rasterize) is dead — no RPC exists. Design-reviewer's current spec-cross-check flow (validated on #62 and #70) produces useful reviews without visual PNG inspection. The quality delta doesn't justify the cost today. Reopen when the cost/benefit flips (e.g. a design change needs pixel-level review, or Penpot upstream ships an RPC, or the Penpot host gets an exporter container on a non-claude-hooks maintenance pass).
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#74
No description provided.