fix(mcp): trust the deployed hostname under streamable-http

FastMCP enables DNS rebinding protection inside __init__ when it is built with
a loopback bind address, and create_server() takes the default 127.0.0.1:

    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]:*"],
        )

main() reassigns settings.host afterwards, which never revisits that decision,
so the allow-list stayed loopback-only. Any client reaching the server under a
real hostname got 421 Misdirected Request with "Invalid Host header". It reads
as a hang rather than a rejection, because clients retry until they time out --
bifrost reported fin as simply disconnected after exhausting its retries.

Rebuild the settings after the host is known. Protection stays on; the loopback
patterns are kept and FIN_MCP_ALLOWED_HOSTS adds to them (comma separated,
"host:*" allows any port). Unlisted hosts are still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTuguQqazhoWUsh2GkPg9a
This commit is contained in:
EislM0203andClaude Opus 5 committed 2026-08-23 14:23:29 +02:00
1 parent 631bdbb13f
commit 43c90dcd43
2 files changed
+24

No files matched your search

+21
View File
@@ -19,6 +19,7 @@ from typing import Any, Literal
try: try:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
except ImportError as exc: except ImportError as exc:
raise ImportError( raise ImportError(
@@ -1107,4 +1108,24 @@ def main() -> None:
# self-contained rather than pinning clients to one replica. # self-contained rather than pinning clients to one replica.
mcp.settings.stateless_http = True mcp.settings.stateless_http = True
# FastMCP turns on DNS rebinding protection during __init__ when it is
# built with a loopback bind address, and create_server() takes the
# default 127.0.0.1. Reassigning settings.host above does not revisit
# that decision, so the allow-list stays loopback-only and any client
# reaching us under a real hostname gets a 421 with "Invalid Host
# header" -- which reads as a hang, because clients retry until they
# time out. Rebuild the settings here so the deployed hostname is
# trusted. FIN_MCP_ALLOWED_HOSTS is a comma-separated list and accepts
# "host:*" to allow any port.
allowed_hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
allowed_hosts += [
host.strip()
for host in os.getenv("FIN_MCP_ALLOWED_HOSTS", "").split(",")
if host.strip()
]
mcp.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=allowed_hosts,
)
mcp.run(transport=transport) mcp.run(transport=transport)
+3
View File
@@ -23,6 +23,9 @@ services:
- FIN_MCP_PORT=8000 - FIN_MCP_PORT=8000
- FIN_MCP_PATH=/mcp - FIN_MCP_PATH=/mcp
- FIN_MCP_ALLOW_WRITES=1 - FIN_MCP_ALLOW_WRITES=1
# Clients reach this service as "mcp" on the compose network, and FastMCP
# rejects any Host it was not told to trust with a 421.
- FIN_MCP_ALLOWED_HOSTS=mcp:*
# No ports mapping on purpose: the API has no auth, so the MCP surface # No ports mapping on purpose: the API has no auth, so the MCP surface
# stays reachable only from inside the compose network. Add a mapping # stays reachable only from inside the compose network. Add a mapping
# only if you also set FIN_API_TOKEN. # only if you also set FIN_API_TOKEN.