Skip to content

Add experimental Server Card extension support (SEP-2127)#3139

Draft
claude[bot] wants to merge 5 commits into
mainfrom
feat/experimental-server-card
Draft

Add experimental Server Card extension support (SEP-2127)#3139
claude[bot] wants to merge 5 commits into
mainfrom
feat/experimental-server-card

Conversation

@claude

@claude claude Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Requested by David Soria Parra · Slack thread

What this changes

Before: a client had no way to learn about a remote MCP server before connecting. There was no standard place to find its endpoint URLs, the headers it requires, or which servers a domain hosts.

After: a server can publish a Server Card, a JSON document describing its name, remotes, required configuration, and metadata, and can publish an AI Catalog at /.well-known/ai-catalog listing the cards its domain offers. A client can discover every card behind a domain in one call, fetch and validate a single card, resolve a remote's URL and headers from user-supplied variable values, and cross-check a fetched card against the live server's initialize result before trusting it.

How

All code lives in new experimental packages. Nothing in the existing public API changes, and mcp/__init__.py is untouched.

mcp.shared.experimental.server_card holds the models: the ServerCard family (Remote, Repository, Input, KeyValueInput), a typed AICatalog subset in the sibling mcp.shared.experimental.ai_catalog, and the pure resolve_remote() helper that turns a Remote plus variable values into a concrete URL and header set with no I/O.

mcp.server.experimental.server_card serves the documents. build_server_card() derives a card from a FastMCP or lowlevel server, mount_server_card(), mount_ai_catalog(), and mount_discovery() attach the routes to a Starlette app, and server_card_entry() plus catalog_identifier() build catalog entries. Every response goes through one discovery_response() chokepoint that applies CORS, Cache-Control, a SHA-256 based ETag, 304 Not Modified revalidation, and OPTIONS preflight handling.

mcp.client.experimental.server_card consumes them. discover_server_cards() walks a domain's catalog, including nested catalogs, and returns per-entry listings and failures. fetch_server_card() and fetch_ai_catalog() fetch single documents, load_server_card() reads one from disk, and low-level create_*_request() / parse_*_response() pairs support ETag revalidation with a caller-owned HTTP client. reconcile_server_card() compares a card against the server's initialize result. Reconciliation is advisory only. It returns a list of CardMismatch values and never raises, so the caller decides policy.

The discovery client is hardened by default under a tunable DiscoveryPolicy:

  • SSRF guard: HTTPS only, IP-literal checks plus a post-DNS re-check that block loopback, private, link-local, CGNAT, ULA, and IPv4-mapped IPv6 addresses, redirects walked manually with every hop re-admitted, and URLs carrying userinfo rejected before any request is made.
  • Resource caps: streamed response size limit, redirect cap, per-fetch timeout, per-catalog entry cap, nesting depth cap, and an aggregate per-probe budget (max_probe_entries) so a hostile catalog tree cannot amplify fan-out. Already-visited catalog URLs are never refetched, so self-referential catalogs terminate after one fetch.
  • Failure isolation: one bad entry (invalid card, DNS failure, timeout, blocked address) is recorded as a DiscoveryFailure and the rest of the walk continues.

Spec and prior work

Implements the Server Card extension (SEP-2127), as amended by experimental-ext-server-card#36.

This PR supersedes #2696 and overlaps with the direction of draft #2951, which reached a similar design independently. Ideas from both informed this implementation, and feedback from their authors is very welcome here.

Testing and docs

  • 166 new tests across the shared-model, server, client, and docs suites. They cover every validator branch, header/ETag/304/preflight behavior over in-memory ASGI, every DiscoveryError reason, the address-guard matrix, redirect re-validation, catalog walk edge cases (nesting, cycles, entry and probe budgets), and a full serve, discover, reconcile round trip against a live streamable_http_app() transport.
  • The new code passes the repo's 100% line and branch coverage gate. Full suite: 5640 passed, 7 skipped, 1 xfailed.
  • Schema fixtures are vendored from the extension repo at 1491bd2. One caveat to flag: the vendored invalid set is not used as a faithful conformance suite. missing-schema.json is deliberately accepted, because ingestion stays lenient about a missing $schema field by design. A dedicated test pins this divergence.
  • New docs page docs/advanced/server-cards.md with four runnable tutorial modules under docs_src/server_cards/, each included in the page and verified by shape tests.

Generated by Claude Code

claude added 5 commits July 21, 2026 06:21
Shared-tier Pydantic models for the experimental-ext-server-card
extension: ServerCard and its Input/KeyValueInput/Repository/Remote
family, resolve_remote for template substitution, and a minimal typed
AICatalog subset. Wire-format constraints (v1 $schema URL, namespaced
name pattern, version-range rejection, url-xor-data catalog entries)
are enforced in validators and pinned by the extension repo's vendored
conformance fixtures.
Server-tier helpers: build_server_card derives the card from a server's
identity fields, route builders and mount helpers serve the card at the
spec-reserved <streamable-http-path>/server-card and the catalog at
/.well-known/ai-catalog.json, and discovery_response is the single
compliance chokepoint (media type, CORS MUSTs, Cache-Control, strong
ETag with If-None-Match 304s, OPTIONS preflight). catalog_identifier
and server_card_entry build urn:air catalog entries, by URL or inline.
Client-tier helpers: fetch_server_card / fetch_ai_catalog /
discover_server_cards run every fetch through a hardened core
(https-only with loopback-http exception, SSRF address guard with
post-DNS re-check, manual redirect walking with per-hop re-admission,
response size and catalog entry/depth caps, Accept and media type
discipline) governed by DiscoveryPolicy. Probes collect per-entry
failures instead of raising, and CardListing exposes the listing chain
for consent UI. Stateless request/parse pairs support host-owned ETag
revalidation, and reconcile_server_card compares card claims to runtime
values without ever enforcing them.
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.
Discovery client:

- Bound each probe with DiscoveryPolicy.max_probe_entries (default 500),
  an aggregate budget over every card and nested-catalog entry one walk
  processes; exhaustion records a single "probe_budget" failure and drops
  the rest. Already-visited nested catalog URLs are never refetched, so
  cyclic or duplicated catalogs terminate. Without this the per-catalog
  entry cap and the depth cap compose multiplicatively (~entries**depth
  fetches from one hostile catalog).
- Catch OSError per entry too: an unresolvable host (socket.gaierror from
  the guard's own DNS resolution) or a tar-pit entry (TimeoutError from
  the per-fetch deadline) becomes a failure instead of killing the whole
  probe, as the DiscoveryResult contract promises. Both exceptions are
  now documented on the public fetchers.
- CardListing.listing_domain/hosting_domain return host[:port] built from
  the parsed hostname, never the raw netloc, and _admit_url rejects
  userinfo-carrying URLs outright, so https://github.com@evil.example/
  can neither be fetched nor rendered as a trusted brand in consent UI.
- _is_blocked_address unwraps IPv4-mapped IPv6 literals and applies the
  IPv4 rules, closing the ::ffff:100.64.0.1 CGNAT bypass (and the
  private-range leak on interpreters without the gh-113171 fix).
- discover_server_cards with http_client=None opens one credential-free
  client for the whole walk instead of one per fetched entry.
- Plain http is refused up front under the hardened policy (the loopback
  carve-out was unreachable: loopback fails the address guard anyway).
- DiscoveryErrorReason is re-exported from the public module, and one
  accept_header() helper replaces the duplicated Accept literals.

Models and server:

- Remote.required_variables now includes required headers that carry no
  value and no default, keyed by header name exactly as resolve_remote
  accepts them, so prompting from the property and then resolving works.
- mount_discovery documents that public_url is the app's public base URL.
- The discovery routes' CORSMiddleware allows only GET, so real browser
  preflights advertise the spec's method list.

Tests cover the budget walk (exact fetch sequence), visited-set dedup,
per-entry DNS-failure and timeout resilience, userinfo rejection and
display, mapped-IPv6 blocking, required-header prompting, Repository and
icons round-trips, and the preflight headers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant