Skip to content
Draft
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
170 changes: 170 additions & 0 deletions docs/advanced/server-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# 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/blob/main/docs/community/sep-guidelines.md)
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, media_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`) 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`.
Clients probe there first and fall back to the MCP-scoped
`/.well-known/mcp/catalog.json` on a 404, so either location is discoverable.

## 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 (with the
`/.well-known/mcp/catalog.json` fallback), 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:weather` |
| `example/dice` | `urn:air:example:dice` |

`server_card_entry` computes this for you, and fills the entry's display name,
description, and version from the card — so a catalog stays consistent with the
cards it points at.
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(entries=[server_card_entry(card, card_url)])
mount_ai_catalog(app, catalog)
29 changes: 29 additions & 0 deletions docs_src/server_cards/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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(entries=[server_card_entry(card, "https://dice.example.com/server-card.json")])

# `by_alias=True` emits the wire names (`$schema`, `_meta`); `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)
11 changes: 11 additions & 0 deletions docs_src/server_cards/tutorial003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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` (falling
# back to `/.well-known/mcp/catalog.json` on a 404), 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, *, well_known_path: str = AI_CATALOG_WELL_KNOWN_PATH) -> 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}", 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