From 8bbe7952c28e4adc5a38d3a657c2aef9909163a1 Mon Sep 17 00:00:00 2001 From: tadasant Date: Wed, 15 Jul 2026 23:14:47 +0000 Subject: [PATCH] docs: add Server Cards (SEP-2127) usage guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Authored via Claude Code, on behalf of @tadasant. --- docs/advanced/server-cards.md | 170 +++++++++++++++++++++++++++ docs_src/server_cards/__init__.py | 0 docs_src/server_cards/tutorial001.py | 36 ++++++ docs_src/server_cards/tutorial002.py | 29 +++++ docs_src/server_cards/tutorial003.py | 11 ++ docs_src/server_cards/tutorial004.py | 21 ++++ mkdocs.yml | 1 + 7 files changed, 268 insertions(+) create mode 100644 docs/advanced/server-cards.md create mode 100644 docs_src/server_cards/__init__.py create mode 100644 docs_src/server_cards/tutorial001.py create mode 100644 docs_src/server_cards/tutorial002.py create mode 100644 docs_src/server_cards/tutorial003.py create mode 100644 docs_src/server_cards/tutorial004.py diff --git a/docs/advanced/server-cards.md b/docs/advanced/server-cards.md new file mode 100644 index 0000000000..440dd10f39 --- /dev/null +++ b/docs/advanced/server-cards.md @@ -0,0 +1,170 @@ +# Server Cards & discovery + +A **Server Card** is a small static JSON document that describes a remote MCP +server — its identity, where it can be reached, and which protocol versions it +speaks — so a client can learn all of that *before* it connects. An **AI +Catalog** is the index that lists a host's cards at a well-known URL, so a client +that knows only a domain can discover the servers behind it. + +This is the SDK's implementation of [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/community/sep-guidelines.md) +and the companion AI Catalog discovery extension. + +!!! warning "Experimental" + + Server Cards and AI Catalogs are **experimental**. Everything on this page + lives under `mcp.server.experimental`, `mcp.client.experimental`, and + `mcp.shared.experimental`, and may change — or be removed — in any release, + without a deprecation cycle. Opt in deliberately, and pin your SDK version if + you ship on top of it. + +A card describes *connectivity*, not capability: it never lists tools, resources, +or prompts. Those stay subject to the normal runtime `list` calls after you +connect. The card only tells a client **how** to reach a server, not what it can +do once connected. + +## How discovery works + +```mermaid +sequenceDiagram + participant C as Client + participant H as Host (example.com) + C->>H: GET /.well-known/ai-catalog.json + H-->>C: AI Catalog { entries: [ { url, media_type } ] } + C->>H: GET the card URL from a catalog entry + H-->>C: Server Card { name, version, remotes[] } + C->>H: connect to remotes[].url (streamable-http / sse) +``` + +The client fetches the catalog, reads the entry for each MCP server, fetches that +server's card, and connects to one of the `remotes` the card advertises. + +## What's in a card + +| Field | Meaning | +| --- | --- | +| `name` | Reverse-DNS `namespace/name` identifier, e.g. `com.example/dice-roller`. | +| `version` | Exact server version (SHOULD be semver; ranges/wildcards are rejected). | +| `description` | One-line human summary (≤ 100 chars). | +| `title` | Optional display name. | +| `website_url` | Optional homepage / documentation link. | +| `repository` | Optional source-repository metadata. | +| `icons` | Optional sized icons for a UI. | +| `remotes` | The HTTP endpoints (`streamable-http` or `sse`) the server is reachable at. | +| `meta` | Optional `_meta` extension metadata, reverse-DNS namespaced. | + +`name`, `version`, and `description` are the only required fields. + +## Building and serving a card + +Build a card from a server's identity with `build_server_card`, then attach it to +the server's ASGI app with `mount_server_card` and advertise it in an AI Catalog +with `mount_ai_catalog`. `build_server_card` takes any object exposing the +standard identity attributes — the low-level `Server` here, but a high-level +`MCPServer` works too: + +```python title="server.py" +--8<-- "docs_src/server_cards/tutorial001.py" +``` + +`build_server_card` reads `version`, `title`, `description`, `website_url`, and +`icons` off the server, and raises `ValueError` if `version` or `description` is +unset — a card cannot exist without them. The `name` you pass is the reverse-DNS +identifier and is validated against the `namespace/name` pattern. + +Because discovery happens *before* authentication, mount both routes **outside** +any auth middleware — a client must be able to read them unauthenticated. If you +mount the MCP endpoint at a non-default path, pass a matching `path` to +`mount_server_card` (the convention is `/server-card`); the +catalog entry carries the real URL, so any reachable path works. + +For mounting the MCP app itself into a larger Starlette/FastAPI application, see +[Add to an existing app](../run/asgi.md). + +## Hosting a card as a static file + +Nothing requires a running server. A card and catalog are plain Pydantic models, +so you can serialize them and serve the JSON from any web server or CDN: + +```python title="publish.py" +--8<-- "docs_src/server_cards/tutorial002.py" +``` + +Serialize with `by_alias=True` so the wire names (`$schema`, `_meta`) are emitted, +and `exclude_none=True` so unset optional fields are dropped. + +## Discovery HTTP semantics + +The routes `mount_server_card` and `mount_ai_catalog` install serve their payload +with a fixed set of discovery headers (`DISCOVERY_HEADERS`): + +| Header | Value | Why | +| --- | --- | --- | +| `Access-Control-Allow-Origin` | `*` | Browser clients fetch cards cross-origin. | +| `Access-Control-Allow-Methods` | `GET` | Discovery is read-only. | +| `Access-Control-Allow-Headers` | `Content-Type` | Allows the negotiated `Accept`/content type. | +| `Cache-Control` | `public, max-age=3600` | Cards change rarely; let clients and CDNs cache. | + +The card route responds with `application/mcp-server-card+json`; the catalog route +with `application/ai-catalog+json`. + +Each response also carries a **strong `ETag`** — the SHA-256 of the serialized +body. A client that sends `If-None-Match` with the stored ETag gets a `304 Not +Modified` when the document is unchanged, so an unchanged card costs no payload: + +```text +GET /.well-known/ai-catalog.json +If-None-Match: "6b86b273ff34fce19d6b804eff5a3f57…" + +304 Not Modified +``` + +The catalog is served from the well-known path `/.well-known/ai-catalog.json`. +Clients probe there first and fall back to the MCP-scoped +`/.well-known/mcp/catalog.json` on a 404, so either location is discoverable. + +## Discovering servers from a client + +The one-call flow takes a host URL and returns validated `ServerCard` objects for +every MCP server the host advertises: + +```python title="client.py" +--8<-- "docs_src/server_cards/tutorial003.py" +``` + +`discover_server_cards` resolves the well-known catalog (with the +`/.well-known/mcp/catalog.json` fallback), then fetches and validates each +referenced card. Malformed documents raise `pydantic.ValidationError`; a card +that omits `$schema` is tolerated and defaulted to the current v1 schema URL. + +If you want to inspect the catalog before fetching cards, compose the lower-level +helpers — `well_known_ai_catalog_url`, `fetch_ai_catalog`, and +`fetch_server_card`: + +```python title="client_lowlevel.py" +--8<-- "docs_src/server_cards/tutorial004.py" +``` + +!!! warning "Discovery fetches untrusted URLs" + + A catalog is remote input, and its entries can point a client at **any** + `http(s)` URL, including other domains. The SDK validates the scheme but + imposes no other network policy — loopback and intranet servers are + legitimate discovery targets. When discovering hosts you do not fully trust, + pass an `http_client` that enforces your own policy (timeouts, capped + redirects, blocked private address ranges). To read a card straight from disk + instead of over the network, use `load_server_card`. + +## Catalog identifiers + +Each MCP entry in a catalog is identified by a `urn:air:` URN derived from the +card's `name`. The reverse-DNS namespace is flipped to forward-DNS and the name +suffix appended: + +| Card `name` | Catalog identifier | +| --- | --- | +| `com.example/weather` | `urn:air:example.com:weather` | +| `example/dice` | `urn:air:example:dice` | + +`server_card_entry` computes this for you, and fills the entry's display name, +description, and version from the card — so a catalog stays consistent with the +cards it points at. diff --git a/docs_src/server_cards/__init__.py b/docs_src/server_cards/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/server_cards/tutorial001.py b/docs_src/server_cards/tutorial001.py new file mode 100644 index 0000000000..85ed9072ba --- /dev/null +++ b/docs_src/server_cards/tutorial001.py @@ -0,0 +1,36 @@ +from mcp.server.experimental.ai_catalog import mount_ai_catalog, server_card_entry +from mcp.server.experimental.server_card import build_server_card, mount_server_card +from mcp.server.lowlevel import Server +from mcp.shared.experimental.ai_catalog import AICatalog +from mcp.shared.experimental.server_card import Remote, Repository + +# The card's identity is read from the server: version and description are +# required (a card without them cannot be built), title/website/icons are copied +# if set. +server = Server( + "dice-roller", + version="1.0.0", + title="Dice Roller", + description="Rolls dice for tabletop games.", + website_url="https://dice.example.com", +) + +# `name` is the reverse-DNS `namespace/name` identifier, passed explicitly +# because the server's display name is free-form. `remotes` advertises where the +# server can actually be reached. +card = build_server_card( + server, + name="com.example/dice-roller", + remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")], + repository=Repository(url="https://github.com/example/dice", source="github"), +) + +# Serve the card next to the MCP endpoint, and advertise it in the host's AI +# Catalog at `/.well-known/ai-catalog.json`. The catalog entry points at the +# absolute URL the card is served from. +app = server.streamable_http_app() +mount_server_card(app, card, path="/mcp/server-card") + +card_url = "https://dice.example.com/mcp/server-card" +catalog = AICatalog(entries=[server_card_entry(card, card_url)]) +mount_ai_catalog(app, catalog) diff --git a/docs_src/server_cards/tutorial002.py b/docs_src/server_cards/tutorial002.py new file mode 100644 index 0000000000..ac23578e40 --- /dev/null +++ b/docs_src/server_cards/tutorial002.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from mcp.server.experimental.ai_catalog import server_card_entry +from mcp.shared.experimental.ai_catalog import AICatalog +from mcp.shared.experimental.server_card import Remote, ServerCard + +# A card can be built directly, without a running server — useful for +# publishing it as a static file behind any web server or CDN. +card = ServerCard( + name="com.example/dice-roller", + version="1.0.0", + description="Rolls dice for tabletop games.", + title="Dice Roller", + remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")], +) +catalog = AICatalog(entries=[server_card_entry(card, "https://dice.example.com/server-card.json")]) + +# `by_alias=True` emits the wire names (`$schema`, `_meta`); `exclude_none=True` +# drops unset optional fields. +card_json = card.model_dump_json(by_alias=True, exclude_none=True) +catalog_json = catalog.model_dump_json(by_alias=True, exclude_none=True) + + +def write_static_site(directory: Path) -> None: + """Write the card and the well-known catalog under `directory`.""" + (directory / "server-card.json").write_text(card_json) + well_known = directory / ".well-known" + well_known.mkdir(parents=True, exist_ok=True) + (well_known / "ai-catalog.json").write_text(catalog_json) diff --git a/docs_src/server_cards/tutorial003.py b/docs_src/server_cards/tutorial003.py new file mode 100644 index 0000000000..880fdac1f3 --- /dev/null +++ b/docs_src/server_cards/tutorial003.py @@ -0,0 +1,11 @@ +from mcp.client.experimental.server_card import discover_server_cards + + +async def main() -> None: + # Fetches the host's AI Catalog from `/.well-known/ai-catalog.json` (falling + # back to `/.well-known/mcp/catalog.json` on a 404), then validates the + # Server Card of every MCP entry it references. + for card in await discover_server_cards("https://dice.example.com"): + print(card.name, card.version, "-", card.description) + for remote in card.remotes or []: + print(" ", remote.type, remote.url, remote.supported_protocol_versions) diff --git a/docs_src/server_cards/tutorial004.py b/docs_src/server_cards/tutorial004.py new file mode 100644 index 0000000000..bc94c3d86d --- /dev/null +++ b/docs_src/server_cards/tutorial004.py @@ -0,0 +1,21 @@ +import httpx + +from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url +from mcp.client.experimental.server_card import fetch_server_card +from mcp.shared.experimental.ai_catalog import MCP_SERVER_CARD_MEDIA_TYPE + + +async def main() -> None: + # The lower-level building blocks, when you want to inspect the catalog + # before fetching cards. Pass your own `http_client` to enforce a network + # policy (timeouts, redirect caps, blocking private address ranges) when + # discovering hosts you do not fully trust. + async with httpx.AsyncClient() as http_client: + catalog_url = well_known_ai_catalog_url("https://dice.example.com") + catalog = await fetch_ai_catalog(catalog_url, http_client=http_client) + + for entry in catalog.entries: + if entry.media_type != MCP_SERVER_CARD_MEDIA_TYPE or entry.url is None: + continue + card = await fetch_server_card(entry.url, http_client=http_client) + print(entry.identifier, "->", card.name) diff --git a/mkdocs.yml b/mkdocs.yml index 5b3f777994..5f5570b9ee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,7 @@ nav: - Middleware: advanced/middleware.md - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md + - Server Cards & discovery: advanced/server-cards.md - Troubleshooting: troubleshooting.md - Migration Guide: migration.md - API Reference: api/