Skip to content

Commit e15f284

Browse files
committed
docs: add Server Cards page with runnable examples
New Advanced page covering serving a card, publishing on a brand domain, static publishing, discovery and connect, ETag revalidation, and the security model (advisory cards, endpoint-keyed dedup, host scoped consent, SSRF policy defaults). Tutorials are pyright-checked docs_src modules proved against the real SDK by tests/docs_src/test_server_cards.py.
1 parent 7d731de commit e15f284

8 files changed

Lines changed: 330 additions & 0 deletions

File tree

docs/advanced/server-cards.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Server Cards
2+
3+
!!! warning "Experimental"
4+
Server Cards are an experimental MCP extension tracking
5+
[SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127).
6+
Everything on this page lives under `experimental` modules and may change or be
7+
removed in any release without a deprecation cycle.
8+
9+
A **Server Card** is a static JSON document that describes a single remote MCP server
10+
well enough for a client to discover it and connect, before any protocol exchange:
11+
identity (`name`, `version`, `description`, `title`, `icons`, `repository`,
12+
`websiteUrl`), the transport endpoints (`remotes[]` with URL templates, header inputs
13+
and supported protocol versions), and namespaced `_meta` for anything else. It is served
14+
as `application/mcp-server-card+json`.
15+
16+
A card deliberately omits two things. Tools, resources and prompts stay behind the
17+
runtime `list` operations. Local install metadata (packages, registries, arguments,
18+
environment) belongs to the MCP Registry's `server.json`. If you need install hints,
19+
put them in `_meta`.
20+
21+
## Serving a card
22+
23+
The spec reserves `GET <your streamable HTTP URL>/server-card` as the default location,
24+
and domain-level discovery reads an **AI Catalog** at `/.well-known/ai-catalog.json`.
25+
`mount_discovery` sets up both on a single-domain deployment:
26+
27+
```python title="serve_card.py" hl_lines="12 19"
28+
--8<-- "docs_src/server_cards/tutorial001.py"
29+
```
30+
31+
`build_server_card` derives `title`, `description`, `version`, `websiteUrl` and `icons`
32+
from the server object, so the card stays consistent with what `serverInfo` reports at
33+
runtime. Explicit keyword arguments override the derived values. The namespaced card
34+
`name` and the public `remotes` URLs are yours to supply, since the server object cannot
35+
know them.
36+
37+
With the app above, `GET /mcp/server-card` and `GET /.well-known/ai-catalog.json` both
38+
answer with the spec's required headers: the correct `Content-Type`, the CORS headers
39+
the spec mandates on card endpoints, a `Cache-Control: public, max-age=3600` default,
40+
and a strong `ETag` that turns a matching `If-None-Match` into an empty `304`.
41+
42+
The card routes are appended to the app after `streamable_http_app()` builds it. Mount
43+
them outside any auth middleware. Discovery is unauthenticated by design, and a card or
44+
catalog must never contain credentials, internal topology or private endpoints.
45+
46+
If you need only one of the two endpoints, or different paths, use the smaller pieces:
47+
`create_server_card_routes` / `mount_server_card` for the card and
48+
`create_ai_catalog_routes` / `mount_ai_catalog` for the catalog. For fully custom
49+
hosting (say a FastAPI route), `discovery_response` is the compliance chokepoint that
50+
produces a correct response from a request and the document bytes.
51+
52+
## Publishing on your brand domain
53+
54+
The catalog belongs on the domain users associate with your service, which is often
55+
different from the API host (think `github.com` versus `api.githubcopilot.com`). In that
56+
case skip `mount_discovery` and build the entry yourself with `server_card_entry`, then
57+
publish it in the catalog your brand domain serves. Entries can point at the hosted card
58+
by URL or inline the whole card as `data`.
59+
60+
A card is just JSON, so static publishing needs no server at all:
61+
62+
```python title="publish_static.py"
63+
--8<-- "docs_src/server_cards/tutorial004.py"
64+
```
65+
66+
Upload the result to your CDN and reference it from your catalog.
67+
68+
## Discovering and connecting
69+
70+
`discover_server_cards` is one probe: it fetches the well-known catalog of the URL's
71+
origin, follows Server Card entries (by URL or inline `data`) and nested catalogs, and
72+
returns every card it found. It never runs implicitly. Your host decides when to probe.
73+
74+
```python title="discover_and_connect.py" hl_lines="10 16"
75+
--8<-- "docs_src/server_cards/tutorial002.py"
76+
```
77+
78+
`resolve_remote` substitutes the card's `{curly_brace}` variables using declared
79+
defaults and your values, and raises a `ValueError` naming every missing required input,
80+
so you can prompt for all of them at once. `Remote.required_variables` lists them up
81+
front.
82+
83+
The probe collects per-entry problems in `DiscoveryResult.failures` instead of raising,
84+
so one hostile or broken entry never hides the rest. Each `CardListing` carries the
85+
listing chain for your consent UI: `listing_domain` is where the catalog listed the
86+
card, `hosting_domain` is where the card itself lives, and they legitimately differ.
87+
88+
For local development the hardened defaults get in the way (plain http and loopback
89+
targets are blocked). Opt in explicitly with
90+
`DiscoveryPolicy(allow_private_addresses=True)`.
91+
92+
## Caching and revalidation
93+
94+
Clients should honor `Cache-Control` and avoid polling. The SDK deliberately ships no
95+
cache storage. Instead, the stateless request/parse pairs let your host revalidate with
96+
its own store:
97+
98+
```python title="revalidate.py" hl_lines="16 18"
99+
--8<-- "docs_src/server_cards/tutorial003.py"
100+
```
101+
102+
Store the `ETag` alongside the card and send it back as `If-None-Match`. An unchanged
103+
card costs a 304 and no body.
104+
105+
## Security model
106+
107+
Cards are unverified, advisory input. The rules that keep discovery safe:
108+
109+
- **Runtime wins.** A card's claims should match the live server, and
110+
`reconcile_server_card` reports any drift, but clients must never treat card contents
111+
as authoritative for security or access-control decisions.
112+
- **De-duplicate on endpoints.** `name` and the catalog `identifier` are self-asserted
113+
and spoofable. `ServerCard.endpoint_urls()` gives you the dedup key: the card's actual
114+
`remotes[]` URLs.
115+
- **Never auto-install.** Consent, its persistence ("not now", "this session",
116+
"always"), decline memory and enterprise allowlists are host application policy. The
117+
SDK exposes the data and stays out of the decision.
118+
- **Hardened fetching.** Every fetch, redirect hop and nested catalog follow is checked
119+
under `DiscoveryPolicy`: https only (plain http is loopback-only), an SSRF guard that
120+
rejects private, loopback, link-local and metadata addresses (checked again after DNS
121+
resolution), bounded redirects, response size and entry count caps, and no cookies or
122+
ambient credentials. If you pass your own `http_client`, keep it credential-free. The
123+
guard resolves DNS before the request while the client re-resolves on connect, so a
124+
DNS rebinding race remains possible between the two.

docs_src/server_cards/__init__.py

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from mcp.server import MCPServer
2+
from mcp.server.experimental.server_card import build_server_card, mount_discovery
3+
from mcp.shared.experimental.server_card import Remote
4+
5+
mcp = MCPServer(
6+
name="weather",
7+
version="1.4.0",
8+
description="Hourly forecasts.",
9+
website_url="https://example.com",
10+
)
11+
12+
card = build_server_card(
13+
mcp,
14+
name="com.example/weather",
15+
remotes=[Remote(type="streamable-http", url="https://mcp.example.com/mcp")],
16+
)
17+
18+
app = mcp.streamable_http_app()
19+
mount_discovery(app, card, public_url="https://mcp.example.com")
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import httpx2
2+
3+
from mcp import Client
4+
from mcp.client.experimental.server_card import discover_server_cards, reconcile_server_card
5+
from mcp.client.streamable_http import streamable_http_client
6+
from mcp.shared.experimental.server_card import resolve_remote
7+
8+
9+
async def main() -> None:
10+
result = await discover_server_cards("https://example.com/docs")
11+
for listing in result.listings:
12+
print(listing.entry.identifier, "listed on", listing.listing_domain, "hosted at", listing.hosting_domain)
13+
14+
chosen = result.listings[0] # your host app: consent UI, dedup on chosen.card.endpoint_urls()
15+
assert chosen.card.remotes is not None
16+
resolved = resolve_remote(chosen.card.remotes[0], {"token": "..."}) # ValueError names missing inputs
17+
18+
async with httpx2.AsyncClient(headers=resolved.headers, follow_redirects=True) as http_client:
19+
transport = streamable_http_client(resolved.url, http_client=http_client)
20+
async with Client(transport) as client:
21+
for mismatch in reconcile_server_card(chosen.card, client.server_info): # advisory: runtime wins
22+
print("card mismatch:", mismatch.field, mismatch.card_value, mismatch.runtime_value)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import httpx2
2+
3+
from mcp.client.experimental.server_card import create_server_card_request, parse_server_card_response
4+
from mcp.shared.experimental.server_card import ServerCard
5+
6+
7+
class CardStore:
8+
"""Your host's cache. Anything that keeps a card and its ETag per URL works."""
9+
10+
def __init__(self) -> None:
11+
self.cards: dict[str, ServerCard] = {}
12+
self.etags: dict[str, str] = {}
13+
14+
15+
async def refresh(store: CardStore, http: httpx2.AsyncClient, url: str) -> ServerCard:
16+
request = create_server_card_request(url, if_none_match=store.etags.get(url))
17+
response = await http.send(request)
18+
if response.status_code != 304: # an unchanged card costs a 304
19+
store.cards[url] = parse_server_card_response(response)
20+
if "etag" in response.headers:
21+
store.etags[url] = response.headers["etag"]
22+
return store.cards[url]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from pathlib import Path
2+
3+
from mcp.shared.experimental.server_card import Remote, ServerCard
4+
5+
card = ServerCard(
6+
name="com.example/weather",
7+
version="1.4.0",
8+
description="Hourly forecasts.",
9+
title="Weather",
10+
website_url="https://example.com",
11+
remotes=[Remote(type="streamable-http", url="https://mcp.example.com/mcp")],
12+
)
13+
14+
15+
def publish(path: Path) -> None:
16+
path.write_text(card.model_dump_json(by_alias=True, exclude_none=True))

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ nav:
6767
- Middleware: advanced/middleware.md
6868
- Extensions: advanced/extensions.md
6969
- MCP Apps: advanced/apps.md
70+
- Server Cards: advanced/server-cards.md
7071
- Troubleshooting: troubleshooting.md
7172
- Migration Guide: migration.md
7273
- API Reference: api/
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""`docs/advanced/server-cards.md`: every claim the page makes, proved against the real SDK."""
2+
3+
import ipaddress
4+
from pathlib import Path
5+
6+
import httpx2
7+
import pytest
8+
9+
from docs_src.server_cards import tutorial001, tutorial002, tutorial003, tutorial004
10+
from mcp import Client
11+
from mcp.client.experimental import _discovery_http
12+
from mcp.client.experimental.server_card import discover_server_cards, load_server_card, reconcile_server_card
13+
from mcp.shared.experimental.server_card import Input, Remote, resolve_remote
14+
15+
# See test_index.py for why this is a per-module mark and not a conftest hook.
16+
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
17+
18+
INITIALIZE = {
19+
"jsonrpc": "2.0",
20+
"id": 1,
21+
"method": "initialize",
22+
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "d", "version": "1"}},
23+
}
24+
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
25+
26+
27+
@pytest.fixture
28+
def public_dns(monkeypatch: pytest.MonkeyPatch) -> None:
29+
"""Pin DNS to a public address so the default discovery policy runs offline.
30+
31+
The SSRF guard resolves every hostname before the request, and the page's
32+
example domains do not resolve in CI.
33+
"""
34+
35+
async def resolve(host: str) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
36+
return [ipaddress.ip_address("93.184.216.34")]
37+
38+
monkeypatch.setattr(_discovery_http, "_host_addresses", resolve)
39+
40+
41+
async def test_mount_discovery_serves_both_endpoints_with_the_spec_headers() -> None:
42+
"""tutorial001: the two GET endpoints exist beside the live transport, with the
43+
page's promised Content-Type, CORS, Cache-Control and ETag/304 behavior."""
44+
transport = httpx2.ASGITransport(app=tutorial001.app)
45+
async with tutorial001.mcp.session_manager.run():
46+
async with httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
47+
initialize = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
48+
card_response = await http.get("/mcp/server-card")
49+
catalog_response = await http.get("/.well-known/ai-catalog.json")
50+
revalidated = await http.get("/mcp/server-card", headers={"If-None-Match": card_response.headers["etag"]})
51+
assert initialize.status_code == 200
52+
assert card_response.headers["content-type"] == "application/mcp-server-card+json"
53+
assert card_response.headers["access-control-allow-origin"] == "*"
54+
assert card_response.headers["access-control-allow-methods"] == "GET"
55+
assert card_response.headers["access-control-allow-headers"] == "Content-Type"
56+
assert card_response.headers["cache-control"] == "public, max-age=3600"
57+
assert catalog_response.headers["content-type"] == "application/ai-catalog+json"
58+
assert (revalidated.status_code, revalidated.content) == (304, b"")
59+
60+
61+
async def test_build_server_card_keeps_the_card_consistent_with_server_info() -> None:
62+
"""tutorial001 + the page's derivation claim: the card carries the server's version,
63+
description and websiteUrl, so `reconcile_server_card` finds no drift after connect."""
64+
assert tutorial001.card.version == "1.4.0"
65+
assert tutorial001.card.description == "Hourly forecasts."
66+
assert tutorial001.card.website_url == "https://example.com"
67+
async with Client(tutorial001.mcp) as client:
68+
assert reconcile_server_card(tutorial001.card, client.server_info) == []
69+
70+
71+
async def test_the_discovery_probe_finds_the_served_card(public_dns: None) -> None:
72+
"""tutorial002: `discover_server_cards` on any page of the origin finds the card
73+
tutorial001 mounted, and the listing exposes the consent-UI domains and the endpoint
74+
dedup key. The tutorial's `main()` itself needs a live network, so the flow is
75+
proved here against the in-memory app."""
76+
http_client = httpx2.AsyncClient(
77+
transport=httpx2.ASGITransport(app=tutorial001.app), base_url="https://mcp.example.com"
78+
)
79+
async with http_client:
80+
result = await discover_server_cards("https://mcp.example.com/docs", http_client=http_client)
81+
assert result.failures == []
82+
(listing,) = result.listings
83+
assert listing.entry.identifier == "urn:air:mcp.example.com:mcp:weather"
84+
assert (listing.listing_domain, listing.hosting_domain) == ("mcp.example.com", "mcp.example.com")
85+
assert listing.card.endpoint_urls() == frozenset({"https://mcp.example.com/mcp"})
86+
assert listing.card == tutorial001.card
87+
88+
89+
def test_resolve_remote_names_every_missing_required_input() -> None:
90+
"""tutorial002's comment: `resolve_remote` raises a ValueError naming the missing
91+
required inputs so a host can prompt for all of them at once."""
92+
remote = Remote(
93+
type="streamable-http",
94+
url="https://{tenant}.example.com/mcp",
95+
variables={"tenant": Input(is_required=True)},
96+
)
97+
assert remote.required_variables == frozenset({"tenant"})
98+
with pytest.raises(ValueError, match="tenant"):
99+
resolve_remote(remote)
100+
assert resolve_remote(remote, {"tenant": "acme"}).url == "https://acme.example.com/mcp"
101+
102+
103+
async def test_the_revalidation_recipe_costs_a_304_when_unchanged() -> None:
104+
"""tutorial003: the first `refresh` parses and stores the card, the second sends the
105+
stored ETag and reuses the cache on the 304."""
106+
store = tutorial003.CardStore()
107+
transport = httpx2.ASGITransport(app=tutorial001.app)
108+
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
109+
first = await tutorial003.refresh(store, http, "https://mcp.example.com/mcp/server-card")
110+
second = await tutorial003.refresh(store, http, "https://mcp.example.com/mcp/server-card")
111+
assert first == tutorial001.card
112+
assert second is first
113+
assert store.etags["https://mcp.example.com/mcp/server-card"].startswith('"')
114+
115+
116+
def test_static_publishing_writes_a_loadable_card(tmp_path: Path) -> None:
117+
"""tutorial004: the written file is a valid card document that loads back."""
118+
target = tmp_path / "server-card.json"
119+
tutorial004.publish(target)
120+
assert load_server_card(target) == tutorial004.card
121+
122+
123+
def test_the_connect_tutorial_exposes_its_client_program() -> None:
124+
"""tutorial002's `main()` needs a live network, so its coverage here is the import
125+
plus the in-memory proof of the same flow above."""
126+
assert callable(tutorial002.main)

0 commit comments

Comments
 (0)