Skip to content

Commit b658e7c

Browse files
Add ETag support to discovery responses
Compute strong SHA-256 ETags for server-card and AI Catalog response bodies, handle matching If-None-Match requests with 304 responses, and cover conditional request behavior in tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 321181b commit b658e7c

4 files changed

Lines changed: 106 additions & 9 deletions

File tree

src/mcp/server/experimental/ai_catalog.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
from __future__ import annotations
1616

17+
import hashlib
18+
1719
from starlette.applications import Starlette
1820
from starlette.requests import Request
1921
from starlette.responses import Response
@@ -42,6 +44,37 @@
4244
}
4345

4446

47+
def _strong_etag(body: bytes) -> str:
48+
return f'"{hashlib.sha256(body).hexdigest()}"'
49+
50+
51+
def _if_none_match_matches(if_none_match: str | None, etag: str) -> bool:
52+
if if_none_match is None:
53+
return False
54+
for candidate in if_none_match.split(","):
55+
candidate = candidate.strip()
56+
if candidate == "*":
57+
return True
58+
if candidate.startswith(("W/", "w/")):
59+
candidate = candidate[2:].strip()
60+
if candidate == etag:
61+
return True
62+
return False
63+
64+
65+
def discovery_response(request: Request, body: bytes, media_type: str) -> Response:
66+
etag = _strong_etag(body)
67+
if _if_none_match_matches(request.headers.get("if-none-match"), etag):
68+
return Response(
69+
status_code=304,
70+
headers={
71+
"ETag": etag,
72+
"Cache-Control": DISCOVERY_HEADERS["Cache-Control"],
73+
},
74+
)
75+
return Response(body, media_type=media_type, headers={**DISCOVERY_HEADERS, "ETag": etag})
76+
77+
4578
def _air_identifier(card_name: str) -> str:
4679
"""Derive an AI Catalog ``urn:air:`` identifier from a Server Card name.
4780
@@ -82,8 +115,8 @@ def ai_catalog_route(catalog: AICatalog, *, path: str = AI_CATALOG_WELL_KNOWN_PA
82115
"""
83116
body = catalog.model_dump_json(by_alias=True, exclude_none=True).encode()
84117

85-
async def endpoint(_request: Request) -> Response:
86-
return Response(body, media_type=AI_CATALOG_MEDIA_TYPE, headers=DISCOVERY_HEADERS)
118+
async def endpoint(request: Request) -> Response:
119+
return discovery_response(request, body, AI_CATALOG_MEDIA_TYPE)
87120

88121
return Route(path, endpoint=endpoint, methods=["GET"], name="ai_catalog")
89122

src/mcp/server/experimental/server_card.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from starlette.responses import Response
2929
from starlette.routing import Route
3030

31-
from mcp.server.experimental.ai_catalog import DISCOVERY_HEADERS
31+
from mcp.server.experimental.ai_catalog import discovery_response
3232
from mcp.shared.experimental.ai_catalog.types import MCP_SERVER_CARD_MEDIA_TYPE
3333
from mcp.shared.experimental.server_card.types import (
3434
Icon,
@@ -112,8 +112,8 @@ def server_card_route(card: ServerCard, *, path: str = "/server-card") -> Route:
112112
"""
113113
body = card.model_dump_json(by_alias=True, exclude_none=True).encode()
114114

115-
async def endpoint(_request: Request) -> Response:
116-
return Response(body, media_type=MCP_SERVER_CARD_MEDIA_TYPE, headers=DISCOVERY_HEADERS)
115+
async def endpoint(request: Request) -> Response:
116+
return discovery_response(request, body, MCP_SERVER_CARD_MEDIA_TYPE)
117117

118118
return Route(path, endpoint=endpoint, methods=["GET"], name="mcp_server_card")
119119

tests/experimental/ai_catalog/test_server.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import re
6+
57
import httpx
68
import pytest
79
from starlette.applications import Starlette
@@ -39,10 +41,16 @@ def test_server_card_entry_falls_back_to_card_name_without_title() -> None:
3941
assert server_card_entry(make_card(), CARD_URL).display_name == "example/dice"
4042

4143

42-
async def _get(app: Starlette, path: str) -> httpx.Response:
44+
async def _get(app: Starlette, path: str, headers: dict[str, str] | None = None) -> httpx.Response:
45+
transport = httpx.ASGITransport(app=app)
46+
async with httpx.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
47+
return await client.get(path, headers=headers)
48+
49+
50+
async def _head(app: Starlette, path: str) -> httpx.Response:
4351
transport = httpx.ASGITransport(app=app)
4452
async with httpx.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
45-
return await client.get(path)
53+
return await client.head(path)
4654

4755

4856
async def test_ai_catalog_route_serves_catalog_with_discovery_headers() -> None:
@@ -56,8 +64,32 @@ async def test_ai_catalog_route_serves_catalog_with_discovery_headers() -> None:
5664
assert response.headers["access-control-allow-methods"] == "GET"
5765
assert response.headers["access-control-allow-headers"] == "Content-Type"
5866
assert response.headers["cache-control"] == "public, max-age=3600"
67+
etag = response.headers["etag"]
68+
assert re.fullmatch(r'"[0-9a-f]{64}"', etag)
69+
assert (await _get(app, "/.well-known/ai-catalog.json")).headers["etag"] == etag
70+
assert (await _head(app, "/.well-known/ai-catalog.json")).headers["etag"] == etag
5971
assert response.text == catalog.model_dump_json(by_alias=True, exclude_none=True)
6072

73+
not_modified = await _get(app, "/.well-known/ai-catalog.json", headers={"If-None-Match": etag})
74+
assert not_modified.status_code == 304
75+
assert not_modified.headers["etag"] == etag
76+
assert not_modified.headers["cache-control"] == "public, max-age=3600"
77+
assert not_modified.content == b""
78+
79+
weak_match = await _get(app, "/.well-known/ai-catalog.json", headers={"If-None-Match": f'"not-it", W/{etag}'})
80+
assert weak_match.status_code == 304
81+
assert weak_match.content == b""
82+
83+
wildcard = await _get(app, "/.well-known/ai-catalog.json", headers={"If-None-Match": "*"})
84+
assert wildcard.status_code == 304
85+
assert wildcard.headers["etag"] == etag
86+
assert wildcard.content == b""
87+
88+
non_matching = await _get(app, "/.well-known/ai-catalog.json", headers={"If-None-Match": '"not-it"'})
89+
assert non_matching.status_code == 200
90+
assert non_matching.headers["etag"] == etag
91+
assert non_matching.text == catalog.model_dump_json(by_alias=True, exclude_none=True)
92+
6193

6294
async def test_mount_ai_catalog_on_existing_app() -> None:
6395
app = Starlette()

tests/experimental/server_card/test_server.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import re
6+
57
import httpx
68
import pytest
79
from starlette.applications import Starlette
@@ -59,10 +61,16 @@ def test_build_server_card_requires_description() -> None:
5961
build_server_card(server, name="example/no-desc")
6062

6163

62-
async def _get(app: Starlette, path: str) -> httpx.Response:
64+
async def _get(app: Starlette, path: str, headers: dict[str, str] | None = None) -> httpx.Response:
65+
transport = httpx.ASGITransport(app=app)
66+
async with httpx.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
67+
return await client.get(path, headers=headers)
68+
69+
70+
async def _head(app: Starlette, path: str) -> httpx.Response:
6371
transport = httpx.ASGITransport(app=app)
6472
async with httpx.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
65-
return await client.get(path)
73+
return await client.head(path)
6674

6775

6876
async def test_server_card_route_serves_card_with_discovery_headers() -> None:
@@ -76,9 +84,33 @@ async def test_server_card_route_serves_card_with_discovery_headers() -> None:
7684
assert response.headers["access-control-allow-methods"] == "GET"
7785
assert response.headers["access-control-allow-headers"] == "Content-Type"
7886
assert response.headers["cache-control"] == "public, max-age=3600"
87+
etag = response.headers["etag"]
88+
assert re.fullmatch(r'"[0-9a-f]{64}"', etag)
89+
assert (await _get(app, CARD_PATH)).headers["etag"] == etag
90+
assert (await _head(app, CARD_PATH)).headers["etag"] == etag
7991
assert response.text == card.model_dump_json(by_alias=True, exclude_none=True)
8092
assert ServerCard.model_validate(response.json()) == card
8193

94+
not_modified = await _get(app, CARD_PATH, headers={"If-None-Match": etag})
95+
assert not_modified.status_code == 304
96+
assert not_modified.headers["etag"] == etag
97+
assert not_modified.headers["cache-control"] == "public, max-age=3600"
98+
assert not_modified.content == b""
99+
100+
weak_match = await _get(app, CARD_PATH, headers={"If-None-Match": f'"not-it", W/{etag}'})
101+
assert weak_match.status_code == 304
102+
assert weak_match.content == b""
103+
104+
wildcard = await _get(app, CARD_PATH, headers={"If-None-Match": "*"})
105+
assert wildcard.status_code == 304
106+
assert wildcard.headers["etag"] == etag
107+
assert wildcard.content == b""
108+
109+
non_matching = await _get(app, CARD_PATH, headers={"If-None-Match": '"not-it"'})
110+
assert non_matching.status_code == 200
111+
assert non_matching.headers["etag"] == etag
112+
assert non_matching.text == card.model_dump_json(by_alias=True, exclude_none=True)
113+
82114

83115
async def test_mount_server_card_on_existing_app_and_client_fetch() -> None:
84116
card = build_server_card(

0 commit comments

Comments
 (0)