-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Add experimental Server Cards support (SEP-2127) #2951
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
SamMorrowDrums
wants to merge
11
commits into
modelcontextprotocol:main
Choose a base branch
from
SamMorrowDrums:experimental-server-card-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
55a36f7
Add experimental Server Cards support (SEP-2127)
dsp-ant 1a1d3f2
Replace well-known server card discovery with AI Catalog discovery
dsp-ant 6dcbdc0
Align experimental Server Cards with SEP-2127 v1 schema
SamMorrowDrums ef03bb1
Add ETag support to discovery responses
SamMorrowDrums f6e40d7
Update ai_catalog.py
SamMorrowDrums 881b6da
Adapt experimental Server Cards imports to mcp-types package restructure
SamMorrowDrums c002308
docs: add Server Cards (SEP-2127) usage guide
tadasant 0d55856
fix(experimental): migrate server cards to httpx2
SamMorrowDrums 06d4b21
fix(experimental): align server cards with discovery spec
SamMorrowDrums fff88ec
Merge branch 'main' into experimental-server-card-v2
SamMorrowDrums c6b89c4
Fix CI and address Server Cards review
SamMorrowDrums File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| # 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, a client must be able to read | ||
| both routes **unauthenticated**. `mount_server_card` and `mount_ai_catalog` append | ||
| their routes to `app.router.routes`, so they sit *inside* the same app — that | ||
| places them ahead of any per-route auth dependencies, but it does **not** exempt | ||
| them from authentication enforced as global ASGI middleware, which wraps every | ||
| request the app handles. If you gate the app with auth middleware, either exclude | ||
| the discovery paths there (e.g. skip enforcement for the card path and | ||
| `/.well-known/ai-catalog.json`) or serve the card and catalog from a separate, | ||
| unauthenticated sub-app mounted alongside the protected one. 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, If-None-Match` | Allows the negotiated content type and cross-origin conditional GETs. | | ||
| | `Access-Control-Expose-Headers` | `ETag` | Lets browser scripts read the `ETag` to revalidate later. | | ||
| | `Cache-Control` | `public, max-age=3600` | Cards change rarely; let clients and CDNs cache. | | ||
|
|
||
| The routes also answer the `OPTIONS` preflight (with `204 No Content` and these | ||
| headers) that a browser sends before a cross-origin conditional GET, because | ||
| `If-None-Match` is not a CORS-safelisted request header. | ||
|
|
||
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| 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.server.transport_security import TransportSecuritySettings | ||
| 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. | ||
| # | ||
| # The card advertises a public host (`dice.example.com`), so the transport must | ||
| # accept that host: the default streamable-HTTP app auto-enables DNS-rebinding | ||
| # protection scoped to localhost, which would reject requests to the advertised | ||
| # `Host` with `421 Misdirected Request`. Configure the real host and the browser | ||
| # origins allowed to call it. | ||
| security = TransportSecuritySettings( | ||
| allowed_hosts=["dice.example.com", "dice.example.com:*"], | ||
| allowed_origins=["https://dice.example.com"], | ||
| ) | ||
| app = server.streamable_http_app(transport_security=security) | ||
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 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.mkdir(parents=True, exist_ok=True) | ||
| (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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| from urllib.parse import urljoin | ||
|
|
||
| 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 | ||
| # Entry URLs may be relative; resolve them against the catalog's | ||
| # location, just as `discover_server_cards` does. | ||
| card = await fetch_server_card(urljoin(catalog_url, entry.url), http_client=http_client) | ||
| print(entry.identifier, "->", card.name) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.