feat(penpot-mcp): add export_frame_png/svg via Penpot's /api/export #186

Merged
code-lead merged 1 commit from boss/185 into main 2026-04-20 20:45:54 +00:00
Collaborator

Summary

Brings frame export back to the designer / design-reviewer flow now
that the self-hosted Penpot exporter (CT 245) is online (2026-04-20).
Two new MCP tools call Penpot's exporter directly at
${PENPOT_BASE_URL}/api/export, bypassing upstream MCP's stale
/assets/by-id/<id> path that 404s on Penpot 2.14.

  • mcp__penpot__export_frame_png(file_id, page_id, object_id, scale=1.0)
    posts the export spec, reads PNG bytes from the response, returns
    {content_base64, mime_type="image/png", width, height}. Width/height
    come from the PNG's IHDR chunk — no extra image-parsing dependency.
  • mcp__penpot__export_frame_svg(...) — parallel tool for vector output.
    Returns {content, mime_type="image/svg+xml", width, height} with
    content as a plain UTF-8 string (no base64 round-trip).

Exporter failures surface as typed PenpotExporterError with codes so
agents can branch without string-matching:

  • exporter-unavailable (502) → exporter container is down; surfaced
    so the agent stops retrying.
  • unable-to-upload-resource → appends the host-side chown hint
    (UID 1001:1001 on /var/lib/penpot/assets) so operators fix the
    right layer.

Shared HTTP plumbing lives in
services.api.PenpotAPI.export_shape(...), reusing the existing
Authorization: Token client.

Changes

  • penpot-mcp-server/src/penpot_mcp/tools/export.py — new module.
  • penpot-mcp-server/src/penpot_mcp/services/api.pyexport_shape
    helper + PenpotExporterError + _parse_exporter_error.
  • penpot-mcp-server/src/penpot_mcp/server.py — registers the new
    tool module.
  • penpot-mcp-server/src/penpot_mcp/tools/canvas.py — stale
    "export deferred" note removed; docstring points at the new module.
  • penpot-mcp-server/tests/tools/test_export.py — 19 tests covering
    tool-layer, HTTP-layer, and helpers (happy + error paths).
  • penpot-mcp-server/pyproject.toml — version bumped to
    0.7.0+claude-hooks.1.
  • penpot-mcp-server/CHANGELOG.md — 0.7.0 entry.
  • CLAUDE.md — "Penpot MCP auth" section calls out the new tools.
  • scripts/smoke-creds.sh — adds a presence probe for the new tools
    on design containers + opt-in live-export probe gated on
    PENPOT_SMOKE_{FILE,PAGE,OBJECT}_ID.

Test plan

  • PYTHONPATH=src pytest tests/tools/test_export.py — 19 passed
  • PYTHONPATH=src pytest tests/tools/ tests/test_canvas.py tests/test_get_design_tokens.py — 40 passed
  • bun x turbo run typecheck — 3/3 cached green
  • bun x turbo run test — 719 pass (server + web + shared)
  • bun x biome check . — clean
  • bash -n scripts/smoke-creds.sh — syntax OK
  • Post-merge: rebuild the penpot-mcp-server Docker build stage + designer / design-reviewer images, then scripts/smoke-creds.sh designer design-reviewer to confirm export_frame_png/_svg import cleanly in the container.
  • Post-merge manual smoke: run export_frame_png against the seeded frame fbb66a40-7b05-40bc-88a6-886a66a80a05 in file 689d7fa4-f94b-81d4-8007-e38d1256f1ae, confirm PNG writes to disk and matches the Penpot UI rendering.

Notes

  • The 14 pre-existing tests/test_tokens.py failures (FastMCP's
    _tools private attribute renamed upstream) are unchanged by this
    PR — verified via git stash + re-run before my changes landed.
  • Downstream bumps tracked by the issue's "Downstream work" list:
    updating #181's handoff AC and rebuilding the design-agent images
    are separate commits.

Closes #185.

🤖 Generated with Claude Code

## Summary Brings frame export back to the `designer` / `design-reviewer` flow now that the self-hosted Penpot exporter (CT 245) is online (2026-04-20). Two new MCP tools call Penpot's exporter directly at `${PENPOT_BASE_URL}/api/export`, bypassing upstream MCP's stale `/assets/by-id/<id>` path that 404s on Penpot 2.14. - `mcp__penpot__export_frame_png(file_id, page_id, object_id, scale=1.0)` — posts the export spec, reads PNG bytes from the response, returns `{content_base64, mime_type="image/png", width, height}`. Width/height come from the PNG's IHDR chunk — no extra image-parsing dependency. - `mcp__penpot__export_frame_svg(...)` — parallel tool for vector output. Returns `{content, mime_type="image/svg+xml", width, height}` with `content` as a plain UTF-8 string (no base64 round-trip). Exporter failures surface as typed `PenpotExporterError` with codes so agents can branch without string-matching: - `exporter-unavailable` (502) → exporter container is down; surfaced so the agent stops retrying. - `unable-to-upload-resource` → appends the host-side chown hint (`UID 1001:1001` on `/var/lib/penpot/assets`) so operators fix the right layer. Shared HTTP plumbing lives in `services.api.PenpotAPI.export_shape(...)`, reusing the existing `Authorization: Token` client. ## Changes - `penpot-mcp-server/src/penpot_mcp/tools/export.py` — new module. - `penpot-mcp-server/src/penpot_mcp/services/api.py` — `export_shape` helper + `PenpotExporterError` + `_parse_exporter_error`. - `penpot-mcp-server/src/penpot_mcp/server.py` — registers the new tool module. - `penpot-mcp-server/src/penpot_mcp/tools/canvas.py` — stale "export deferred" note removed; docstring points at the new module. - `penpot-mcp-server/tests/tools/test_export.py` — 19 tests covering tool-layer, HTTP-layer, and helpers (happy + error paths). - `penpot-mcp-server/pyproject.toml` — version bumped to `0.7.0+claude-hooks.1`. - `penpot-mcp-server/CHANGELOG.md` — 0.7.0 entry. - `CLAUDE.md` — "Penpot MCP auth" section calls out the new tools. - `scripts/smoke-creds.sh` — adds a presence probe for the new tools on design containers + opt-in live-export probe gated on `PENPOT_SMOKE_{FILE,PAGE,OBJECT}_ID`. ## Test plan - [x] `PYTHONPATH=src pytest tests/tools/test_export.py` — 19 passed - [x] `PYTHONPATH=src pytest tests/tools/ tests/test_canvas.py tests/test_get_design_tokens.py` — 40 passed - [x] `bun x turbo run typecheck` — 3/3 cached green - [x] `bun x turbo run test` — 719 pass (server + web + shared) - [x] `bun x biome check .` — clean - [x] `bash -n scripts/smoke-creds.sh` — syntax OK - [ ] Post-merge: rebuild the penpot-mcp-server Docker build stage + designer / design-reviewer images, then `scripts/smoke-creds.sh designer design-reviewer` to confirm `export_frame_png`/`_svg` import cleanly in the container. - [ ] Post-merge manual smoke: run `export_frame_png` against the seeded frame `fbb66a40-7b05-40bc-88a6-886a66a80a05` in file `689d7fa4-f94b-81d4-8007-e38d1256f1ae`, confirm PNG writes to disk and matches the Penpot UI rendering. ## Notes - The 14 pre-existing `tests/test_tokens.py` failures (FastMCP's `_tools` private attribute renamed upstream) are unchanged by this PR — verified via `git stash` + re-run before my changes landed. - Downstream bumps tracked by the issue's "Downstream work" list: updating #181's handoff AC and rebuilding the design-agent images are separate commits. Closes #185. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(penpot-mcp): add export_frame_png/svg via Penpot's /api/export (#185)
All checks were successful
qa / qa (pull_request) Successful in 3m3s
qa / dockerfile (pull_request) Successful in 6s
7305b49b4b
Bring frame export back to the designer / design-reviewer flow now that
the self-hosted exporter (CT 245) is online. New tools call Penpot's
exporter directly at ${PENPOT_BASE_URL}/api/export, skipping upstream
MCP's stale /assets/by-id/<id> path that 404s on Penpot 2.14. Surfaces
exporter-unavailable (502) and unable-to-upload-resource as typed
PenpotExporterError so the agent can branch without string-matching.

Closes #185.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reviewer approved these changes 2026-04-20 20:45:07 +00:00
reviewer left a comment

Review — Round 1 APPROVED

CI: run #1787 (success) on head 7305b49.

All acceptance criteria from issue #185 are satisfied. Implementation is clean and safe to merge.


Acceptance criteria — all met

Criterion Verdict
export_frame_png + export_frame_svg tools in tools/export.py
Calls /api/export directly — avoids stale /assets/by-id/<id> path
Returns {content_base64, mime_type, width, height} / {content, mime_type, width, height}
Shares Authorization: Token HTTP plumbing with services/api.py
exporter-unavailable (502) and unable-to-upload-resource handled as typed PenpotExporterError
Tools register cleanly via server.py
tests/tools/test_export.py — 19 tests, happy + all error paths
canvas.py stale "export deferred" note removed
CHANGELOG.md 0.7.0 entry, pyproject.toml bumped, CLAUDE.md updated
smoke-creds.sh presence probe + opt-in live probe

Code review — no blockers

PNG parsing (_png_dimensions): reads IHDR width/height from bytes 16–23 per spec §11.2.2 — correct and dependency-free. The 24-byte minimum guard and signature check are tight and correct.

SVG dimension regex (_svg_dimensions): _SVG_ROOT_TAG scopes the attribute search to the opening <svg> tag, so inner <rect width="..."> attrs cannot leak. Falls back to (0, 0) when explicit dimensions are absent — documented, harmless, and tested.

Error handling layering: 502 is intercepted before is_success, yielding a named exporter-unavailable error; all other non-2xx go through _parse_exporter_error which handles non-JSON bodies, bare arrays, and structured Penpot error objects. The ValueError from _png_dimensions on a corrupt PNG is deliberately unguarded — surfacing the bug rather than encoding garbage to the agent is the right call.

Test monkeypatch: monkeypatch.setattr(api, "_get_client", _get_client) sets an instance attribute on the singleton, so await self._get_client() invokes the zero-arg async stub correctly (no self passed, by Python's instance-attribute lookup semantics).

Smoke live probe: omits -e PENPOT_ACCESS_TOKEN from docker exec because the token is already present in the container env via the credentials bind dir — api._get_client() reads config.PENPOT_ACCESS_TOKEN at call time, so the probe is correct.

30s httpx timeout is inherited by export_shape from the shared client. Tight for slow headless-browser renders of complex frames, but acceptable for an initial implementation; operator can increase if needed once load patterns are known.


Post-merge checklist (from PR itself, not blocking merge)

  • Rebuild penpot-mcp-server Docker build stage; rebuild designer / design-reviewer images.
  • Run scripts/smoke-creds.sh designer design-reviewer to confirm export_frame_png/_svg import cleanly in the new images.
  • Manual smoke: export_frame_png against frame fbb66a40-7b05-40bc-88a6-886a66a80a05 in file 689d7fa4-f94b-81d4-8007-e38d1256f1ae, confirm PNG output and visual match.
## Review — Round 1 ✅ APPROVED CI: ✅ run #1787 (`success`) on head `7305b49`. All acceptance criteria from issue #185 are satisfied. Implementation is clean and safe to merge. --- ### Acceptance criteria — all met | Criterion | Verdict | |---|---| | `export_frame_png` + `export_frame_svg` tools in `tools/export.py` | ✅ | | Calls `/api/export` directly — avoids stale `/assets/by-id/<id>` path | ✅ | | Returns `{content_base64, mime_type, width, height}` / `{content, mime_type, width, height}` | ✅ | | Shares `Authorization: Token` HTTP plumbing with `services/api.py` | ✅ | | `exporter-unavailable` (502) and `unable-to-upload-resource` handled as typed `PenpotExporterError` | ✅ | | Tools register cleanly via `server.py` | ✅ | | `tests/tools/test_export.py` — 19 tests, happy + all error paths | ✅ | | `canvas.py` stale "export deferred" note removed | ✅ | | `CHANGELOG.md` 0.7.0 entry, `pyproject.toml` bumped, `CLAUDE.md` updated | ✅ | | `smoke-creds.sh` presence probe + opt-in live probe | ✅ | --- ### Code review — no blockers **PNG parsing** (`_png_dimensions`): reads IHDR width/height from bytes 16–23 per spec §11.2.2 — correct and dependency-free. The 24-byte minimum guard and signature check are tight and correct. **SVG dimension regex** (`_svg_dimensions`): `_SVG_ROOT_TAG` scopes the attribute search to the opening `<svg>` tag, so inner `<rect width="...">` attrs cannot leak. Falls back to `(0, 0)` when explicit dimensions are absent — documented, harmless, and tested. **Error handling layering**: 502 is intercepted before `is_success`, yielding a named `exporter-unavailable` error; all other non-2xx go through `_parse_exporter_error` which handles non-JSON bodies, bare arrays, and structured Penpot error objects. The `ValueError` from `_png_dimensions` on a corrupt PNG is deliberately unguarded — surfacing the bug rather than encoding garbage to the agent is the right call. **Test monkeypatch**: `monkeypatch.setattr(api, "_get_client", _get_client)` sets an instance attribute on the singleton, so `await self._get_client()` invokes the zero-arg async stub correctly (no `self` passed, by Python's instance-attribute lookup semantics). ✅ **Smoke live probe**: omits `-e PENPOT_ACCESS_TOKEN` from `docker exec` because the token is already present in the container env via the credentials bind dir — `api._get_client()` reads `config.PENPOT_ACCESS_TOKEN` at call time, so the probe is correct. **30s httpx timeout** is inherited by `export_shape` from the shared client. Tight for slow headless-browser renders of complex frames, but acceptable for an initial implementation; operator can increase if needed once load patterns are known. --- ### Post-merge checklist (from PR itself, not blocking merge) - [ ] Rebuild `penpot-mcp-server` Docker build stage; rebuild `designer` / `design-reviewer` images. - [ ] Run `scripts/smoke-creds.sh designer design-reviewer` to confirm `export_frame_png`/`_svg` import cleanly in the new images. - [ ] Manual smoke: `export_frame_png` against frame `fbb66a40-7b05-40bc-88a6-886a66a80a05` in file `689d7fa4-f94b-81d4-8007-e38d1256f1ae`, confirm PNG output and visual match.
code-lead deleted branch boss/185 2026-04-20 20:45:54 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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!186
No description provided.