Skip to content

Commit e3536df

Browse files
committed
refactor: address review feedback on the Server Card API surface
- Replace build_server_card() with the ServerCard.from_server() classmethod, matching the SDK's from_* alternate-constructor idiom; the _ServerIdentity protocol moves to mcp.shared.experimental.server_card alongside it. - Make DiscoveryResult iterable over its listings (__iter__/__len__), so 'for listing in result:' works without the .listings attribute hop. - Remove the client-side server_card_url() helper: card URLs must come from an AI Catalog entry per the discovery spec, never be constructed by the client. fetch_server_card's docstring now says so. - Explain the RFC 6598 shared address space constant in the SSRF guard and rename it _CGNAT_NETWORK -> _SHARED_ADDRESS_SPACE; ipaddress reports these addresses as neither private nor global, so the guard names them explicitly. All symbols are experimental (no deprecation cycle), so the removals are clean.
1 parent 3d50b08 commit e3536df

11 files changed

Lines changed: 210 additions & 215 deletions

File tree

docs/advanced/server-cards.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ and domain-level discovery reads an **AI Catalog** at `/.well-known/ai-catalog.j
2828
--8<-- "docs_src/server_cards/tutorial001.py"
2929
```
3030

31-
`build_server_card` derives `title`, `description`, `version`, `websiteUrl` and `icons`
32-
from the server object, so the card stays consistent with what `serverInfo` reports at
33-
runtime. Explicit keyword arguments override the derived values. The namespaced card
34-
`name` and the public `remotes` URLs are yours to supply, since the server object cannot
35-
know them.
31+
`ServerCard.from_server` derives `title`, `description`, `version`, `websiteUrl` and
32+
`icons` from the server object, so the card stays consistent with what `serverInfo`
33+
reports at runtime. Explicit keyword arguments override the derived values. The
34+
namespaced card `name` and the public `remotes` URLs are yours to supply, since the
35+
server object cannot know them. (A card that has nothing to derive from is plain
36+
`ServerCard(...)` — see static publishing below.)
3637

3738
With the app above, `GET /mcp/server-card` and `GET /.well-known/ai-catalog.json` both
3839
answer with the spec's required headers: the correct `Content-Type`, the CORS headers

docs_src/server_cards/tutorial001.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from mcp.server import MCPServer
2-
from mcp.server.experimental.server_card import build_server_card, mount_discovery
3-
from mcp.shared.experimental.server_card import Remote
2+
from mcp.server.experimental.server_card import mount_discovery
3+
from mcp.shared.experimental.server_card import Remote, ServerCard
44

55
mcp = MCPServer(
66
name="weather",
@@ -9,7 +9,7 @@
99
website_url="https://example.com",
1010
)
1111

12-
card = build_server_card(
12+
card = ServerCard.from_server(
1313
mcp,
1414
name="com.example/weather",
1515
remotes=[Remote(type="streamable-http", url="https://mcp.example.com/mcp")],

docs_src/server_cards/tutorial002.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
async def main() -> None:
1010
result = await discover_server_cards("https://example.com/docs")
11-
for listing in result.listings:
11+
for listing in result:
1212
print(listing.entry.identifier, "listed on", listing.listing_domain, "hosted at", listing.hosting_domain)
1313

1414
chosen = result.listings[0] # your host app: consent UI, dedup on chosen.card.endpoint_urls()

src/mcp/client/experimental/_discovery_http.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
]
3232

3333
_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
34-
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
34+
# RFC 6598 shared address space (carrier-grade NAT). `ipaddress` reports these
35+
# addresses as neither private nor global, so the SSRF guard must name them explicitly.
36+
_SHARED_ADDRESS_SPACE = ipaddress.ip_network("100.64.0.0/10")
3537

3638

3739
@dataclass(frozen=True, slots=True, kw_only=True)
@@ -136,7 +138,7 @@ def _is_blocked_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address)
136138
# properties would miss CGNAT everywhere and, before the gh-113171
137139
# ipaddress fix, the private v4 ranges too.
138140
address = address.ipv4_mapped
139-
if address.version == 4 and address in _CGNAT_NETWORK:
141+
if address.version == 4 and address in _SHARED_ADDRESS_SPACE:
140142
return True
141143
# is_private covers loopback, RFC 1918, ULA and the unspecified address.
142144
return address.is_private or address.is_link_local or address.is_multicast or address.is_reserved

src/mcp/client/experimental/server_card.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import os
14+
from collections.abc import Iterator
1415
from dataclasses import dataclass
1516
from pathlib import Path
1617
from urllib.parse import urlsplit
@@ -35,7 +36,7 @@
3536
AICatalog,
3637
CatalogEntry,
3738
)
38-
from mcp.shared.experimental.server_card import RESERVED_SERVER_CARD_SUFFIX, SERVER_CARD_MEDIA_TYPE, ServerCard
39+
from mcp.shared.experimental.server_card import SERVER_CARD_MEDIA_TYPE, ServerCard
3940

4041
__all__ = [
4142
"DiscoveryPolicy",
@@ -50,7 +51,6 @@
5051
"discover_server_cards",
5152
"load_server_card",
5253
"well_known_ai_catalog_url",
53-
"server_card_url",
5454
"create_server_card_request",
5555
"parse_server_card_response",
5656
"create_ai_catalog_request",
@@ -113,12 +113,20 @@ class DiscoveryResult:
113113
"""Everything one discovery probe produced.
114114
115115
A bad entry never kills the probe. It lands in `failures` while the
116-
other entries still produce `listings`.
116+
other entries still produce `listings`. Iterating the result iterates
117+
the listings, so `for listing in result:` reads naturally; check
118+
`failures` explicitly for what went wrong along the way.
117119
"""
118120

119121
listings: list[CardListing]
120122
failures: list[DiscoveryFailure]
121123

124+
def __iter__(self) -> Iterator[CardListing]:
125+
return iter(self.listings)
126+
127+
def __len__(self) -> int:
128+
return len(self.listings)
129+
122130

123131
@dataclass(frozen=True, slots=True)
124132
class CardMismatch:
@@ -141,21 +149,6 @@ def well_known_ai_catalog_url(url: str) -> str:
141149
return f"{parts.scheme}://{parts.netloc}{AI_CATALOG_WELL_KNOWN_PATH}"
142150

143151

144-
def server_card_url(streamable_http_url: str) -> str:
145-
"""The spec-reserved card URL for a streamable HTTP transport URL.
146-
147-
The suffix is appended to the transport URL, not the domain root:
148-
`https://host/mcp` becomes `https://host/mcp/server-card`.
149-
150-
Raises:
151-
ValueError: If `streamable_http_url` is not absolute http(s).
152-
"""
153-
parts = urlsplit(streamable_http_url)
154-
if parts.scheme not in ("http", "https") or not parts.netloc:
155-
raise ValueError(f"expected an absolute http(s) URL, got {streamable_http_url!r}")
156-
return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}{RESERVED_SERVER_CARD_SUFFIX}"
157-
158-
159152
def load_server_card(path: str | os.PathLike[str]) -> ServerCard:
160153
"""Parse a Server Card from a local file. No network is involved.
161154
@@ -174,6 +167,8 @@ async def fetch_server_card(
174167
) -> ServerCard:
175168
"""Fetch and parse a Server Card from `url` under `policy`.
176169
170+
`url` should come from an AI Catalog entry (`CatalogEntry.url`, as
171+
`discover_server_cards` follows them), never be constructed client-side.
177172
A missing `$schema` is defaulted on ingestion. A wrong one is rejected.
178173
Avoid passing an `http_client` that carries cookies or ambient
179174
credentials. Discovery requests must never send any.

src/mcp/server/experimental/server_card.py

Lines changed: 1 addition & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,9 @@
1212

1313
import hashlib
1414
import re
15-
from collections.abc import Awaitable, Callable, Sequence
16-
from typing import Any, Protocol
15+
from collections.abc import Awaitable, Callable
1716
from urllib.parse import urlsplit
1817

19-
from mcp_types import Icon
2018
from starlette.applications import Starlette
2119
from starlette.middleware.cors import CORSMiddleware
2220
from starlette.requests import Request
@@ -34,13 +32,10 @@
3432
from mcp.shared.experimental.server_card import (
3533
RESERVED_SERVER_CARD_SUFFIX,
3634
SERVER_CARD_MEDIA_TYPE,
37-
Remote,
38-
Repository,
3935
ServerCard,
4036
)
4137

4238
__all__ = [
43-
"build_server_card",
4439
"create_server_card_routes",
4540
"mount_server_card",
4641
"create_ai_catalog_routes",
@@ -58,68 +53,6 @@
5853
}
5954

6055

61-
class _ServerIdentity(Protocol):
62-
"""The identity surface `build_server_card` reads.
63-
64-
Both `MCPServer` (properties) and the lowlevel `Server` (plain attributes)
65-
satisfy this structurally.
66-
"""
67-
68-
@property
69-
def name(self) -> str: ...
70-
@property
71-
def title(self) -> str | None: ...
72-
@property
73-
def version(self) -> str | None: ...
74-
@property
75-
def description(self) -> str | None: ...
76-
@property
77-
def website_url(self) -> str | None: ...
78-
@property
79-
def icons(self) -> list[Icon] | None: ...
80-
81-
82-
def build_server_card(
83-
server: _ServerIdentity,
84-
*,
85-
name: str,
86-
remotes: Sequence[Remote] | None = None,
87-
repository: Repository | None = None,
88-
description: str | None = None,
89-
title: str | None = None,
90-
version: str | None = None,
91-
website_url: str | None = None,
92-
icons: Sequence[Icon] | None = None,
93-
meta: dict[str, Any] | None = None,
94-
) -> ServerCard:
95-
"""Build a `ServerCard` from a server's identity fields.
96-
97-
Title, description, version, website URL and icons come from the server
98-
object. Explicit keyword arguments override the derived values, which
99-
keeps the card consistent with what `serverInfo` reports at runtime. The
100-
namespaced `name` and the public `remotes` URLs are never derivable, so
101-
the caller supplies them.
102-
103-
Raises:
104-
pydantic.ValidationError: If the result violates a card constraint,
105-
for example a server description over 100 characters or a version
106-
that is unset and not overridden.
107-
"""
108-
resolved_icons = list(icons) if icons is not None else server.icons
109-
fields: dict[str, Any] = {
110-
"name": name,
111-
"version": version if version is not None else server.version,
112-
"description": description if description is not None else server.description,
113-
"title": title if title is not None else server.title,
114-
"website_url": website_url if website_url is not None else server.website_url,
115-
"icons": resolved_icons,
116-
"repository": repository,
117-
"remotes": list(remotes) if remotes is not None else None,
118-
"meta": meta,
119-
}
120-
return ServerCard.model_validate(fields)
121-
122-
12356
def discovery_response(
12457
request: Request,
12558
body: bytes,

src/mcp/shared/experimental/server_card.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
"""
1313

1414
import re
15-
from collections.abc import Mapping
15+
from collections.abc import Mapping, Sequence
1616
from dataclasses import dataclass
17-
from typing import Any, Final, Literal
17+
from typing import Any, Final, Literal, Protocol
1818

1919
from mcp_types import Icon
2020
from pydantic import Field, field_validator
@@ -109,6 +109,27 @@ def required_variables(self) -> frozenset[str]:
109109
return frozenset(names)
110110

111111

112+
class _ServerIdentity(Protocol):
113+
"""The identity surface `ServerCard.from_server` reads.
114+
115+
Both `MCPServer` (properties) and the lowlevel `Server` (plain attributes)
116+
satisfy this structurally.
117+
"""
118+
119+
@property
120+
def name(self) -> str: ...
121+
@property
122+
def title(self) -> str | None: ...
123+
@property
124+
def version(self) -> str | None: ...
125+
@property
126+
def description(self) -> str | None: ...
127+
@property
128+
def website_url(self) -> str | None: ...
129+
@property
130+
def icons(self) -> list[Icon] | None: ...
131+
132+
112133
class ServerCard(_CardModel):
113134
"""A Server Card document (`application/mcp-server-card+json`).
114135
@@ -127,6 +148,48 @@ class ServerCard(_CardModel):
127148
remotes: list[Remote] | None = None
128149
meta: dict[str, Any] | None = Field(default=None, alias="_meta")
129150

151+
@classmethod
152+
def from_server(
153+
cls,
154+
server: _ServerIdentity,
155+
*,
156+
name: str,
157+
remotes: Sequence[Remote] | None = None,
158+
repository: Repository | None = None,
159+
description: str | None = None,
160+
title: str | None = None,
161+
version: str | None = None,
162+
website_url: str | None = None,
163+
icons: Sequence[Icon] | None = None,
164+
meta: dict[str, Any] | None = None,
165+
) -> "ServerCard":
166+
"""Build a card from a server's identity fields.
167+
168+
Title, description, version, website URL and icons come from the
169+
server object. Explicit keyword arguments override the derived
170+
values, which keeps the card consistent with what `serverInfo`
171+
reports at runtime. The namespaced `name` and the public `remotes`
172+
URLs are never derivable, so the caller supplies them.
173+
174+
Raises:
175+
pydantic.ValidationError: If the result violates a card
176+
constraint, for example a server description over 100
177+
characters or a version that is unset and not overridden.
178+
"""
179+
resolved_icons = list(icons) if icons is not None else server.icons
180+
fields: dict[str, Any] = {
181+
"name": name,
182+
"version": version if version is not None else server.version,
183+
"description": description if description is not None else server.description,
184+
"title": title if title is not None else server.title,
185+
"website_url": website_url if website_url is not None else server.website_url,
186+
"icons": resolved_icons,
187+
"repository": repository,
188+
"remotes": list(remotes) if remotes is not None else None,
189+
"meta": meta,
190+
}
191+
return cls.model_validate(fields)
192+
130193
@field_validator("schema_")
131194
@classmethod
132195
def _schema_url_is_the_v1_url(cls, value: str) -> str:

0 commit comments

Comments
 (0)