Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dlt/_workspace/deployment/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,8 @@ class McpConfiguration(BaseConfiguration):
"""Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL."""
stateless_http: bool = False
"""Stateless mode for horizontal scaling (no session affinity)."""
host_origin_protection: bool = False
"""Enable FastMCP's DNS-rebinding (Host header) protection. Off by default because
the launcher runs behind the runtime's authenticated proxy, which rewrites the Host
header; FastMCP >= 3.4.3 turns this guard on by default and would otherwise reject
every proxied request with 421 "Invalid Host header"."""
25 changes: 24 additions & 1 deletion dlt/_workspace/deployment/launchers/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,44 @@ def _resolve_config(sections: Tuple[str, ...]) -> McpConfiguration:
return resolve_configuration(McpConfiguration(), sections=sections)


def _fastmcp_supports_host_origin_protection() -> bool:
"""True if the installed FastMCP accepts the `host_origin_protection` run arg.

The DNS-rebinding guard (and this option) landed in FastMCP 3.4.3. Passing the
argument to an older FastMCP would raise `TypeError`, so gate on the version.
"""
import semver
from importlib.metadata import version

try:
return semver.Version.parse(version("fastmcp")) >= semver.Version.parse("3.4.3")
except Exception:
return False


def run_mcp_instance(instance: Any, port: int, sections: Tuple[str, ...]) -> None:
"""Run a FastMCP instance with resolved configuration.

Shared entry point for both the MCP launcher (module-level detection)
and the job launcher (return value fallback).
"""
config = _resolve_config(sections)
instance.run(
run_kwargs: Dict[str, Any] = dict(
transport=config.transport,
host="0.0.0.0",
port=port,
path=config.path,
log_level=config.log_level,
stateless_http=config.stateless_http,
)
# FastMCP >= 3.4.3 enables DNS-rebinding (Host header) protection by default. Behind
# the runtime's reverse proxy (modal / tower / local runner), which rewrites the Host
# header, that guard rejects every request with 421. These servers only receive traffic
# from the authenticated runtime proxy, so the guard is redundant; disable it by default
# (overridable via the `host_origin_protection` config). Older FastMCP has no such option.
if _fastmcp_supports_host_origin_protection():
run_kwargs["host_origin_protection"] = config.host_origin_protection
instance.run(**run_kwargs)


def run(entry_point: TRuntimeEntryPoint) -> None:
Expand Down
36 changes: 36 additions & 0 deletions tests/workspace/deployment/test_launchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,42 @@ def test_mcp_config_override() -> None:
del os.environ["JOBS__MCP_SERVER__MCP__STATELESS_HTTP"]


def test_mcp_launcher_disables_host_origin_protection() -> None:
"""Behind the runtime proxy the launcher disables FastMCP's DNS-rebinding guard
(off by default) when the installed FastMCP supports it, so requests don't 421."""
entry_point = _entry(f"{WORKSPACE}.mcp_server", port=5000)
with (
patch("fastmcp.FastMCP.run") as mock_run,
patch(
"dlt._workspace.deployment.launchers.mcp._fastmcp_supports_host_origin_protection",
return_value=True,
),
):
from dlt._workspace.deployment.launchers.mcp import run

run(entry_point)

assert mock_run.call_args[1]["host_origin_protection"] is False


def test_mcp_launcher_omits_host_origin_protection_on_old_fastmcp() -> None:
"""FastMCP < 3.4.3 has no `host_origin_protection` arg, so the launcher must not
pass it (doing so would raise TypeError)."""
entry_point = _entry(f"{WORKSPACE}.mcp_server", port=5000)
with (
patch("fastmcp.FastMCP.run") as mock_run,
patch(
"dlt._workspace.deployment.launchers.mcp._fastmcp_supports_host_origin_protection",
return_value=False,
),
):
from dlt._workspace.deployment.launchers.mcp import run

run(entry_point)

assert "host_origin_protection" not in mock_run.call_args[1]


def test_launcher_fails_without_port() -> None:
"""Interactive launchers fail if run_args.port is not provided."""
entry_point = _entry(f"{WORKSPACE}.mcp_server") # no port in run_args
Expand Down