Skip to content

TransportSecuritySettings.allowed_hosts=[] (default) silently rejects external Host headers with HTTP 421 #2688

@ptrhrsch-arch

Description

@ptrhrsch-arch

Summary
When deploying an MCP server behind a reverse proxy (nginx, Cloudflare, etc.) for external access, the SDK's default TransportSecuritySettings(allowed_hosts=[]) rejects HTTP requests whose Host header doesn't match the SDK's internal expected hostname. The response is HTTP 421 ("Misdirected Request") with no structured body explaining the cause. The cause becomes obvious only after reading the SDK source — the default is empty list, and the empty-list code path means "reject everything not in the (empty) allowlist."
Documentation does not surface that allowed_hosts must be explicitly populated for any deployment where the public-facing Host header differs from the SDK's process-internal hostname.
Reproducer

from mcp.server.sse import TransportSecuritySettings, SseServerTransport
from mcp.server import Server

# Default settings — empty allowed_hosts
security = TransportSecuritySettings()  # allowed_hosts=[] by default

transport = SseServerTransport("/messages/", transport_security=security)
# ... mount the transport in your ASGI app behind nginx ...

# Curl from outside:
# $ curl -X POST https://your-public-host/messages/ -H "Host: your-public-host" ...
# Response: HTTP/1.1 421 Misdirected Request
# Body: (empty or generic)

Expected behavior
One of:
The default for allowed_hosts is a sensible permissive value (e.g., ["*"] or None with no filtering), and a CHANGELOG note marks the security implication.
The default stays empty-list-strict, but the SDK emits a startup-time warning when transports are constructed with empty allowed_hosts AND no localhost binding, AND the 421 response includes a structured body / WWW-Authenticate-style header indicating the cause.
Actual behavior
Default rejects everything that isn't an internal hostname. No startup warning. No structured 421 body. Cause is invisible until the user reads the SDK source.
Suggested fix
Combined approach:
Startup warning — if TransportSecuritySettings() is instantiated with default empty allowed_hosts AND the server binds to anything other than 127.0.0.1/::1, log a clear WARNING with example config.
Structured 421 body — when a request is rejected by allowed_hosts mismatch, return a JSON body like {"error": "host_not_allowed", "received_host": "...", "configure": "TransportSecuritySettings.allowed_hosts"}. Helps users self-diagnose.
Documentation — add an "Exposing MCP servers behind a reverse proxy" section to the README or docs covering this exact case.
Workaround (current PolyBot mitigation)
Explicitly construct TransportSecuritySettings with the public hostname in allowed_hosts:

security = TransportSecuritySettings(
    allowed_hosts=["your-public-host.example.com", "your-public-host.example.com:443"],
)
transport = SseServerTransport("/messages/", transport_security=security)

Works; user has to discover the setting on their own.
Environment
mcp Python SDK version: 1.27.1
Reverse proxy: nginx (terminating TLS, forwarding to MCP server on localhost)
Deployment: VM with public DNS via DuckDNS
Severity
Low-to-medium — security default is correct (fail-closed is right), but user experience is poor and the diagnosis is opaque. A combination of warning + structured response body + a doc section would resolve it cleanly without weakening the default.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions