fetch-rendered-page-mcp
fetch-rendered-page-mcp
Chromium で動的サイト(React / Vue などの SPA)をレンダリングしてから本文を Markdown で返す MCP サーバです。軽量 HTTP 取得 → 不十分ならブラウザへ自動フォールバックするので、静的サイトでは速く、動的サイトでも確実に中身を取れます。
標準の web_fetch では取れない「JS 実行後にしか中身が出ないページ」を埋めるのが狙いです。
提供ツール
fetch_rendered_page — 単一ページの本文取得
引数 | 型 | 説明 |
| string(必須) | 取得対象の URL |
| boolean | true で HTTP をスキップし必ずブラウザ使用 |
| number | 返す本文の最大文字数(行単位で切り詰め) |
|
| 出力形式(既定 markdown) |
| string | このセレクタが現れるまで待つ |
| string | storageState プロファイル名 |
fetch_rendered_pages — 複数ページの一括取得
引数 | 型 | 説明 |
| string[](必須) | 最大 |
| boolean | 全 URL について必ずブラウザ使用 |
| string | 全 URL に共通で適用される待機セレクタ |
| 上と同じ |
一部が失敗しても成功したぶんは返り、isError は付きません。失敗した URL は本文中に [取得失敗 / 種類] として個別に載り、冒頭に成功 / 失敗の件数サマリが付きます。1 件も取れなかった場合だけ isError: true になります。
list_page_links — リンク列挙
引数 | 型 | 説明 |
| string(必須) | 対象ページ |
| boolean | 同一オリジンのみに絞る |
| number | 返す最大件数(既定 200) |
| 上と同じ |
ローカル実行
npm ci
npx playwright install chromium
# 開発実行 (tsx watch)
npm run dev
# ビルドして実行
npm run build
npm start
# テスト
npm test
# MCP Inspector
npx -y @modelcontextprotocol/inspector@1.0.0疎通確認:
curl http://localhost:8080/health
# => {"status":"ok","browser":{"inFlight":0,"queued":0,"connected":false}}ローカルで http://localhost:xxxx を取得したい場合だけ ALLOW_PRIVATE_NETWORK=true を付けてください(本番では絶対に付けないこと)。
Docker ビルド
# 重要:イメージタグ (v1.61.1) と package.json の playwright (1.61.1) は必ず一致させる
docker build -t fetch-rendered-page .
docker run --rm -p 8080:8080 ^
-e MCP_AUTH_USER=myuser ^
-e MCP_AUTH_PASSWORD=mysecret ^
fetch-rendered-pageマルチステージ構成について
Docker のレイヤは加算的なので、単一ステージで RUN npm ci してから RUN npm prune --omit=dev しても、prune は削除マーカーを足すだけでイメージは縮みません。同じ理由で RUN chown -R /app も /app 配下を丸ごと新レイヤに複製します。
そこで builder / runtime の 2 ステージに分け、builder 内で npm ci → tsc → npm prune --omit=dev まで済ませ、剪定後の node_modules と dist だけを runtime へ渡しています。builder ごと捨てられるので prune が実際に効きます。
ベースイメージの上に積まれるぶんの実測値:
構成 | 内訳 | 合計 |
単一ステージ | node_modules 117MB + npm キャッシュ 24MB + chown 複製 79MB ほか | 約 221MB |
マルチステージ | 本番 node_modules 78MB + dist 0.2MB ほか | 約 79MB |
builder も runtime も同じ Playwright イメージを使っています。builder を node:22-slim などに替えれば中間ステージは軽くなりますが、ベースイメージを 2 種類 pull することになり、将来 native モジュールが混ざったときに Node のバージョン差で噛むため、揃えてあります。
なお Dockerfile は COPY package.json package-lock.json .npmrc ./ と明示しているので、lockfile が無いとビルドがそこで止まります(サイレントに依存がズレるより良い、という判断です)。
Cloud Run デプロイ
gcloud run deploy fetch-rendered-page ^
--source . ^
--region asia-northeast1 ^
--memory 2Gi ^
--cpu 1 ^
--concurrency 2 ^
--timeout 120 ^
--set-env-vars "MCP_AUTH_USER=myuser,MCP_AUTH_PASSWORD=mysecret,MCP_ENDPOINT_PATH=/abcde01234"デプロイ後、払い出されたホスト名を MCP_ALLOWED_HOSTS に設定して更新する:
gcloud run services update fetch-rendered-page ^
--region asia-northeast1 ^
--update-env-vars "MCP_ALLOWED_HOSTS=fetch-rendered-page-xxxxxxxx.asia-northeast1.run.app"--concurrency を必ず指定する
Cloud Run の既定同時実行数は 80 です。Chromium の BrowserContext は 1 つで数百 MB 使うため、既定のまま複数リクエストが重なると 2Gi でも OOM します。
Cloud Run 側:
--concurrency 2(インスタンスに同時に流す数を絞る)アプリ側:
MAX_CONCURRENCY=2(context の同時生成数を絞る)
の二段構えで守ります。片方だけだと、もう片方が抜け道になります。
--timeout と FETCH_TIMEOUT_MS の関係
FETCH_TIMEOUT_MS(既定 45 秒)は アプリ内の総時間予算。HTTP 取得・ナビゲーション・描画待ちがこの中を分け合います。--timeout(Cloud Run) はリクエスト全体の上限。
必ず FETCH_TIMEOUT_MS < --timeout にしてください。逆だと Cloud Run が先に切り、原因の分からない 504 になります。
メモリと min-instances
メモリ: 最低 2Gi。Chromium は 1 context で idle 500MB〜1GB、負荷時はそれ以上。
min-instances=0: アイドル課金ゼロ。初回だけブラウザ launch のぶん遅い(Chromium 自体は同梱済みなので DL は無し)。
min-instances=1: コールドスタートを消せるが常時課金。個人利用なら 0 で十分。
claude.ai への登録
カスタムコネクタに次の形式の URL を登録します(カスタムヘッダ欄が無いため、認証情報は URL に埋め込む):
https://<MCP_AUTH_USER>:<MCP_AUTH_PASSWORD>@<your-host>.run.app<MCP_ENDPOINT_PATH>ログイン後のページを取得する (storageState)
1. 手元でログイン状態を取る
npm run capture:login -- --url https://github.com/login --out .\secrets\github.json画面付きの Chromium が開くので、そこでログインしてからターミナルで Enter を押すと、Cookie と localStorage が JSON で保存されます。
2. Secret Manager に入れて Cloud Run にマウント
gcloud secrets create fetch-mcp-github --data-file=".\secrets\github.json"
gcloud run services update fetch-rendered-page ^
--region asia-northeast1 ^
--update-secrets "/secrets/github.json=fetch-github:latest"3. プロファイルを登録
MCP_STORAGE_PROFILES=[{"name":"github","file":"/secrets/github.json","allowedHosts":["github.com","*.github.com"]}]以降 profile: "github" を指定すると、ログイン済みの状態でページを取得します。
allowedHostsは必須です。 プロファイル名も URL も LLM 経由で決まるため、束縛が無いとprofile=github+url=https://evil.exampleで GitHub のセッション Cookie を外部へ送れてしまいます。リダイレクトで許可ホストの外へ出た場合も打ち切ります。なお storageState の JSON はログイン済みの本人そのものです。リポジトリにコミットしないでください(
.gitignoreにsecrets/を入れてあります)。
セキュリティについて
SSRF ガード
任意の URL をサーバ側から取得し、その中身を LLM に返す構造上、「取得したページに書かれた指示で次の取得先が決まる」経路(間接プロンプトインジェクション)が成立し得ます。そこで 3 段で塞いでいます。
スキーム制限 —
http/https以外を拒否名前ベースの拒否 —
localhost,metadata.google.internal,*.internal解決後 IP の判定 — loopback / RFC1918 / link-local / CGNAT / IPv6 ULA / IPv4-mapped などを拒否
加えて:
リダイレクトはホップごとに再検証(
公開ホスト → 302 → 169.254.169.254を止める)DNS rebinding 対策として、自前で名前解決し、検証済みの IP を socket に直接渡す(検証と接続の間に応答を差し替えられない)
ページ内から出るリクエストにも同じ判定を適用(ページの JS が内部 IP を読んで DOM に描くのを防ぐ)
なお GCP のメタデータサーバは Metadata-Flavor: Google ヘッダを要求するため、素の SSRF では元々読めません。上記は「VPC を繋いだ場合の内部サービス」「将来の管理エンドポイント」を含めた多層防御です。
Chromium サンドボックスについて(既知の受け入れリスク)
Playwright 公式の Docker ドキュメントは「このイメージは testing / development 用途向けであり、untrusted なサイトを開くのは非推奨。scraping するなら別ユーザ + seccomp profile を使え」としています。
しかし Cloud Run では --security-opt seccomp= も --ipc=host も指定できないため、この公式推奨構成は取れません。したがって --no-sandbox のまま運用し、隔離境界は「コンテナ / Cloud Run そのもの」に委ねています。
代わりに次で被害範囲を抑えています。
SSRF ガードで内部ネットワークへの到達を遮断
サービスアカウントの権限を最小化(このサービスは GCP API を呼ばないので、権限なしの専用 SA を割り当てるのが望ましい)
コンテナ内に永続的な機密を置かない(storageState を使う場合はマウントされたそれのみ)
実装メモ
なぜ networkidle を使わないのか
Playwright の API ドキュメントは networkidle を DISCOURAGED としており、代替として「web-first assertion で readiness を判断せよ」としています。ただしそれは 待つべき要素を知っているテスト の話で、任意の未知サイトを相手にする汎用レンダラでは使えません(どのセレクタを待てばよいか分からない)。
そこでこの用途における readiness を自前で定義しました。欲しいのは「ネットワークの静けさ」ではなく「本文がもう増えないこと」なので、document.body.textContent の長さと要素数が一定時間変化しなくなった時点を完了とみなします。広告やトラッキングがポーリングを続けるページでも、本文さえ安定すれば抜けられます。
描画完了の目印が分かっている場合は wait_for_selector を指定してください(そちらが優先されます)。
なぜ fetch() ではなく node:https を使うのか
グローバルの fetch() では、このサーバに必要な 3 点がいずれも満たせません。
lookupを差し込めない(接続先 IP の検証に undici Agent が必要 = 依存が増える)Response.text()が常に UTF-8 デコードする → Shift_JIS のサイトが化けるredirect: 'follow'だとリダイレクト先を検証できない
node:https なら 3 つとも追加依存なしで解決できます。
なぜエンドポイントパスをルートパターンとして登録しないのか
Express 5 のルータは path-to-regexp v8 を使っており、: ( * などを含む文字列はパターンとして解釈され、場合によっては起動時に例外を投げます。MCP_ENDPOINT_PATH にはランダム文字列を入れる想定なので、app.use() 内で req.path を単純比較しています。
依存バージョンについての注意
.npmrc に min-release-age=7(公開から 7 日未満のバージョンを使わない)を設定しています。公開直後に差し替えられた悪意あるバージョンを掴まないためのサプライチェーン対策です。
この設定が効くのは npm 11.10.0 以降です。 それより古い npm では黙って無視されるため、package-lock.json を更新するときは npm のバージョンを確認してください。
npm --version # 11.10.0 以上であることplaywrightは 完全一致ピンです。上げるときは Dockerfile のイメージタグも同時に上げてください。バージョンがズレるとブラウザ実行ファイルを見つけられません。公開されたばかりのバージョンを明示ピンすると、この設定により
npm ciがエラーになります。7 日待つか、一時的に設定を緩めてください。MCP SDK は v1 系を使っています。v2(
@modelcontextprotocol/serverなど)では、ステートレスな Streamable HTTP 構成がcreateMcpHandlerにほぼ 1:1 で対応します。
構成
src/
├── index.mts エントリ(Express + MCP + 認証)
├── config.mts 環境変数の一括パース・検証
├── logger.mts Cloud Logging 向け構造化ログ
├── auth.mts Basic 認証(SHA-256 正規化の定数時間比較)
├── ssrf.mts URL 検証・プライベート IP 判定・DNS rebinding 対策
├── httpFetch.mts SSRF ガード付き HTTP 取得(node:https 直叩き)
├── charset.mts 文字コード判定・デコード
├── browser.mts Chromium シングルトン + 同時実行セマフォ
├── storageState.mts ログイン済みセッションの解決とホスト束縛
├── extract.mts HTML → Markdown / text、リンク抽出、切り詰め
├── fetcher.mts 時間予算管理と HTTP → ブラウザのフォールバック
└── tools.mts MCP ツール定義
scripts/
└── capture-login.mts storageState をローカルで取得するヘルパー
test/
└── unit.test.mts node:test によるユニットテスト今後の拡張ポイント
スクリーンショット取得ツール(画像はトークン消費が大きいので用途を絞る前提)
取得結果のキャッシュ(同一 URL の短期再取得を抑える)
MCP SDK v2 への移行