Add experimental Server Cards support (SEP-2127)#2696
Conversation
Adds SDK support for MCP Server Cards: static metadata documents that describe a remote server's identity, transport endpoints, and supported protocol versions for pre-connection discovery. - mcp.shared.experimental.server_card: Pydantic models (ServerCard, Server, Remote, Package, ...) mirroring mcp.types conventions and validating purely through Pydantic. - mcp.server.experimental.server_card: build_server_card derives a card from a server's identity; server_card_route / mount_server_card serve it from a Starlette app at /.well-known/mcp/server-card. - mcp.client.experimental.server_card: fetch_server_card / load_server_card / well_known_url ingest and validate a card. Full test coverage for the new modules.
|
Is this SEP going to be in the current spec release? Otherwise maybe this PR should be draft |
|
@maxisbey the Server Card SEP as been moved to proposing an extension and it's almost ready, I agree I think it would be reasonable to leave the experimental PR as a draft, but I think the majority of blockers are now done, and likely the SEP will be up for review shortly. |
|
@SamMorrowDrums hmm would it make more sense for this to be marked as an extension in the SDK rather than experimental? Currently experimental is only for Tasks (which will soon be fully deleted form the SDK anyway). If it does make sense as an extension then we also need to figure out a proper way to put extensions in the SDK as currently it's not possible (can't edit capabilities), but all that will definitely be coming with v2 release :) |
|
Makes sense @maxisbey - I think the fortunate thing here is that this extension doesn't interact to much with the server itself (it's an odd fit for an extension - being broadly a link that will server at a well known location on the domain that the server relates to - such as github.com rather than the api.githubcopilot.com domain the server is hosted on for example). So it's a helper utility more than an extension in the usual sense at least. Sorry if I'm being a bit vague, but the intent is that we make it easy for people to produce server cards from their servers, so it's trivial for them to advertise the server for discovery purposes on the agentic web. It's otherwise closer to the registry server.json than a protocol feature per se. Hope that clarifies. |
Server cards are no longer served from a fixed .well-known path. Discovery now goes through an AI Catalog (https://github.com/Agent-Card/ai-catalog) published at /.well-known/ai-catalog.json, whose entries point at server cards hosted anywhere: - Add mcp.shared.experimental.ai_catalog: Pydantic models for the AI Catalog CDDL schema (entries, host, publisher, trust manifest), enforcing the url/data exclusivity and trust-manifest identity binding rules. The transitional MCP Catalog (/.well-known/mcp/catalog.json) is a structural subset and parses with the same models. - Add mcp.server.experimental.ai_catalog: build catalog entries from server cards (urn:mcp:server:<name>) and serve catalogs from the well-known path. - Add discover_server_cards(): fetch a host's catalog (AI Catalog path with fallback to the MCP Catalog path), then fetch or inline-validate every MCP server entry. Non-http(s) card URLs from the catalog are rejected. - Drop WELL_KNOWN_PATH and well_known_url; fetch_server_card now takes the card URL directly and server_card_route/mount_server_card require an explicit path. Review fixes: - Fix the version-range validator rejecting valid semver prereleases like 1.0.0-x; wildcard segments now only count in the release part, and bare "x"/"*" are caught. - Serve discovery documents with the CORS headers the spec requires (MUST) and Cache-Control (SHOULD), exported as DISCOVERY_HEADERS. - Restrict URL resolution to http(s) schemes to match its error message. - Rename httpx_client to http_client and default to create_mcp_http_client() (30s timeout) to match SDK conventions. - Document that lenient ingestion defaults a missing $schema/specVersion, diverging from the JSON Schema's required fields. - Correct the mount_server_card docstring: mounting does not bypass auth middleware. - Add missing test package __init__.py files; assert response headers and bodies in route tests; patch the SDK's own client factory instead of httpx.AsyncClient.
| Remote, | ||
| Repository, | ||
| ServerCard, | ||
| ) | ||
|
|
||
| __all__ = ["build_server_card", "server_card_route", "mount_server_card"] | ||
|
|
||
|
|
||
| class _ServerIdentity(Protocol): |
There was a problem hiding this comment.
🔴 The _ServerIdentity Protocol declares its members as plain mutable attributes, but MCPServer exposes name/version/title/description/website_url/icons as read-only @property getters, so the documented usage of passing an MCPServer to build_server_card() fails pyright ("name is invariant because it is mutable... property is not assignable to str"). Declaring the protocol members as @property getters (e.g. @property\ndef name(self) -> str: ...) lets both the low-level Server and MCPServer satisfy the protocol.
Extended reasoning...
The bug: build_server_card() documents (in its docstring and in the PR description) that it accepts "A low-level Server or high-level MCPServer (anything exposing the standard identity attributes)". The _ServerIdentity Protocol it uses to type that parameter declares its members as plain attributes:
class _ServerIdentity(Protocol):
name: str
version: str | None
title: str | None
description: str | None
website_url: str | None
icons: list[Icon] | NoneUnder pyright's structural-typing rules, a plain (non-Final, non-property) Protocol attribute is treated as mutable and therefore invariant — a class can only satisfy it with a settable attribute of exactly that type. MCPServer exposes all six identity members as read-only @property getters delegating to the wrapped low-level server (src/mcp/server/mcpserver/server.py:209-235), which do not satisfy mutable protocol attributes.
The triggering code path: any type-checked user code that follows the documented usage:
from mcp.server.experimental.server_card import build_server_card
from mcp.server.mcpserver import MCPServer
mcp = MCPServer(name="dice", version="1.0.0", description="Rolls dice.")
card = build_server_card(mcp, name="example/dice")Step-by-step proof (reproduced against this branch with pyright 1.1.408, the project's strict configuration):
- Save the snippet above inside the repo and run
pyrighton it. - Pyright reports:
error: Argument of type "MCPServer[Any]" cannot be assigned to parameter "server" of type "_ServerIdentity" in function "build_server_card" "MCPServer[Any]" is incompatible with protocol "_ServerIdentity" "name" is invariant because it is mutable "name" is an incompatible type "property" is not assignable to "str" "version" is invariant because it is mutable ... (reportArgumentType) - Replacing
MCPServerwith the low-levelServermakes the error disappear, becauseServer.__init__assigns plain instance attributes (src/mcp/server/lowlevel/server.py:188-194).
Why existing checks don't catch it: the PR's tests (tests/experimental/server_card/test_server.py) only ever pass the low-level Server to build_server_card(), so the repo's "pyright clean" CI never exercises the documented MCPServer path. Runtime behavior is unaffected — Protocols aren't enforced at runtime and duck typing works — so nothing fails functionally either.
Impact: this SDK ships py.typed and is checked in strict mode, so downstream users who type-check their code and follow the documented MCPServer usage get a reportArgumentType error on a call pattern the docstring explicitly advertises. It's a typing/API-contract issue rather than a runtime bug, but it effectively breaks the high-level half of the advertised API surface for type-checked users.
Fix: declare the protocol members as read-only properties:
class _ServerIdentity(Protocol):
@property
def name(self) -> str: ...
@property
def version(self) -> str | None: ...
# ... etc.Read-only protocol properties are covariant, so both the low-level Server's plain instance attributes and MCPServer's @property getters satisfy them, and existing callers are unaffected.
| async def fetch_ai_catalog(url: str, *, http_client: httpx.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: | ||
| httpx.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()) |
There was a problem hiding this comment.
🟡 The Raises: sections of fetch_ai_catalog (and fetch_server_card / discover_server_cards in server_card.py) only list httpx.HTTPError and pydantic.ValidationError, but response.json() raises json.JSONDecodeError when a 2xx response carries a non-JSON body — a realistic outcome when probing well-known paths on arbitrary hosts (e.g. a 200 + HTML index page). Consider adding json.JSONDecodeError to the Raises: sections (as load_server_card already does) or wrapping it.
Extended reasoning...
What the gap is. fetch_ai_catalog ends with return AICatalog.model_validate(response.json()). httpx.Response.json() delegates to json.loads and does not wrap decode failures, so if the response body is not valid JSON the call raises json.JSONDecodeError straight out of the function. The docstring's Raises: section only documents httpx.HTTPError and pydantic.ValidationError. The same pattern (and the same omission) exists in fetch_server_card and, transitively, discover_server_cards in src/mcp/client/experimental/server_card.py.
Why this path is realistic. These functions are explicitly designed for probing well-known paths on arbitrary hosts (/.well-known/ai-catalog.json, falling back to /.well-known/mcp/catalog.json). A very common failure mode for such probes is a server that answers unknown paths with 200 + an HTML page — SPAs with catch-all routes, reverse proxies serving an index page, captive portals, misconfigured CDNs. In that case raise_for_status() passes (it's a 2xx), and the next line, response.json(), raises json.JSONDecodeError, which the caller has no documented reason to expect.
Step-by-step example.
- A client calls
await discover_server_cards("https://intranet.example.com"). well_known_ai_catalog_urlresolves tohttps://intranet.example.com/.well-known/ai-catalog.json.- The host is fronted by an SPA / reverse proxy that returns
200 OKwith<!DOCTYPE html>...for any unknown path. response.raise_for_status()succeeds (status is 200), so the documentedhttpx.HTTPErrorpath is not taken.response.json()callsjson.loads("<!DOCTYPE html>...")→json.JSONDecodeError: Expecting value: line 1 column 1 (char 0)propagates out offetch_ai_catalog/discover_server_cards.- A caller that wrapped the call in
except (httpx.HTTPError, pydantic.ValidationError)— exactly what the docstring tells them to expect — does not catch it and crashes.
Why nothing else prevents it. Nothing between raise_for_status() and model_validate() checks the Content-Type or guards the decode, and pydantic.ValidationError is only reached after a successful JSON parse. This is also internally inconsistent within the PR: load_server_card in the same module does document json.JSONDecodeError: If the file is not valid JSON in its Raises: section, and the repo's AGENTS.md asks that public APIs document exceptions a caller would reasonably catch — a non-JSON body from an untrusted remote host is at least as likely as a non-JSON local file.
Impact and fix. This is a documentation-completeness issue, not a runtime bug — the exception still propagates and is debuggable — so it shouldn't block the PR. The smallest fix is to add json.JSONDecodeError: If the response body is not valid JSON. to the Raises: sections of fetch_ai_catalog, fetch_server_card, and discover_server_cards. Alternatively, wrap the decode (e.g. catch json.JSONDecodeError and re-raise as a ValidationError-style "document is not a valid AI Catalog / Server Card" error), which would make the existing two documented exceptions exhaustive.
Summary
Adds first-class SDK support for MCP Server Cards (SEP-2127) under the experimental namespaces, so:
A Server Card is a static metadata document (typically at
https://<host>/.well-known/mcp/server-card) describing a remote server's identity, transport endpoints, and supported protocol versions. It deliberately omits primitive listings (tools/resources/prompts), which stay subject to runtime listing.API
Layout
mcp.shared.experimental.server_cardServerCard,Server,Remote,Package, transports, args, ...), mirroringmcp.typesconventions (camelCase wire format, reusesIcon)mcp.server.experimental.server_cardbuild_server_card(derive from a server's identity) +server_card_route/mount_server_card(Starlette)mcp.client.experimental.server_cardfetch_server_card/load_server_card/well_known_urlDesign notes
mcp.types— no separate JSON-Schema/CLI layer. Malformed cards raisepydantic.ValidationError; version ranges (which the field pattern can't express) are rejected by a validator.$schemadefaults to the canonical v1 URL so generation needs no boilerplate; ingestion is lenient about a missing$schema.ServerCardvsServer(registryserver.json) split matches the spec;Serveraddspackages.mcpAPI surface.Tests
tests/experimental/server_card/; 100% line + branch coverage on the three new modules (verified withstrict-no-cover).ruff+pyrightclean.Serverwith a package); server→client verified end-to-end over an in-memory ASGI transport.Supersedes #2692 (which added this only as a standalone example app).