Skip to content

Commit 0d55856

Browse files
fix(experimental): migrate server cards to httpx2
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 96e7cd05-3257-4fde-ad04-bf7098028bb6
1 parent c002308 commit 0d55856

7 files changed

Lines changed: 69 additions & 69 deletions

File tree

docs_src/server_cards/tutorial004.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import httpx
1+
import httpx2
22

33
from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url
44
from mcp.client.experimental.server_card import fetch_server_card
@@ -10,7 +10,7 @@ async def main() -> None:
1010
# before fetching cards. Pass your own `http_client` to enforce a network
1111
# policy (timeouts, redirect caps, blocking private address ranges) when
1212
# discovering hosts you do not fully trust.
13-
async with httpx.AsyncClient() as http_client:
13+
async with httpx2.AsyncClient() as http_client:
1414
catalog_url = well_known_ai_catalog_url("https://dice.example.com")
1515
catalog = await fetch_ai_catalog(catalog_url, http_client=http_client)
1616

src/mcp/client/experimental/ai_catalog.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from urllib.parse import urljoin, urlsplit
2222

23-
import httpx
23+
import httpx2
2424

2525
from mcp.shared._httpx_utils import create_mcp_http_client
2626
from mcp.shared.experimental.ai_catalog.types import (
@@ -47,7 +47,7 @@ def well_known_ai_catalog_url(url: str, *, well_known_path: str = AI_CATALOG_WEL
4747
return urljoin(f"{parts.scheme}://{parts.netloc}", well_known_path)
4848

4949

50-
async def fetch_ai_catalog(url: str, *, http_client: httpx.AsyncClient | None = None) -> AICatalog:
50+
async def fetch_ai_catalog(url: str, *, http_client: httpx2.AsyncClient | None = None) -> AICatalog:
5151
"""Fetch and validate the AI Catalog at ``url``.
5252
5353
``url`` is fetched as-is — catalogs are location-independent; use
@@ -56,7 +56,7 @@ async def fetch_ai_catalog(url: str, *, http_client: httpx.AsyncClient | None =
5656
auth, otherwise a short-lived client with MCP defaults is used.
5757
5858
Raises:
59-
httpx.HTTPError: If the request fails or returns a non-2xx status.
59+
httpx2.HTTPError: If the request fails or returns a non-2xx status.
6060
pydantic.ValidationError: If the document is not a valid AI Catalog.
6161
"""
6262
if http_client is None:

src/mcp/client/experimental/server_card.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from pathlib import Path
2323
from urllib.parse import urljoin, urlsplit
2424

25-
import httpx
25+
import httpx2
2626

2727
from mcp.client.experimental.ai_catalog import fetch_ai_catalog, well_known_ai_catalog_url
2828
from mcp.shared._httpx_utils import create_mcp_http_client
@@ -35,15 +35,15 @@
3535
__all__ = ["fetch_server_card", "load_server_card", "discover_server_cards"]
3636

3737

38-
async def fetch_server_card(url: str, *, http_client: httpx.AsyncClient | None = None) -> ServerCard:
38+
async def fetch_server_card(url: str, *, http_client: httpx2.AsyncClient | None = None) -> ServerCard:
3939
"""Fetch and validate the Server Card at ``url``.
4040
4141
``url`` is the card's location, typically taken from an AI Catalog
4242
entry's ``url``. Pass an existing ``http_client`` to reuse connection
4343
pooling / auth, otherwise a short-lived client with MCP defaults is used.
4444
4545
Raises:
46-
httpx.HTTPError: If the request fails or returns a non-2xx status.
46+
httpx2.HTTPError: If the request fails or returns a non-2xx status.
4747
pydantic.ValidationError: If the document is not a valid Server Card.
4848
"""
4949
if http_client is None:
@@ -54,7 +54,7 @@ async def fetch_server_card(url: str, *, http_client: httpx.AsyncClient | None =
5454
return ServerCard.model_validate(response.json())
5555

5656

57-
async def discover_server_cards(url: str, *, http_client: httpx.AsyncClient | None = None) -> list[ServerCard]:
57+
async def discover_server_cards(url: str, *, http_client: httpx2.AsyncClient | None = None) -> list[ServerCard]:
5858
"""Discover the MCP servers advertised by the host of ``url``.
5959
6060
Fetches the host's AI Catalog from ``/.well-known/ai-catalog.json``
@@ -73,7 +73,7 @@ async def discover_server_cards(url: str, *, http_client: httpx.AsyncClient | No
7373
Raises:
7474
ValueError: If ``url`` is not an absolute http(s) URL, or the catalog
7575
references a card at a non-http(s) URL.
76-
httpx.HTTPError: If a request fails or returns a non-2xx status.
76+
httpx2.HTTPError: If a request fails or returns a non-2xx status.
7777
pydantic.ValidationError: If the catalog or a referenced card is invalid.
7878
"""
7979
if http_client is None:
@@ -83,7 +83,7 @@ async def discover_server_cards(url: str, *, http_client: httpx.AsyncClient | No
8383
catalog_url = well_known_ai_catalog_url(url)
8484
try:
8585
catalog = await fetch_ai_catalog(catalog_url, http_client=http_client)
86-
except httpx.HTTPStatusError as exc:
86+
except httpx2.HTTPStatusError as exc:
8787
if exc.response.status_code != 404:
8888
raise
8989
catalog_url = well_known_ai_catalog_url(url, well_known_path=MCP_CATALOG_WELL_KNOWN_PATH)

tests/experimental/ai_catalog/test_client.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import functools
66

7-
import httpx
7+
import httpx2
88
import pytest
99
from pydantic import ValidationError
1010
from starlette.applications import Starlette
@@ -49,8 +49,8 @@ def test_well_known_ai_catalog_url_rejects_non_http_scheme() -> None:
4949

5050
async def test_fetch_with_provided_client() -> None:
5151
app = Starlette(routes=[ai_catalog_route(CATALOG)])
52-
transport = httpx.ASGITransport(app=app)
53-
async with httpx.AsyncClient(transport=transport) as client:
52+
transport = httpx2.ASGITransport(app=app)
53+
async with httpx2.AsyncClient(transport=transport) as client:
5454
catalog = await fetch_ai_catalog("https://example.com/.well-known/ai-catalog.json", http_client=client)
5555
assert catalog == CATALOG
5656

@@ -59,11 +59,11 @@ async def test_fetch_with_default_client(monkeypatch: pytest.MonkeyPatch) -> Non
5959
# Cover the branch that creates its own client, without touching the
6060
# network: bind the module's client factory to an in-memory ASGI transport.
6161
app = Starlette(routes=[ai_catalog_route(CATALOG)])
62-
transport = httpx.ASGITransport(app=app)
62+
transport = httpx2.ASGITransport(app=app)
6363
monkeypatch.setattr(
6464
client_module,
6565
"create_mcp_http_client",
66-
functools.partial(httpx.AsyncClient, transport=transport, follow_redirects=True),
66+
functools.partial(httpx2.AsyncClient, transport=transport, follow_redirects=True),
6767
)
6868
catalog = await fetch_ai_catalog("https://example.com/.well-known/ai-catalog.json")
6969
assert catalog == CATALOG
@@ -74,15 +74,15 @@ async def bad(_request: object) -> JSONResponse:
7474
return JSONResponse({"specVersion": "1.0"}) # entries missing
7575

7676
app = Starlette(routes=[Route("/.well-known/ai-catalog.json", bad, methods=["GET"])])
77-
transport = httpx.ASGITransport(app=app)
78-
async with httpx.AsyncClient(transport=transport) as client:
77+
transport = httpx2.ASGITransport(app=app)
78+
async with httpx2.AsyncClient(transport=transport) as client:
7979
with pytest.raises(ValidationError):
8080
await fetch_ai_catalog("https://example.com/.well-known/ai-catalog.json", http_client=client)
8181

8282

8383
async def test_fetch_raises_for_http_error() -> None:
8484
app = Starlette(routes=[]) # nothing at the well-known path -> 404
85-
transport = httpx.ASGITransport(app=app)
86-
async with httpx.AsyncClient(transport=transport) as client:
87-
with pytest.raises(httpx.HTTPStatusError):
85+
transport = httpx2.ASGITransport(app=app)
86+
async with httpx2.AsyncClient(transport=transport) as client:
87+
with pytest.raises(httpx2.HTTPStatusError):
8888
await fetch_ai_catalog("https://example.com/.well-known/ai-catalog.json", http_client=client)

tests/experimental/ai_catalog/test_server.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import re
66

7-
import httpx
7+
import httpx2
88
import pytest
99
from starlette.applications import Starlette
1010

@@ -41,15 +41,15 @@ def test_server_card_entry_falls_back_to_card_name_without_title() -> None:
4141
assert server_card_entry(make_card(), CARD_URL).display_name == "example/dice"
4242

4343

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:
44+
async def _get(app: Starlette, path: str, headers: dict[str, str] | None = None) -> httpx2.Response:
45+
transport = httpx2.ASGITransport(app=app)
46+
async with httpx2.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
4747
return await client.get(path, headers=headers)
4848

4949

50-
async def _head(app: Starlette, path: str) -> httpx.Response:
51-
transport = httpx.ASGITransport(app=app)
52-
async with httpx.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
50+
async def _head(app: Starlette, path: str) -> httpx2.Response:
51+
transport = httpx2.ASGITransport(app=app)
52+
async with httpx2.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
5353
return await client.head(path)
5454

5555

tests/experimental/server_card/test_client.py

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
from pathlib import Path
88

9-
import httpx
9+
import httpx2
1010
import pytest
1111
from pydantic import ValidationError
1212
from starlette.applications import Starlette
@@ -39,20 +39,20 @@ def make_discovery_app(*entries: CatalogEntry, catalog_path: str | None = None)
3939

4040

4141
async def test_fetch_server_card_from_url() -> None:
42-
transport = httpx.ASGITransport(app=make_discovery_app())
43-
async with httpx.AsyncClient(transport=transport) as client:
42+
transport = httpx2.ASGITransport(app=make_discovery_app())
43+
async with httpx2.AsyncClient(transport=transport) as client:
4444
card = await fetch_server_card(CARD_URL, http_client=client)
4545
assert card == CARD
4646

4747

4848
async def test_fetch_server_card_with_default_client(monkeypatch: pytest.MonkeyPatch) -> None:
4949
# Cover the branch that creates its own client, without touching the
5050
# network: bind the module's client factory to an in-memory ASGI transport.
51-
transport = httpx.ASGITransport(app=make_discovery_app())
51+
transport = httpx2.ASGITransport(app=make_discovery_app())
5252
monkeypatch.setattr(
5353
client_module,
5454
"create_mcp_http_client",
55-
functools.partial(httpx.AsyncClient, transport=transport, follow_redirects=True),
55+
functools.partial(httpx2.AsyncClient, transport=transport, follow_redirects=True),
5656
)
5757
assert await fetch_server_card(CARD_URL) == CARD
5858

@@ -62,43 +62,43 @@ async def bad(_request: object) -> JSONResponse:
6262
return JSONResponse({"name": "missing-required-fields"})
6363

6464
app = Starlette(routes=[Route(CARD_PATH, bad, methods=["GET"])])
65-
transport = httpx.ASGITransport(app=app)
66-
async with httpx.AsyncClient(transport=transport) as client:
65+
transport = httpx2.ASGITransport(app=app)
66+
async with httpx2.AsyncClient(transport=transport) as client:
6767
with pytest.raises(ValidationError):
6868
await fetch_server_card(CARD_URL, http_client=client)
6969

7070

7171
async def test_fetch_raises_for_http_error() -> None:
7272
app = Starlette(routes=[]) # nothing at the card URL -> 404
73-
transport = httpx.ASGITransport(app=app)
74-
async with httpx.AsyncClient(transport=transport) as client:
75-
with pytest.raises(httpx.HTTPStatusError):
73+
transport = httpx2.ASGITransport(app=app)
74+
async with httpx2.AsyncClient(transport=transport) as client:
75+
with pytest.raises(httpx2.HTTPStatusError):
7676
await fetch_server_card(CARD_URL, http_client=client)
7777

7878

7979
async def test_discover_server_cards_via_well_known_catalog() -> None:
80-
transport = httpx.ASGITransport(app=make_discovery_app())
81-
async with httpx.AsyncClient(transport=transport) as client:
80+
transport = httpx2.ASGITransport(app=make_discovery_app())
81+
async with httpx2.AsyncClient(transport=transport) as client:
8282
cards = await discover_server_cards("https://example.com", http_client=client)
8383
assert cards == [CARD]
8484

8585

8686
async def test_discover_server_cards_with_default_client(monkeypatch: pytest.MonkeyPatch) -> None:
8787
# Cover the branch that creates its own client, without touching the
8888
# network: bind the module's client factory to an in-memory ASGI transport.
89-
transport = httpx.ASGITransport(app=make_discovery_app())
89+
transport = httpx2.ASGITransport(app=make_discovery_app())
9090
monkeypatch.setattr(
9191
client_module,
9292
"create_mcp_http_client",
93-
functools.partial(httpx.AsyncClient, transport=transport, follow_redirects=True),
93+
functools.partial(httpx2.AsyncClient, transport=transport, follow_redirects=True),
9494
)
9595
assert await discover_server_cards("https://example.com") == [CARD]
9696

9797

9898
async def test_discover_server_cards_resolves_relative_entry_url() -> None:
9999
entry = server_card_entry(CARD, CARD_PATH) # relative to the catalog location
100-
transport = httpx.ASGITransport(app=make_discovery_app(entry))
101-
async with httpx.AsyncClient(transport=transport) as client:
100+
transport = httpx2.ASGITransport(app=make_discovery_app(entry))
101+
async with httpx2.AsyncClient(transport=transport) as client:
102102
cards = await discover_server_cards("https://example.com/mcp", http_client=client)
103103
assert cards == [CARD]
104104

@@ -110,8 +110,8 @@ async def test_discover_server_cards_reads_inline_data_entries() -> None:
110110
media_type="application/mcp-server-card+json",
111111
data=CARD.model_dump(mode="json", by_alias=True, exclude_none=True),
112112
)
113-
transport = httpx.ASGITransport(app=make_discovery_app(entry))
114-
async with httpx.AsyncClient(transport=transport) as client:
113+
transport = httpx2.ASGITransport(app=make_discovery_app(entry))
114+
async with httpx2.AsyncClient(transport=transport) as client:
115115
cards = await discover_server_cards("https://example.com", http_client=client)
116116
assert cards == [CARD]
117117

@@ -125,17 +125,17 @@ async def test_discover_server_cards_ignores_non_card_entries() -> None:
125125
url="https://example.com/agent.json",
126126
)
127127
app = make_discovery_app(server_card_entry(CARD, CARD_URL), other)
128-
transport = httpx.ASGITransport(app=app)
129-
async with httpx.AsyncClient(transport=transport) as client:
128+
transport = httpx2.ASGITransport(app=app)
129+
async with httpx2.AsyncClient(transport=transport) as client:
130130
cards = await discover_server_cards("https://example.com", http_client=client)
131131
assert cards == [CARD]
132132

133133

134134
async def test_discover_server_cards_rejects_non_http_card_url() -> None:
135135
"""A hostile catalog must not steer the client to non-http(s) schemes."""
136136
entry = server_card_entry(CARD, CARD_URL).model_copy(update={"url": "file:///etc/passwd"})
137-
transport = httpx.ASGITransport(app=make_discovery_app(entry))
138-
async with httpx.AsyncClient(transport=transport) as client:
137+
transport = httpx2.ASGITransport(app=make_discovery_app(entry))
138+
async with httpx2.AsyncClient(transport=transport) as client:
139139
with pytest.raises(ValueError, match="non-http"):
140140
await discover_server_cards("https://example.com", http_client=client)
141141

@@ -147,25 +147,25 @@ async def test_discover_server_cards_ignores_non_mcp_entries() -> None:
147147
media_type="application/a2a-agent-card+json",
148148
url="https://agents.example.com/researchAssistant",
149149
)
150-
transport = httpx.ASGITransport(app=make_discovery_app(agent_entry, server_card_entry(CARD, CARD_URL)))
151-
async with httpx.AsyncClient(transport=transport) as client:
150+
transport = httpx2.ASGITransport(app=make_discovery_app(agent_entry, server_card_entry(CARD, CARD_URL)))
151+
async with httpx2.AsyncClient(transport=transport) as client:
152152
cards = await discover_server_cards("https://example.com", http_client=client)
153153
assert cards == [CARD]
154154

155155

156156
async def test_discover_server_cards_falls_back_to_mcp_catalog_path() -> None:
157157
app = make_discovery_app(catalog_path=MCP_CATALOG_WELL_KNOWN_PATH) # no /.well-known/ai-catalog.json
158-
transport = httpx.ASGITransport(app=app)
159-
async with httpx.AsyncClient(transport=transport) as client:
158+
transport = httpx2.ASGITransport(app=app)
159+
async with httpx2.AsyncClient(transport=transport) as client:
160160
cards = await discover_server_cards("https://example.com", http_client=client)
161161
assert cards == [CARD]
162162

163163

164164
async def test_discover_server_cards_raises_when_no_catalog_exists() -> None:
165165
app = Starlette(routes=[]) # 404 on both well-known paths
166-
transport = httpx.ASGITransport(app=app)
167-
async with httpx.AsyncClient(transport=transport) as client:
168-
with pytest.raises(httpx.HTTPStatusError):
166+
transport = httpx2.ASGITransport(app=app)
167+
async with httpx2.AsyncClient(transport=transport) as client:
168+
with pytest.raises(httpx2.HTTPStatusError):
169169
await discover_server_cards("https://example.com", http_client=client)
170170

171171

@@ -174,9 +174,9 @@ async def error(_request: object) -> Response:
174174
return Response(status_code=500)
175175

176176
app = Starlette(routes=[Route("/.well-known/ai-catalog.json", error, methods=["GET"])])
177-
transport = httpx.ASGITransport(app=app)
178-
async with httpx.AsyncClient(transport=transport) as client:
179-
with pytest.raises(httpx.HTTPStatusError) as excinfo:
177+
transport = httpx2.ASGITransport(app=app)
178+
async with httpx2.AsyncClient(transport=transport) as client:
179+
with pytest.raises(httpx2.HTTPStatusError) as excinfo:
180180
await discover_server_cards("https://example.com", http_client=client)
181181
assert excinfo.value.response.status_code == 500
182182

tests/experimental/server_card/test_server.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import re
66

7-
import httpx
7+
import httpx2
88
import pytest
99
from starlette.applications import Starlette
1010

@@ -61,15 +61,15 @@ def test_build_server_card_requires_description() -> None:
6161
build_server_card(server, name="example/no-desc")
6262

6363

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:
64+
async def _get(app: Starlette, path: str, headers: dict[str, str] | None = None) -> httpx2.Response:
65+
transport = httpx2.ASGITransport(app=app)
66+
async with httpx2.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
6767
return await client.get(path, headers=headers)
6868

6969

70-
async def _head(app: Starlette, path: str) -> httpx.Response:
71-
transport = httpx.ASGITransport(app=app)
72-
async with httpx.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
70+
async def _head(app: Starlette, path: str) -> httpx2.Response:
71+
transport = httpx2.ASGITransport(app=app)
72+
async with httpx2.AsyncClient(transport=transport, base_url="https://dice.example.com") as client:
7373
return await client.head(path)
7474

7575

@@ -121,7 +121,7 @@ async def test_mount_server_card_on_existing_app_and_client_fetch() -> None:
121121
app = Starlette()
122122
mount_server_card(app, card, path=CARD_PATH)
123123

124-
transport = httpx.ASGITransport(app=app)
125-
async with httpx.AsyncClient(transport=transport) as client:
124+
transport = httpx2.ASGITransport(app=app)
125+
async with httpx2.AsyncClient(transport=transport) as client:
126126
fetched = await fetch_server_card(f"https://dice.example.com{CARD_PATH}", http_client=client)
127127
assert fetched == card

0 commit comments

Comments
 (0)