Skip to content

Commit 1db4632

Browse files
committed
feat: serve Server Cards and AI Catalogs over HTTP (experimental)
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.
1 parent f2d46d3 commit 1db4632

4 files changed

Lines changed: 714 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Experimental: tracks SEP-2127 (the Server Card extension).
2+
3+
Nothing under `mcp.server.experimental` carries a stability guarantee. Any
4+
symbol here may change or be removed in any release without a deprecation
5+
cycle.
6+
"""
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
"""Serve a Server Card and an AI Catalog over HTTP (experimental, tracks SEP-2127).
2+
3+
The route builders return plain Starlette routes, so they compose with
4+
`streamable_http_app()` or any ASGI application. Every response goes through
5+
`discovery_response`, the single enforcement point for the discovery spec's
6+
HTTP requirements: media type, CORS, caching headers and conditional requests.
7+
8+
Mount these routes outside any auth middleware. Discovery is unauthenticated
9+
by design, and a card or catalog MUST NOT contain credentials, internal
10+
network topology or private endpoints.
11+
"""
12+
13+
import hashlib
14+
import re
15+
from collections.abc import Awaitable, Callable, Sequence
16+
from typing import Any, Protocol
17+
from urllib.parse import urlsplit
18+
19+
from mcp_types import Icon
20+
from starlette.applications import Starlette
21+
from starlette.middleware.cors import CORSMiddleware
22+
from starlette.requests import Request
23+
from starlette.responses import Response
24+
from starlette.routing import Route, request_response
25+
from starlette.types import ASGIApp
26+
27+
from mcp.shared.experimental._base import SERVER_CARD_NAME_PATTERN, is_loopback_host
28+
from mcp.shared.experimental.ai_catalog import (
29+
AI_CATALOG_MEDIA_TYPE,
30+
AI_CATALOG_WELL_KNOWN_PATH,
31+
AICatalog,
32+
CatalogEntry,
33+
)
34+
from mcp.shared.experimental.server_card import (
35+
RESERVED_SERVER_CARD_SUFFIX,
36+
SERVER_CARD_MEDIA_TYPE,
37+
Remote,
38+
Repository,
39+
ServerCard,
40+
)
41+
42+
__all__ = [
43+
"build_server_card",
44+
"create_server_card_routes",
45+
"mount_server_card",
46+
"create_ai_catalog_routes",
47+
"mount_ai_catalog",
48+
"mount_discovery",
49+
"server_card_entry",
50+
"catalog_identifier",
51+
"discovery_response",
52+
]
53+
54+
_CORS_HEADERS = {
55+
"Access-Control-Allow-Origin": "*",
56+
"Access-Control-Allow-Methods": "GET",
57+
"Access-Control-Allow-Headers": "Content-Type",
58+
}
59+
60+
61+
class _ServerIdentity(Protocol):
62+
"""The identity surface `build_server_card` reads.
63+
64+
Both `MCPServer` (properties) and the lowlevel `Server` (plain attributes)
65+
satisfy this structurally.
66+
"""
67+
68+
@property
69+
def name(self) -> str: ...
70+
@property
71+
def title(self) -> str | None: ...
72+
@property
73+
def version(self) -> str | None: ...
74+
@property
75+
def description(self) -> str | None: ...
76+
@property
77+
def website_url(self) -> str | None: ...
78+
@property
79+
def icons(self) -> list[Icon] | None: ...
80+
81+
82+
def build_server_card(
83+
server: _ServerIdentity,
84+
*,
85+
name: str,
86+
remotes: Sequence[Remote] | None = None,
87+
repository: Repository | None = None,
88+
description: str | None = None,
89+
title: str | None = None,
90+
version: str | None = None,
91+
website_url: str | None = None,
92+
icons: Sequence[Icon] | None = None,
93+
meta: dict[str, Any] | None = None,
94+
) -> ServerCard:
95+
"""Build a `ServerCard` from a server's identity fields.
96+
97+
Title, description, version, website URL and icons come from the server
98+
object. Explicit keyword arguments override the derived values, which
99+
keeps the card consistent with what `serverInfo` reports at runtime. The
100+
namespaced `name` and the public `remotes` URLs are never derivable, so
101+
the caller supplies them.
102+
103+
Raises:
104+
pydantic.ValidationError: If the result violates a card constraint,
105+
for example a server description over 100 characters or a version
106+
that is unset and not overridden.
107+
"""
108+
resolved_icons = list(icons) if icons is not None else server.icons
109+
fields: dict[str, Any] = {
110+
"name": name,
111+
"version": version if version is not None else server.version,
112+
"description": description if description is not None else server.description,
113+
"title": title if title is not None else server.title,
114+
"website_url": website_url if website_url is not None else server.website_url,
115+
"icons": resolved_icons,
116+
"repository": repository,
117+
"remotes": list(remotes) if remotes is not None else None,
118+
"meta": meta,
119+
}
120+
return ServerCard.model_validate(fields)
121+
122+
123+
def discovery_response(
124+
request: Request,
125+
body: bytes,
126+
media_type: str,
127+
*,
128+
cache_control: str = "public, max-age=3600",
129+
) -> Response:
130+
"""Answer one discovery request (card or catalog) per the extension spec.
131+
132+
This is the compliance chokepoint the built-in routes use. It is public so
133+
custom hosting (FastAPI apps, non-default paths, multi-card hosts) can
134+
stay compliant. It emits the CORS headers the spec requires on every
135+
response, a `Cache-Control` header (pass `cache_control=""` to omit it), a
136+
strong SHA-256 `ETag`, a `304 Not Modified` for a matching
137+
`If-None-Match`, and an empty 200 for `OPTIONS` preflight.
138+
"""
139+
headers = dict(_CORS_HEADERS)
140+
if request.method == "OPTIONS":
141+
return Response(status_code=200, headers=headers)
142+
if cache_control:
143+
headers["Cache-Control"] = cache_control
144+
etag = '"' + hashlib.sha256(body).hexdigest() + '"'
145+
headers["ETag"] = etag
146+
if _if_none_match_matches(request.headers.get("if-none-match"), etag):
147+
return Response(status_code=304, headers=headers)
148+
return Response(content=body, media_type=media_type, headers=headers)
149+
150+
151+
def _if_none_match_matches(header_value: str | None, etag: str) -> bool:
152+
"""Whether an `If-None-Match` header matches a strong ETag.
153+
154+
Accepts the exact strong tag, its `W/`-prefixed weak form, `*`, or any
155+
member of a comma-separated list.
156+
"""
157+
if header_value is None:
158+
return False
159+
for candidate in header_value.split(","):
160+
stripped = candidate.strip()
161+
if stripped == "*" or stripped.removeprefix("W/") == etag:
162+
return True
163+
return False
164+
165+
166+
def _cors_endpoint(handler: Callable[[Request], Awaitable[Response]]) -> ASGIApp:
167+
"""Wrap a handler with `CORSMiddleware`, belt and braces over the explicit headers."""
168+
return CORSMiddleware(
169+
app=request_response(handler),
170+
allow_origins=["*"],
171+
allow_methods=["GET", "OPTIONS"],
172+
allow_headers=["Content-Type"],
173+
)
174+
175+
176+
def _document_routes(path: str, body: bytes, media_type: str) -> list[Route]:
177+
"""GET and OPTIONS routes serving one static discovery document."""
178+
179+
async def handler(request: Request) -> Response:
180+
return discovery_response(request, body, media_type)
181+
182+
return [Route(path, endpoint=_cors_endpoint(handler), methods=["GET", "OPTIONS"])]
183+
184+
185+
def create_server_card_routes(
186+
card: ServerCard,
187+
*,
188+
streamable_http_path: str = "/mcp",
189+
path: str | None = None,
190+
) -> list[Route]:
191+
"""Routes serving `card` at the spec-reserved path.
192+
193+
The default path is the streamable HTTP path plus `/server-card`, for
194+
example `/mcp/server-card`. Pass `path` to host the card at any other
195+
unreserved URI instead.
196+
"""
197+
resolved_path = path if path is not None else streamable_http_path.rstrip("/") + RESERVED_SERVER_CARD_SUFFIX
198+
body = card.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8")
199+
return _document_routes(resolved_path, body, SERVER_CARD_MEDIA_TYPE)
200+
201+
202+
def create_ai_catalog_routes(catalog: AICatalog, *, path: str = AI_CATALOG_WELL_KNOWN_PATH) -> list[Route]:
203+
"""Routes serving `catalog`, by default at the well-known discovery path."""
204+
body = catalog.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8")
205+
return _document_routes(path, body, AI_CATALOG_MEDIA_TYPE)
206+
207+
208+
def mount_server_card(
209+
app: Starlette,
210+
card: ServerCard,
211+
*,
212+
streamable_http_path: str = "/mcp",
213+
path: str | None = None,
214+
) -> None:
215+
"""Append the card routes to an already-built app.
216+
217+
Works on the result of `streamable_http_app()`. Mount outside any auth
218+
middleware. Discovery is unauthenticated by design.
219+
"""
220+
app.router.routes.extend(create_server_card_routes(card, streamable_http_path=streamable_http_path, path=path))
221+
222+
223+
def mount_ai_catalog(app: Starlette, catalog: AICatalog, *, path: str = AI_CATALOG_WELL_KNOWN_PATH) -> None:
224+
"""Append the catalog routes to an already-built app."""
225+
app.router.routes.extend(create_ai_catalog_routes(catalog, path=path))
226+
227+
228+
def _require_public_http_url(url: str) -> str:
229+
"""Validate an absolute http(s) URL and return its host.
230+
231+
Raises:
232+
ValueError: If `url` is not absolute http(s), or uses plain http to a
233+
host that is not loopback. The spec requires HTTPS in production.
234+
"""
235+
parts = urlsplit(url)
236+
if parts.scheme not in ("http", "https") or not parts.hostname:
237+
raise ValueError(f"expected an absolute http(s) URL, got {url!r}")
238+
if parts.scheme == "http" and not is_loopback_host(parts.hostname):
239+
raise ValueError(f"plain http is only allowed for loopback hosts, got {url!r}")
240+
return parts.hostname
241+
242+
243+
def catalog_identifier(card_name: str, *, publisher: str) -> str:
244+
"""The recommended catalog URN for a card name.
245+
246+
`catalog_identifier("com.example/weather", publisher="example.com")`
247+
returns `"urn:air:example.com:mcp:weather"`. The `mcp` namespace segment
248+
comes from the discovery spec.
249+
250+
Raises:
251+
ValueError: If `card_name` does not match the `namespace/name` pattern.
252+
"""
253+
if re.fullmatch(SERVER_CARD_NAME_PATTERN, card_name) is None:
254+
raise ValueError(f"card name must be namespace/name, got {card_name!r}")
255+
local_name = card_name.split("/", 1)[1]
256+
return f"urn:air:{publisher}:mcp:{local_name}"
257+
258+
259+
def server_card_entry(
260+
card: ServerCard,
261+
*,
262+
publisher: str,
263+
url: str | None = None,
264+
identifier: str | None = None,
265+
) -> CatalogEntry:
266+
"""A `CatalogEntry` advertising `card`.
267+
268+
With `url` the entry points at the hosted card. With `url=None` the full
269+
card is inlined as the entry's `data`. The identifier defaults to
270+
`catalog_identifier(card.name, publisher=publisher)`. Display fields are
271+
not duplicated from the card beyond `displayName`. Clients read title,
272+
description and version from the card itself.
273+
274+
Raises:
275+
ValueError: If `url` is relative, or plain http to a non-loopback host.
276+
"""
277+
resolved_identifier = identifier if identifier is not None else catalog_identifier(card.name, publisher=publisher)
278+
if url is None:
279+
data = card.model_dump(by_alias=True, exclude_none=True, mode="json")
280+
return CatalogEntry(
281+
identifier=resolved_identifier, type=SERVER_CARD_MEDIA_TYPE, data=data, display_name=card.title
282+
)
283+
_require_public_http_url(url)
284+
return CatalogEntry(identifier=resolved_identifier, type=SERVER_CARD_MEDIA_TYPE, url=url, display_name=card.title)
285+
286+
287+
def mount_discovery(
288+
app: Starlette,
289+
card: ServerCard,
290+
*,
291+
public_url: str,
292+
streamable_http_path: str = "/mcp",
293+
) -> None:
294+
"""Mount both discovery endpoints on a single-domain deployment.
295+
296+
Serves the card at the reserved path under `streamable_http_path` and a
297+
single-entry AI Catalog at the well-known path. The catalog entry's URL is
298+
absolute (catalogs are fetched cross-domain) and the entry's URN publisher
299+
is the host of `public_url`.
300+
301+
Per the best-practices guidance, the catalog belongs on the domain users
302+
associate with the service, which may not be the API app. In that case use
303+
`server_card_entry` to build an entry for the catalog on your brand
304+
domain instead.
305+
306+
Raises:
307+
ValueError: If `public_url` is not absolute http(s), or is plain http
308+
to a non-loopback host.
309+
"""
310+
publisher = _require_public_http_url(public_url)
311+
card_path = streamable_http_path.rstrip("/") + RESERVED_SERVER_CARD_SUFFIX
312+
entry = server_card_entry(card, publisher=publisher, url=public_url.rstrip("/") + card_path)
313+
mount_server_card(app, card, streamable_http_path=streamable_http_path)
314+
mount_ai_catalog(app, AICatalog(spec_version="1.0", entries=[entry]))

‎tests/server/experimental/__init__.py‎

Whitespace-only changes.

0 commit comments

Comments
 (0)