|
| 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. |
0 commit comments