Skip to content

Commit 2c8355f

Browse files
bokelleyclaude
andcommitted
feat(server): synthesize host:* siblings for bare allowed_hosts (#518)
When an adopter passes ``allowed_hosts=['acme.localhost']`` to ``create_mcp_server`` / ``serve``, the ``host:*`` sibling (``acme.localhost:*``) is now auto-added so requests on either bare or port-suffixed Host headers pass FastMCP's transport-security check. Mirrors the port-stripping ``InMemorySubdomainTenantRouter`` does at lookup time — registering once now covers both surfaces. Salesagent adopter feedback flagged the asymmetry: their dev setup needed both ``acme.localhost`` (router) and ``acme.localhost:*`` (allowlist) per host, while the spec'd surface should accept a single declaration. Hosts that already include ``:`` (explicit port or wildcard) pass through unchanged — adopter signaled they're managing the form themselves. Idempotent: both forms in the input produce no duplicates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a9fd18a commit 2c8355f

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

src/adcp/server/serve.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,40 @@ async def _serve() -> None:
14461446
sock.close()
14471447

14481448

1449+
def _expand_allowed_hosts(hosts: Sequence[str]) -> list[str]:
1450+
"""Synthesize ``host:*`` siblings for bare hosts.
1451+
1452+
FastMCP's :class:`TransportSecurityMiddleware` matches the request's
1453+
``Host`` header literally against the configured ``allowed_hosts``
1454+
list. A bare host like ``acme.localhost`` matches a request without
1455+
a port suffix; the same request from a browser hitting
1456+
``http://acme.localhost:3001`` carries ``Host: acme.localhost:3001``
1457+
and is rejected with ``421 Misdirected Request``.
1458+
1459+
Adopters had to register both ``acme.localhost`` and
1460+
``acme.localhost:*`` explicitly. This helper synthesizes the second
1461+
form when the input has no ``:`` separator, mirroring the
1462+
port-stripping done in ``InMemorySubdomainTenantRouter`` so the two
1463+
surfaces stay symmetric. Hosts that already include ``:`` (already
1464+
have an explicit port or wildcard) pass through unchanged.
1465+
1466+
Idempotent: if the adopter passed both ``acme.localhost`` and
1467+
``acme.localhost:*``, the result still contains each only once.
1468+
"""
1469+
seen: set[str] = set()
1470+
result: list[str] = []
1471+
for host in hosts:
1472+
if host not in seen:
1473+
seen.add(host)
1474+
result.append(host)
1475+
if ":" not in host:
1476+
wildcard = f"{host}:*"
1477+
if wildcard not in seen:
1478+
seen.add(wildcard)
1479+
result.append(wildcard)
1480+
return result
1481+
1482+
14491483
def create_mcp_server(
14501484
handler: ADCPHandler[Any],
14511485
*,
@@ -1587,6 +1621,12 @@ def create_mcp_server(
15871621
# tenant table (e.g. :class:`SubdomainTenantMiddleware`) can set
15881622
# ``enable_dns_rebinding_protection=False`` so the MCP-layer check
15891623
# doesn't duplicate the upstream validation.
1624+
#
1625+
# ``_expand_allowed_hosts`` synthesizes the ``host:*`` sibling for
1626+
# any bare host (no ``:``) so adopters who pass ``acme.localhost``
1627+
# also cover requests on ``acme.localhost:3001``. Mirrors the port
1628+
# stripping :class:`InMemorySubdomainTenantRouter` does at lookup
1629+
# time so the two surfaces stay symmetric.
15901630
if (
15911631
enable_dns_rebinding_protection is not None
15921632
or allowed_hosts is not None
@@ -1600,7 +1640,10 @@ def create_mcp_server(
16001640
if enable_dns_rebinding_protection is not None:
16011641
ts.enable_dns_rebinding_protection = enable_dns_rebinding_protection
16021642
if allowed_hosts:
1603-
ts.allowed_hosts = [*ts.allowed_hosts, *allowed_hosts]
1643+
ts.allowed_hosts = [
1644+
*ts.allowed_hosts,
1645+
*_expand_allowed_hosts(allowed_hosts),
1646+
]
16041647
if allowed_origins:
16051648
ts.allowed_origins = [*ts.allowed_origins, *allowed_origins]
16061649
_register_handler_tools(

tests/test_serve_transport_security.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,91 @@ def test_enable_dns_rebinding_protection_false_disables_check() -> None:
8282
ts = mcp.settings.transport_security
8383
assert ts is not None
8484
assert ts.enable_dns_rebinding_protection is False
85+
86+
87+
# ---- Bare-host → ``host:*`` synthesis (issue #518) ----
88+
89+
90+
def test_bare_host_synthesizes_port_wildcard_sibling() -> None:
91+
"""``allowed_hosts=['acme.localhost']`` registers both
92+
``acme.localhost`` and ``acme.localhost:*`` so requests on either
93+
bare or port-suffixed Host headers pass the transport check.
94+
95+
Mirrors the port-stripping :class:`InMemorySubdomainTenantRouter`
96+
does at lookup time — registering once should cover both surfaces.
97+
"""
98+
mcp = create_mcp_server(
99+
_StubHandler(),
100+
name="t",
101+
allowed_hosts=["acme.localhost"],
102+
)
103+
ts = mcp.settings.transport_security
104+
assert ts is not None
105+
assert "acme.localhost" in ts.allowed_hosts
106+
assert "acme.localhost:*" in ts.allowed_hosts
107+
108+
109+
def test_explicit_port_wildcard_passes_through_unchanged() -> None:
110+
"""When the adopter already passes ``acme.localhost:*``, no
111+
further synthesis happens — the bare ``acme.localhost`` is NOT
112+
auto-added because the input has an explicit ``:`` separator
113+
(the adopter signaled they're managing the form themselves)."""
114+
mcp = create_mcp_server(
115+
_StubHandler(),
116+
name="t",
117+
allowed_hosts=["acme.localhost:*"],
118+
)
119+
ts = mcp.settings.transport_security
120+
assert ts is not None
121+
assert "acme.localhost:*" in ts.allowed_hosts
122+
# Adopter passed the port-wildcard form; we don't second-guess.
123+
assert "acme.localhost" not in ts.allowed_hosts
124+
125+
126+
def test_explicit_port_passes_through_unchanged() -> None:
127+
"""A specific port (``acme.localhost:3001``) is left alone — the
128+
``:`` discriminator covers any port-bearing form."""
129+
mcp = create_mcp_server(
130+
_StubHandler(),
131+
name="t",
132+
allowed_hosts=["acme.localhost:3001"],
133+
)
134+
ts = mcp.settings.transport_security
135+
assert ts is not None
136+
assert "acme.localhost:3001" in ts.allowed_hosts
137+
assert "acme.localhost" not in ts.allowed_hosts
138+
139+
140+
def test_mixed_bare_and_explicit_hosts_each_get_correct_treatment() -> None:
141+
"""Bare hosts get the wildcard sibling; explicit-port hosts pass
142+
through. A list with both forms produces the right combined set."""
143+
mcp = create_mcp_server(
144+
_StubHandler(),
145+
name="t",
146+
allowed_hosts=["acme.localhost", "beta.example.com:8443"],
147+
)
148+
ts = mcp.settings.transport_security
149+
assert ts is not None
150+
# Bare host gets both forms.
151+
assert "acme.localhost" in ts.allowed_hosts
152+
assert "acme.localhost:*" in ts.allowed_hosts
153+
# Explicit-port host stays as-is, no bare-host synthesis.
154+
assert "beta.example.com:8443" in ts.allowed_hosts
155+
assert "beta.example.com" not in ts.allowed_hosts
156+
157+
158+
def test_idempotent_when_both_forms_passed() -> None:
159+
"""Adopter who already passes both ``acme.localhost`` and
160+
``acme.localhost:*`` doesn't end up with duplicates after
161+
expansion."""
162+
mcp = create_mcp_server(
163+
_StubHandler(),
164+
name="t",
165+
allowed_hosts=["acme.localhost", "acme.localhost:*"],
166+
)
167+
ts = mcp.settings.transport_security
168+
assert ts is not None
169+
bare_count = sum(1 for h in ts.allowed_hosts if h == "acme.localhost")
170+
wildcard_count = sum(1 for h in ts.allowed_hosts if h == "acme.localhost:*")
171+
assert bare_count == 1
172+
assert wildcard_count == 1

0 commit comments

Comments
 (0)