Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 286 additions & 0 deletions docs/server-cards.md
Original file line number Diff line number Diff line change
@@ -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 `<streamable-http-url>/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
(`"<hex>"`). 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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down