| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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.
| card = build_server_card( | ||
| mcp, | ||
| name="com.example/weather", | ||
| remotes=[Remote(type="streamable-http", url="https://mcp.example.com/mcp")], | ||
| ) |
There was a problem hiding this comment.
Should this be a function or an object instantiation, e.g. card = MCPServerCard(...)?
Sorry, something went wrong.
| 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) |
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?
Sorry, something went wrong.
| _REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) | ||
| _CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") |
There was a problem hiding this comment.
What is this about?
Sorry, something went wrong.
| 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}" |
There was a problem hiding this comment.
A server card url should always be read from the ai-catalog, never constructed.
Sorry, something went wrong.
- 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
| Back | FazBrowse Home | New Git URL |
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:
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
Generated by Claude Code