Vercel の Bot Protection が MCP サーバーを止めていた

Vercel の Bot Protection が MCP サーバーを止めていた

Inqbox にはリモート MCP サーバーのエンドポイントが用意されており、claude.ai のコネクタに https://inqbox.jp/api/mcp を登録すると、問い合わせの一覧取得や振り分けを AI アシスタントから操作できます。

しかし、この連携が 2 週間ほど応答できない状態になっていました。発覚した契機は、Claude Code を起動した際に次のエラーログが出力されたことです。

claude.ai inqbox (405): Error POSTing to endpoint: MCP server returned 405. (error_code: mcp_sse_error)

HTTP ステータスコードが 405(Method Not Allowed)であったため、当初はアプリケーション側の実装コードに問題があると考えました。

405 はこちらが返している

このアプリケーションは Next.js の App Router で構築しており、該当する src/app/api/mcp/route.ts の実装は次のようになっています。Streamable HTTP の POST リクエストのみを受け付ける設計であり、SSE(GET)やセッション終了(DELETE)には対応していないため、明示的に 405 を返しています。

export const POST = requireMcpAuth(
  auth,
  (request, claims) =>
    handleMcpRequest(request, String(claims.sub ?? ""), claims),
  { resource: MCP_RESOURCE },
);

function methodNotAllowed(): Response {
  return new Response(null, { status: 405, headers: { Allow: "POST" } });
}
export const GET = methodNotAllowed;
export const DELETE = methodNotAllowed;

つまり、405 が返るのは GET でリクエストされたとき に限られます。claude.ai のエラーログは「Error POSTing to endpoint」と表示しながら、内部エラーコードとしては mcp_sse_error を返していました。この挙動から、初回の POST リクエストが何らかの原因で失敗し、クライアントが旧仕様の SSE トランスポート(GET リクエスト)へフォールバックした結果、サーバー側の 405 応答を踏んだと推測できます。

そこで、最初の POST リクエストがなぜ失敗したのかを確認するため、curl で直接リクエストを送信しました。

$ curl -s -D - -o /dev/null -X POST https://inqbox.jp/api/mcp \
    -H 'content-type: application/json' \
    --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
HTTP/2 429
server: Vercel
x-vercel-mitigated: challenge

返ってきたレスポンスは 429 であり、レスポンスヘッダーには x-vercel-mitigated: challenge が含まれていました。レスポンス本文は Vercel Security Checkpoint という HTML で、JavaScript の実行を要求する画面が返されていました。リクエストは Next.js の route handler まで到達していませんでした。

未認証のリクエストであれば、本来は route handler から 401 と WWW-Authenticate ヘッダーが返る設計になっています。しかし、その手前にある Vercel のインフラ層でリクエストが遮断されていました。

全部のパスが 429 だった

遮断されていたのは /api/mcp だけではありませんでした。

GET  /                                         -> 429 challenge
GET  /.well-known/oauth-protected-resource/api/mcp -> 429 challenge
GET  /api/auth/jwks                            -> 429 challenge
POST /api/mcp                                  -> 429 challenge

確認したところ、トップページを含むすべてのパスで 429 が返る状態でした。User-Agent ヘッダーをブラウザのものに変更しても結果は変わりません。Vercel の判定は User-Agent 文字列単体ではなく、TLS のフィンガープリントなどを解析して行われるため、curl からのリクエストはすべて bot として判定されます。

通常のブラウザでアクセスしている限り、管理画面は問題なく操作できていたため、外見上は障害が起きているように見えませんでした。JavaScript を実行できない非ブラウザのクライアントからの通信のみが、全パスで遮断されていました。MCP クライアントは JavaScript を実行しないため、この条件に合致して弾かれていました。

Firewall の設定を API で読む

vercel CLI が環境の問題で動作しなかったため、Vercel の REST API を直接呼び出して設定を確認しました。VERCEL_TOKEN を発行していれば、Firewall のアクティブな設定を取得できます。

$ curl -H "Authorization: Bearer $VERCEL_TOKEN" \
    "https://api.vercel.com/v1/security/firewall/config/active?projectId=$P&teamId=$T"

取得した設定のうち、managedRules の内容は次のとおりでした。

"managedRules": {
  "bot_protection": {
    "action": "challenge",
    "active": true,
    "updatedAt": "2024-09-30T15:01:13.298Z"
  }
}

設定上、Bot Protection が challenge アクションで有効になっていました。更新日時は 9 月 30 日付です。履歴を確認したところ、同じ日の数分後に問い合わせ送信エンドポイント(/api/inquiries)に対するレートリミットのルールを追加していました。スパム対策として Firewall を設定した際、Bot Protection も同時に有効化されていたことが分かりました。

設定したレートリミット自体は意図どおりに動作していましたが、並行して有効になった Bot Protection が、MCP クライアントからのリクエストをすべて bot と判定して遮断していました。

Bypass ルールを足す

Bot Protection による保護自体は維持する必要があります。そこで、ブラウザ以外のクライアントが通信することを前提としたパスのみ、カスタムルールを追加して判定をバイパス(Bypass)させる方針を採りました。

Inqbox では認証基盤として Better Auth を採用しています。MCP クライアントがサーバー側から直接アクセスするパスを @better-auth/mcp および @better-auth/oauth-provider の仕様から整理すると、次の 5 つに分類できます。

  • /api/mcp(MCP サーバー本体)
  • /.well-known/oauth-protected-resource/... および /.well-known/oauth-authorization-server(RFC 9728 / RFC 8414 に基づくディスカバリ文書)
  • /api/auth/.well-known/...(issuer 直下のディスカバリを参照するクライアント向け)
  • /api/auth/oauth2/token などのトークン交換・失効・イントロスペクション
  • /api/auth/jwks(アクセストークンの署名検証エンドポイント)

authorize や consent の画面はブラウザ側で開くため除外できますが、設定を簡潔にするため /api/auth/oauth2/ の前方一致でまとめて対象に含めました。Bypass ルールは bot 判定処理をスキップするだけであり、アプリケーション側の認証処理は通常どおり機能します。

ルールの追加も API から実行できます。rules.insert アクションを PATCH メソッドで送信します。

{
  "action": "rules.insert",
  "id": null,
  "value": {
    "name": "MCP 連携(Bot Protection を通さない)",
    "active": true,
    "conditionGroup": [
      { "conditions": [ { "type": "path", "op": "eq",  "value": "/api/mcp" } ] },
      { "conditions": [ { "type": "path", "op": "pre", "value": "/.well-known/oauth-" } ] },
      { "conditions": [ { "type": "path", "op": "pre", "value": "/api/auth/.well-known/" } ] },
      { "conditions": [ { "type": "path", "op": "pre", "value": "/api/auth/oauth2/" } ] },
      { "conditions": [ { "type": "path", "op": "eq",  "value": "/api/auth/jwks" } ] }
    ],
    "action": { "mitigate": { "action": "bypass" } }
  }
}

conditionGroup 配列の要素同士は OR 条件、各グループ内の conditions は AND 条件として評価されます。op の指定では eq が完全一致、pre が前方一致を表します。

ダッシュボードから作成するルールと異なり、API 経由で PATCH を送信したルールは即座に反映されました。設定後に curl で検証を行いました。

$ curl -s -o /dev/null https://inqbox.jp/api/mcp -w "%{http_code} %header{x-vercel-mitigated}\n"
405

検証の結果、レスポンスコードとして 405 が返り、ヘッダーから x-vercel-mitigated が消えていることを確認できました。同じ 405 でも、今度はインフラ層の遮断ではなく、アプリケーション側の route handler が意図どおりに返したステータスコードです。ディスカバリ文書のエンドポイントも、JSON を正しく返すようになりました。

$ curl -s https://inqbox.jp/.well-known/oauth-protected-resource/api/mcp
{"resource":"https://inqbox.jp/api/mcp","authorization_servers":["https://inqbox.jp/api/auth"],"bearer_methods_supported":["header"]}

トップページへのリクエストは引き続き challenge のステータスを維持しており、Bot Protection が他のパスで継続して機能していることも確認できました。

クライアント側ログと実リクエストの乖離

claude.ai が表示した 405 エラーは、アプリケーションのコードが設計どおりに返した応答であり、その意味では正しい情報でした。しかし、それは初回の POST リクエストが Vercel の手前で 429 によって遮断された結果、クライアントが SSE にフォールバックして発生した 2 次的なエラーでした。遮断の痕跡は、クライアント側のエラーログには記録されていませんでした。

Bot Protection のようなセキュリティ機能は、一般的な Web ブラウザからの正常なトラフィックには影響を与えないように設計されています。そのため、開発者がブラウザで画面を操作して確認している限り、通信の遮断には気づきにくい性質があります。MCP のように、機械同士が通信する API エンドポイントを Web サイトと同じドメイン上で運用している場合、外見上は稼働しているように見えても通信が停止してしまうリスクが存在します。

Web サイトのスパム対策などで Firewall のルールを調整する際は、ブラウザ以外のクライアントがアクセスする API やディスカバリ用のパスをあらかじめ洗い出し、バイパス対象として定義しておく設計が求められます。

望月 涼太 / polidog

望月 涼太 / polidog

Co-Founder / Web Engineer