diff --git a/docs/server-cards.md b/docs/server-cards.md new file mode 100644 index 000000000..8fe71edfa --- /dev/null +++ b/docs/server-cards.md @@ -0,0 +1,286 @@ +# Server Cards & AI Catalog Discovery + +!!! warning "Experimental" + Server Cards and AI Catalog support live under `mcp.shared.experimental`, + `mcp.server.experimental`, and `mcp.client.experimental`. **These APIs are + experimental and may change without notice** between releases. Pin your MCP + version if you depend on them. + +A **Server Card** ([SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol)) +is a small, static JSON document that describes a remote MCP server — its +identity, transport endpoints, and supported protocol versions — so a client can +learn *how to connect* **before** it initializes a session. A server publishes +its card at a URL and advertises it through an **AI Catalog**, a JSON index that +hosts serve from a well-known path for zero-configuration discovery. + +A Server Card describes remote connectivity only. It does **not** list a server's +primitives (tools, resources, prompts) — those remain subject to the usual +runtime listing once a session is established. + +```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 -> card URLs) + C->>H: GET /mcp/server-card + H-->>C: Server Card (remotes, protocol versions) + C->>H: initialize() against a remote's URL +``` + +## What's in a Server Card + +The [`ServerCard`][mcp.shared.experimental.server_card.ServerCard] model +(re-exported from `mcp.shared.experimental.server_card`) carries: + +| Field | Purpose | +| --- | --- | +| `name` | Reverse-DNS `namespace/name` identifier, e.g. `io.modelcontextprotocol/everything`. | +| `version` | Exact server version. Ranges/wildcards are rejected. | +| `description` | Short human-readable summary (≤ 100 chars). | +| `title`, `website_url`, `icons`, `repository` | Optional display / source metadata. | +| `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`. | + +## Building a card (server side) + +[`build_server_card`][mcp.server.experimental.server_card.build_server_card] +derives a card from a running server's identity, so you only supply the +reverse-DNS `name` and the remote endpoints to advertise. Pass a low-level +[`Server`][mcp.server.lowlevel.Server] — or anything exposing the standard +identity attributes (`name`, `version`, `title`, `description`, `website_url`, +`icons`). The server's `version` and `description` must be set, or it raises +`ValueError`. + +```python title="build_card.py" +from mcp.server.lowlevel import Server +from mcp.server.experimental.server_card import build_server_card +from mcp.shared.experimental.server_card import Input, KeyValueInput, Remote + +server = Server( + "dice-roller", + version="1.2.0", + title="Dice Roller", + description="Rolls dice and returns the results.", + website_url="https://dice.example.com", +) + +card = build_server_card( + server, + name="com.example/dice-roller", + remotes=[ + Remote( + type="streamable-http", + url="https://dice.example.com/mcp", + headers=[ + KeyValueInput( + name="Authorization", + value="Bearer {token}", + variables={"token": Input(is_secret=True, is_required=True)}, + ) + ], + supported_protocol_versions=["2025-06-18"], + ) + ], +) +``` + +## Serving the card and AI Catalog + +A card is only discoverable once it is referenced from an AI Catalog — clients +read a card's URL from a catalog entry rather than guessing it. Mount both onto +the Starlette app returned by `streamable_http_app()`: + +* [`mount_server_card`][mcp.server.experimental.server_card.mount_server_card] + adds the card route. The recommended path is `/server-card` + — `/mcp/server-card` when the MCP endpoint is the default `/mcp`. +* [`server_card_entry`][mcp.server.experimental.ai_catalog.server_card_entry] + builds the catalog entry that points at the card's absolute URL, and + [`mount_ai_catalog`][mcp.server.experimental.ai_catalog.mount_ai_catalog] + serves the catalog at `/.well-known/ai-catalog.json`. + +```python title="server.py" +from mcp.server.lowlevel import Server +from mcp.server.experimental.server_card import build_server_card, mount_server_card +from mcp.server.experimental.ai_catalog import mount_ai_catalog, server_card_entry +from mcp.shared.experimental.ai_catalog import AICatalog +from mcp.shared.experimental.server_card import Remote + +server = Server( + "dice-roller", + version="1.2.0", + title="Dice Roller", + description="Rolls dice and returns the results.", +) + +# ... register the server's tools, resources and prompts as usual ... + +card = build_server_card( + server, + name="com.example/dice-roller", + remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")], +) + +card_url = "https://dice.example.com/mcp/server-card" +catalog = AICatalog(entries=[server_card_entry(card, card_url)]) + +app = server.streamable_http_app() # MCP is served at /mcp +mount_server_card(app, card, path="/mcp/server-card") # GET /mcp/server-card +mount_ai_catalog(app, catalog) # GET /.well-known/ai-catalog.json +``` + +Run it with any ASGI server, e.g. `uvicorn server:app --host 0.0.0.0 --port 8000`. + +!!! note "Mount outside authentication" + Pre-connection discovery happens before a session (and before any token + exchange), so the card and catalog routes must be reachable **without + authentication**. Mount them outside any auth middleware. + +### Static hosting + +You don't need a running ASGI app to publish discovery documents — both models +serialize to spec-compliant JSON you can host as static files: + +```python title="dump.py" +card_json = card.model_dump_json(by_alias=True, exclude_none=True) +catalog_json = catalog.model_dump_json(by_alias=True, exclude_none=True) +``` + +Use `by_alias=True` so the `$schema` and `_meta` keys serialize with their +wire-format names. + +## Discovery HTTP semantics + +Both [`mount_server_card`][mcp.server.experimental.server_card.mount_server_card] +and [`mount_ai_catalog`][mcp.server.experimental.ai_catalog.mount_ai_catalog] +serve their payloads through a shared discovery responder that applies a fixed +set of headers (`DISCOVERY_HEADERS`): + +| Header | Value | Why | +| --- | --- | --- | +| `Access-Control-Allow-Origin` | `*` | Lets browser-based clients read the document cross-origin. | +| `Access-Control-Allow-Methods` | `GET` | Discovery is read-only. | +| `Access-Control-Allow-Headers` | `Content-Type` | Permits the conditional-request / content headers. | +| `Cache-Control` | `public, max-age=3600` | Documents are static; clients may cache for an hour. | + +The card is served as `application/mcp-server-card+json` and the catalog as +`application/ai-catalog+json`. + +### ETags and conditional GET + +Each response carries a **strong ETag** — the SHA-256 hash of the serialized body +(`""`). A client that has already fetched a document can revalidate cheaply +by sending the ETag back in an `If-None-Match` header. If the body is unchanged +the server replies `304 Not Modified` with an empty body, so only the headers +travel over the wire: + +```text title="Conditional GET" +GET /.well-known/ai-catalog.json +← 200 OK + ETag: "9f2c…" + Cache-Control: public, max-age=3600 + +GET /.well-known/ai-catalog.json + If-None-Match: "9f2c…" +← 304 Not Modified +``` + +Both strong and weak (`W/"…"`) validators, comma-separated lists, and `*` are +accepted in `If-None-Match`. + +### Well-known location and fallback + +Hosts publish their catalog at `AI_CATALOG_WELL_KNOWN_PATH` +(`/.well-known/ai-catalog.json`). The MCP discovery extension also defines an +MCP-scoped catalog at `MCP_CATALOG_WELL_KNOWN_PATH` +(`/.well-known/mcp/catalog.json`), which is a structural subset of an AI Catalog. +The client discovery helper tries the AI Catalog path first and falls back to the +MCP-scoped path on a `404`. + +## Discovering servers (client side) + +### One call: catalog → cards + +[`discover_server_cards`][mcp.client.experimental.server_card.discover_server_cards] +does the whole flow: fetch the host's catalog (with the well-known fallback), +then validate the Server Card for every MCP entry — fetched from the entry's +`url` or read from inline `data`. Entries with other media types are ignored. + +```python title="discover.py" +import anyio + +from mcp.client.experimental.server_card import discover_server_cards + + +async def main() -> None: + cards = await discover_server_cards("https://dice.example.com") + for card in cards: + print(card.name, card.version) + for remote in card.remotes or []: + print(" ", remote.type, remote.url, remote.supported_protocol_versions) + + +anyio.run(main) +``` + +You can pass `url` as a bare origin or any URL on the host (e.g. its `/mcp` +endpoint) — the catalog is always resolved against the host root. + +### Lower-level helpers + +To inspect the catalog yourself, resolve the well-known URL with +[`well_known_ai_catalog_url`][mcp.client.experimental.ai_catalog.well_known_ai_catalog_url], +fetch it with +[`fetch_ai_catalog`][mcp.client.experimental.ai_catalog.fetch_ai_catalog], and +fetch individual cards with +[`fetch_server_card`][mcp.client.experimental.server_card.fetch_server_card]: + +```python title="discover_manual.py" +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 list_cards(origin: str) -> None: + catalog = await fetch_ai_catalog(well_known_ai_catalog_url(origin)) + for entry in catalog.entries: + if entry.media_type == MCP_SERVER_CARD_MEDIA_TYPE and entry.url is not None: + card = await fetch_server_card(entry.url) + print(card.name, "->", [r.url for r in card.remotes or []]) +``` + +A card you already have on disk can be validated with +[`load_server_card`][mcp.client.experimental.server_card.load_server_card]. + +!!! warning "Discovery fetches untrusted hosts" + Catalog and card URLs come from a remote document and may point anywhere, + including other domains. The SDK rejects non-`http(s)` card URLs but imposes + no network policy beyond that — loopback and intranet servers are legitimate + discovery targets. When discovering hosts you don't control, pass an + `http_client` that enforces your own policy (e.g. blocking private address + ranges or capping redirects): + + ```python title="guarded.py" + from mcp.client.experimental.server_card import discover_server_cards + from mcp.shared._httpx_utils import create_mcp_http_client + + + async def discover_guarded(origin: str) -> None: + async with create_mcp_http_client() as http_client: + cards = await discover_server_cards(origin, http_client=http_client) + print(f"discovered {len(cards)} server card(s)") + ``` + +## Catalog identifiers + +Every MCP entry in a catalog is identified by a `urn:air:` URN (the +`AI_CATALOG_URN_PREFIX`). The identifier is derived from the card's reverse-DNS `name` by flipping the +namespace to forward-DNS and appending the suffix: + +```text +card name: com.example/weather +identifier: urn:air:example.com:weather +``` + +[`server_card_entry`][mcp.server.experimental.ai_catalog.server_card_entry] +computes this for you, so a hand-built catalog stays consistent with the cards it +advertises. diff --git a/mkdocs.yml b/mkdocs.yml index cb89faf0f..ab335b676 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ nav: - Concepts: concepts.md - Low-Level Server: low-level-server.md - Authorization: authorization.md + - Server Cards: server-cards.md - Testing: testing.md - API Reference: api/