Skip to content

Commit 89e0f23

Browse files
committed
docs: add Server Cards (SEP-2127) usage guide
Document the experimental Server Cards / AI Catalog discovery feature: building and serving a Server Card, mounting it plus the AI Catalog on the ASGI app, the discovery HTTP semantics (well-known path + fallback, strong SHA-256 ETags, conditional GET, CORS/Cache-Control headers), and client-side discovery. Adds docs/server-cards.md (with verified, type-checked snippets) and a single nav entry. No source or test changes. 🤖 Authored via Claude Code, on behalf of @tadasant.
1 parent b658e7c commit 89e0f23

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

docs/server-cards.md

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
# Server Cards & AI Catalog Discovery
2+
3+
!!! warning "Experimental"
4+
Server Cards and AI Catalog support live under `mcp.shared.experimental`,
5+
`mcp.server.experimental`, and `mcp.client.experimental`. **These APIs are
6+
experimental and may change without notice** between releases. Pin your MCP
7+
version if you depend on them.
8+
9+
A **Server Card** ([SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol))
10+
is a small, static JSON document that describes a remote MCP server — its
11+
identity, transport endpoints, and supported protocol versions — so a client can
12+
learn *how to connect* **before** it initializes a session. A server publishes
13+
its card at a URL and advertises it through an **AI Catalog**, a JSON index that
14+
hosts serve from a well-known path for zero-configuration discovery.
15+
16+
A Server Card describes remote connectivity only. It does **not** list a server's
17+
primitives (tools, resources, prompts) — those remain subject to the usual
18+
runtime listing once a session is established.
19+
20+
```mermaid
21+
sequenceDiagram
22+
participant C as Client
23+
participant H as Host (example.com)
24+
C->>H: GET /.well-known/ai-catalog.json
25+
H-->>C: AI Catalog (entries -> card URLs)
26+
C->>H: GET /mcp/server-card
27+
H-->>C: Server Card (remotes, protocol versions)
28+
C->>H: initialize() against a remote's URL
29+
```
30+
31+
## What's in a Server Card
32+
33+
The [`ServerCard`][mcp.shared.experimental.server_card.ServerCard] model
34+
(re-exported from `mcp.shared.experimental.server_card`) carries:
35+
36+
| Field | Purpose |
37+
| --- | --- |
38+
| `name` | Reverse-DNS `namespace/name` identifier, e.g. `io.modelcontextprotocol/everything`. |
39+
| `version` | Exact server version. Ranges/wildcards are rejected. |
40+
| `description` | Short human-readable summary (≤ 100 chars). |
41+
| `title`, `website_url`, `icons`, `repository` | Optional display / source metadata. |
42+
| `remotes` | A list of [`Remote`][mcp.shared.experimental.server_card.Remote] endpoints — the transport `type` (`streamable-http` or `sse`), the `url` (a template that may contain `{variables}`), required `headers`, and `supported_protocol_versions`. |
43+
44+
## Building a card (server side)
45+
46+
[`build_server_card`][mcp.server.experimental.server_card.build_server_card]
47+
derives a card from a running server's identity, so you only supply the
48+
reverse-DNS `name` and the remote endpoints to advertise. Pass a low-level
49+
[`Server`][mcp.server.lowlevel.Server] — or anything exposing the standard
50+
identity attributes (`name`, `version`, `title`, `description`, `website_url`,
51+
`icons`). The server's `version` and `description` must be set, or it raises
52+
`ValueError`.
53+
54+
```python title="build_card.py"
55+
from mcp.server.lowlevel import Server
56+
from mcp.server.experimental.server_card import build_server_card
57+
from mcp.shared.experimental.server_card import Input, KeyValueInput, Remote
58+
59+
server = Server(
60+
"dice-roller",
61+
version="1.2.0",
62+
title="Dice Roller",
63+
description="Rolls dice and returns the results.",
64+
website_url="https://dice.example.com",
65+
)
66+
67+
card = build_server_card(
68+
server,
69+
name="com.example/dice-roller",
70+
remotes=[
71+
Remote(
72+
type="streamable-http",
73+
url="https://dice.example.com/mcp",
74+
headers=[
75+
KeyValueInput(
76+
name="Authorization",
77+
value="Bearer {token}",
78+
variables={"token": Input(is_secret=True, is_required=True)},
79+
)
80+
],
81+
supported_protocol_versions=["2025-06-18"],
82+
)
83+
],
84+
)
85+
```
86+
87+
## Serving the card and AI Catalog
88+
89+
A card is only discoverable once it is referenced from an AI Catalog — clients
90+
read a card's URL from a catalog entry rather than guessing it. Mount both onto
91+
the Starlette app returned by `streamable_http_app()`:
92+
93+
* [`mount_server_card`][mcp.server.experimental.server_card.mount_server_card]
94+
adds the card route. The recommended path is `<streamable-http-url>/server-card`
95+
`/mcp/server-card` when the MCP endpoint is the default `/mcp`.
96+
* [`server_card_entry`][mcp.server.experimental.ai_catalog.server_card_entry]
97+
builds the catalog entry that points at the card's absolute URL, and
98+
[`mount_ai_catalog`][mcp.server.experimental.ai_catalog.mount_ai_catalog]
99+
serves the catalog at `/.well-known/ai-catalog.json`.
100+
101+
```python title="server.py"
102+
from mcp.server.lowlevel import Server
103+
from mcp.server.experimental.server_card import build_server_card, mount_server_card
104+
from mcp.server.experimental.ai_catalog import mount_ai_catalog, server_card_entry
105+
from mcp.shared.experimental.ai_catalog import AICatalog
106+
from mcp.shared.experimental.server_card import Remote
107+
108+
server = Server(
109+
"dice-roller",
110+
version="1.2.0",
111+
title="Dice Roller",
112+
description="Rolls dice and returns the results.",
113+
)
114+
115+
# ... register the server's tools, resources and prompts as usual ...
116+
117+
card = build_server_card(
118+
server,
119+
name="com.example/dice-roller",
120+
remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")],
121+
)
122+
123+
card_url = "https://dice.example.com/mcp/server-card"
124+
catalog = AICatalog(entries=[server_card_entry(card, card_url)])
125+
126+
app = server.streamable_http_app() # MCP is served at /mcp
127+
mount_server_card(app, card, path="/mcp/server-card") # GET /mcp/server-card
128+
mount_ai_catalog(app, catalog) # GET /.well-known/ai-catalog.json
129+
```
130+
131+
Run it with any ASGI server, e.g. `uvicorn server:app --host 0.0.0.0 --port 8000`.
132+
133+
!!! note "Mount outside authentication"
134+
Pre-connection discovery happens before a session (and before any token
135+
exchange), so the card and catalog routes must be reachable **without
136+
authentication**. Mount them outside any auth middleware.
137+
138+
### Static hosting
139+
140+
You don't need a running ASGI app to publish discovery documents — both models
141+
serialize to spec-compliant JSON you can host as static files:
142+
143+
```python title="dump.py"
144+
card_json = card.model_dump_json(by_alias=True, exclude_none=True)
145+
catalog_json = catalog.model_dump_json(by_alias=True, exclude_none=True)
146+
```
147+
148+
Use `by_alias=True` so the `$schema` and `_meta` keys serialize with their
149+
wire-format names.
150+
151+
## Discovery HTTP semantics
152+
153+
Both [`mount_server_card`][mcp.server.experimental.server_card.mount_server_card]
154+
and [`mount_ai_catalog`][mcp.server.experimental.ai_catalog.mount_ai_catalog]
155+
serve their payloads through a shared discovery responder that applies a fixed
156+
set of headers (`DISCOVERY_HEADERS`):
157+
158+
| Header | Value | Why |
159+
| --- | --- | --- |
160+
| `Access-Control-Allow-Origin` | `*` | Lets browser-based clients read the document cross-origin. |
161+
| `Access-Control-Allow-Methods` | `GET` | Discovery is read-only. |
162+
| `Access-Control-Allow-Headers` | `Content-Type` | Permits the conditional-request / content headers. |
163+
| `Cache-Control` | `public, max-age=3600` | Documents are static; clients may cache for an hour. |
164+
165+
The card is served as `application/mcp-server-card+json` and the catalog as
166+
`application/ai-catalog+json`.
167+
168+
### ETags and conditional GET
169+
170+
Each response carries a **strong ETag** — the SHA-256 hash of the serialized body
171+
(`"<hex>"`). A client that has already fetched a document can revalidate cheaply
172+
by sending the ETag back in an `If-None-Match` header. If the body is unchanged
173+
the server replies `304 Not Modified` with an empty body, so only the headers
174+
travel over the wire:
175+
176+
```text title="Conditional GET"
177+
GET /.well-known/ai-catalog.json
178+
← 200 OK
179+
ETag: "9f2c…"
180+
Cache-Control: public, max-age=3600
181+
182+
GET /.well-known/ai-catalog.json
183+
If-None-Match: "9f2c…"
184+
← 304 Not Modified
185+
```
186+
187+
Both strong and weak (`W/"…"`) validators, comma-separated lists, and `*` are
188+
accepted in `If-None-Match`.
189+
190+
### Well-known location and fallback
191+
192+
Hosts publish their catalog at `AI_CATALOG_WELL_KNOWN_PATH`
193+
(`/.well-known/ai-catalog.json`). The MCP discovery extension also defines an
194+
MCP-scoped catalog at `MCP_CATALOG_WELL_KNOWN_PATH`
195+
(`/.well-known/mcp/catalog.json`), which is a structural subset of an AI Catalog.
196+
The client discovery helper tries the AI Catalog path first and falls back to the
197+
MCP-scoped path on a `404`.
198+
199+
## Discovering servers (client side)
200+
201+
### One call: catalog → cards
202+
203+
[`discover_server_cards`][mcp.client.experimental.server_card.discover_server_cards]
204+
does the whole flow: fetch the host's catalog (with the well-known fallback),
205+
then validate the Server Card for every MCP entry — fetched from the entry's
206+
`url` or read from inline `data`. Entries with other media types are ignored.
207+
208+
```python title="discover.py"
209+
import anyio
210+
211+
from mcp.client.experimental.server_card import discover_server_cards
212+
213+
214+
async def main() -> None:
215+
cards = await discover_server_cards("https://dice.example.com")
216+
for card in cards:
217+
print(card.name, card.version)
218+
for remote in card.remotes or []:
219+
print(" ", remote.type, remote.url, remote.supported_protocol_versions)
220+
221+
222+
anyio.run(main)
223+
```
224+
225+
You can pass `url` as a bare origin or any URL on the host (e.g. its `/mcp`
226+
endpoint) — the catalog is always resolved against the host root.
227+
228+
### Lower-level helpers
229+
230+
To inspect the catalog yourself, resolve the well-known URL with
231+
[`well_known_ai_catalog_url`][mcp.client.experimental.ai_catalog.well_known_ai_catalog_url],
232+
fetch it with
233+
[`fetch_ai_catalog`][mcp.client.experimental.ai_catalog.fetch_ai_catalog], and
234+
fetch individual cards with
235+
[`fetch_server_card`][mcp.client.experimental.server_card.fetch_server_card]:
236+
237+
```python title="discover_manual.py"
238+
from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url
239+
from mcp.client.experimental.server_card import fetch_server_card
240+
from mcp.shared.experimental.ai_catalog import MCP_SERVER_CARD_MEDIA_TYPE
241+
242+
243+
async def list_cards(origin: str) -> None:
244+
catalog = await fetch_ai_catalog(well_known_ai_catalog_url(origin))
245+
for entry in catalog.entries:
246+
if entry.media_type == MCP_SERVER_CARD_MEDIA_TYPE and entry.url is not None:
247+
card = await fetch_server_card(entry.url)
248+
print(card.name, "->", [r.url for r in card.remotes or []])
249+
```
250+
251+
A card you already have on disk can be validated with
252+
[`load_server_card`][mcp.client.experimental.server_card.load_server_card].
253+
254+
!!! warning "Discovery fetches untrusted hosts"
255+
Catalog and card URLs come from a remote document and may point anywhere,
256+
including other domains. The SDK rejects non-`http(s)` card URLs but imposes
257+
no network policy beyond that — loopback and intranet servers are legitimate
258+
discovery targets. When discovering hosts you don't control, pass an
259+
`http_client` that enforces your own policy (e.g. blocking private address
260+
ranges or capping redirects):
261+
262+
```python title="guarded.py"
263+
from mcp.client.experimental.server_card import discover_server_cards
264+
from mcp.shared._httpx_utils import create_mcp_http_client
265+
266+
267+
async def discover_guarded(origin: str) -> None:
268+
async with create_mcp_http_client() as http_client:
269+
cards = await discover_server_cards(origin, http_client=http_client)
270+
print(f"discovered {len(cards)} server card(s)")
271+
```
272+
273+
## Catalog identifiers
274+
275+
Every MCP entry in a catalog is identified by a `urn:air:` URN (the
276+
`AI_CATALOG_URN_PREFIX`). The identifier is derived from the card's reverse-DNS `name` by flipping the
277+
namespace to forward-DNS and appending the suffix:
278+
279+
```text
280+
card name: com.example/weather
281+
identifier: urn:air:example.com:weather
282+
```
283+
284+
[`server_card_entry`][mcp.server.experimental.ai_catalog.server_card_entry]
285+
computes this for you, so a hand-built catalog stays consistent with the cards it
286+
advertises.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ nav:
1818
- Concepts: concepts.md
1919
- Low-Level Server: low-level-server.md
2020
- Authorization: authorization.md
21+
- Server Cards: server-cards.md
2122
- Testing: testing.md
2223
- API Reference: api/
2324

0 commit comments

Comments
 (0)