何が起きたか
Cloud Run上でfastmcpを使ったMCPサーバーを、カスタムドメイン+LB配下で運用している。3ヶ月ほど再デプロイされていなかったので、久しぶりにリリースした。するとカスタムドメイン経由のリクエストが、コード変更を何も加えていないのに全滅した。
HTTP/1.1 421 Misdirected Request
レスポンスボディはほぼ空。curlで叩いてもレイテンシ1msで即座に返ってくる。何かの重い処理でエラーになっているのではなく、リクエストを受けた瞬間に弾かれている挙動だった。
不可解だったのは、同じイメージでもCloud Runの*.run.app直URLに素で叩くと、マウントされていないパスへの404は普通に返ってくること。つまり「アプリが死んでいる」わけではなく、カスタムドメイン経由のリクエストだけが狙い撃ちで弾かれていた。
間違ったバージョンを疑っていた
pyproject.tomlはこうなっていた。
dependencies = [
"fastmcp>=3.4.2",
]
手元のuv.lockは3.4.2にピンされていたので、まずはその通りのバージョンをscratch venvにインストールして、fastmcpのHTTPトランスポート周りと、その下で使われているmcp SDK(mcp/server/transport_security.py)のDNS rebinding対策コードを読んだ。
class TransportSecurityMiddleware:
def __init__(self, settings: TransportSecuritySettings | None = None):
# 明示指定がなければ後方互換のためDNS rebinding対策を無効化
self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False)
fastmcp==3.4.2のコード全体をTransportSecurity、allowed_hosts、dns_rebindingでgrepしても一件もヒットしない。つまりこのミドルウェアにsecurity_settingsを明示的に渡している箇所がどこにも無く、理屈上は何もしないはずだった。それなのに本番は落ちている。
ここで学んだこと:pyproject.tomlの>=Xは「書いた時点で成立していた条件」でしかなく、「今動いているバージョン」を保証しない。 >=3.4.2と書いてあっても、実際にデプロイされていたイメージは別のバージョンを解決していた。
本当のバージョンを突き止める
答えはCloud Runの起動ログにそのまま出ていた。
│ FastMCP 3.4.3 │
3.4.2ではなく3.4.3。原因はci.sh releaseスクリプト(パッケージ自体のバージョンをbumpするだけの処理)が、その延長でuv lockも走らせていたこと。バージョン制約に上限が無かったため、resolverは数日前にリリースされていた最新の互換バージョン3.4.3を素直に選んでいた。
fastmcp==3.4.3で同じ調査をやり直すと、一発で説明がついた。
# fastmcp/settings.py
http_host_origin_protection: bool = True
http_allowed_hosts: list[str] | None = None
3.4.3で「Hostヘッダー検証をデフォルト有効」にする設定が追加されていて、先ほど見つけたのと同じTransportSecurityMiddlewareに配線されていた。
# fastmcp/server/mixins/transport.py
allowed_hosts=(
allowed_hosts
if allowed_hosts is not None
else fastmcp.settings.http_allowed_hosts
),
http_allowed_hostsはデフォルトNone=実質空リスト。つまりカスタムドメインのHostヘッダーは何であれ許可リストに一致せず、全部弾かれる。
# mcp/server/transport_security.py
async def validate_request(self, request, is_post=False):
...
host = request.headers.get("host")
if not self._validate_host(host):
return Response("Invalid Host header", status_code=421)
これが421の正体。DNS rebinding対策自体はローカルホストで動くMCPサーバーには真っ当な機能だが、バージョン番号だけ見ると「マイナーどころかパッチっぽい」のに、デフォルト有効の破壊的変更として出荷されていた。
途中で疑った別の犯人
Host許可リストにたどり着く前に、別の説を疑っていた時期がある。MCPクライアントからのOAuth認可リクエストが、それっぽいMisdirected Requestを返していたので、最初はredirect_uriやCIMD(Client ID Metadata Document)検証のバグを疑った(これはこれで別に実在するハマりどころなので知っておいて損はないが、今回の原因ではなかった)。OAuthとは無関係な、素の/mcpへの未認証リクエストでも同じ421が再現できて初めて、アプリのロジックより手前、トランスポート層のミドルウェアで起きていることがはっきりした。
対処
まず即時復旧として、バージョンにたまたま守られる状態ではなく明示的に3.4.3以前の挙動へ戻す。
env {
name = "FASTMCP_HTTP_HOST_ORIGIN_PROTECTION"
value = "false"
}
fastmcpの設定モデルはextra="ignore"なので、古いバージョンにこの環境変数を渡しても単に無視されるだけで害はない。つまりまだ3.4.3を踏んでいない他サービスにも、保険として先に入れておける。
次に、将来同じ事故が起きないようバージョン上限を切る。
dependencies = [
"fastmcp>=3.4.2,<3.5.0",
]
この制約でuv lockをやり直したら、ちょうど3.4.2に収束した。新しいパッチ版が今は特に必要なかったことの裏付けにもなった。
学び
-
>=だけの緩いバージョン指定は、動いているライブラリの速さに比例して本番リスクになる。 パッチっぽい番号(3.4.2→3.4.3)でもデフォルト有効の挙動変更が入ることがある。 -
CI側でlockfileを再生成するステップ(バージョンbumpスクリプトが
uv lock/poetry lockを叩くなど)は、自分が触っていない依存関係まで静かにアップグレードしうる。 自分の差分だけでなく、lockファイルの差分もレビュー対象にする。 -
新しいセキュリティのデフォルト機能は、黙って採用されるものであってはいけない。 DNS rebinding対策自体はローカル専用サーバーには妥当なデフォルトだが、同じライブラリがカスタムドメイン・LB配下で使われるケース(Hostヘッダーが
localhostになることはまず無い)では、変更点が目立つ形でアナウンスされるべきだった。 - デプロイ直後にレイテンシ1msでエラーが出たら、まずデプロイを疑う。 リクエスト処理の奥深くで起きたバグではなく、入り口で即座に弾かれている軽い処理だという手がかりになる。
fastmcpをカスタムドメイン配下で動かしているなら、http_host_origin_protectionが今どちらの挙動になっているのか一度確認しておいた方がいい。本番で気づくより先に、FASTMCP_HTTP_ALLOWED_HOSTSを明示設定するか、意図的にオフにしておくのがおすすめ。