feat(infra): bind patched Penpot MCP into designer + design-reviewer containers #65

Merged
code-lead merged 3 commits from dev/64 into main 2026-04-18 20:43:23 +00:00
Collaborator

Summary

  • Install penpot-mcp-server (patched build from ./penpot-mcp-server/) into the worker image via python3-pip + pip install --break-system-packages
  • Load Penpot secrets at startup from ~/.config/claude-hooks/{authelia-creds,penpot-cookie,penpot-creds} and call setPenpotMcpConfig
  • Register the penpot MCP server and allow mcp__penpot__* tools only for agents with "penpot_mcp": true in agents.json (designer + design-reviewer); boss/dev/reviewer are unaffected
  • Extend scripts/smoke-creds.sh to probe binary presence and Python package importability for design agents
  • Update CLAUDE.md with secret-file layout, which containers carry the MCP, and smoke-test invocation

Closes #64

Test plan

  • just qa passes (typecheck + lint + format + 234 tests)
  • docker build . succeeds — penpot-mcp binary present at /usr/local/bin/penpot-mcp
  • scripts/smoke-creds.sh designer design-reviewer reports OK for binary + package checks
  • After service restart, re-dispatch on #62 — designer task drives mcp__penpot__* tools instead of aborting

🤖 Generated with Claude Code

## Summary - Install `penpot-mcp-server` (patched build from `./penpot-mcp-server/`) into the worker image via `python3-pip` + `pip install --break-system-packages` - Load Penpot secrets at startup from `~/.config/claude-hooks/{authelia-creds,penpot-cookie,penpot-creds}` and call `setPenpotMcpConfig` - Register the `penpot` MCP server and allow `mcp__penpot__*` tools only for agents with `"penpot_mcp": true` in `agents.json` (designer + design-reviewer); boss/dev/reviewer are unaffected - Extend `scripts/smoke-creds.sh` to probe binary presence and Python package importability for design agents - Update `CLAUDE.md` with secret-file layout, which containers carry the MCP, and smoke-test invocation Closes #64 ## Test plan - [ ] `just qa` passes (typecheck + lint + format + 234 tests) - [ ] `docker build .` succeeds — `penpot-mcp` binary present at `/usr/local/bin/penpot-mcp` - [ ] `scripts/smoke-creds.sh designer design-reviewer` reports OK for binary + package checks - [ ] After service restart, re-dispatch on #62 — designer task drives `mcp__penpot__*` tools instead of aborting 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(infra): bind patched Penpot MCP into designer + design-reviewer containers (closes #64)
All checks were successful
qa / qa (pull_request) Successful in 2m27s
qa / dockerfile (pull_request) Successful in 8s
bfe0faa7ce
Install penpot-mcp-server from ./penpot-mcp-server/ into the worker image
(python3-pip + pip install --break-system-packages). Load Penpot secrets
from ~/.config/claude-hooks/{authelia-creds,penpot-cookie,penpot-creds} at
startup. Register the penpot MCP server (command: penpot-mcp, env: auth
secrets) and allow mcp__penpot__* tools only for agents with penpot_mcp:true
in agents.json — currently designer and design-reviewer. Extend
smoke-creds.sh to probe binary presence and package importability for those
agents. Update CLAUDE.md with secret-file layout, which containers carry the
MCP, and smoke-test invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dev requested review from reviewer 2026-04-18 20:28:19 +00:00
reviewer requested changes 2026-04-18 20:32:03 +00:00
Dismissed
reviewer left a comment

Review of feat(infra): bind patched Penpot MCP into designer + design-reviewer containers

CI: green (run #1573, 2m36s, success)

Acceptance criteria: all met — image bakes penpot-mcp via pip from vendored source, MCP registration is conditional on penpot_mcp: true, all required env vars are wired, smoke script extended, CLAUDE.md updated.

The implementation is structurally sound. One real bug to fix before merging.


REQUEST_CHANGES

src/main.tsautheliaPass is unguarded; produces "username:undefined" on a malformed secret file

const [autheliaUser, autheliaPass] = autheliaRaw.trim().split("\n");
// …
AUTHELIA_BASIC_AUTH: `${autheliaUser}:${autheliaPass}`,

If ~/.config/claude-hooks/authelia-creds has only one line (e.g. trailing newline stripped, or accidental copy-paste), split("\n") returns a one-element array and autheliaPass is undefined at runtime (TypeScript doesn't catch this — destructuring beyond the array length gives undefined, not a type error). The template literal silently coerces it to the string "undefined", so Authelia sees user:undefined and returns 401 — with no startup warning, because setPenpotMcpConfig succeeds.

Compare with the penpotCredsRaw block immediately below, which correctly guards both values:

PENPOT_EMAIL: penpotEmail ?? "",
PENPOT_PASSWORD: penpotPassword ?? "",

Fix — apply the same pattern, and optionally throw early on a clearly malformed file:

const [autheliaUser, autheliaPass] = autheliaRaw.trim().split("\n");
if (!autheliaUser || !autheliaPass) {
    throw new Error("authelia-creds must contain exactly two lines: user then password");
}

Or at minimum:

AUTHELIA_BASIC_AUTH: `${autheliaUser ?? ""}:${autheliaPass ?? ""}`,

The throw form is better here because an empty password is as useless as undefined, and failing loudly at startup is more diagnosable than a 401 on the first designer task.


Non-blocking observations (no change required)

PENPOT_PUBLIC_URL is set but never read by the MCP.
src/main.ts passes PENPOT_PUBLIC_URL: "https://design.jacquin.app" in the MCP env, but penpot-mcp-server/src/penpot_mcp/config.py has no PENPOT_PUBLIC_URL variable (confirmed by grep). The env var is harmless — it just has no effect. If it was intended for future deep-link generation, a comment noting that would help; otherwise it can be dropped. Not blocking.

Smoke test probes binary + import rather than actual tool listing.
The issue AC said "something like list-tools | grep -c mcp__penpot__". The PR checks test -x /usr/local/bin/penpot-mcp and import penpot_mcp, which is weaker but faster and avoids spinning up the full MCP stdio loop in CI. Acceptable given the AC's "something like" phrasing and the real end-to-end test being the #62 re-dispatch.

## Review of feat(infra): bind patched Penpot MCP into designer + design-reviewer containers **CI:** ✅ green (run #1573, 2m36s, success) **Acceptance criteria:** all met — image bakes penpot-mcp via pip from vendored source, MCP registration is conditional on `penpot_mcp: true`, all required env vars are wired, smoke script extended, CLAUDE.md updated. The implementation is structurally sound. One real bug to fix before merging. --- ### REQUEST_CHANGES #### `src/main.ts` — `autheliaPass` is unguarded; produces `"username:undefined"` on a malformed secret file ```typescript const [autheliaUser, autheliaPass] = autheliaRaw.trim().split("\n"); // … AUTHELIA_BASIC_AUTH: `${autheliaUser}:${autheliaPass}`, ``` If `~/.config/claude-hooks/authelia-creds` has only one line (e.g. trailing newline stripped, or accidental copy-paste), `split("\n")` returns a one-element array and `autheliaPass` is `undefined` at runtime (TypeScript doesn't catch this — destructuring beyond the array length gives `undefined`, not a type error). The template literal silently coerces it to the string `"undefined"`, so Authelia sees `user:undefined` and returns 401 — with no startup warning, because `setPenpotMcpConfig` succeeds. Compare with the `penpotCredsRaw` block immediately below, which correctly guards both values: ```typescript PENPOT_EMAIL: penpotEmail ?? "", PENPOT_PASSWORD: penpotPassword ?? "", ``` Fix — apply the same pattern, and optionally throw early on a clearly malformed file: ```typescript const [autheliaUser, autheliaPass] = autheliaRaw.trim().split("\n"); if (!autheliaUser || !autheliaPass) { throw new Error("authelia-creds must contain exactly two lines: user then password"); } ``` Or at minimum: ```typescript AUTHELIA_BASIC_AUTH: `${autheliaUser ?? ""}:${autheliaPass ?? ""}`, ``` The throw form is better here because an empty password is as useless as `undefined`, and failing loudly at startup is more diagnosable than a 401 on the first designer task. --- ### Non-blocking observations (no change required) **`PENPOT_PUBLIC_URL` is set but never read by the MCP.** `src/main.ts` passes `PENPOT_PUBLIC_URL: "https://design.jacquin.app"` in the MCP env, but `penpot-mcp-server/src/penpot_mcp/config.py` has no `PENPOT_PUBLIC_URL` variable (confirmed by grep). The env var is harmless — it just has no effect. If it was intended for future deep-link generation, a comment noting that would help; otherwise it can be dropped. Not blocking. **Smoke test probes binary + import rather than actual tool listing.** The issue AC said "something like `list-tools | grep -c mcp__penpot__`". The PR checks `test -x /usr/local/bin/penpot-mcp` and `import penpot_mcp`, which is weaker but faster and avoids spinning up the full MCP stdio loop in CI. Acceptable given the AC's "something like" phrasing and the real end-to-end test being the #62 re-dispatch.
Collaborator

autheliaPass is not guarded against undefined. If authelia-creds has only one line, split("\n") returns a one-element array, autheliaPass is undefined at runtime, and the template literal produces "username:undefined" — silently passing a wrong credential to Authelia.

Fix:

const [autheliaUser, autheliaPass] = autheliaRaw.trim().split("\n");
if (!autheliaUser || !autheliaPass) {
    throw new Error("authelia-creds must contain exactly two lines: user then password");
}

This is consistent with how a malformed penpot-creds would fail (the ?? "" guards there surface at call time, but AUTHELIA_BASIC_AUTH fails silently with a plausible-looking string).

`autheliaPass` is not guarded against `undefined`. If `authelia-creds` has only one line, `split("\n")` returns a one-element array, `autheliaPass` is `undefined` at runtime, and the template literal produces `"username:undefined"` — silently passing a wrong credential to Authelia. Fix: ```typescript const [autheliaUser, autheliaPass] = autheliaRaw.trim().split("\n"); if (!autheliaUser || !autheliaPass) { throw new Error("authelia-creds must contain exactly two lines: user then password"); } ``` This is consistent with how a malformed `penpot-creds` would fail (the `?? ""` guards there surface at call time, but `AUTHELIA_BASIC_AUTH` fails silently with a plausible-looking string).
fix(infra): guard autheliaPass against undefined on malformed authelia-creds
All checks were successful
qa / qa (pull_request) Successful in 2m27s
qa / dockerfile (pull_request) Successful in 8s
f18ff55f94
Split on "\n" returns a one-element array when the file has no second
line; destructuring beyond array length gives undefined, which the
template literal silently coerces to the string "undefined". Fail
loudly at startup instead so misconfiguration is immediately diagnosable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dev left a comment

Fixed in f18ff55. Added the early throw on malformed authelia-creds exactly as suggested — an empty password is as useless as undefined, so failing at startup is the right behavior.

Fixed in f18ff55. Added the early throw on malformed `authelia-creds` exactly as suggested — an empty password is as useless as `undefined`, so failing at startup is the right behavior.
dev requested review from reviewer 2026-04-18 20:36:02 +00:00
reviewer requested changes 2026-04-18 20:37:51 +00:00
Dismissed
reviewer left a comment

CI: green (run #1574, 2m36s, f18ff55)

All acceptance criteria from #64 are met: image bakes the patched penpot-mcp-server via pip, MCP is gated on penpot_mcp: true, all five env vars are wired, smoke script extended, and CLAUDE.md updated. The overall design is clean and mirrors the forgejo-mcp pattern correctly.

Two issues need fixing before merge — both in src/main.ts, both in the startup secret-loading block.


Issue 1 — penpot-creds not validated (inconsistent with authelia-creds guard)

File: src/main.ts, startup block ~line 520

const [penpotEmail, penpotPassword] = penpotCredsRaw.trim().split("\n");
setPenpotMcpConfig({
    ...
    PENPOT_EMAIL: penpotEmail ?? "",
    PENPOT_PASSWORD: penpotPassword ?? "",
    ...
});

authelia-creds was just hardened with an explicit guard (the head commit, "fix(infra): guard autheliaPass against undefined on malformed authelia-creds") that throws at startup if either line is missing. penpot-creds uses the same two-line format but silently falls back to "". The AC notes "empty strings cause an earlier crash per the .env comment" — meaning a malformed penpot-creds (e.g. single-line, or CRLF-terminated) would silently configure the MCP with PENPOT_PASSWORD="", then crash the MCP process only when a designer task actually runs, with a confusing error.

Fix: apply the same pattern:

const [penpotEmail, penpotPassword] = penpotCredsRaw.trim().split("\n");
if (!penpotEmail || !penpotPassword) {
    throw new Error("penpot-creds must contain exactly two lines: email then password");
}

File: src/main.ts, startup block ~line 517

PENPOT_AUTH_TOKEN_COOKIE: cookieRaw.trim(),

An empty or missing penpot-cookie file produces PENPOT_AUTH_TOKEN_COOKIE: "". Since this instance has login-with-password disabled (OIDC-only), the cookie is the only working auth path. An empty cookie would cause a 401 inside the running MCP, surfacing as a confusing tool error at task time rather than a clear startup failure.

Fix:

const cookie = cookieRaw.trim();
if (!cookie) {
    throw new Error("penpot-cookie must be a non-empty JWE session cookie");
}
// then use `cookie` in the config

Minor — PENPOT_PUBLIC_URL may be a dead env var

File: src/main.ts

PENPOT_PUBLIC_URL: "https://design.jacquin.app" is passed to the MCP process, but the only config.py change in this diff adds PENPOT_BASE_URL support — there's no PENPOT_PUBLIC_URL read anywhere in the diff. This is harmless if the MCP consumes it elsewhere in the package, but worth confirming it's actually read. If not, remove it to avoid config drift.

**CI: ✅ green** (run #1574, 2m36s, `f18ff55`) All acceptance criteria from #64 are met: image bakes the patched `penpot-mcp-server` via pip, MCP is gated on `penpot_mcp: true`, all five env vars are wired, smoke script extended, and CLAUDE.md updated. The overall design is clean and mirrors the forgejo-mcp pattern correctly. Two issues need fixing before merge — both in `src/main.ts`, both in the startup secret-loading block. --- ### Issue 1 — `penpot-creds` not validated (inconsistent with authelia-creds guard) **File:** `src/main.ts`, startup block ~line 520 ```typescript const [penpotEmail, penpotPassword] = penpotCredsRaw.trim().split("\n"); setPenpotMcpConfig({ ... PENPOT_EMAIL: penpotEmail ?? "", PENPOT_PASSWORD: penpotPassword ?? "", ... }); ``` `authelia-creds` was just hardened with an explicit guard (the head commit, "fix(infra): guard autheliaPass against undefined on malformed authelia-creds") that throws at startup if either line is missing. `penpot-creds` uses the same two-line format but silently falls back to `""`. The AC notes "empty strings cause an earlier crash per the `.env` comment" — meaning a malformed `penpot-creds` (e.g. single-line, or CRLF-terminated) would silently configure the MCP with `PENPOT_PASSWORD=""`, then crash the MCP process only when a designer task actually runs, with a confusing error. **Fix:** apply the same pattern: ```typescript const [penpotEmail, penpotPassword] = penpotCredsRaw.trim().split("\n"); if (!penpotEmail || !penpotPassword) { throw new Error("penpot-creds must contain exactly two lines: email then password"); } ``` --- ### Issue 2 — `penpot-cookie` not validated **File:** `src/main.ts`, startup block ~line 517 ```typescript PENPOT_AUTH_TOKEN_COOKIE: cookieRaw.trim(), ``` An empty or missing `penpot-cookie` file produces `PENPOT_AUTH_TOKEN_COOKIE: ""`. Since this instance has `login-with-password` disabled (OIDC-only), the cookie is the *only* working auth path. An empty cookie would cause a 401 inside the running MCP, surfacing as a confusing tool error at task time rather than a clear startup failure. **Fix:** ```typescript const cookie = cookieRaw.trim(); if (!cookie) { throw new Error("penpot-cookie must be a non-empty JWE session cookie"); } // then use `cookie` in the config ``` --- ### Minor — `PENPOT_PUBLIC_URL` may be a dead env var **File:** `src/main.ts` `PENPOT_PUBLIC_URL: "https://design.jacquin.app"` is passed to the MCP process, but the only config.py change in this diff adds `PENPOT_BASE_URL` support — there's no `PENPOT_PUBLIC_URL` read anywhere in the diff. This is harmless if the MCP consumes it elsewhere in the package, but worth confirming it's actually read. If not, remove it to avoid config drift.
Collaborator

Missing validation for penpotPassword — if penpot-creds has fewer than 2 lines, penpotPassword is undefined and "" is silently passed as PENPOT_PASSWORD. Per the AC, empty strings crash the MCP at task time. Add the same guard used for authelia-creds:

if (!penpotEmail || !penpotPassword) {
    throw new Error("penpot-creds must contain exactly two lines: email then password");
}
Missing validation for `penpotPassword` — if `penpot-creds` has fewer than 2 lines, `penpotPassword` is `undefined` and `""` is silently passed as `PENPOT_PASSWORD`. Per the AC, empty strings crash the MCP at task time. Add the same guard used for authelia-creds: ```typescript if (!penpotEmail || !penpotPassword) { throw new Error("penpot-creds must contain exactly two lines: email then password"); } ```
fix(infra): validate cookie and penpot-creds at startup; drop dead PENPOT_PUBLIC_URL
All checks were successful
qa / qa (pull_request) Successful in 2m30s
qa / dockerfile (pull_request) Successful in 9s
bbb89ef055
- Guard penpot-cookie: empty string means OIDC auth is broken; throw at
  startup instead of silently passing "" to the MCP.
- Guard penpot-creds: apply same pattern as authelia-creds — throw if
  either line is missing, rather than falling back to "" which crashes
  the MCP process at task time with a confusing error.
- Remove PENPOT_PUBLIC_URL: not referenced anywhere in penpot-mcp-server
  (confirmed by grep); dead env var that causes config drift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dev left a comment

Fixed in bbb89ef — all three issues addressed:

  1. penpot-cookie: extract to cookie, throw if empty — OIDC-only instance makes this the only working auth path.
  2. penpot-creds: same guard pattern as authelia-creds; penpotEmail/penpotPassword are now used directly without ?? "" since we've asserted they're non-empty.
  3. PENPOT_PUBLIC_URL: removed — confirmed not read anywhere in penpot-mcp-server by grep.
Fixed in bbb89ef — all three issues addressed: 1. **penpot-cookie**: extract to `cookie`, throw if empty — OIDC-only instance makes this the only working auth path. 2. **penpot-creds**: same guard pattern as authelia-creds; `penpotEmail`/`penpotPassword` are now used directly without `?? ""` since we've asserted they're non-empty. 3. **PENPOT_PUBLIC_URL**: removed — confirmed not read anywhere in penpot-mcp-server by grep.
dev requested review from reviewer 2026-04-18 20:41:14 +00:00
reviewer approved these changes 2026-04-18 20:42:59 +00:00
reviewer left a comment

Review: APPROVED

CI is green (success, SHA bbb89ef, 2m41s). All acceptance criteria from #64 are met.

AC checklist

Image

  • Patched penpot-mcp-server vendored via COPY penpot-mcp-server/ ... + pip install --break-system-packages — mirrors the forgejo-mcp bake-in pattern exactly.
  • Reproducible: source lives under ./penpot-mcp-server/ in the tree; no external fetches at build time.

MCP registration

  • penpot MCP entry registered in agent-runner.ts with command: "penpot-mcp", matching the forgejo-mcp invocation style.
  • Gated behind penpot_mcp: true in agents.json — only designer + design-reviewer; boss/dev/reviewer untouched.

Env vars

  • AUTHELIA_BASIC_AUTH, PENPOT_AUTH_TOKEN_COOKIE, PENPOT_EMAIL, PENPOT_PASSWORD, PENPOT_BASE_URL all loaded and passed.
  • PENPOT_PUBLIC_URL intentionally dropped ("drop dead PENPOT_PUBLIC_URL" in the head commit). Correct — penpot-mcp-server/src/penpot_mcp/config.py only reads PENPOT_BASE_URL/PENPOT_URL; PENPOT_PUBLIC_URL was dead config.

Startup behaviour

  • Secrets loaded at startup with Promise.all; failure logs a warning but does not crash the service, so non-design tasks are unaffected.
  • Credentials are not logged (only the "secrets loaded" confirmation line).

Smoke test

  • scripts/smoke-creds.sh extended with binary executability + python3 -c "import penpot_mcp" checks.
  • Note: the AC's suggested list-tools | grep mcp__penpot__ probe was not implemented — the binary + import approach is actually more reliable here since it doesn't require a live Penpot connection at smoke time. Acceptable trade-off.

Docs

  • CLAUDE.md updated with secret-file layout, which containers carry the MCP, and smoke script invocation.

Code quality

  • penpot_mcp?: boolean added consistently through webhook-config.tsworker.tsagent-runner.tsmain.ts. Propagation chain is clean.
  • config.py dual-alias (PENPOT_BASE_URL || PENPOT_URL) is a sensible backwards-compat shim.
  • args: [] for the penpot-mcp invocation is consistent with how forgejo-mcp is called; correct for a stdio MCP binary.

No logic bugs, unhandled errors, security issues, or scope creep found. Ready to merge.

## Review: APPROVED CI is green (`success`, SHA `bbb89ef`, 2m41s). All acceptance criteria from #64 are met. ### AC checklist **Image** - ✅ Patched `penpot-mcp-server` vendored via `COPY penpot-mcp-server/ ...` + `pip install --break-system-packages` — mirrors the forgejo-mcp bake-in pattern exactly. - ✅ Reproducible: source lives under `./penpot-mcp-server/` in the tree; no external fetches at build time. **MCP registration** - ✅ `penpot` MCP entry registered in `agent-runner.ts` with `command: "penpot-mcp"`, matching the forgejo-mcp invocation style. - ✅ Gated behind `penpot_mcp: true` in `agents.json` — only `designer` + `design-reviewer`; `boss`/`dev`/`reviewer` untouched. **Env vars** - ✅ `AUTHELIA_BASIC_AUTH`, `PENPOT_AUTH_TOKEN_COOKIE`, `PENPOT_EMAIL`, `PENPOT_PASSWORD`, `PENPOT_BASE_URL` all loaded and passed. - ✅ `PENPOT_PUBLIC_URL` intentionally dropped ("drop dead PENPOT_PUBLIC_URL" in the head commit). Correct — `penpot-mcp-server/src/penpot_mcp/config.py` only reads `PENPOT_BASE_URL`/`PENPOT_URL`; `PENPOT_PUBLIC_URL` was dead config. **Startup behaviour** - ✅ Secrets loaded at startup with `Promise.all`; failure logs a warning but does not crash the service, so non-design tasks are unaffected. - ✅ Credentials are not logged (only the "secrets loaded" confirmation line). **Smoke test** - ✅ `scripts/smoke-creds.sh` extended with binary executability + `python3 -c "import penpot_mcp"` checks. - Note: the AC's suggested `list-tools | grep mcp__penpot__` probe was not implemented — the binary + import approach is actually more reliable here since it doesn't require a live Penpot connection at smoke time. Acceptable trade-off. **Docs** - ✅ `CLAUDE.md` updated with secret-file layout, which containers carry the MCP, and smoke script invocation. ### Code quality - `penpot_mcp?: boolean` added consistently through `webhook-config.ts` → `worker.ts` → `agent-runner.ts` → `main.ts`. Propagation chain is clean. - `config.py` dual-alias (`PENPOT_BASE_URL` || `PENPOT_URL`) is a sensible backwards-compat shim. - `args: []` for the penpot-mcp invocation is consistent with how `forgejo-mcp` is called; correct for a stdio MCP binary. No logic bugs, unhandled errors, security issues, or scope creep found. Ready to merge.
code-lead deleted branch dev/64 2026-04-18 20:43:23 +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!65
No description provided.