We run a Model Context Protocol server on Cloud Run, built with fastmcp, sitting behind a custom domain on an HTTPS load balancer. It hadn't been redeployed in three months. When we finally shipped a routine release, every single request started failing with 421 Misdirected Request. No code changes. No infra changes we were aware of. Just a version bump.
This is the story of tracking that down, and why an unbounded fastmcp>=3.4.2 in pyproject.toml was the real culprit.
The symptom
After redeploying, every request through the custom domain returned:
HTTP/1.1 421 Misdirected Request
with an empty or near-empty body. Curl reproduced it instantly (1ms latency), which ruled out anything resembling real request processing — this was a fast, early rejection.
The confusing part: the exact same image, hit directly via its own *.run.app URL with no custom domain, returned a normal 404 for an unmounted path instead of 421. Something was specifically rejecting the custom domain Host header, not /mcp requests in general.
Chasing the wrong SDK version
pyproject.toml had:
dependencies = [
"fastmcp>=3.4.2",
]
uv.lock on disk was already pinned to 3.4.2. I installed that exact version into a scratch venv and read through fastmcp's HTTP transport code and the underlying mcp SDK's mcp/server/transport_security.py, which implements DNS-rebinding protection:
class TransportSecurityMiddleware:
def __init__(self, settings: TransportSecuritySettings | None = None):
# If not specified, disable DNS rebinding protection by default
# for backwards compatibility
self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False)
In fastmcp==3.4.2, nothing in the codebase ever passed security_settings to this middleware — a global search for TransportSecurity, allowed_hosts, and dns_rebinding came back empty. So this middleware should have been a no-op. And yet production was failing.
The lesson here: >=X in a dependency spec describes what was true when the constraint was written, not what's actually running. pyproject.toml said >=3.4.2; the container that was actually deployed had resolved something newer.
Finding the real version
The Cloud Run startup logs told the real story:
│ FastMCP 3.4.3 │
Not 3.4.2. The routine ci.sh release step that bumps the package's own version had also run uv lock, and because the constraint had no upper bound, the resolver happily picked up the newest compatible release — 3.4.3 — which had shipped days earlier.
Re-running my investigation against fastmcp==3.4.3 immediately explained everything:
# fastmcp/settings.py
http_host_origin_protection: bool = True
http_allowed_hosts: list[str] | None = None
3.4.3 added host-origin protection enabled by default, wired through to the same TransportSecurityMiddleware we'd already found:
# fastmcp/server/mixins/transport.py
allowed_hosts=(
allowed_hosts
if allowed_hosts is not None
else fastmcp.settings.http_allowed_hosts
),
With http_allowed_hosts left at its default None, the effective allowlist is empty. Every incoming Host header — our custom domain, anything — fails the check:
# 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)
That's the 421. It's a real security feature — DNS-rebinding protection is a legitimate concern for locally-hosted MCP servers — but it shipped as a default-on breaking change in what looked, from the version number, like a patch release.
A red herring along the way
Before landing on the host-allowlist explanation, we chased a different theory: an OAuth authorization request from an MCP client returned a plausible-looking Misdirected Request, and it was tempting to blame a redirect_uri/CIMD (Client ID Metadata Document) validation mismatch — that's a real, separate failure mode worth knowing about, but it wasn't the cause here. It was only after reproducing 421 on a plain, unauthenticated request to the base /mcp endpoint — nothing OAuth-related at all — that it became clear this was happening below the application logic, in transport-level middleware.
The fix
Immediate mitigation — restore the pre-3.4.3 behavior explicitly, rather than relying on accidental version pinning:
env {
name = "FASTMCP_HTTP_HOST_ORIGIN_PROTECTION"
value = "false"
}
fastmcp's settings model uses extra="ignore", so this env var is a safe no-op on any version that doesn't understand it yet — meaning it's also cheap insurance to add to services still running an older fastmcp, before they ever hit this problem.
Then, cap the blast radius of future upgrades:
dependencies = [
"fastmcp>=3.4.2,<3.5.0",
]
Re-locking against that constraint happened to resolve back down to 3.4.2 — a nice confirmation that the newer patch wasn't otherwise needed.
Takeaways
-
An unbounded
>=constraint on a fast-moving dependency is a live production risk, not just a style nit. A "patch" version bump (3.4.2→3.4.3) shipped a default-on behavioral change. -
Any CI step that re-runs your lockfile resolver (a version-bump script calling
uv lock,poetry lock, etc.) can silently upgrade transitive-looking dependencies you never touched. Review the lockfile diff, not just your own changed files. -
New security defaults deserve explicit configuration, not silent adoption. DNS-rebinding protection is the right default for a library that's often run as a local, loopback-only server. It's a much riskier default to ship without a loud changelog entry when the same library is also commonly deployed behind a custom domain, where the request's Host header will never be
localhostin the first place. - When a 1ms-latency error shows up right after a deploy, suspect the deploy, not the traffic. The fast rejection time was the tell that this was a cheap, early-stage check — not an app bug deep in request handling.
If you're running fastmcp behind a custom domain, go check whether you're pinned tightly enough to know which http_host_origin_protection behavior you're actually getting, and configure FASTMCP_HTTP_ALLOWED_HOSTS (or explicitly opt out) rather than finding out in production.
Top comments (0)