Replace httpx and httpx-sse with httpx2 (#2972)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-07-14 18:05:08 +02:00 • committed by GitHub
commit 2713b53b12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,4 +1,4 @@
"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`.
"""Unit tests for the standalone SEP-990 jwt-bearer `httpx2.Auth`.
The provider's authorization server is configuration; these tests assert that authorization-server
metadata is fetched only from the configured issuer, that the resource server is never consulted for
@ -9,7 +9,7 @@ import base64
import json
import urllib.parse
import httpx
import httpx2
import pytest
from mcp.client.auth import OAuthFlowError, OAuthTokenError
@ -81,13 +81,13 @@ def make_provider(
def mock_transport(
requests: list[httpx.Request],
requests: list[httpx2.Request],
*,
asm: bytes | int = 200,
token: bytes | int = 200,
rs_first_status: int = 401,
rs_first_headers: dict[str, str] | None = None,
) -> httpx.MockTransport:
) -> httpx2.MockTransport:
"""Build a `MockTransport` that records every request and serves the configured ASM and token.
`asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body).
@ -96,41 +96,41 @@ def mock_transport(
"""
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
def handle(request: httpx2.Request) -> httpx2.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
if rs_hits == 1:
return httpx.Response(rs_first_status, headers=rs_first_headers or {})
return httpx.Response(200, json={"ok": True})
return httpx2.Response(rs_first_status, headers=rs_first_headers or {})
return httpx2.Response(200, json={"ok": True})
if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH):
if isinstance(asm, int):
return httpx.Response(asm)
return httpx.Response(200, content=asm, headers={"content-type": "application/json"})
return httpx2.Response(asm)
return httpx2.Response(200, content=asm, headers={"content-type": "application/json"})
if host == "auth.example.com" and path == "/token":
if isinstance(token, int):
return httpx.Response(token, json={"error": "invalid_grant"})
return httpx.Response(200, content=token, headers={"content-type": "application/json"})
return httpx2.Response(token, json={"error": "invalid_grant"})
return httpx2.Response(200, content=token, headers={"content-type": "application/json"})
raise AssertionError(f"unexpected request: {request.method} {request.url}") # pragma: no cover
return httpx.MockTransport(handle)
return httpx2.MockTransport(handle)
def form(request: httpx.Request) -> dict[str, str]:
def form(request: httpx2.Request) -> dict[str, str]:
return dict(urllib.parse.parse_qsl(request.content.decode()))
@pytest.mark.anyio
async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None:
"""A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
record: list[tuple[str, str]] = []
storage = InMemoryStorage()
auth = make_provider(storage, record=record)
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth
) as http:
response = await http.post(f"{RS}/mcp")
@ -165,10 +165,10 @@ async def test_resource_server_metadata_is_never_consulted() -> None:
This is the by-construction property: the AS is configuration, so the resource server has no
input into where the ID-JAG or client secret go. Any GET to the RS host fails the test.
"""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
auth = make_provider()
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
@ -183,11 +183,11 @@ async def test_resource_server_metadata_is_never_consulted() -> None:
@pytest.mark.anyio
async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None:
"""If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http:
async with httpx2.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
@ -200,10 +200,10 @@ async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() ->
@pytest.mark.anyio
async def test_asm_5xx_stops_discovery_and_raises() -> None:
"""A 5xx at the issuer's well-known stops discovery without trying further URLs."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http:
async with httpx2.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
@ -213,11 +213,11 @@ async def test_asm_5xx_stops_discovery_and_raises() -> None:
@pytest.mark.anyio
async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None:
"""RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="issuer mismatch"):
@ -230,11 +230,11 @@ async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> N
@pytest.mark.anyio
async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None:
"""A `token_endpoint` off the configured issuer's origin is refused before any credential is sent."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"):
@ -247,7 +247,7 @@ async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_ass
@pytest.mark.anyio
async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None:
"""A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
auth = make_provider(scope="mcp")
transport = mock_transport(
@ -257,7 +257,7 @@ async def test_403_insufficient_scope_unions_challenged_scope_with_configured()
rs_first_status=403,
rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'},
)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
async with httpx2.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
@ -268,12 +268,12 @@ async def test_403_insufficient_scope_unions_challenged_scope_with_configured()
@pytest.mark.anyio
async def test_403_without_insufficient_scope_does_not_reauthorize() -> None:
"""A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"})
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
async with httpx2.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 403
@ -283,20 +283,20 @@ async def test_403_without_insufficient_scope_does_not_reauthorize() -> None:
@pytest.mark.anyio
async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None:
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http:
async with httpx2.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http:
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
await http.post(f"{RS}/mcp")
@pytest.mark.anyio
async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
auth = make_provider(token_endpoint_auth_method="client_secret_basic")
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
@ -310,12 +310,12 @@ async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
@pytest.mark.anyio
async def test_stored_token_is_reused_without_reauthorizing() -> None:
"""A valid stored token is sent on the first request; on success no ASM or /token is fetched."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600))
auth = make_provider(storage)
transport = mock_transport(requests, rs_first_status=200)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
async with httpx2.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 200
@ -326,25 +326,25 @@ async def test_stored_token_is_reused_without_reauthorizing() -> None:
@pytest.mark.anyio
async def test_second_401_re_exchanges_without_refetching_asm() -> None:
"""ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
def handle(request: httpx2.Request) -> httpx2.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
# First and third RS hits draw a 401; second and fourth succeed.
return httpx.Response(401 if rs_hits in (1, 3) else 200)
return httpx2.Response(401 if rs_hits in (1, 3) else 200)
if host == "auth.example.com" and path == ASM_PATH:
return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"})
return httpx2.Response(200, content=asm_body(), headers={"content-type": "application/json"})
assert host == "auth.example.com" and path == "/token"
return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"})
return httpx2.Response(200, content=token_body(), headers={"content-type": "application/json"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http:
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle), auth=auth) as http:
await http.post(f"{RS}/mcp")
await http.post(f"{RS}/mcp")
@ -358,11 +358,11 @@ async def test_second_401_re_exchanges_without_refetching_asm() -> None:
@pytest.mark.anyio
async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None:
"""With no configured scope and no scope in the token response, the stored token records None."""
requests: list[httpx.Request] = []
requests: list[httpx2.Request] = []
storage = InMemoryStorage()
auth = make_provider(storage, scope=None)
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")