Skip to main content
Glama
blono

fetch-rendered-page-mcp

by blono

fetch-rendered-page-mcp

Chromium で動的サイト(React / Vue などの SPA)をレンダリングしてから本文を Markdown で返す MCP サーバです。軽量 HTTP 取得 → 不十分ならブラウザへ自動フォールバックするので、静的サイトでは速く、動的サイトでも確実に中身を取れます。

標準の web_fetch では取れない「JS 実行後にしか中身が出ないページ」を埋めるのが狙いです。


提供ツール

fetch_rendered_page — 単一ページの本文取得

引数

説明

url

string(必須)

取得対象の URL

force_browser

boolean

true で HTTP をスキップし必ずブラウザ使用

max_length

number

返す本文の最大文字数(行単位で切り詰め)

format

markdown | text

出力形式(既定 markdown)

wait_for_selector

string

このセレクタが現れるまで待つ

profile

string

storageState プロファイル名

fetch_rendered_pages — 複数ページの一括取得

引数

説明

urls

string[](必須)

最大 MAX_BATCH_URLS

force_browser

boolean

全 URL について必ずブラウザ使用

wait_for_selector

string

全 URL に共通で適用される待機セレクタ

max_length / format / profile

上と同じ

一部が失敗しても成功したぶんは返り、isError は付きません。失敗した URL は本文中に [取得失敗 / 種類] として個別に載り、冒頭に成功 / 失敗の件数サマリが付きます。1 件も取れなかった場合だけ isError: true になります。

引数

説明

url

string(必須)

対象ページ

same_origin_only

boolean

同一オリジンのみに絞る

limit

number

返す最大件数(既定 200)

force_browser / profile

上と同じ


ローカル実行

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 citscnpm prune --omit=dev まで済ませ、剪定後の node_modulesdist だけを 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 の同時生成数を絞る)

の二段構えで守ります。片方だけだと、もう片方が抜け道になります。

--timeoutFETCH_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 はログイン済みの本人そのものです。リポジトリにコミットしないでください(.gitignoresecrets/ を入れてあります)。


セキュリティについて

SSRF ガード

任意の URL をサーバ側から取得し、その中身を LLM に返す構造上、「取得したページに書かれた指示で次の取得先が決まる」経路(間接プロンプトインジェクション)が成立し得ます。そこで 3 段で塞いでいます。

  1. スキーム制限http / https 以外を拒否

  2. 名前ベースの拒否localhost, metadata.google.internal, *.internal

  3. 解決後 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 ドキュメントは networkidleDISCOURAGED としており、代替として「web-first assertion で readiness を判断せよ」としています。ただしそれは 待つべき要素を知っているテスト の話で、任意の未知サイトを相手にする汎用レンダラでは使えません(どのセレクタを待てばよいか分からない)。

そこでこの用途における readiness を自前で定義しました。欲しいのは「ネットワークの静けさ」ではなく「本文がもう増えないこと」なので、document.body.textContent の長さと要素数が一定時間変化しなくなった時点を完了とみなします。広告やトラッキングがポーリングを続けるページでも、本文さえ安定すれば抜けられます。

描画完了の目印が分かっている場合は wait_for_selector を指定してください(そちらが優先されます)。

なぜ fetch() ではなく node:https を使うのか

グローバルの fetch() では、このサーバに必要な 3 点がいずれも満たせません。

  1. lookup を差し込めない(接続先 IP の検証に undici Agent が必要 = 依存が増える)

  2. Response.text() が常に UTF-8 デコードする → Shift_JIS のサイトが化ける

  3. redirect: 'follow' だとリダイレクト先を検証できない

node:https なら 3 つとも追加依存なしで解決できます。

なぜエンドポイントパスをルートパターンとして登録しないのか

Express 5 のルータは path-to-regexp v8 を使っており、: ( * などを含む文字列はパターンとして解釈され、場合によっては起動時に例外を投げます。MCP_ENDPOINT_PATH にはランダム文字列を入れる想定なので、app.use() 内で req.path を単純比較しています。


依存バージョンについての注意

.npmrcmin-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 への移行