fix(examples): accept remote Host headers when bound off localhost

The streamable-http server bound to 0.0.0.0/a LAN IP answered TCP and
/watch but returned 421 "Invalid Host header" on /mcp for every remote
node — which broke multi-node play entirely. FastMCP freezes DNS-rebinding
protection (a localhost-only Host allowlist) at CONSTRUCTION, and this
module builds its FastMCP at import time with the default 127.0.0.1 host;
flipping settings.host in _serve afterward never updated the frozen
allowlist, so the LAN Host was always rejected.

When UNDERSTONE_HOST is off localhost, drop the allowlist in _serve before
run() — matching the SDK's own default for a non-localhost bind. The /mcp
and /watch routes are unauthenticated by design, so serve only on a trusted
network (documented).

Regression test pins the mechanism: a default FastMCP 421s a foreign Host,
a protection-disabled one accepts it. Tests 420 -> 421.
This commit is contained in:
Patrick Buckley
2026-06-13 00:51:28 -07:00
parent efa8664e4d
commit 1468ca7972
2 changed files with 62 additions and 0 deletions
@@ -237,3 +237,52 @@ def test_watch_routes_coexist_with_mcp(live_server: str) -> None:
joined, watch_status = asyncio.run(_drive_both())
assert "@" in joined # the MCP tool still returns a real frame
assert watch_status == 200 # and the watch route still answers
def test_streamable_http_host_gate_off_localhost() -> None:
"""A non-localhost bind must accept remote `Host` headers on /mcp.
REGRESSION: FastMCP freezes DNS-rebinding protection (a localhost-only Host
allowlist) at CONSTRUCTION, and ``server`` builds its FastMCP at import with
the default 127.0.0.1 host. A 0.0.0.0/LAN bind therefore answered TCP and
`/watch` but 421'd `/mcp` for every remote node ("Invalid Host header").
``_serve`` drops the allowlist when bound off localhost; this pins the
mechanism — a default instance rejects a foreign Host, a protection-disabled
one accepts it (a 421 in the second case is the bug returning).
Uses fresh FastMCP instances (not the module singleton) so there is no
shared-state or app-cache coupling with the live-server tests above.
"""
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.testclient import TestClient
foreign = {
"Host": "192.168.0.239:8077",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "probe", "version": "0"},
},
}
# Default (localhost-baked allowlist) — a remote Host is refused.
locked = FastMCP("hostgate-locked")
with TestClient(locked.streamable_http_app()) as client:
assert client.post("/mcp", headers=foreign, json=init).status_code == 421
# Protection disabled (what _serve does off localhost) — remote Host accepted.
opened = FastMCP("hostgate-open")
opened.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False
)
with TestClient(opened.streamable_http_app()) as client:
resp = client.post("/mcp", headers=foreign, json=init)
assert resp.status_code != 421, f"remote Host still rejected: {resp.status_code} {resp.text}"
+13
View File
@@ -41,6 +41,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.responses import HTMLResponse, JSONResponse, Response
from understone import cli, sim, watch
@@ -563,6 +564,18 @@ def _serve() -> None:
mcp.settings.port = port
mcp.settings.streamable_http_path = os.environ.get("UNDERSTONE_PATH", "/mcp")
mcp.settings.stateless_http = False
# FastMCP freezes DNS-rebinding protection (a localhost-only Host
# allowlist) at CONSTRUCTION, and this module builds `mcp` at import
# with the default 127.0.0.1 host — so a 0.0.0.0/LAN bind would 421
# "Invalid Host" on /mcp for every remote node (the multi-node case).
# Binding off localhost means we intend to accept other hosts, so drop
# the allowlist here (matching the SDK's own default for a non-localhost
# bind). These routes are unauthenticated by design — serve only on a
# trusted network. UNDERSTONE_HOST controls the bind.
if host not in ("127.0.0.1", "localhost", "::1"):
mcp.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False
)
# The spectator page is only reachable over http, so its URL is composed
# here from the bind address. A 0.0.0.0 bind should advertise a host a
# browser can actually reach (see the README Watch section).