Skip to content

Commit 3d50b08

Browse files
committed
fix: harden Server Card discovery and close review gaps (experimental)
Discovery client: - Bound each probe with DiscoveryPolicy.max_probe_entries (default 500), an aggregate budget over every card and nested-catalog entry one walk processes; exhaustion records a single "probe_budget" failure and drops the rest. Already-visited nested catalog URLs are never refetched, so cyclic or duplicated catalogs terminate. Without this the per-catalog entry cap and the depth cap compose multiplicatively (~entries**depth fetches from one hostile catalog). - Catch OSError per entry too: an unresolvable host (socket.gaierror from the guard's own DNS resolution) or a tar-pit entry (TimeoutError from the per-fetch deadline) becomes a failure instead of killing the whole probe, as the DiscoveryResult contract promises. Both exceptions are now documented on the public fetchers. - CardListing.listing_domain/hosting_domain return host[:port] built from the parsed hostname, never the raw netloc, and _admit_url rejects userinfo-carrying URLs outright, so https://github.com@evil.example/ can neither be fetched nor rendered as a trusted brand in consent UI. - _is_blocked_address unwraps IPv4-mapped IPv6 literals and applies the IPv4 rules, closing the ::ffff:100.64.0.1 CGNAT bypass (and the private-range leak on interpreters without the gh-113171 fix). - discover_server_cards with http_client=None opens one credential-free client for the whole walk instead of one per fetched entry. - Plain http is refused up front under the hardened policy (the loopback carve-out was unreachable: loopback fails the address guard anyway). - DiscoveryErrorReason is re-exported from the public module, and one accept_header() helper replaces the duplicated Accept literals. Models and server: - Remote.required_variables now includes required headers that carry no value and no default, keyed by header name exactly as resolve_remote accepts them, so prompting from the property and then resolving works. - mount_discovery documents that public_url is the app's public base URL. - The discovery routes' CORSMiddleware allows only GET, so real browser preflights advertise the spec's method list. Tests cover the budget walk (exact fetch sequence), visited-set dedup, per-entry DNS-failure and timeout resilience, userinfo rejection and display, mapped-IPv6 blocking, required-header prompting, Repository and icons round-trips, and the preflight headers.
1 parent e15f284 commit 3d50b08

8 files changed

Lines changed: 446 additions & 56 deletions

File tree

docs/advanced/server-cards.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ The card routes are appended to the app after `streamable_http_app()` builds it.
4343
them outside any auth middleware. Discovery is unauthenticated by design, and a card or
4444
catalog must never contain credentials, internal topology or private endpoints.
4545

46+
`public_url` is the externally visible base URL at which the app's root is served,
47+
typically just the origin. Only include a path prefix when a reverse proxy really serves
48+
the app under it, since the catalog entry advertises `public_url` plus the card path.
49+
4650
If you need only one of the two endpoints, or different paths, use the smaller pieces:
4751
`create_server_card_routes` / `mount_server_card` for the card and
4852
`create_ai_catalog_routes` / `mount_ai_catalog` for the catalog. For fully custom
@@ -116,9 +120,11 @@ Cards are unverified, advisory input. The rules that keep discovery safe:
116120
"always"), decline memory and enterprise allowlists are host application policy. The
117121
SDK exposes the data and stays out of the decision.
118122
- **Hardened fetching.** Every fetch, redirect hop and nested catalog follow is checked
119-
under `DiscoveryPolicy`: https only (plain http is loopback-only), an SSRF guard that
120-
rejects private, loopback, link-local and metadata addresses (checked again after DNS
121-
resolution), bounded redirects, response size and entry count caps, and no cookies or
122-
ambient credentials. If you pass your own `http_client`, keep it credential-free. The
123-
guard resolves DNS before the request while the client re-resolves on connect, so a
124-
DNS rebinding race remains possible between the two.
123+
under `DiscoveryPolicy`: https only (plain http needs `allow_private_addresses=True`),
124+
no userinfo in URLs, an SSRF guard that rejects private, loopback, link-local and
125+
metadata addresses (checked again after DNS resolution), bounded redirects, response
126+
size and entry count caps, a whole-probe entry budget with already-visited catalogs
127+
never refetched, and no cookies or ambient credentials. If you pass your own
128+
`http_client`, keep it credential-free. The guard resolves DNS before the request
129+
while the client re-resolves on connect, so a DNS rebinding race remains possible
130+
between the two.

src/mcp/client/experimental/_discovery_http.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Hardened HTTP core for discovery fetches. Private module.
22
33
Every network fetch (including every redirect hop and every nested catalog
4-
follow) is re-admitted under the same rules: http(s) scheme only, plain http
5-
only to loopback hosts, an SSRF address guard checked before the request,
6-
bounded redirects, a streamed response size cap, and a media type check.
4+
follow) is re-admitted under the same rules: http(s) scheme only, no userinfo,
5+
plain http only under `allow_private_addresses`, an SSRF address guard checked
6+
before the request, bounded redirects, a streamed response size cap, and a
7+
media type check.
78
"""
89

910
import ipaddress
@@ -15,7 +16,6 @@
1516
import httpx2
1617

1718
from mcp.shared._httpx_utils import create_mcp_http_client
18-
from mcp.shared.experimental._base import is_loopback_host
1919
from mcp.shared.experimental.ai_catalog import MAX_CATALOG_NESTING_DEPTH
2020

2121
DiscoveryErrorReason = Literal[
@@ -27,6 +27,7 @@
2727
"insecure_transport",
2828
"invalid_entry",
2929
"catalog_depth",
30+
"probe_budget",
3031
]
3132

3233
_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
@@ -44,12 +45,21 @@ class DiscoveryPolicy:
4445
The address guard resolves DNS before the request and re-checks every
4546
redirect hop, but the HTTP client re-resolves when it connects, so a
4647
DNS rebinding race remains possible between the check and the connect.
48+
49+
`max_catalog_entries` caps one catalog document and `max_catalog_depth`
50+
caps nesting, but neither bounds their product, so `max_probe_entries` is
51+
the aggregate budget: the total card and nested-catalog entries one
52+
discovery probe will process across every catalog it walks. A probe that
53+
exhausts it records a single `probe_budget` failure and returns what it
54+
has, which also bounds the probe's total fetches and worst-case duration
55+
(`max_probe_entries * timeout_seconds`).
4756
"""
4857

4958
max_response_bytes: int = 1_048_576
5059
max_redirects: int = 3
5160
max_catalog_entries: int = 100
5261
max_catalog_depth: int = MAX_CATALOG_NESTING_DEPTH
62+
max_probe_entries: int = 500
5363
allow_private_addresses: bool = False
5464
timeout_seconds: float = 10.0
5565

@@ -71,6 +81,16 @@ def __init__(self, message: str, *, url: str, reason: DiscoveryErrorReason) -> N
7181
self.reason = reason
7282

7383

84+
def accept_header(media_type: str) -> str:
85+
"""The Accept header for one discovery media type.
86+
87+
The canonical type is preferred, with plain `application/json` at a lower
88+
quality because static hosts and CDNs commonly serve it. This is the one
89+
place the lenience is written down; `check_media_type` mirrors it.
90+
"""
91+
return f"{media_type}, application/json;q=0.5"
92+
93+
7494
def check_status(response: httpx2.Response, url: str) -> None:
7595
"""Reject any non-2xx response.
7696
@@ -111,6 +131,11 @@ async def _host_addresses(host: str) -> list[ipaddress.IPv4Address | ipaddress.I
111131

112132
def _is_blocked_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
113133
"""Whether `address` is off-limits for discovery: anything not publicly routable."""
134+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
135+
# Judge `::ffff:a.b.c.d` by its embedded IPv4 rules. Relying on the v6
136+
# properties would miss CGNAT everywhere and, before the gh-113171
137+
# ipaddress fix, the private v4 ranges too.
138+
address = address.ipv4_mapped
114139
if address.version == 4 and address in _CGNAT_NETWORK:
115140
return True
116141
# is_private covers loopback, RFC 1918, ULA and the unspecified address.
@@ -121,21 +146,33 @@ async def _admit_url(url: str, policy: DiscoveryPolicy) -> None:
121146
"""Apply the scheme rule and the SSRF address guard to one fetch target.
122147
123148
Raises:
124-
DiscoveryError: `reason="insecure_transport"` for a non-http(s) URL or
125-
plain http to a non-loopback host, `reason="blocked_address"` when
126-
the host is (or resolves to) a non-public address.
149+
DiscoveryError: `reason="insecure_transport"` for a non-http(s) URL, a
150+
URL carrying userinfo, or plain http under the hardened policy,
151+
`reason="blocked_address"` when the host is (or resolves to) a
152+
non-public address.
127153
"""
128154
parts = urlsplit(url)
129155
host = parts.hostname
130156
if parts.scheme not in ("http", "https") or not host:
131157
raise DiscoveryError(
132158
f"discovery requires an absolute http(s) URL, got {url!r}", url=url, reason="insecure_transport"
133159
)
160+
if "@" in parts.netloc:
161+
# Discovery never sends credentials, and `user@host` URLs exist mainly
162+
# to make a hostile target read as a trusted brand in consent UI.
163+
raise DiscoveryError(
164+
f"discovery URLs must not carry userinfo, got {url!r}", url=url, reason="insecure_transport"
165+
)
134166
if policy.allow_private_addresses:
135167
return
136-
if parts.scheme == "http" and not is_loopback_host(host):
168+
if parts.scheme == "http":
169+
# Under the hardened policy loopback targets are blocked by the
170+
# address guard anyway, so plain http (loopback included) never
171+
# survives it; local development opts in via allow_private_addresses.
137172
raise DiscoveryError(
138-
f"plain http is only allowed for loopback hosts, got {url!r}", url=url, reason="insecure_transport"
173+
f"plain http is only allowed with allow_private_addresses=True, got {url!r}",
174+
url=url,
175+
reason="insecure_transport",
139176
)
140177
for address in await _host_addresses(host):
141178
if _is_blocked_address(address):
@@ -162,7 +199,7 @@ async def _fetch(client: httpx2.AsyncClient, url: str, media_type: str, policy:
162199
current = url
163200
for _ in range(policy.max_redirects + 1):
164201
await _admit_url(current, policy)
165-
headers = {"Accept": f"{media_type}, application/json;q=0.5"}
202+
headers = {"Accept": accept_header(media_type)}
166203
request = client.build_request("GET", current, headers=headers)
167204
response = await client.send(request, stream=True, follow_redirects=False)
168205
try:

0 commit comments

Comments
 (0)