Skip to content

Commit daaee6a

Browse files
Merge pull request #1 from tadasant/docs/server-cards-guide
docs: add Server Cards (SEP-2127) usage guide
2 parents 9f42184 + 8bbe795 commit daaee6a

7 files changed

Lines changed: 268 additions & 0 deletions

File tree

docs/advanced/server-cards.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Server Cards & discovery
2+
3+
A **Server Card** is a small static JSON document that describes a remote MCP
4+
server — its identity, where it can be reached, and which protocol versions it
5+
speaks — so a client can learn all of that *before* it connects. An **AI
6+
Catalog** is the index that lists a host's cards at a well-known URL, so a client
7+
that knows only a domain can discover the servers behind it.
8+
9+
This is the SDK's implementation of [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/community/sep-guidelines.md)
10+
and the companion AI Catalog discovery extension.
11+
12+
!!! warning "Experimental"
13+
14+
Server Cards and AI Catalogs are **experimental**. Everything on this page
15+
lives under `mcp.server.experimental`, `mcp.client.experimental`, and
16+
`mcp.shared.experimental`, and may change — or be removed — in any release,
17+
without a deprecation cycle. Opt in deliberately, and pin your SDK version if
18+
you ship on top of it.
19+
20+
A card describes *connectivity*, not capability: it never lists tools, resources,
21+
or prompts. Those stay subject to the normal runtime `list` calls after you
22+
connect. The card only tells a client **how** to reach a server, not what it can
23+
do once connected.
24+
25+
## How discovery works
26+
27+
```mermaid
28+
sequenceDiagram
29+
participant C as Client
30+
participant H as Host (example.com)
31+
C->>H: GET /.well-known/ai-catalog.json
32+
H-->>C: AI Catalog { entries: [ { url, media_type } ] }
33+
C->>H: GET the card URL from a catalog entry
34+
H-->>C: Server Card { name, version, remotes[] }
35+
C->>H: connect to remotes[].url (streamable-http / sse)
36+
```
37+
38+
The client fetches the catalog, reads the entry for each MCP server, fetches that
39+
server's card, and connects to one of the `remotes` the card advertises.
40+
41+
## What's in a card
42+
43+
| Field | Meaning |
44+
| --- | --- |
45+
| `name` | Reverse-DNS `namespace/name` identifier, e.g. `com.example/dice-roller`. |
46+
| `version` | Exact server version (SHOULD be semver; ranges/wildcards are rejected). |
47+
| `description` | One-line human summary (≤ 100 chars). |
48+
| `title` | Optional display name. |
49+
| `website_url` | Optional homepage / documentation link. |
50+
| `repository` | Optional source-repository metadata. |
51+
| `icons` | Optional sized icons for a UI. |
52+
| `remotes` | The HTTP endpoints (`streamable-http` or `sse`) the server is reachable at. |
53+
| `meta` | Optional `_meta` extension metadata, reverse-DNS namespaced. |
54+
55+
`name`, `version`, and `description` are the only required fields.
56+
57+
## Building and serving a card
58+
59+
Build a card from a server's identity with `build_server_card`, then attach it to
60+
the server's ASGI app with `mount_server_card` and advertise it in an AI Catalog
61+
with `mount_ai_catalog`. `build_server_card` takes any object exposing the
62+
standard identity attributes — the low-level `Server` here, but a high-level
63+
`MCPServer` works too:
64+
65+
```python title="server.py"
66+
--8<-- "docs_src/server_cards/tutorial001.py"
67+
```
68+
69+
`build_server_card` reads `version`, `title`, `description`, `website_url`, and
70+
`icons` off the server, and raises `ValueError` if `version` or `description` is
71+
unset — a card cannot exist without them. The `name` you pass is the reverse-DNS
72+
identifier and is validated against the `namespace/name` pattern.
73+
74+
Because discovery happens *before* authentication, mount both routes **outside**
75+
any auth middleware — a client must be able to read them unauthenticated. If you
76+
mount the MCP endpoint at a non-default path, pass a matching `path` to
77+
`mount_server_card` (the convention is `<streamable-http-url>/server-card`); the
78+
catalog entry carries the real URL, so any reachable path works.
79+
80+
For mounting the MCP app itself into a larger Starlette/FastAPI application, see
81+
[Add to an existing app](../run/asgi.md).
82+
83+
## Hosting a card as a static file
84+
85+
Nothing requires a running server. A card and catalog are plain Pydantic models,
86+
so you can serialize them and serve the JSON from any web server or CDN:
87+
88+
```python title="publish.py"
89+
--8<-- "docs_src/server_cards/tutorial002.py"
90+
```
91+
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.
94+
95+
## Discovery HTTP semantics
96+
97+
The routes `mount_server_card` and `mount_ai_catalog` install serve their payload
98+
with a fixed set of discovery headers (`DISCOVERY_HEADERS`):
99+
100+
| Header | Value | Why |
101+
| --- | --- | --- |
102+
| `Access-Control-Allow-Origin` | `*` | Browser clients fetch cards cross-origin. |
103+
| `Access-Control-Allow-Methods` | `GET` | Discovery is read-only. |
104+
| `Access-Control-Allow-Headers` | `Content-Type` | Allows the negotiated `Accept`/content type. |
105+
| `Cache-Control` | `public, max-age=3600` | Cards change rarely; let clients and CDNs cache. |
106+
107+
The card route responds with `application/mcp-server-card+json`; the catalog route
108+
with `application/ai-catalog+json`.
109+
110+
Each response also carries a **strong `ETag`** — the SHA-256 of the serialized
111+
body. A client that sends `If-None-Match` with the stored ETag gets a `304 Not
112+
Modified` when the document is unchanged, so an unchanged card costs no payload:
113+
114+
```text
115+
GET /.well-known/ai-catalog.json
116+
If-None-Match: "6b86b273ff34fce19d6b804eff5a3f57…"
117+
118+
304 Not Modified
119+
```
120+
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.
124+
125+
## Discovering servers from a client
126+
127+
The one-call flow takes a host URL and returns validated `ServerCard` objects for
128+
every MCP server the host advertises:
129+
130+
```python title="client.py"
131+
--8<-- "docs_src/server_cards/tutorial003.py"
132+
```
133+
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.
138+
139+
If you want to inspect the catalog before fetching cards, compose the lower-level
140+
helpers — `well_known_ai_catalog_url`, `fetch_ai_catalog`, and
141+
`fetch_server_card`:
142+
143+
```python title="client_lowlevel.py"
144+
--8<-- "docs_src/server_cards/tutorial004.py"
145+
```
146+
147+
!!! warning "Discovery fetches untrusted URLs"
148+
149+
A catalog is remote input, and its entries can point a client at **any**
150+
`http(s)` URL, including other domains. The SDK validates the scheme but
151+
imposes no other network policy — loopback and intranet servers are
152+
legitimate discovery targets. When discovering hosts you do not fully trust,
153+
pass an `http_client` that enforces your own policy (timeouts, capped
154+
redirects, blocked private address ranges). To read a card straight from disk
155+
instead of over the network, use `load_server_card`.
156+
157+
## Catalog identifiers
158+
159+
Each MCP entry in a catalog is identified by a `urn:air:` URN derived from the
160+
card's `name`. The reverse-DNS namespace is flipped to forward-DNS and the name
161+
suffix appended:
162+
163+
| Card `name` | Catalog identifier |
164+
| --- | --- |
165+
| `com.example/weather` | `urn:air:example.com:weather` |
166+
| `example/dice` | `urn:air:example:dice` |
167+
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.

docs_src/server_cards/__init__.py

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from mcp.server.experimental.ai_catalog import mount_ai_catalog, server_card_entry
2+
from mcp.server.experimental.server_card import build_server_card, mount_server_card
3+
from mcp.server.lowlevel import Server
4+
from mcp.shared.experimental.ai_catalog import AICatalog
5+
from mcp.shared.experimental.server_card import Remote, Repository
6+
7+
# The card's identity is read from the server: version and description are
8+
# required (a card without them cannot be built), title/website/icons are copied
9+
# if set.
10+
server = Server(
11+
"dice-roller",
12+
version="1.0.0",
13+
title="Dice Roller",
14+
description="Rolls dice for tabletop games.",
15+
website_url="https://dice.example.com",
16+
)
17+
18+
# `name` is the reverse-DNS `namespace/name` identifier, passed explicitly
19+
# because the server's display name is free-form. `remotes` advertises where the
20+
# server can actually be reached.
21+
card = build_server_card(
22+
server,
23+
name="com.example/dice-roller",
24+
remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")],
25+
repository=Repository(url="https://github.com/example/dice", source="github"),
26+
)
27+
28+
# Serve the card next to the MCP endpoint, and advertise it in the host's AI
29+
# Catalog at `/.well-known/ai-catalog.json`. The catalog entry points at the
30+
# absolute URL the card is served from.
31+
app = server.streamable_http_app()
32+
mount_server_card(app, card, path="/mcp/server-card")
33+
34+
card_url = "https://dice.example.com/mcp/server-card"
35+
catalog = AICatalog(entries=[server_card_entry(card, card_url)])
36+
mount_ai_catalog(app, catalog)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from pathlib import Path
2+
3+
from mcp.server.experimental.ai_catalog import server_card_entry
4+
from mcp.shared.experimental.ai_catalog import AICatalog
5+
from mcp.shared.experimental.server_card import Remote, ServerCard
6+
7+
# A card can be built directly, without a running server — useful for
8+
# publishing it as a static file behind any web server or CDN.
9+
card = ServerCard(
10+
name="com.example/dice-roller",
11+
version="1.0.0",
12+
description="Rolls dice for tabletop games.",
13+
title="Dice Roller",
14+
remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")],
15+
)
16+
catalog = AICatalog(entries=[server_card_entry(card, "https://dice.example.com/server-card.json")])
17+
18+
# `by_alias=True` emits the wire names (`$schema`, `_meta`); `exclude_none=True`
19+
# drops unset optional fields.
20+
card_json = card.model_dump_json(by_alias=True, exclude_none=True)
21+
catalog_json = catalog.model_dump_json(by_alias=True, exclude_none=True)
22+
23+
24+
def write_static_site(directory: Path) -> None:
25+
"""Write the card and the well-known catalog under `directory`."""
26+
(directory / "server-card.json").write_text(card_json)
27+
well_known = directory / ".well-known"
28+
well_known.mkdir(parents=True, exist_ok=True)
29+
(well_known / "ai-catalog.json").write_text(catalog_json)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from mcp.client.experimental.server_card import discover_server_cards
2+
3+
4+
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.
8+
for card in await discover_server_cards("https://dice.example.com"):
9+
print(card.name, card.version, "-", card.description)
10+
for remote in card.remotes or []:
11+
print(" ", remote.type, remote.url, remote.supported_protocol_versions)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import httpx
2+
3+
from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url
4+
from mcp.client.experimental.server_card import fetch_server_card
5+
from mcp.shared.experimental.ai_catalog import MCP_SERVER_CARD_MEDIA_TYPE
6+
7+
8+
async def main() -> None:
9+
# The lower-level building blocks, when you want to inspect the catalog
10+
# before fetching cards. Pass your own `http_client` to enforce a network
11+
# policy (timeouts, redirect caps, blocking private address ranges) when
12+
# discovering hosts you do not fully trust.
13+
async with httpx.AsyncClient() as http_client:
14+
catalog_url = well_known_ai_catalog_url("https://dice.example.com")
15+
catalog = await fetch_ai_catalog(catalog_url, http_client=http_client)
16+
17+
for entry in catalog.entries:
18+
if entry.media_type != MCP_SERVER_CARD_MEDIA_TYPE or entry.url is None:
19+
continue
20+
card = await fetch_server_card(entry.url, http_client=http_client)
21+
print(entry.identifier, "->", card.name)

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ nav:
6767
- Middleware: advanced/middleware.md
6868
- Extensions: advanced/extensions.md
6969
- MCP Apps: advanced/apps.md
70+
- Server Cards & discovery: advanced/server-cards.md
7071
- Troubleshooting: troubleshooting.md
7172
- Migration Guide: migration.md
7273
- API Reference: api/

0 commit comments

Comments
 (0)