Skip to content

Commit 06d4b21

Browse files
fix(experimental): align server cards with discovery spec
Adopt the current AI Catalog entry wire shape and MCP identifier namespace. Remove the retired MCP catalog fallback, keep metadata instability explicit, and tighten the focused tests and documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 13eb8021-1971-4c4f-9d07-4e98c0aaff7b
1 parent 0d55856 commit 06d4b21

16 files changed

Lines changed: 136 additions & 179 deletions

File tree

docs/advanced/server-cards.md

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ speaks — so a client can learn all of that *before* it connects. An **AI
66
Catalog** is the index that lists a host's cards at a well-known URL, so a client
77
that knows only a domain can discover the servers behind it.
88

9-
This is the SDK's implementation of [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/community/sep-guidelines.md)
9+
This is the SDK's implementation of [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127)
1010
and the companion AI Catalog discovery extension.
1111

1212
!!! warning "Experimental"
@@ -29,7 +29,7 @@ sequenceDiagram
2929
participant C as Client
3030
participant H as Host (example.com)
3131
C->>H: GET /.well-known/ai-catalog.json
32-
H-->>C: AI Catalog { entries: [ { url, media_type } ] }
32+
H-->>C: AI Catalog { entries: [ { url, type } ] }
3333
C->>H: GET the card URL from a catalog entry
3434
H-->>C: Server Card { name, version, remotes[] }
3535
C->>H: connect to remotes[].url (streamable-http / sse)
@@ -89,8 +89,8 @@ so you can serialize them and serve the JSON from any web server or CDN:
8989
--8<-- "docs_src/server_cards/tutorial002.py"
9090
```
9191

92-
Serialize with `by_alias=True` so the wire names (`$schema`, `_meta`) are emitted,
93-
and `exclude_none=True` so unset optional fields are dropped.
92+
Serialize with `by_alias=True` so the wire names (`$schema`, `_meta`, `type`) are
93+
emitted, and `exclude_none=True` so unset optional fields are dropped.
9494

9595
## Discovery HTTP semantics
9696

@@ -118,9 +118,8 @@ If-None-Match: "6b86b273ff34fce19d6b804eff5a3f57…"
118118
304 Not Modified
119119
```
120120

121-
The catalog is served from the well-known path `/.well-known/ai-catalog.json`.
122-
Clients probe there first and fall back to the MCP-scoped
123-
`/.well-known/mcp/catalog.json` on a 404, so either location is discoverable.
121+
The catalog is served from the well-known path
122+
`/.well-known/ai-catalog.json`.
124123

125124
## Discovering servers from a client
126125

@@ -131,10 +130,10 @@ every MCP server the host advertises:
131130
--8<-- "docs_src/server_cards/tutorial003.py"
132131
```
133132

134-
`discover_server_cards` resolves the well-known catalog (with the
135-
`/.well-known/mcp/catalog.json` fallback), then fetches and validates each
136-
referenced card. Malformed documents raise `pydantic.ValidationError`; a card
137-
that omits `$schema` is tolerated and defaulted to the current v1 schema URL.
133+
`discover_server_cards` resolves the well-known catalog, then fetches and
134+
validates each referenced card. Malformed documents raise
135+
`pydantic.ValidationError`; a card that omits `$schema` is tolerated and
136+
defaulted to the current v1 schema URL.
138137

139138
If you want to inspect the catalog before fetching cards, compose the lower-level
140139
helpers — `well_known_ai_catalog_url`, `fetch_ai_catalog`, and
@@ -162,9 +161,9 @@ suffix appended:
162161

163162
| Card `name` | Catalog identifier |
164163
| --- | --- |
165-
| `com.example/weather` | `urn:air:example.com:weather` |
166-
| `example/dice` | `urn:air:example:dice` |
164+
| `com.example/weather` | `urn:air:example.com:mcp:weather` |
165+
| `example/dice` | `urn:air:example:mcp:dice` |
167166

168-
`server_card_entry` computes this for you, and fills the entry's display name,
169-
description, and version from the card so a catalog stays consistent with the
170-
cards it points at.
167+
`server_card_entry` computes this for you and emits only the identifier, type,
168+
and card URL. Human-readable fields remain on the card so the catalog cannot
169+
drift from it.

docs_src/server_cards/tutorial001.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@
3232
mount_server_card(app, card, path="/mcp/server-card")
3333

3434
card_url = "https://dice.example.com/mcp/server-card"
35-
catalog = AICatalog(entries=[server_card_entry(card, card_url)])
35+
catalog = AICatalog(spec_version="1.0", entries=[server_card_entry(card, card_url)])
3636
mount_ai_catalog(app, catalog)

docs_src/server_cards/tutorial002.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
title="Dice Roller",
1414
remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")],
1515
)
16-
catalog = AICatalog(entries=[server_card_entry(card, "https://dice.example.com/server-card.json")])
16+
catalog = AICatalog(
17+
spec_version="1.0",
18+
entries=[server_card_entry(card, "https://dice.example.com/server-card.json")],
19+
)
1720

18-
# `by_alias=True` emits the wire names (`$schema`, `_meta`); `exclude_none=True`
19-
# drops unset optional fields.
21+
# `by_alias=True` emits the wire names (`$schema`, `_meta`, `type`);
22+
# `exclude_none=True` drops unset optional fields.
2023
card_json = card.model_dump_json(by_alias=True, exclude_none=True)
2124
catalog_json = catalog.model_dump_json(by_alias=True, exclude_none=True)
2225

docs_src/server_cards/tutorial003.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33

44
async def main() -> None:
5-
# Fetches the host's AI Catalog from `/.well-known/ai-catalog.json` (falling
6-
# back to `/.well-known/mcp/catalog.json` on a 404), then validates the
7-
# Server Card of every MCP entry it references.
5+
# Fetches the host's AI Catalog from `/.well-known/ai-catalog.json`, then
6+
# validates the Server Card of every MCP entry it references.
87
for card in await discover_server_cards("https://dice.example.com"):
98
print(card.name, card.version, "-", card.description)
109
for remote in card.remotes or []:

src/mcp/client/experimental/ai_catalog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
__all__ = ["well_known_ai_catalog_url", "fetch_ai_catalog"]
3333

3434

35-
def well_known_ai_catalog_url(url: str, *, well_known_path: str = AI_CATALOG_WELL_KNOWN_PATH) -> str:
35+
def well_known_ai_catalog_url(url: str) -> str:
3636
"""Resolve the well-known AI Catalog URL for a server's origin.
3737
3838
Accepts either a bare origin (``https://example.com``) or any URL on the
@@ -44,7 +44,7 @@ def well_known_ai_catalog_url(url: str, *, well_known_path: str = AI_CATALOG_WEL
4444
parts = urlsplit(url)
4545
if parts.scheme not in ("http", "https") or not parts.netloc:
4646
raise ValueError(f"Expected an absolute http(s) URL, got {url!r}")
47-
return urljoin(f"{parts.scheme}://{parts.netloc}", well_known_path)
47+
return urljoin(f"{parts.scheme}://{parts.netloc}", AI_CATALOG_WELL_KNOWN_PATH)
4848

4949

5050
async def fetch_ai_catalog(url: str, *, http_client: httpx2.AsyncClient | None = None) -> AICatalog:

src/mcp/client/experimental/server_card.py

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@
2626

2727
from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url
2828
from mcp.shared._httpx_utils import create_mcp_http_client
29-
from mcp.shared.experimental.ai_catalog.types import (
30-
MCP_CATALOG_WELL_KNOWN_PATH,
31-
MCP_SERVER_CARD_MEDIA_TYPE,
32-
)
29+
from mcp.shared.experimental.ai_catalog.types import MCP_SERVER_CARD_MEDIA_TYPE
3330
from mcp.shared.experimental.server_card.types import ServerCard
3431

3532
__all__ = ["fetch_server_card", "load_server_card", "discover_server_cards"]
@@ -57,11 +54,10 @@ async def fetch_server_card(url: str, *, http_client: httpx2.AsyncClient | None
5754
async def discover_server_cards(url: str, *, http_client: httpx2.AsyncClient | None = None) -> list[ServerCard]:
5855
"""Discover the MCP servers advertised by the host of ``url``.
5956
60-
Fetches the host's AI Catalog from ``/.well-known/ai-catalog.json``
61-
(falling back to the MCP-scoped ``/.well-known/mcp/catalog.json`` on a
62-
404), then validates the Server Card of every MCP server entry — fetched
63-
from the entry's ``url`` or read from its inline ``data``. Entries with
64-
other media types are ignored.
57+
Fetches the host's AI Catalog from ``/.well-known/ai-catalog.json``, then
58+
validates the Server Card of every MCP server entry — fetched from the
59+
entry's ``url`` or read from its inline ``data``. Entries with other types
60+
are ignored.
6561
6662
Card URLs are taken from the fetched catalog and may point anywhere,
6763
including other domains. Non-http(s) card URLs are rejected; beyond that,
@@ -81,13 +77,7 @@ async def discover_server_cards(url: str, *, http_client: httpx2.AsyncClient | N
8177
return await discover_server_cards(url, http_client=client)
8278

8379
catalog_url = well_known_ai_catalog_url(url)
84-
try:
85-
catalog = await fetch_ai_catalog(catalog_url, http_client=http_client)
86-
except httpx2.HTTPStatusError as exc:
87-
if exc.response.status_code != 404:
88-
raise
89-
catalog_url = well_known_ai_catalog_url(url, well_known_path=MCP_CATALOG_WELL_KNOWN_PATH)
90-
catalog = await fetch_ai_catalog(catalog_url, http_client=http_client)
80+
catalog = await fetch_ai_catalog(catalog_url, http_client=http_client)
9181

9282
cards: list[ServerCard] = []
9383
for entry in catalog.entries:

src/mcp/server/experimental/ai_catalog.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
A server advertises its MCP server(s) by serving an AI Catalog from the
66
well-known path, with one entry per Server Card::
77
8-
catalog = AICatalog(entries=[server_card_entry(card, "https://example.com/server-card")])
8+
catalog = AICatalog(
9+
spec_version="1.0",
10+
entries=[server_card_entry(card, "https://example.com/server-card")],
11+
)
912
mount_ai_catalog(server.streamable_http_app(), catalog) # GET /.well-known/ai-catalog.json
1013
1114
To write a catalog to a file instead, use
@@ -44,10 +47,6 @@
4447
}
4548

4649

47-
def _strong_etag(body: bytes) -> str:
48-
return f'"{hashlib.sha256(body).hexdigest()}"'
49-
50-
5150
def _if_none_match_matches(if_none_match: str | None, etag: str) -> bool:
5251
if if_none_match is None:
5352
return False
@@ -63,7 +62,8 @@ def _if_none_match_matches(if_none_match: str | None, etag: str) -> bool:
6362

6463

6564
def discovery_response(request: Request, body: bytes, media_type: str) -> Response:
66-
etag = _strong_etag(body)
65+
"""Build a cacheable discovery response with conditional ETag handling."""
66+
etag = f'"{hashlib.sha256(body).hexdigest()}"'
6767
if _if_none_match_matches(request.headers.get("if-none-match"), etag):
6868
return Response(
6969
status_code=304,
@@ -78,28 +78,26 @@ def _air_identifier(card_name: str) -> str:
7878
The card ``name`` is ``namespace/suffix`` in reverse-DNS form
7979
(``com.example/weather``); the namespace labels are reversed to forward-DNS
8080
(``com.example`` -> ``example.com``) and the suffix appended:
81-
``urn:air:example.com:weather``.
81+
``urn:air:example.com:mcp:weather``.
8282
"""
8383
namespace, _, suffix = card_name.partition("/")
8484
publisher = ".".join(reversed(namespace.split(".")))
85-
return f"{AI_CATALOG_URN_PREFIX}{publisher}:{suffix}"
85+
return f"{AI_CATALOG_URN_PREFIX}{publisher}:mcp:{suffix}"
8686

8787

8888
def server_card_entry(card: ServerCard, url: str) -> CatalogEntry:
8989
"""Build the catalog entry advertising ``card``, served at ``url``.
9090
9191
The entry's identifier is derived from the card's ``name``
92-
(``urn:air:{publisher}:{name}``); display name, description and version are
93-
taken from the card. ``url`` should be the absolute URL the card is
94-
retrievable from, since catalogs may be fetched cross-domain.
92+
(``urn:air:{publisher}:mcp:{name}``). Human-readable fields stay on the
93+
Server Card so the catalog cannot drift from it. ``url`` should be the
94+
absolute URL the card is retrievable from, since catalogs may be fetched
95+
cross-domain.
9596
"""
9697
return CatalogEntry(
9798
identifier=_air_identifier(card.name),
98-
display_name=card.title or card.name,
9999
media_type=MCP_SERVER_CARD_MEDIA_TYPE,
100100
url=url,
101-
description=card.description,
102-
version=card.version,
103101
)
104102

105103

src/mcp/server/experimental/server_card.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
mount_server_card(server.streamable_http_app(), card, path="/mcp/server-card")
1212
1313
Clients learn the card's URL from a catalog entry, so any reachable path works;
14-
the convention only matters for fallback probing.
14+
the convention gives publishers a predictable default.
1515
1616
A hosted card is only discoverable once it is registered in an AI Catalog (see
1717
``mcp.server.experimental.ai_catalog``); clients learn a card's URL from a

src/mcp/shared/experimental/ai_catalog/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
AI_CATALOG_MEDIA_TYPE,
1515
AI_CATALOG_URN_PREFIX,
1616
AI_CATALOG_WELL_KNOWN_PATH,
17-
MCP_CATALOG_WELL_KNOWN_PATH,
1817
MCP_SERVER_CARD_MEDIA_TYPE,
1918
AICatalog,
2019
Attestation,
@@ -30,7 +29,6 @@
3029
"AI_CATALOG_MEDIA_TYPE",
3130
"AI_CATALOG_URN_PREFIX",
3231
"AI_CATALOG_WELL_KNOWN_PATH",
33-
"MCP_CATALOG_WELL_KNOWN_PATH",
3432
"MCP_SERVER_CARD_MEDIA_TYPE",
3533
"AICatalog",
3634
"Attestation",

src/mcp/shared/experimental/ai_catalog/types.py

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,14 @@
44
55
An AI Catalog is a typed, nestable JSON container for discovering
66
heterogeneous AI artifacts (MCP servers, A2A agents, skills, nested
7-
catalogs, ...). Each entry declares its artifact type via a media type and
8-
either references the artifact by URL or embeds it inline. Hosts advertise a
9-
catalog at ``/.well-known/ai-catalog.json`` so clients can discover artifacts
10-
— for MCP, the Server Cards in ``mcp.shared.experimental.server_card`` —
11-
without prior configuration.
7+
catalogs, ...). Each entry declares its artifact type via a type identifier
8+
and either references the artifact by URL or embeds it inline. Hosts advertise
9+
a catalog at ``/.well-known/ai-catalog.json`` so clients can discover
10+
artifacts — for MCP, the Server Cards in
11+
``mcp.shared.experimental.server_card`` — without prior configuration.
1212
1313
The models mirror the normative CDDL schema of the AI Catalog specification,
14-
including the optional Trust Manifest extension. The MCP Catalog defined by
15-
the MCP discovery extension is a structural subset of an AI Catalog, so these
16-
models ingest both document flavours.
14+
including the optional Trust Manifest extension.
1715
1816
See https://github.com/Agent-Card/ai-catalog and
1917
https://github.com/modelcontextprotocol/experimental-ext-server-card/blob/main/docs/discovery.md.
@@ -34,12 +32,10 @@
3432
MCP_SERVER_CARD_MEDIA_TYPE = "application/mcp-server-card+json"
3533
#: Well-known path an AI Catalog is published at, relative to the host root.
3634
AI_CATALOG_WELL_KNOWN_PATH = "/.well-known/ai-catalog.json"
37-
#: Well-known path of the MCP-scoped catalog defined by the MCP discovery
38-
#: extension. A structural subset of an AI Catalog, so it parses with these models.
39-
MCP_CATALOG_WELL_KNOWN_PATH = "/.well-known/mcp/catalog.json"
4035
#: URN prefix for AI Catalog entry identifiers. MCP server entries use
41-
#: ``urn:air:{publisher}:{name}`` where ``publisher`` is the forward-DNS form of
42-
#: the card name's namespace (``com.example/weather`` -> ``urn:air:example.com:weather``).
36+
#: ``urn:air:{publisher}:mcp:{name}`` where ``publisher`` is the forward-DNS
37+
#: form of the card name's namespace
38+
#: (``com.example/weather`` -> ``urn:air:example.com:mcp:weather``).
4339
AI_CATALOG_URN_PREFIX = "urn:air:"
4440

4541

@@ -135,7 +131,11 @@ class TrustManifest(MCPModel):
135131
"""Detached JWS signature computed over the Trust Manifest content."""
136132

137133
metadata: dict[str, Any] | None = None
138-
"""Open map for custom or non-standard trust metadata."""
134+
"""Open map for custom trust data.
135+
136+
This field is unstable and may be replaced by structured extensions before
137+
AI Catalog v1.
138+
"""
139139

140140

141141
class Publisher(MCPModel):
@@ -185,17 +185,17 @@ class CatalogEntry(MCPModel):
185185
is its name suffix.
186186
"""
187187

188-
display_name: str
188+
display_name: str | None = None
189189
"""Human-readable name for the artifact."""
190190

191-
media_type: str
192-
"""Media type identifying the artifact type (e.g. ``"application/mcp-server-card+json"``)."""
191+
media_type: str = Field(validation_alias="type", serialization_alias="type")
192+
"""The serialized ``type`` identifier (e.g. ``"application/mcp-server-card+json"``)."""
193193

194194
url: str | None = None
195195
"""URL where the full artifact document can be retrieved."""
196196

197197
data: Any = None
198-
"""The complete artifact document inline; its structure is determined by ``media_type``."""
198+
"""The complete artifact document inline; its structure is determined by ``type``."""
199199

200200
version: str | None = None
201201
"""Version of the artifact. Semantic versioning is recommended."""
@@ -216,7 +216,11 @@ class CatalogEntry(MCPModel):
216216
"""When this entry was last modified."""
217217

218218
metadata: dict[str, Any] | None = None
219-
"""Open map for custom or non-standard metadata."""
219+
"""Open map for custom data.
220+
221+
This field is unstable and may be replaced by structured extensions before
222+
AI Catalog v1.
223+
"""
220224

221225
@model_validator(mode="after")
222226
def _check_content_and_trust(self) -> CatalogEntry:
@@ -235,16 +239,13 @@ def _check_content_and_trust(self) -> CatalogEntry:
235239
class AICatalog(MCPModel):
236240
"""A catalog of AI artifacts, served as ``application/ai-catalog+json``.
237241
238-
A minimal catalog is just ``entries`` — names, media types and URLs. A
242+
A minimal catalog is just ``specVersion`` and ``entries``. A
239243
catalog may be served from any URL; hosts that want automated discovery
240244
publish one at ``/.well-known/ai-catalog.json``.
241245
"""
242246

243-
spec_version: str = "1.0"
244-
"""The AI Catalog specification version, in ``"Major.Minor"`` format.
245-
246-
Required by the specification; defaulted here for documents that omit it.
247-
"""
247+
spec_version: str
248+
"""The AI Catalog specification version, in ``"Major.Minor"`` format."""
248249

249250
entries: list[CatalogEntry]
250251
"""The cataloged artifacts. May be empty."""
@@ -253,4 +254,8 @@ class AICatalog(MCPModel):
253254
"""The operator of this catalog."""
254255

255256
metadata: dict[str, Any] | None = None
256-
"""Open map for custom or non-standard metadata."""
257+
"""Open map for custom data.
258+
259+
This field is unstable and may be replaced by structured extensions before
260+
AI Catalog v1.
261+
"""

0 commit comments

Comments
 (0)