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