|
| 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])) |
0 commit comments