Skip to content

HTTP transport rejects every non-loopback request with 421: FastMCP is constructed before WHATSAPP_MCP_HOST is applied #216

Description

@stv-io

Summary

With WHATSAPP_MCP_TRANSPORT=http and WHATSAPP_MCP_HOST=0.0.0.0, the server starts and logs that it is listening on 0.0.0.0:8000 — but rejects every request that isn't addressed to loopback:

WARNING  Invalid Host header: whatsapp-mcp.homelab.svc.cluster.local:8000   transport_security.py:71
INFO:    10.52.0.67:38536 - "GET /mcp HTTP/1.1" 421 Misdirected Request

Since remote transport is the reason this fork exists over upstream's stdio-only server, this makes the HTTP transport unusable for its main use case: reaching the server by any name other than localhost.

Seen on v0.6.0 (7f518e2) with mcp 1.29.1, running in Kubernetes with a client in another pod.

Cause

The MCP SDK auto-enables DNS-rebinding protection when FastMCP is constructed with the default host, pinning allowed_hosts to loopback (mcp/server/fastmcp/server.py):

if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
    transport_security = TransportSecuritySettings(
        enable_dns_rebinding_protection=True,
        allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
        allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
    )

main.py constructs the server at import time, before any env var is read:

# main.py:61
mcp = FastMCP("whatsapp")

and only applies the host afterwards, in __main__:

mcp.settings.host = resolve_host(os.getenv("WHATSAPP_MCP_HOST"))
mcp.settings.port = resolve_port(os.getenv("WHATSAPP_MCP_PORT"))

Mutating settings.host after the fact does not revisit the allowlist, so a server that ends up bound to 0.0.0.0 still carries the loopback-only policy the constructor chose. run() reads self.settings.transport_security lazily, so the value set at construction is the one enforced.

The deferred env-var handling is deliberate (the comment above line 61 notes it keeps imports side-effect free), so the fix isn't to move construction — it's to update the security settings alongside host and port.

Reproduce

docker run --rm -e WHATSAPP_MCP_TRANSPORT=http -e WHATSAPP_MCP_HOST=0.0.0.0 -p 8000:8000 <image>
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: example.internal:8000' \
     -H 'Accept: text/event-stream' http://127.0.0.1:8000/mcp
# 421

Any Host header other than localhost/127.0.0.1/[::1] reproduces it — a container name, a Kubernetes service DNS name, a LAN hostname, a reverse-proxy Host.

Suggested fix

Mirror what __main__ already does for host and port. Roughly:

if transport != "stdio":
    mcp.settings.host = resolve_host(os.getenv("WHATSAPP_MCP_HOST"))
    mcp.settings.port = resolve_port(os.getenv("WHATSAPP_MCP_PORT"))

    # The SDK picked a loopback-only allowlist at construction time, because
    # FastMCP() defaulted to 127.0.0.1. Re-derive it now that the real host
    # is known, or the server 421s every non-loopback caller.
    allowed = [h.strip() for h in os.getenv("WHATSAPP_MCP_ALLOWED_HOSTS", "").split(",") if h.strip()]
    if allowed:
        mcp.settings.transport_security = TransportSecuritySettings(
            enable_dns_rebinding_protection=True,
            allowed_hosts=allowed,
        )
    elif mcp.settings.host not in ("127.0.0.1", "localhost", "::1"):
        mcp.settings.transport_security = TransportSecuritySettings(
            enable_dns_rebinding_protection=False,
        )

Keeping an explicit WHATSAPP_MCP_ALLOWED_HOSTS is worth it: an operator who has deliberately bound to 0.0.0.0 can still keep rebinding protection on, rather than choosing between "unreachable" and "protection off". Entries accept the SDK's host:* wildcard form.

Whatever shape it takes, it's worth documenting next to WHATSAPP_MCP_HOST — the failure is quiet from the server side (a WARNING line and a 421) and looks like a client or networking problem, not a policy decision.

Workaround

For anyone hitting this before a fix lands: sitecustomize.py on the Python path, which the interpreter imports before main.py runs, so the constructor is patched without touching upstream source:

import os

def _install() -> None:
    raw = os.environ.get("MCP_ALLOWED_HOSTS", "").strip()
    if not raw:
        return
    hosts = [h.strip() for h in raw.split(",") if h.strip()]
    try:
        from mcp.server.fastmcp.server import FastMCP
        from mcp.server.transport_security import TransportSecuritySettings
    except ImportError:
        return

    original_init = FastMCP.__init__

    def __init__(self, *args, **kwargs):
        if kwargs.get("transport_security") is None:
            kwargs["transport_security"] = TransportSecuritySettings(
                enable_dns_rebinding_protection=True,
                allowed_hosts=hosts,
            )
        original_init(self, *args, **kwargs)

    FastMCP.__init__ = __init__

_install()

Then MCP_ALLOWED_HOSTS="whatsapp-mcp.homelab.svc.cluster.local:*,localhost:*". Verified: allowed hosts complete an MCP initialize handshake, disallowed hosts still get 421, so rebinding protection stays meaningful.

Happy to send a PR if the suggested shape looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions