Skip to content

Commit f39840d

Browse files
Fix http transport rejecting remote clients with 421 (#4)
* Fix http transport rejecting remote clients with 421 FastMCP is constructed at import time with the default host 127.0.0.1, which auto-locks DNS-rebinding protection to a localhost-only Host allowlist. Rebinding the host to 0.0.0.0 in main() does not update that frozen allowlist, so remote clients get "421 Invalid Host header". Set transport_security explicitly in http mode: derive allowed hosts from VYMCP_PUBLIC_URL (overridable via VYMCP_ALLOWED_HOSTS, or "*" to disable the check behind a trusted proxy). * Wrap origins comprehension to satisfy E501
1 parent 111f480 commit f39840d

3 files changed

Lines changed: 47 additions & 2 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ VYMCP_TRANSPORT=stdio
2222
VYMANAGER_API_TOKEN=vym_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2323

2424
# http only: bind address, port, and the externally reachable URL.
25+
# To accept remote clients, bind all interfaces (VYMCP_HOST=0.0.0.0) AND set
26+
# VYMCP_PUBLIC_URL to the address clients actually use — the server only accepts
27+
# requests whose Host header matches it (DNS-rebinding protection).
2528
VYMCP_HOST=127.0.0.1
2629
VYMCP_PORT=8080
2730
# VYMCP_PUBLIC_URL=https://vymcp.example.com
31+
32+
# Optional: override the allowed Host header values (comma-separated, e.g.
33+
# "192.168.9.103:8080,vymcp.example.com"). Defaults to the VYMCP_PUBLIC_URL host.
34+
# Set to "*" to disable the Host check entirely (only behind a trusted proxy).
35+
# VYMCP_ALLOWED_HOSTS=192.168.9.103:8080

src/vymcp/config.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from __future__ import annotations
44

55
import os
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, field
7+
from urllib.parse import urlparse
78

89
_FALSEY = {"0", "false", "no", "off"}
910
_TRUTHY = {"1", "true", "yes", "on"}
@@ -52,6 +53,9 @@ class ServerConfig:
5253
port: int = 8080
5354
public_url: str = "http://127.0.0.1:8080" # externally reachable URL (http mode)
5455
api_token: str | None = None
56+
# Host header values the http server accepts (DNS-rebinding protection). Empty
57+
# tuple means allow any host (protection disabled).
58+
allowed_hosts: tuple[str, ...] = field(default_factory=tuple)
5559

5660
@classmethod
5761
def from_env(cls) -> ServerConfig:
@@ -77,6 +81,20 @@ def from_env(cls) -> ServerConfig:
7781

7882
host = os.environ.get("VYMCP_HOST", "127.0.0.1")
7983
public_url = os.environ.get("VYMCP_PUBLIC_URL") or f"http://{host}:{port}"
84+
public_url = public_url.rstrip("/")
85+
86+
# Which Host headers the http server accepts. Explicit override wins;
87+
# otherwise default to the public URL's host:port so external clients that
88+
# reach the server at its advertised address are allowed. "*" disables the
89+
# DNS-rebinding check entirely (use only behind a trusted proxy).
90+
allowed_env = os.environ.get("VYMCP_ALLOWED_HOSTS", "").strip()
91+
if allowed_env == "*":
92+
allowed_hosts: tuple[str, ...] = ()
93+
elif allowed_env:
94+
allowed_hosts = tuple(h.strip() for h in allowed_env.split(",") if h.strip())
95+
else:
96+
netloc = urlparse(public_url).netloc
97+
allowed_hosts = (netloc,) if netloc else ()
8098

8199
return cls(
82100
base_url=base_url.rstrip("/"),
@@ -85,8 +103,9 @@ def from_env(cls) -> ServerConfig:
85103
transport=transport,
86104
host=host,
87105
port=port,
88-
public_url=public_url.rstrip("/"),
106+
public_url=public_url,
89107
api_token=api_token,
108+
allowed_hosts=allowed_hosts,
90109
)
91110

92111
def client_config(self, token: str) -> Config:

src/vymcp/server.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,29 @@ def main() -> None:
104104
config = server_config()
105105
if config.transport == "http":
106106
from mcp.server.auth.settings import AuthSettings
107+
from mcp.server.transport_security import TransportSecuritySettings
107108

108109
from .auth import VyManagerTokenVerifier
109110

110111
mcp.settings.host = config.host
111112
mcp.settings.port = config.port
113+
# FastMCP auto-configures DNS-rebinding protection for localhost at
114+
# construction time (allowing only 127.0.0.1/localhost). Since we bind and
115+
# advertise a different address in http mode, replace it with an allowlist
116+
# derived from config, or disable it when no hosts are configured.
117+
if config.allowed_hosts:
118+
origins = [
119+
f"{scheme}://{h}" for h in config.allowed_hosts for scheme in ("http", "https")
120+
]
121+
mcp.settings.transport_security = TransportSecuritySettings(
122+
enable_dns_rebinding_protection=True,
123+
allowed_hosts=list(config.allowed_hosts),
124+
allowed_origins=origins,
125+
)
126+
else:
127+
mcp.settings.transport_security = TransportSecuritySettings(
128+
enable_dns_rebinding_protection=False,
129+
)
112130
mcp.settings.auth = AuthSettings(
113131
issuer_url=config.base_url, # type: ignore[arg-type] # pydantic coerces str->AnyHttpUrl
114132
resource_server_url=config.public_url, # type: ignore[arg-type]

0 commit comments

Comments
 (0)