Skip to content
169 changes: 169 additions & 0 deletions docs/advanced/server-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# 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/pull/2127)
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, 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 `<streamable-http-url>/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`, `type`) 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`.

## 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, 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:mcp:weather` |
| `example/dice` | `urn:air:example:mcp:dice` |

`server_card_entry` computes this for you and emits only the identifier, type,
and card URL. Human-readable fields remain on the card so the catalog cannot
drift from it.
Empty file.
36 changes: 36 additions & 0 deletions docs_src/server_cards/tutorial001.py
Original file line number Diff line number Diff line change
@@ -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(spec_version="1.0", entries=[server_card_entry(card, card_url)])
mount_ai_catalog(app, catalog)
32 changes: 32 additions & 0 deletions docs_src/server_cards/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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(
spec_version="1.0",
entries=[server_card_entry(card, "https://dice.example.com/server-card.json")],
)

# `by_alias=True` emits the wire names (`$schema`, `_meta`, `type`);
# `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)
10 changes: 10 additions & 0 deletions docs_src/server_cards/tutorial003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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`, 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)
21 changes: 21 additions & 0 deletions docs_src/server_cards/tutorial004.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import httpx2

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 httpx2.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)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/client/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Experimental client-side MCP features.

WARNING: These APIs are experimental and may change without notice.
"""
67 changes: 67 additions & 0 deletions src/mcp/client/experimental/ai_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Ingest AI Catalogs.

WARNING: These APIs are experimental and may change without notice.

A client discovers the AI artifacts a host advertises by fetching its catalog
from the well-known location::

from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url

catalog = await fetch_ai_catalog(well_known_ai_catalog_url("https://dice.example.com"))
for entry in catalog.entries:
print(entry.identifier, entry.media_type, entry.url)

For the MCP-specific flow — fetch the catalog and the Server Cards it
advertises in one call — see
``mcp.client.experimental.server_card.discover_server_cards``.
"""

from __future__ import annotations

from urllib.parse import urljoin, urlsplit

import httpx2

from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared.experimental.ai_catalog.types import (
AI_CATALOG_MEDIA_TYPE,
AI_CATALOG_WELL_KNOWN_PATH,
AICatalog,
)

__all__ = ["well_known_ai_catalog_url", "fetch_ai_catalog"]


def well_known_ai_catalog_url(url: str) -> str:
"""Resolve the well-known AI Catalog URL for a server's origin.

Accepts either a bare origin (``https://example.com``) or any URL on the
server (e.g. its ``/mcp`` endpoint); the catalog lives at the host root.

Raises:
ValueError: If ``url`` is not an absolute http(s) URL.
"""
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError(f"Expected an absolute http(s) URL, got {url!r}")
return urljoin(f"{parts.scheme}://{parts.netloc}", AI_CATALOG_WELL_KNOWN_PATH)


async def fetch_ai_catalog(url: str, *, http_client: httpx2.AsyncClient | None = None) -> AICatalog:
"""Fetch and validate the AI Catalog at ``url``.

``url`` is fetched as-is — catalogs are location-independent; use
:func:`well_known_ai_catalog_url` to resolve a host's conventional
location. Pass an existing ``http_client`` to reuse connection pooling /
auth, otherwise a short-lived client with MCP defaults is used.

Raises:
httpx2.HTTPError: If the request fails or returns a non-2xx status.
pydantic.ValidationError: If the document is not a valid AI Catalog.
"""
if http_client is None:
async with create_mcp_http_client() as client:
return await fetch_ai_catalog(url, http_client=client)
response = await http_client.get(url, headers={"Accept": f"{AI_CATALOG_MEDIA_TYPE}, application/json"})
response.raise_for_status()
return AICatalog.model_validate(response.json())
Loading
Loading