fetch-rendered-page-mcp
by blono
README.md
# 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`** になります。
### `list_page_links` — リンク列挙
| 引数 | 型 | 説明 |
| --- | --- | --- |
| `url` | string(必須) | 対象ページ |
| `same_origin_only` | boolean | 同一オリジンのみに絞る |
| `limit` | number | 返す最大件数(既定 200) |
| `force_browser` / `profile` | | 上と同じ |
---
## ローカル実行
```bash
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
```
疎通確認:
```bash
curl http://localhost:8080/health
# => {"status":"ok","browser":{"inFlight":0,"queued":0,"connected":false}}
```
ローカルで `http://localhost:xxxx` を取得したい場合だけ `ALLOW_PRIVATE_NETWORK=true` を付けてください(本番では絶対に付けないこと)。
---
## Docker ビルド
```bash
# 重要:イメージタグ (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 デプロイ
```bash
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` に設定して更新する:
```bash
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. 手元でログイン状態を取る
```bash
npm run capture:login -- --url https://github.com/login --out .\secrets\github.json
```
画面付きの Chromium が開くので、そこでログインしてからターミナルで Enter を押すと、Cookie と localStorage が JSON で保存されます。
### 2. Secret Manager に入れて Cloud Run にマウント
```bash
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 段で塞いでいます。
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 ドキュメントは `networkidle` を **DISCOURAGED** としており、代替として「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` を単純比較しています。
---
## 依存バージョンについての注意
`.npmrc` に `min-release-age=7`(公開から 7 日未満のバージョンを使わない)を設定しています。公開直後に差し替えられた悪意あるバージョンを掴まないためのサプライチェーン対策です。
**この設定が効くのは npm 11.10.0 以降です。** それより古い npm では黙って無視されるため、`package-lock.json` を更新するときは npm のバージョンを確認してください。
```bash
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 への移行
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues