Add experimental Server Card extension support (SEP-2127) - #3139
Draft
claude[bot] wants to merge 9 commits into
Draft
Add experimental Server Card extension support (SEP-2127)#3139claude[bot] wants to merge 9 commits into
claude[bot] wants to merge 9 commits into
Conversation
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.
dsp-ant
requested changes
Aug 2, 2026
Comment on lines
+12
to
+16
| card = build_server_card( | ||
| mcp, | ||
| name="com.example/weather", | ||
| remotes=[Remote(type="streamable-http", url="https://mcp.example.com/mcp")], | ||
| ) |
Member
There was a problem hiding this comment.
Should this be a function or an object instantiation, e.g. card = MCPServerCard(...)?
Comment on lines
+9
to
+22
| async def main() -> None: | ||
| result = await discover_server_cards("https://example.com/docs") | ||
| for listing in result.listings: | ||
| print(listing.entry.identifier, "listed on", listing.listing_domain, "hosted at", listing.hosting_domain) | ||
|
|
||
| chosen = result.listings[0] # your host app: consent UI, dedup on chosen.card.endpoint_urls() | ||
| assert chosen.card.remotes is not None | ||
| resolved = resolve_remote(chosen.card.remotes[0], {"token": "..."}) # ValueError names missing inputs | ||
|
|
||
| async with httpx2.AsyncClient(headers=resolved.headers, follow_redirects=True) as http_client: | ||
| transport = streamable_http_client(resolved.url, http_client=http_client) | ||
| async with Client(transport) as client: | ||
| for mismatch in reconcile_server_card(chosen.card, client.server_info): # advisory: runtime wins | ||
| print("card mismatch:", mismatch.field, mismatch.card_value, mismatch.runtime_value) |
Member
There was a problem hiding this comment.
Same here, I am curious if a better interface is something like
result = await MCPServerCardDiscovery(url)
for listing in result:
...
whats the most idiomatic way?
Comment on lines
+33
to
+34
| _REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) | ||
| _CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") |
Comment on lines
+144
to
+156
| def server_card_url(streamable_http_url: str) -> str: | ||
| """The spec-reserved card URL for a streamable HTTP transport URL. | ||
|
|
||
| The suffix is appended to the transport URL, not the domain root: | ||
| `https://host/mcp` becomes `https://host/mcp/server-card`. | ||
|
|
||
| Raises: | ||
| ValueError: If `streamable_http_url` is not absolute http(s). | ||
| """ | ||
| parts = urlsplit(streamable_http_url) | ||
| if parts.scheme not in ("http", "https") or not parts.netloc: | ||
| raise ValueError(f"expected an absolute http(s) URL, got {streamable_http_url!r}") | ||
| return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}{RESERVED_SERVER_CARD_SUFFIX}" |
Member
There was a problem hiding this comment.
A server card url should always be read from the ai-catalog, never constructed.
- Replace build_server_card() with the ServerCard.from_server() classmethod, matching the SDK's from_* alternate-constructor idiom; the _ServerIdentity protocol moves to mcp.shared.experimental.server_card alongside it. - Make DiscoveryResult iterable over its listings (__iter__/__len__), so 'for listing in result:' works without the .listings attribute hop. - Remove the client-side server_card_url() helper: card URLs must come from an AI Catalog entry per the discovery spec, never be constructed by the client. fetch_server_card's docstring now says so. - Explain the RFC 6598 shared address space constant in the SSRF guard and rename it _CGNAT_NETWORK -> _SHARED_ADDRESS_SPACE; ipaddress reports these addresses as neither private nor global, so the guard names them explicitly. All symbols are experimental (no deprecation cycle), so the removals are clean.
The extension spec's CORS section requires 'Access-Control-Allow-Headers: Content-Type, If-None-Match' and 'Access-Control-Expose-Headers: ETag' on hosted card and catalog endpoints; the served responses allowed only Content-Type and exposed nothing, which would stop a browser-based client from reading the ETag or sending If-None-Match for a cross-origin 304 revalidation. Both the explicit discovery_response headers and the CORSMiddleware preflight config now emit the full set, with tests asserting the headers on the card response and on a browser preflight requesting If-None-Match.
main now types Client.server_info as Implementation | None (serverInfo is optional at 2026-07-28) and defaults server version to "" instead of None. - ServerCard.from_server treats a derived empty version as unset, so a server without a version still fails card validation - reconcile_server_card accepts server_info=None: an anonymous server makes no identity claim, so only the protocol version check applies
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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-cataloglisting 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'sinitializeresult before trusting it.How
All code lives in new
experimentalpackages. Nothing in the existing public API changes, andmcp/__init__.pyis untouched.mcp.shared.experimental.server_cardholds the models: theServerCardfamily (Remote,Repository,Input,KeyValueInput), a typedAICatalogsubset in the siblingmcp.shared.experimental.ai_catalog, and the pureresolve_remote()helper that turns aRemoteplus variable values into a concrete URL and header set with no I/O.mcp.server.experimental.server_cardserves the documents.build_server_card()derives a card from a FastMCP or lowlevel server,mount_server_card(),mount_ai_catalog(), andmount_discovery()attach the routes to a Starlette app, andserver_card_entry()pluscatalog_identifier()build catalog entries. Every response goes through onediscovery_response()chokepoint that applies CORS,Cache-Control, a SHA-256 basedETag,304 Not Modifiedrevalidation, andOPTIONSpreflight handling.mcp.client.experimental.server_cardconsumes them.discover_server_cards()walks a domain's catalog, including nested catalogs, and returns per-entry listings and failures.fetch_server_card()andfetch_ai_catalog()fetch single documents,load_server_card()reads one from disk, and low-levelcreate_*_request()/parse_*_response()pairs support ETag revalidation with a caller-owned HTTP client.reconcile_server_card()compares a card against the server'sinitializeresult. Reconciliation is advisory only. It returns a list ofCardMismatchvalues and never raises, so the caller decides policy.The discovery client is hardened by default under a tunable
DiscoveryPolicy: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.DiscoveryFailureand 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
DiscoveryErrorreason, 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 livestreamable_http_app()transport.missing-schema.jsonis deliberately accepted, because ingestion stays lenient about a missing$schemafield by design. A dedicated test pins this divergence.docs/advanced/server-cards.mdwith four runnable tutorial modules underdocs_src/server_cards/, each included in the page and verified by shape tests.Generated by Claude Code