From ab61799f6600ec7e9bf1acd0973c51787331f46d Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sat, 13 Jun 2026 00:51:28 -0700 Subject: [PATCH] fix(examples): accept remote Host headers when bound off localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. (cherry picked from commit 1468ca797281f5ae897aaa6f1de5abbdd59eaa64) --- .../door-game/tests/test_mcp_integration.py | 49 +++++++++++++++++++ examples/door-game/understone/server.py | 13 +++++ 2 files changed, 62 insertions(+) diff --git a/examples/door-game/tests/test_mcp_integration.py b/examples/door-game/tests/test_mcp_integration.py index 09b245d4..7bbdcaf8 100644 --- a/examples/door-game/tests/test_mcp_integration.py +++ b/examples/door-game/tests/test_mcp_integration.py @@ -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}" diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index ddafc8e1..9718380b 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -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).