|
| 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. |
0 commit comments