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.
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 whoseHostheader 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_hostsmust be explicitly populated for any deployment where the public-facing Host header differs from the SDK's process-internal hostname.Reproducer
Expected behavior
One of:
The default for
allowed_hostsis a sensible permissive value (e.g.,["*"]orNonewith 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_hostsAND 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 emptyallowed_hostsAND 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_hostsmismatch, 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
TransportSecuritySettingswith the public hostname inallowed_hosts: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.