はじめに
Penpotをセルフホストで起動し、Claude CodeからMCP経由でデザインファイルを読み書き・作成し、最終的にHTML/CSSへ変換するまでを試したログです。VSCode拡張パネルのClaude Codeセッションから接続する過程でいくつか詰まったポイントがあったので、その解決手順も含めて記録しています。
環境
- OS: Windows 11, Docker Desktop 27.4.0 / Compose v2.31.0
- Penpot: セルフホスト(公式docker-compose.yaml、v2.16.2)、最終的には
http://penpot.local:9001(Traefikリバースプロキシ経由。詳細は「つまずいたポイント②」参照) - Claude Code: VSCode拡張パネル
- Penpot MCP: Penpot 2.16に同梱の
penpot-mcpサービス(PENPOT_FLAGSにenable-mcp)
つまずいたポイント①: VSCode拡張パネルからPenpot MCPに繋がらない
claude mcp add penpot -t http "http://localhost:9001/mcp/stream?userToken=<キー>"(スコープ未指定 = local)で登録し、claude mcp get penpotでは✔ Connectedと出るのに、VSCode拡張パネルのチャットからは該当ツールが一切見えない、という現象が発生した。
原因と解決:
- ローカルスコープ(
~/.claude.json)ではなく、プロジェクトスコープの.mcp.jsonに登録し直す必要があった。これによりプロジェクト直下にclaude mcp add penpot -t http "http://localhost:9001/mcp/stream?userToken=<キー>" -s project.mcp.jsonが生成される。 - MCPサーバー構成はセッション開始時に確定するため、「Developer: Reload Window」でウィンドウを再読み込みし、新しいチャットセッションとして開始し直す必要があった。
- さらに、Penpotのブラウザ側でMCPプラグインを実際に起動していないと、トークンが正しくても
というエラーになる。ファイルを開いた状態でプラグインメニューから「MCP Server」プラグインを起動しておく必要がある。
Error: No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token. - 注意点として、プラグインを開き直すたびにトークンが再発行されることがあり、その都度
.mcp.jsonの更新とウィンドウ再読み込みが必要だった。
以上3点(project scope登録・ウィンドウ再読み込み・ブラウザ側プラグイン起動)が揃って初めて、VSCode拡張パネルのチャットからPenpot MCPのツール(execute_code / export_shape / high_level_overview / penpot_api_info)が使えるようになった。
つまずいたポイント②: export_shape(画像/SVGエクスポート)が動作しない
デザインを確認するためにexport_shapeでPNG/SVGを書き出そうとすると、毎回失敗した。原因調査にかなり時間をかけたので、その過程を記録しておく。
第一段階の仮説と検証
最初に出たエラーはhttp errorという抽象的なものだった。exporterコンテナのログを見ると、こう出ていた:
page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:9001/render.html?...
exporterコンテナは、書き出し処理の内部でヘッドレスブラウザ(Playwright)を使い、PENPOT_PUBLIC_URI(=http://localhost:9001)へ実際に遷移してレンダリング結果を取得する仕組みになっている。ところがコンテナ内のlocalhostはそのコンテナ自身を指すため、フロントエンドコンテナには繋がらずERR_CONNECTION_REFUSEDになっていた。
docker-compose.yamlにはPENPOT_INTERNAL_URI: http://penpot-frontend:8080というコンテナ間通信用の値も用意されていたが、exporterのコード(app.js)を直接grepして確認したところ、render.htmlへの遷移URLは常にpublic-uri(=PENPOT_PUBLIC_URI)から組み立てられており、internal-uriはこの用途には使われていないことが分かった。
回避策を模索(途中経過)
① extra_hostsでコンテナ内のlocalhostを上書き: localhost:host-gatewayを追加したが、コンテナのデフォルトの/etc/hostsに既に127.0.0.1 localhostが存在しており、そちらが優先されて無効だった。
② 別ホスト名(penpot.local)をWindowsのhostsファイルとDocker両方に登録: 127.0.0.1 penpot.localをhostsファイルに追加し、PENPOT_PUBLIC_URIをhttp://penpot.local:9001に変更、exporterにextra_hosts: penpot.local:host-gatewayを追加。exporterのナビゲーションログはexec:handle:endまで到達し遷移自体は成功したが、export_shapeはFailed to fetchという別のエラーで失敗した。
③ PENPOT_PUBLIC_URIを内部サービス名に変更(hostsファイル不要の代替案): PENPOT_PUBLIC_URI: http://penpot-frontend:8080としてDocker Composeのサービス名解決だけで完結させる方法も試した。この場合もexporterはhttp://penpot-frontend:8080/render.htmlへの遷移に成功していたが、export_shapeは同じくFailed to fetchで失敗した。
④ exporter専用の/etc/hostsをバインドマウント: Windowsのhostsファイルを一切変更せず、docker-compose.yamlのvolumesでexporterコンテナだけに独自の/etc/hosts(localhostをDocker Desktopのホストゲートウェイ192.168.65.254に向ける内容)を与える方法も試した。Node.jsのhttp.getではこの解決結果で疎通できたが、exporterが内部で使うヘッドレスブラウザ(Chromium)はlocalhostという名前を特別扱いし、hostsファイルの上書きを無視して常にループバック(自分自身)へ直接解決してしまうという仕様があり、この方法では直らなかった。
真の原因: 2つの経路が同時に同じURLを必要としていた
execute_codeでpenpotUtils.exportImage()を直接呼び出し、生のスタックトレースを取得したところ、③の構成(PENPOT_PUBLIC_URI: http://penpot-frontend:8080)のときに失敗箇所が正確に特定できた。エラーオブジェクトに含まれていた実際のfetch先はこうだった:
http://penpot-frontend:8080/assets/by-id/9d64f578-e8e7-431a-9525-4da44971f96d
これはexporterコンテナではなく、ユーザーの実ブラウザ自身が、書き出しに必要なアセット(フォント等)を取得するためにPENPOT_PUBLIC_URIを使って組み立てたURLだった。つまり:
-
exporterコンテナ → フロントエンド:
PENPOT_PUBLIC_URIがlocalhostだとChromiumの仕様で解決できない。別ホスト名にする必要がある -
実ブラウザ → フロントエンド:
PENPOT_PUBLIC_URIがDocker内部専用のホスト名(penpot-frontend等)だと実ブラウザから解決できない
この2つを同時に満たすには、「実ブラウザからもDockerコンテナ内部からも、同じホスト名・同じポート番号で到達できる」経路が必要だとわかった。
最終的な解決策: Traefikリバースプロキシ
docker-compose.yamlに元々コメントアウトされていたTraefikの仕組みを実際に有効化した。
services:
penpot-traefik:
image: traefik:v3.3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:9001"
ports:
- "9001:9001"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
networks:
penpot:
aliases:
- penpot.local # Docker内部DNSでこの名前がTraefikに解決される
penpot-frontend:
# ports: の直接公開はやめ、Traefik経由にする
labels:
- "traefik.enable=true"
- "traefik.http.routers.penpot.rule=Host(`penpot.local`)"
- "traefik.http.services.penpot.loadbalancer.server.port=8080"
PENPOT_PUBLIC_URI: http://penpot.local:9001 とし、Windowsのhostsファイルに127.0.0.1 penpot.localを1行追加した。これにより:
-
実ブラウザ:
http://penpot.local:9001→ hostsファイルで127.0.0.1に解決 → ホストの9001番 → Traefik → frontend:8080 -
exporterコンテナ:
http://penpot.local:9001→ Dockerの内部DNSでTraefikのコンテナに解決(localhostではないのでChromiumの特別扱いを受けない)→ Traefik自身も9001番でリッスンしているのでポート番号も一致 → frontend:8080
ポイントは、Traefikが「ホスト公開ポート」と「Docker内部からの接続ポート」を同じ番号(9001)に揃えてくれることと、localhostという特別な名前を避けたこと。この2つが揃って、ようやくexport_shapeが成功した。
実際にexport_shapeで登録画面のPNG画像取得に成功し、Claude Codeのチャット上にインライン表示された(タイトル・入力欄・ボタンが意図通りのレイアウトで書き出されていることを確認済み)。記事化する際は、このタイミングでチャット上に表示された画像をスクリーンショットして貼り付けるとよい。
Traefikに/var/run/docker.sockをマウントしているため、このコンテナはDockerデーモンを操作できる状態になります。Traefikの標準的な使い方ではありますが、この構成はあくまでローカル開発環境限定と考え、本番相当の環境にそのまま持ち込まないよう注意してください。
教訓
- Chromium/Playwrightは
localhostという文字列を特別扱いし、/etc/hostsの上書きを無視する。自己ホスト型サービスでヘッドレスブラウザが絡む書き出し機能を扱うときは要注意 -
PENPOT_PUBLIC_URIのような「公開URL」設定は、名前解決の文脈(実ブラウザ/コンテナ内部)によって指す先が変わってはいけない、という制約に気づきにくい。生のスタックトレース(今回はexecute_codeで直接API呼び出しして取得)を見て初めて、失敗箇所が「exporterの遷移」ではなく「ブラウザ側のアセット取得」だったと判明した - 公式docker-compose.yamlにコメントアウトされているTraefik設定は、まさにこの手のホスト名/ポート不整合を解決するために用意されていた
① 読み取り専用デモ
ページ一覧
[
{ "id": "d974ef74-bf1e-8019-8008-79a57a3a895a", "name": "Page 1" }
]
デザイン構造(階層)
最終的に作成した「登録画面」を含む構造:
Root Frame
├─ Board (board)
│ └─ Rectangle
└─ 登録画面 (board, flex/column, gap:24)
├─ 画面タイトル (text) 「アカウント登録」
├─ メールアドレス入力欄 (board, flex/column, gap:8)
│ ├─ メールアドレス ラベル (text)
│ └─ メールアドレス 入力ボックス (board, flex/row)
│ └─ メールアドレス プレースホルダー (text)
├─ パスワード入力欄 (board, flex/column, gap:8)
│ ├─ パスワード ラベル (text)
│ └─ パスワード 入力ボックス (board, flex/row)
│ └─ パスワード プレースホルダー (text)
└─ 登録ボタン (board, flex/row)
└─ 登録ボタン ラベル (text)
MCP経由で、ボードの入れ子構造だけでなく各ボードのFlexレイアウト情報(方向・gap)まで正確に読み取れた。
カラースタイル一覧
| 名前 | 値 |
|---|---|
| Primary | #6750A4 |
| Background | #F5F5F7 |
| Text Primary | #1A1A1A |
| Border | #D0D0D6 |
| Surface White | #FFFFFF |
ライブラリを確認したところ、上記5色それぞれが2件ずつ、計10件登録されている状態だった(スクリーンショット参照)。③で「カラーは新規作成せず、既存ライブラリから名前で検索して再利用」という対応を取った背景はこれだが、具体的にどの操作で重複登録が起きたか(同じ色を作成するコードを2回実行してしまったのか、MCP側の挙動なのか等)はまだ特定できていない。記事化する際は、再現手順を振り返って原因を明記するか、「原因未特定」として注意喚起にとどめるかを判断してほしい。
② 軽い書き込みデモ
レイヤー名の命名規則チェック
指摘した不統一点:
- 日英混在: 「メールアドレス Field」のように日本語+英語が混在
-
役割が分からない汎用名の多用:
Textという名前が複数箇所で重複 - デフォルト名の放置: 既存の「Board」「Rectangle」がデフォルト名のまま
リネーム Before / After
| 変更前 | 変更後 |
|---|---|
| Text(タイトル) | 画面タイトル |
| メールアドレス Field | メールアドレス入力欄 |
| Text(ラベル) | メールアドレス ラベル |
| Input | メールアドレス 入力ボックス |
| Text(プレースホルダー) | メールアドレス プレースホルダー |
| パスワード Field | パスワード入力欄 |
| Text(ラベル) | パスワード ラベル |
| Input | パスワード 入力ボックス |
| Text(プレースホルダー) | パスワード プレースホルダー |
| Text(ボタンラベル) | 登録ボタン ラベル |
Spacing(余白)の洗い出しとトークン提案
| 用途 | 値 |
|---|---|
| 画面の左右パディング | 24px |
| 画面の上下パディング | 40px |
| 画面内の要素間gap | 24px |
| 入力欄内(ラベル→入力ボックス)のgap | 8px |
| 入力ボックス内側の左右パディング | 12px |
出現していた値が8 / 12 / 24 / 40の4種類のみだったため、以下のトークンとして定義:
| トークン名 | 値 |
|---|---|
spacing.xs |
8px |
spacing.sm |
12px |
spacing.md |
24px |
spacing.lg |
40px |
トークン適用時に踏んだ不具合
rowGap / columnGapへのトークン適用は成功したが、paddingLeft / paddingTop / paddingRight / paddingBottomへのトークン適用は常にエラーになった:
[PENPOT PLUGIN] Value not valid: Field message is invalid: . Code: :error
複数のシェイプ・複数のプロパティ名で再現したため、Penpot 2.16のプラグインAPI側の制限/不具合の可能性が高い。padding系の値は生の数値のまま残している。
③ 作成デモ: ログイン画面
登録画面と同じデザインシステム(カラー5色・Spacingトークン4つ)を踏襲して「ログイン画面」を新規作成した。
作成前の方針:
- タイトル「ログイン」
- メールアドレス/パスワード入力欄(登録画面と同じField構造を再利用)
- 「ログイン」ボタン(Primaryカラー)
- 「アカウントをお持ちでない方はこちら」というリンク風テキスト(登録画面への導線)
実装の振り返り:
- カラーは新規作成せず、既存ライブラリから名前で検索して再利用(②で判明した重複作成問題を踏まえた対応)
-
spacing.md/spacing.xsトークンをrowGapに適用 - 「ラベル+入力ボックス」構造を関数化して再利用し、2画面間でパターンの一貫性を確保
⑤ デザイン→コード変換デモ
HTML要素へのマッピング方針
| Penpot要素 | HTML要素 |
|---|---|
| 登録画面 (board) | <main class="screen"> |
| 画面タイトル (text) | <h1> |
| 入力欄(ラベル+入力ボックス+プレースホルダー) | <div class="field"><label>...</label><input placeholder="..."></div> |
| 登録ボタン+ラベル | <button type="submit">登録する</button> |
生成したHTML
<main class="screen">
<form class="register-form">
<h1>アカウント登録</h1>
<div class="field">
<label for="email">メールアドレス</label>
<input id="email" type="email" placeholder="example@email.com">
</div>
<div class="field">
<label for="password">パスワード</label>
<input id="password" type="password" placeholder="8文字以上">
</div>
<button type="submit">登録する</button>
</form>
</main>
生成したCSS
:root {
--color-primary: #6750A4;
--color-background: #F5F5F7;
--color-text-primary: #1A1A1A;
--color-border: #D0D0D6;
--color-surface-white: #FFFFFF;
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 24px;
--spacing-lg: 40px;
}
.screen {
width: 375px;
box-sizing: border-box;
background: var(--color-background);
}
.register-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-lg) var(--spacing-md);
box-sizing: border-box;
}
.register-form h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--color-text-primary);
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.field label {
font-size: 13px;
font-weight: 400;
color: var(--color-text-primary);
}
.field input {
height: 44px;
box-sizing: border-box;
padding: 0 var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-white);
font-size: 14px;
color: var(--color-text-primary);
}
.field input::placeholder {
color: var(--color-border);
}
.register-form button[type="submit"] {
height: 48px;
border: none;
border-radius: 24px;
background: var(--color-primary);
color: var(--color-surface-white);
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
実際にdemo-output/registration.htmlとして書き出し、ブラウザで見た目を確認した。
再現できなかった/差異があった部分
-
フォントファミリー: Penpot側で
fontFamilyを明示的に設定していなかったため、変換先でも具体的な書体を指定していない - 影・グラデーション: 今回のデザインには使われていなかったため、再現の可否を試す機会がなかった
-
意図的な差異: Penpotでは「入力ボックス」と「プレースホルダーテキスト」が別レイヤーだが、コードでは
<input placeholder>に統合。1:1の機械的変換ではなく意味を汲んだ変換
⑥ 締めくくり: Claude Code視点での振り返り
良かった点
-
execute_codeによるJS直接実行で、シェイプ作成・階層取得・リネーム・トークン適用まで一通り自然な流れでできた -
penpotUtilsのヘルパー関数(shapeStructure/findShapesなど)が充実しており、階層構造の把握や要素検索が簡単だった - Flexレイアウトのプロパティが細かく取得・制御でき、デザインの「余白の意図」までコードで扱えた
- デザイントークンをコード内で参照しながらUIを組み立てられ、複数画面にまたがる一貫性を保ちやすかった
- 取得した色・spacing・階層構造がそのままHTML/CSS変換のソースとして使えた
つまずいた点
- VSCode拡張パネルのセッションでのMCP設定反映方法が特殊(project scope + ウィンドウ再読み込みが必要)
- Penpotブラウザ側でMCPプラグインを開いていないと接続エラーになる
- プラグインを開き直すたびにトークンが再発行される
-
createText()は引数必須で、引数なしで呼ぶとエラー -
fontWeightに現在のフォントで未サポートの値("600")を指定するとエラー - スクリプト実行中に例外が起きると、既に作成済みの中間シェイプ・カラーがロールバックされず残る
- ライブラリカラーの削除APIが存在しない(手動でのUI操作が必要)
- Spacingトークンのpadding系プロパティへの適用が常にエラーになる不具合
-
export_shape(画像/SVGエクスポート)が最初は動作しなかった(詳細は「つまずいたポイント②」参照)。原因は「exporterコンテナのChromiumがlocalhostを特別扱いして/etc/hostsの上書きを無視する」ことと「PENPOT_PUBLIC_URIを変えると今度は実ブラウザ側のアセット取得が壊れる」ことが同時に絡んだ構造的な問題で、最終的にはTraefikリバースプロキシ(公式docker-compose.yamlにコメントアウトされていた仕組み)を有効化し、実ブラウザ・Dockerコンテナ双方から同じホスト名・同じポートで到達できるようにして解決した
注意すべき点
- スクリプト失敗時に孤立シェイプが残ることを前提に、後片付けしやすい単位で実行する
- 冪等性を意識した書き方(既存要素を名前で検索して再利用)をしないと、リトライのたびに重複が増える
- MCPキーやトークンなどの機密情報をログや記事に貼らないよう注意する
- self-hosted環境特有のネットワーク制約は事前に把握しておくと手戻りが少ない
まとめ
- PenpotのセルフホストとMCP接続自体は公式ドキュメント通りに進めれば大きくは詰まらないが、**VSCode拡張パネルのClaude Code特有の設定反映(project scope + ウィンドウ再読み込み)**は事前に知っておかないとハマりやすいポイントだった
- MCP経由で「読み取り→分析→軽い書き込み→ゼロからの作成→コード変換」まで一通り自然な流れで実行でき、特にFlexレイアウトやデザイントークンの情報まで正確に扱えたのは実用性が高いと感じた
-
export_shapeの失敗は、実ブラウザとDockerコンテナ内部という「2つの異なる経路が同じURLを別の意味で必要としていた」という構造的な問題で、原因調査には時間がかかったが、Traefikリバースプロキシで解決できた。self-hosted構成でヘッドレスブラウザ絡みの機能を使う場合は、同様の壁にぶつかる可能性がある - デザイン→コード変換は「1:1の機械的変換」ではなく「意味を汲んだ変換」になっており、AIならではの強みが出ていた部分だった
理論上「MCPでデザインツールを操作できる」と聞くのと、実際に手元のセルフホスト環境で最後まで通すのとでは、詰まるポイントの解像度が全然違うと感じた回でした。特にexport_shapeの原因調査は、生のスタックトレースを取得して初めて本当の失敗箇所が分かる類のものだったので、抽象的なエラーメッセージだけで諦めずに掘り下げる価値があったと思います。





