Skip to content

Commit 5e4acb5

Browse files
committed
fix: complete the spec's CORS header set on discovery responses
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.
1 parent e3536df commit 5e4acb5

3 files changed

Lines changed: 24 additions & 13 deletions

File tree

src/mcp/server/experimental/server_card.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
_CORS_HEADERS = {
5050
"Access-Control-Allow-Origin": "*",
5151
"Access-Control-Allow-Methods": "GET",
52-
"Access-Control-Allow-Headers": "Content-Type",
52+
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
53+
"Access-Control-Expose-Headers": "ETag",
5354
}
5455

5556

@@ -102,14 +103,15 @@ def _cors_endpoint(handler: Callable[[Request], Awaitable[Response]]) -> ASGIApp
102103
The middleware short-circuits real browser preflights (OPTIONS with an
103104
`Origin` and a requested method), answering with `GET` as the only allowed
104105
method; Starlette also advertises the CORS-safelisted request headers
105-
beside `Content-Type` there, a valid superset of the spec's example. A
106-
bare OPTIONS still reaches `discovery_response`.
106+
beside `Content-Type` and `If-None-Match` there, a valid superset of the
107+
spec's example. A bare OPTIONS still reaches `discovery_response`.
107108
"""
108109
return CORSMiddleware(
109110
app=request_response(handler),
110111
allow_origins=["*"],
111112
allow_methods=["GET"],
112-
allow_headers=["Content-Type"],
113+
allow_headers=["Content-Type", "If-None-Match"],
114+
expose_headers=["ETag"],
113115
)
114116

115117

tests/docs_src/test_server_cards.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ async def test_mount_discovery_serves_both_endpoints_with_the_spec_headers() ->
5252
assert card_response.headers["content-type"] == "application/mcp-server-card+json"
5353
assert card_response.headers["access-control-allow-origin"] == "*"
5454
assert card_response.headers["access-control-allow-methods"] == "GET"
55-
assert card_response.headers["access-control-allow-headers"] == "Content-Type"
55+
assert card_response.headers["access-control-allow-headers"] == "Content-Type, If-None-Match"
56+
assert card_response.headers["access-control-expose-headers"] == "ETag"
5657
assert card_response.headers["cache-control"] == "public, max-age=3600"
5758
assert catalog_response.headers["content-type"] == "application/ai-catalog+json"
5859
assert (revalidated.status_code, revalidated.content) == (304, b"")

tests/server/experimental/test_server_card.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,16 @@ async def test_card_is_served_with_its_media_type_at_the_reserved_path() -> None
6262
assert ServerCard.model_validate_json(response.content) == card
6363

6464

65-
async def test_card_responses_carry_the_three_cors_headers() -> None:
66-
"""Spec-mandated MUST: `Access-Control-Allow-Origin: *`, `-Methods: GET` and
67-
`-Headers: Content-Type` on the card response."""
65+
async def test_card_responses_carry_the_four_cors_headers() -> None:
66+
"""Spec-mandated MUST: `Access-Control-Allow-Origin: *`, `-Methods: GET`,
67+
`-Headers: Content-Type, If-None-Match` and `Access-Control-Expose-Headers: ETag`
68+
on the card response, so browser clients can revalidate cross-origin."""
6869
async with _client_for(Starlette(routes=create_server_card_routes(_card()))) as client:
6970
response = await client.get("/mcp/server-card")
7071
assert response.headers["access-control-allow-origin"] == "*"
7172
assert response.headers["access-control-allow-methods"] == "GET"
72-
assert response.headers["access-control-allow-headers"] == "Content-Type"
73+
assert response.headers["access-control-allow-headers"] == "Content-Type, If-None-Match"
74+
assert response.headers["access-control-expose-headers"] == "ETag"
7375

7476

7577
async def test_card_responses_carry_the_default_cache_control() -> None:
@@ -90,17 +92,23 @@ async def test_options_preflight_returns_the_cors_headers_and_no_body() -> None:
9092

9193

9294
async def test_browser_preflight_through_the_cors_middleware_is_allowed() -> None:
93-
"""Spec-mandated: a real browser preflight (Origin plus requested method) succeeds
94-
and advertises GET as the only allowed method, so web-based hosts can fetch the card
95-
cross-origin. Starlette answers this one, not `discovery_response`."""
95+
"""Spec-mandated: a real browser preflight (Origin plus requested method) succeeds,
96+
advertises GET as the only allowed method and allows the `If-None-Match` request
97+
header, so web-based hosts can fetch and revalidate the card cross-origin.
98+
Starlette answers this one, not `discovery_response`."""
9699
async with _client_for(Starlette(routes=create_server_card_routes(_card()))) as client:
97100
response = await client.options(
98101
"/mcp/server-card",
99-
headers={"Origin": "https://host.example.org", "Access-Control-Request-Method": "GET"},
102+
headers={
103+
"Origin": "https://host.example.org",
104+
"Access-Control-Request-Method": "GET",
105+
"Access-Control-Request-Headers": "if-none-match",
106+
},
100107
)
101108
assert response.status_code == 200
102109
assert response.headers["access-control-allow-origin"] == "*"
103110
assert response.headers["access-control-allow-methods"] == "GET"
111+
assert "if-none-match" in response.headers["access-control-allow-headers"].lower()
104112

105113

106114
# -- ETag / If-None-Match ----------------------------------------------------------------

0 commit comments

Comments
 (0)