Skip to content

Commit c6b89c4

Browse files
Fix CI and address Server Cards review
Fix pre-commit pyright and the failing build_server_card test, and address the cubic-dev-ai review threads validated against the SEP-2127 discovery spec. - _ServerIdentity: declare members read-only so the invariant writable-attr protocol stops rejecting Server.version: str vs version: str | None (pyright). - build_server_card: treat empty version as unset (Server.version defaults to "" not None), so the required-version check fires; fix the stale test comment. - Discovery CORS (spec MUST): allow If-None-Match, expose ETag, and answer the OPTIONS preflight browsers send before a cross-origin conditional GET; add "OPTIONS" to both discovery routes; update the docs table and add preflight tests. - Docs/tutorials: tutorial001 configures TransportSecuritySettings for the advertised public host (default localhost protection would 421); tutorial002 creates the output directory before writing; tutorial004 resolves relative entry URLs against the catalog URL; CatalogEntry.identifier docstring uses the correct urn:air:{publisher}:mcp:{name} form; clarify the auth-middleware caveat for globally-applied ASGI middleware. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23994aca-a562-4387-b723-5c4fd4f9a1ca
1 parent fff88ec commit c6b89c4

9 files changed

Lines changed: 132 additions & 33 deletions

File tree

docs/advanced/server-cards.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,18 @@ standard identity attributes — the low-level `Server` here, but a high-level
7171
unset — a card cannot exist without them. The `name` you pass is the reverse-DNS
7272
identifier and is validated against the `namespace/name` pattern.
7373

74-
Because discovery happens *before* authentication, mount both routes **outside**
75-
any auth middleware — a client must be able to read them unauthenticated. If you
76-
mount the MCP endpoint at a non-default path, pass a matching `path` to
77-
`mount_server_card` (the convention is `<streamable-http-url>/server-card`); the
78-
catalog entry carries the real URL, so any reachable path works.
74+
Because discovery happens *before* authentication, a client must be able to read
75+
both routes **unauthenticated**. `mount_server_card` and `mount_ai_catalog` append
76+
their routes to `app.router.routes`, so they sit *inside* the same app — that
77+
places them ahead of any per-route auth dependencies, but it does **not** exempt
78+
them from authentication enforced as global ASGI middleware, which wraps every
79+
request the app handles. If you gate the app with auth middleware, either exclude
80+
the discovery paths there (e.g. skip enforcement for the card path and
81+
`/.well-known/ai-catalog.json`) or serve the card and catalog from a separate,
82+
unauthenticated sub-app mounted alongside the protected one. If you mount the MCP
83+
endpoint at a non-default path, pass a matching `path` to `mount_server_card` (the
84+
convention is `<streamable-http-url>/server-card`); the catalog entry carries the
85+
real URL, so any reachable path works.
7986

8087
For mounting the MCP app itself into a larger Starlette/FastAPI application, see
8188
[Add to an existing app](../run/asgi.md).
@@ -101,9 +108,14 @@ with a fixed set of discovery headers (`DISCOVERY_HEADERS`):
101108
| --- | --- | --- |
102109
| `Access-Control-Allow-Origin` | `*` | Browser clients fetch cards cross-origin. |
103110
| `Access-Control-Allow-Methods` | `GET` | Discovery is read-only. |
104-
| `Access-Control-Allow-Headers` | `Content-Type` | Allows the negotiated `Accept`/content type. |
111+
| `Access-Control-Allow-Headers` | `Content-Type, If-None-Match` | Allows the negotiated content type and cross-origin conditional GETs. |
112+
| `Access-Control-Expose-Headers` | `ETag` | Lets browser scripts read the `ETag` to revalidate later. |
105113
| `Cache-Control` | `public, max-age=3600` | Cards change rarely; let clients and CDNs cache. |
106114

115+
The routes also answer the `OPTIONS` preflight (with `204 No Content` and these
116+
headers) that a browser sends before a cross-origin conditional GET, because
117+
`If-None-Match` is not a CORS-safelisted request header.
118+
107119
The card route responds with `application/mcp-server-card+json`; the catalog route
108120
with `application/ai-catalog+json`.
109121

docs_src/server_cards/tutorial001.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from mcp.server.experimental.ai_catalog import mount_ai_catalog, server_card_entry
22
from mcp.server.experimental.server_card import build_server_card, mount_server_card
33
from mcp.server.lowlevel import Server
4+
from mcp.server.transport_security import TransportSecuritySettings
45
from mcp.shared.experimental.ai_catalog import AICatalog
56
from mcp.shared.experimental.server_card import Remote, Repository
67

@@ -28,7 +29,17 @@
2829
# Serve the card next to the MCP endpoint, and advertise it in the host's AI
2930
# Catalog at `/.well-known/ai-catalog.json`. The catalog entry points at the
3031
# absolute URL the card is served from.
31-
app = server.streamable_http_app()
32+
#
33+
# The card advertises a public host (`dice.example.com`), so the transport must
34+
# accept that host: the default streamable-HTTP app auto-enables DNS-rebinding
35+
# protection scoped to localhost, which would reject requests to the advertised
36+
# `Host` with `421 Misdirected Request`. Configure the real host and the browser
37+
# origins allowed to call it.
38+
security = TransportSecuritySettings(
39+
allowed_hosts=["dice.example.com", "dice.example.com:*"],
40+
allowed_origins=["https://dice.example.com"],
41+
)
42+
app = server.streamable_http_app(transport_security=security)
3243
mount_server_card(app, card, path="/mcp/server-card")
3344

3445
card_url = "https://dice.example.com/mcp/server-card"

docs_src/server_cards/tutorial002.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
def write_static_site(directory: Path) -> None:
2828
"""Write the card and the well-known catalog under `directory`."""
29+
directory.mkdir(parents=True, exist_ok=True)
2930
(directory / "server-card.json").write_text(card_json)
3031
well_known = directory / ".well-known"
3132
well_known.mkdir(parents=True, exist_ok=True)

docs_src/server_cards/tutorial004.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from urllib.parse import urljoin
2+
13
import httpx2
24

35
from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url
@@ -17,5 +19,7 @@ async def main() -> None:
1719
for entry in catalog.entries:
1820
if entry.media_type != MCP_SERVER_CARD_MEDIA_TYPE or entry.url is None:
1921
continue
20-
card = await fetch_server_card(entry.url, http_client=http_client)
22+
# Entry URLs may be relative; resolve them against the catalog's
23+
# location, just as `discover_server_cards` does.
24+
card = await fetch_server_card(urljoin(catalog_url, entry.url), http_client=http_client)
2125
print(entry.identifier, "->", card.name)

src/mcp/server/experimental/ai_catalog.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,16 @@
3737
__all__ = ["DISCOVERY_HEADERS", "server_card_entry", "ai_catalog_route", "mount_ai_catalog"]
3838

3939
#: Response headers for discovery endpoints (catalogs and the artifacts they
40-
#: reference): CORS headers so browser clients can read them, plus a caching
41-
#: hint.
40+
#: reference): the CORS headers the discovery spec requires so browser clients
41+
#: can read them and use conditional GETs, plus a caching hint. ``If-None-Match``
42+
#: is allowed (and ``ETag`` exposed) so a cross-origin browser can revalidate a
43+
#: cached card; because ``If-None-Match`` is not a CORS-safelisted request
44+
#: header, the routes also answer the ``OPTIONS`` preflight it triggers.
4245
DISCOVERY_HEADERS = {
4346
"Access-Control-Allow-Origin": "*",
4447
"Access-Control-Allow-Methods": "GET",
45-
"Access-Control-Allow-Headers": "Content-Type",
48+
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
49+
"Access-Control-Expose-Headers": "ETag",
4650
"Cache-Control": "public, max-age=3600",
4751
}
4852

@@ -62,7 +66,16 @@ def _if_none_match_matches(if_none_match: str | None, etag: str) -> bool:
6266

6367

6468
def discovery_response(request: Request, body: bytes, media_type: str) -> Response:
65-
"""Build a cacheable discovery response with conditional ETag handling."""
69+
"""Build a cacheable discovery response with conditional ETag handling.
70+
71+
A ``GET`` returns ``body`` with the discovery headers and a strong ``ETag``,
72+
or ``304 Not Modified`` when the request's ``If-None-Match`` already matches.
73+
An ``OPTIONS`` request is answered as a CORS preflight (``204`` with the
74+
discovery headers, no body) so browser clients may send the non-safelisted
75+
``If-None-Match`` header on the follow-up conditional ``GET``.
76+
"""
77+
if request.method == "OPTIONS":
78+
return Response(status_code=204, headers=DISCOVERY_HEADERS)
6679
etag = f'"{hashlib.sha256(body).hexdigest()}"'
6780
if _if_none_match_matches(request.headers.get("if-none-match"), etag):
6881
return Response(
@@ -102,18 +115,20 @@ def server_card_entry(card: ServerCard, url: str) -> CatalogEntry:
102115

103116

104117
def ai_catalog_route(catalog: AICatalog, *, path: str = AI_CATALOG_WELL_KNOWN_PATH) -> Route:
105-
"""Build a Starlette GET route that serves ``catalog`` at ``path``.
118+
"""Build a Starlette route that serves ``catalog`` at ``path``.
106119
107120
Add it to a new app — ``Starlette(routes=[ai_catalog_route(catalog)])`` —
108121
or an existing one via :func:`mount_ai_catalog`. The payload is serialized
109-
once and served with the CORS and caching headers discovery requires.
122+
once and served with the CORS and caching headers discovery requires; the
123+
route also answers the ``OPTIONS`` CORS preflight browsers send before a
124+
cross-origin conditional GET.
110125
"""
111126
body = catalog.model_dump_json(by_alias=True, exclude_none=True).encode()
112127

113128
async def endpoint(request: Request) -> Response:
114129
return discovery_response(request, body, AI_CATALOG_MEDIA_TYPE)
115130

116-
return Route(path, endpoint=endpoint, methods=["GET"], name="ai_catalog")
131+
return Route(path, endpoint=endpoint, methods=["GET", "OPTIONS"], name="ai_catalog")
117132

118133

119134
def mount_ai_catalog(app: Starlette, catalog: AICatalog, *, path: str = AI_CATALOG_WELL_KNOWN_PATH) -> None:

src/mcp/server/experimental/server_card.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,27 @@
4141

4242

4343
class _ServerIdentity(Protocol):
44-
"""The identity attributes shared by the low-level ``Server`` and ``MCPServer``."""
44+
"""The identity attributes shared by the low-level ``Server`` and ``MCPServer``.
4545
46-
name: str
47-
version: str | None
48-
title: str | None
49-
description: str | None
50-
website_url: str | None
51-
icons: list[Icon] | None
46+
The members are declared read-only (via ``@property``) so the protocol is
47+
satisfied by both the low-level ``Server`` (which stores ``version`` as a
48+
plain ``str``) and ``MCPServer`` (which exposes read-only properties). A
49+
writable attribute would be invariant and reject ``Server.version: str``
50+
against ``version: str | None``.
51+
"""
52+
53+
@property
54+
def name(self) -> str: ...
55+
@property
56+
def version(self) -> str | None: ...
57+
@property
58+
def title(self) -> str | None: ...
59+
@property
60+
def description(self) -> str | None: ...
61+
@property
62+
def website_url(self) -> str | None: ...
63+
@property
64+
def icons(self) -> list[Icon] | None: ...
5265

5366

5467
def build_server_card(
@@ -82,7 +95,7 @@ def build_server_card(
8295
pydantic.ValidationError: If the resulting card is invalid (e.g. ``name``
8396
is not reverse-DNS).
8497
"""
85-
if server.version is None:
98+
if not server.version:
8699
raise ValueError("server.version must be set to build a Server Card")
87100
if not server.description:
88101
raise ValueError("server.description must be set to build a Server Card")
@@ -100,22 +113,23 @@ def build_server_card(
100113

101114

102115
def server_card_route(card: ServerCard, *, path: str = "/server-card") -> Route:
103-
"""Build a Starlette GET route that serves ``card`` at ``path``.
116+
"""Build a Starlette route that serves ``card`` at ``path``.
104117
105118
``path`` defaults to ``/server-card``, the recommended location
106119
(``<streamable-http-url>/server-card``). Add the route to
107120
a new app — ``Starlette(routes=[server_card_route(card)])`` — or an existing
108121
one via :func:`mount_server_card`, and advertise the resulting URL in an AI
109122
Catalog entry. The payload is serialized once and served as
110123
``application/mcp-server-card+json`` with the CORS and caching headers
111-
discovery requires.
124+
discovery requires; the route also answers the ``OPTIONS`` CORS preflight
125+
browsers send before a cross-origin conditional GET.
112126
"""
113127
body = card.model_dump_json(by_alias=True, exclude_none=True).encode()
114128

115129
async def endpoint(request: Request) -> Response:
116130
return discovery_response(request, body, MCP_SERVER_CARD_MEDIA_TYPE)
117131

118-
return Route(path, endpoint=endpoint, methods=["GET"], name="mcp_server_card")
132+
return Route(path, endpoint=endpoint, methods=["GET", "OPTIONS"], name="mcp_server_card")
119133

120134

121135
def mount_server_card(app: Starlette, card: ServerCard, *, path: str = "/server-card") -> None:

src/mcp/shared/experimental/ai_catalog/types.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,9 @@ class CatalogEntry(MCPModel):
180180
identifier: str
181181
"""Identifier for the artifact; SHOULD be a URN or URI.
182182
183-
MCP server entries use ``urn:air:{publisher}:{name}``, where ``publisher`` is
184-
the forward-DNS form of the referenced Server Card's namespace and ``name``
185-
is its name suffix.
183+
MCP server entries use ``urn:air:{publisher}:mcp:{name}``, where ``publisher``
184+
is the forward-DNS form of the referenced Server Card's namespace and
185+
``name`` is its name suffix.
186186
"""
187187

188188
display_name: str | None = None

tests/experimental/ai_catalog/test_server.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ async def _head(app: Starlette, path: str) -> httpx2.Response:
4646
return await client.head(path)
4747

4848

49+
async def _options(app: Starlette, path: str) -> httpx2.Response:
50+
transport = httpx2.ASGITransport(app=app)
51+
async with httpx2.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
52+
return await client.options(path)
53+
54+
4955
async def test_ai_catalog_route_serves_catalog_with_discovery_headers() -> None:
5056
catalog = AICatalog(spec_version="1.0", entries=[server_card_entry(make_card(), CARD_URL)])
5157
app = Starlette(routes=[ai_catalog_route(catalog)])
@@ -55,7 +61,8 @@ async def test_ai_catalog_route_serves_catalog_with_discovery_headers() -> None:
5561
# Discovery requires CORS headers (MUST) and caching headers (SHOULD).
5662
assert response.headers["access-control-allow-origin"] == "*"
5763
assert response.headers["access-control-allow-methods"] == "GET"
58-
assert response.headers["access-control-allow-headers"] == "Content-Type"
64+
assert response.headers["access-control-allow-headers"] == "Content-Type, If-None-Match"
65+
assert response.headers["access-control-expose-headers"] == "ETag"
5966
assert response.headers["cache-control"] == "public, max-age=3600"
6067
etag = response.headers["etag"]
6168
assert re.fullmatch(r'"[0-9a-f]{64}"', etag)
@@ -68,7 +75,7 @@ async def test_ai_catalog_route_serves_catalog_with_discovery_headers() -> None:
6875
assert not_modified.headers["etag"] == etag
6976
assert not_modified.headers["access-control-allow-origin"] == "*"
7077
assert not_modified.headers["access-control-allow-methods"] == "GET"
71-
assert not_modified.headers["access-control-allow-headers"] == "Content-Type"
78+
assert not_modified.headers["access-control-allow-headers"] == "Content-Type, If-None-Match"
7279
assert not_modified.headers["cache-control"] == "public, max-age=3600"
7380
assert not_modified.content == b""
7481

@@ -87,6 +94,20 @@ async def test_ai_catalog_route_serves_catalog_with_discovery_headers() -> None:
8794
assert non_matching.text == catalog.model_dump_json(by_alias=True, exclude_none=True)
8895

8996

97+
async def test_ai_catalog_route_answers_cors_preflight() -> None:
98+
"""A browser conditional GET sends `If-None-Match`, a non-safelisted header,
99+
so it preflights with OPTIONS; the route must answer it with the CORS headers."""
100+
catalog = AICatalog(spec_version="1.0", entries=[server_card_entry(make_card(), CARD_URL)])
101+
app = Starlette(routes=[ai_catalog_route(catalog)])
102+
response = await _options(app, "/.well-known/ai-catalog.json")
103+
assert response.status_code == 204
104+
assert response.headers["access-control-allow-origin"] == "*"
105+
assert response.headers["access-control-allow-methods"] == "GET"
106+
assert response.headers["access-control-allow-headers"] == "Content-Type, If-None-Match"
107+
assert response.headers["access-control-expose-headers"] == "ETag"
108+
assert response.content == b""
109+
110+
90111
async def test_mount_ai_catalog_on_existing_app() -> None:
91112
app = Starlette()
92113
mount_ai_catalog(app, AICatalog(spec_version="1.0", entries=[]))

tests/experimental/server_card/test_server.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def test_build_server_card_from_server_identity() -> None:
4848

4949

5050
def test_build_server_card_requires_version() -> None:
51-
server = Server("no-version", description="desc") # version defaults to None
51+
server = Server("no-version", description="desc") # version defaults to ""
5252
with pytest.raises(ValueError) as excinfo:
5353
build_server_card(server, name="example/no-version")
5454
assert str(excinfo.value) == "server.version must be set to build a Server Card"
@@ -73,6 +73,12 @@ async def _head(app: Starlette, path: str) -> httpx2.Response:
7373
return await client.head(path)
7474

7575

76+
async def _options(app: Starlette, path: str) -> httpx2.Response:
77+
transport = httpx2.ASGITransport(app=app)
78+
async with httpx2.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
79+
return await client.options(path)
80+
81+
7682
async def test_server_card_route_serves_card_with_discovery_headers() -> None:
7783
card = build_server_card(make_server(), name="example/dice")
7884
app = Starlette(routes=[server_card_route(card, path=CARD_PATH)])
@@ -82,7 +88,8 @@ async def test_server_card_route_serves_card_with_discovery_headers() -> None:
8288
# Discovery requires CORS headers (MUST) and caching headers (SHOULD).
8389
assert response.headers["access-control-allow-origin"] == "*"
8490
assert response.headers["access-control-allow-methods"] == "GET"
85-
assert response.headers["access-control-allow-headers"] == "Content-Type"
91+
assert response.headers["access-control-allow-headers"] == "Content-Type, If-None-Match"
92+
assert response.headers["access-control-expose-headers"] == "ETag"
8693
assert response.headers["cache-control"] == "public, max-age=3600"
8794
etag = response.headers["etag"]
8895
assert re.fullmatch(r'"[0-9a-f]{64}"', etag)
@@ -112,6 +119,20 @@ async def test_server_card_route_serves_card_with_discovery_headers() -> None:
112119
assert non_matching.text == card.model_dump_json(by_alias=True, exclude_none=True)
113120

114121

122+
async def test_server_card_route_answers_cors_preflight() -> None:
123+
"""A browser conditional GET sends `If-None-Match`, a non-safelisted header,
124+
so it preflights with OPTIONS; the route must answer it with the CORS headers."""
125+
card = build_server_card(make_server(), name="example/dice")
126+
app = Starlette(routes=[server_card_route(card, path=CARD_PATH)])
127+
response = await _options(app, CARD_PATH)
128+
assert response.status_code == 204
129+
assert response.headers["access-control-allow-origin"] == "*"
130+
assert response.headers["access-control-allow-methods"] == "GET"
131+
assert response.headers["access-control-allow-headers"] == "Content-Type, If-None-Match"
132+
assert response.headers["access-control-expose-headers"] == "ETag"
133+
assert response.content == b""
134+
135+
115136
async def test_mount_server_card_on_existing_app_and_client_fetch() -> None:
116137
card = build_server_card(
117138
make_server(),

0 commit comments

Comments
 (0)