DEV Community

Hiroshi Toyama
Hiroshi Toyama

Posted on

How an Unbounded fastmcp Version Constraint Took Down Production with 421 Misdirected Request

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
Enter fullscreen mode Exit fullscreen mode

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",
]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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                                 │
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
),
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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",
]
Enter fullscreen mode Exit fullscreen mode

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.23.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 localhost in 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)