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
51 changed files with 1029 additions and 948 deletions

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")

View file

@ -6,7 +6,7 @@ import time
from unittest import mock
from urllib.parse import parse_qs, quote, unquote, urlparse
import httpx
import httpx2
import pytest
from inline_snapshot import Is, snapshot
from pydantic import AnyHttpUrl, AnyUrl
@ -111,7 +111,7 @@ def oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockToken
@pytest.fixture
def prm_metadata_response():
"""PRM metadata response with scopes."""
return httpx.Response(
return httpx2.Response(
200,
content=(
b'{"resource": "https://api.example.com/v1/mcp", '
@ -124,7 +124,7 @@ def prm_metadata_response():
@pytest.fixture
def prm_metadata_without_scopes_response():
"""PRM metadata response without scopes."""
return httpx.Response(
return httpx2.Response(
200,
content=(
b'{"resource": "https://api.example.com/v1/mcp", '
@ -137,20 +137,20 @@ def prm_metadata_without_scopes_response():
@pytest.fixture
def init_response_with_www_auth_scope():
"""Initial 401 response with WWW-Authenticate header containing scope."""
return httpx.Response(
return httpx2.Response(
401,
headers={"WWW-Authenticate": 'Bearer scope="special:scope from:www-authenticate"'},
request=httpx.Request("GET", "https://api.example.com/test"),
request=httpx2.Request("GET", "https://api.example.com/test"),
)
@pytest.fixture
def init_response_without_www_auth_scope():
"""Initial 401 response without WWW-Authenticate scope."""
return httpx.Response(
return httpx2.Response(
401,
headers={},
request=httpx.Request("GET", "https://api.example.com/test"),
request=httpx2.Request("GET", "https://api.example.com/test"),
)
@ -290,8 +290,8 @@ class TestOAuthFlow:
)
# Test without WWW-Authenticate (fallback)
init_response = httpx.Response(
status_code=401, headers={}, request=httpx.Request("GET", "https://request-api.example.com")
init_response = httpx2.Response(
status_code=401, headers={}, request=httpx2.Request("GET", "https://request-api.example.com")
)
urls = build_protected_resource_metadata_discovery_urls(
@ -408,7 +408,7 @@ class TestOAuthFallback:
)
# Create a test request
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/v1/mcp")
# Mock the auth flow
auth_flow = oauth_provider.async_auth_flow(test_request)
@ -418,7 +418,7 @@ class TestOAuthFallback:
assert "Authorization" not in request.headers
# Send a 401 response to trigger the OAuth flow
response = httpx.Response(
response = httpx2.Response(
401,
headers={
"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
@ -433,7 +433,7 @@ class TestOAuthFallback:
# Send a successful discovery response with minimal protected resource metadata
# Note: auth server URL has a path (/v1/mcp), so only path-based URLs will be tried
discovery_response = httpx.Response(
discovery_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com/v1/mcp"]}',
request=discovery_request,
@ -448,7 +448,7 @@ class TestOAuthFallback:
assert oauth_metadata_request_1.method == "GET"
# Send a 404 response
oauth_metadata_response_1 = httpx.Response(
oauth_metadata_response_1 = httpx2.Response(
404,
content=b"Not Found",
request=oauth_metadata_request_1,
@ -460,7 +460,7 @@ class TestOAuthFallback:
assert oauth_metadata_request_2.method == "GET"
# Send a 400 response
oauth_metadata_response_2 = httpx.Response(
oauth_metadata_response_2 = httpx2.Response(
400,
content=b"Bad Request",
request=oauth_metadata_request_2,
@ -472,7 +472,7 @@ class TestOAuthFallback:
assert oauth_metadata_request_3.method == "GET"
# Send a 500 response
oauth_metadata_response_3 = httpx.Response(
oauth_metadata_response_3 = httpx2.Response(
500,
content=b"Internal Server Error",
request=oauth_metadata_request_3,
@ -490,7 +490,7 @@ class TestOAuthFallback:
assert token_request.method == "POST"
# Send a successful token response
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=(
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
@ -506,7 +506,7 @@ class TestOAuthFallback:
assert str(final_request.url) == "https://api.example.com/v1/mcp"
# Send final success response to properly close the generator
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -521,7 +521,7 @@ class TestOAuthFallback:
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token"
}"""
response = httpx.Response(200, content=content)
response = httpx2.Response(200, content=content)
# Should set metadata; the empty path is preserved (no trailing slash added)
await oauth_provider._handle_oauth_metadata_response(response)
@ -532,8 +532,8 @@ class TestOAuthFallback:
async def test_prioritize_www_auth_scope_over_prm(
self,
oauth_provider: OAuthClientProvider,
prm_metadata_response: httpx.Response,
init_response_with_www_auth_scope: httpx.Response,
prm_metadata_response: httpx2.Response,
init_response_with_www_auth_scope: httpx2.Response,
):
"""Test that WWW-Authenticate scope is prioritized over PRM scopes."""
# First, process PRM metadata to set protected_resource_metadata with scopes
@ -552,8 +552,8 @@ class TestOAuthFallback:
async def test_prioritize_prm_scopes_when_no_www_auth_scope(
self,
oauth_provider: OAuthClientProvider,
prm_metadata_response: httpx.Response,
init_response_without_www_auth_scope: httpx.Response,
prm_metadata_response: httpx2.Response,
init_response_without_www_auth_scope: httpx2.Response,
):
"""Test that PRM scopes are prioritized when WWW-Authenticate header has no scopes."""
# Process the PRM metadata to set protected_resource_metadata with scopes
@ -572,8 +572,8 @@ class TestOAuthFallback:
async def test_omit_scope_when_no_prm_scopes_or_www_auth(
self,
oauth_provider: OAuthClientProvider,
prm_metadata_without_scopes_response: httpx.Response,
init_response_without_www_auth_scope: httpx.Response,
prm_metadata_without_scopes_response: httpx2.Response,
init_response_without_www_auth_scope: httpx2.Response,
):
"""Test that scope is omitted when PRM has no scopes and WWW-Authenticate doesn't specify scope."""
# Process the PRM metadata without scopes
@ -981,7 +981,7 @@ class TestRegistrationResponse:
"""Test that response.aread() is called before accessing response.text."""
# Track if aread() was called
class MockResponse(httpx.Response):
class MockResponse(httpx2.Response):
def __init__(self):
self.status_code = 400
self._aread_called = False
@ -1068,7 +1068,7 @@ def test_registration_request_sends_application_type():
class TestAuthFlow:
"""Test the auth flow in httpx."""
"""Test the auth flow in httpx2."""
@pytest.mark.anyio
async def test_auth_flow_with_valid_tokens(
@ -1082,7 +1082,7 @@ class TestAuthFlow:
oauth_provider._initialized = True
# Create a test request
test_request = httpx.Request("GET", "https://api.example.com/test")
test_request = httpx2.Request("GET", "https://api.example.com/test")
# Mock the auth flow
auth_flow = oauth_provider.async_auth_flow(test_request)
@ -1092,7 +1092,7 @@ class TestAuthFlow:
assert request.headers["Authorization"] == "Bearer test_access_token"
# Send a successful response
response = httpx.Response(200)
response = httpx2.Response(200)
try:
await auth_flow.asend(response)
except StopAsyncIteration:
@ -1107,7 +1107,7 @@ class TestAuthFlow:
oauth_provider._initialized = True
# Create a test request
test_request = httpx.Request("GET", "https://api.example.com/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/mcp")
# Mock the auth flow
auth_flow = oauth_provider.async_auth_flow(test_request)
@ -1117,7 +1117,7 @@ class TestAuthFlow:
assert "Authorization" not in request.headers
# Send a 401 response to trigger the OAuth flow
response = httpx.Response(
response = httpx2.Response(
401,
headers={
"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
@ -1131,7 +1131,7 @@ class TestAuthFlow:
assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource"
# Send a successful discovery response with minimal protected resource metadata
discovery_response = httpx.Response(
discovery_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
request=discovery_request,
@ -1144,7 +1144,7 @@ class TestAuthFlow:
assert "mcp-protocol-version" in oauth_metadata_request.headers
# Send a successful OAuth metadata response
oauth_metadata_response = httpx.Response(
oauth_metadata_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
@ -1161,7 +1161,7 @@ class TestAuthFlow:
assert str(registration_request.url) == "https://auth.example.com/register"
# Send a successful registration response
registration_response = httpx.Response(
registration_response = httpx2.Response(
201,
content=b'{"client_id": "test_client_id", "client_secret": "test_client_secret", "redirect_uris": ["http://localhost:3030/callback"]}',
request=registration_request,
@ -1179,7 +1179,7 @@ class TestAuthFlow:
assert "code=test_auth_code" in token_request.content.decode()
# Send a successful token response
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=(
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
@ -1195,7 +1195,7 @@ class TestAuthFlow:
assert str(final_request.url) == "https://api.example.com/mcp"
# Send final success response to properly close the generator
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -1217,7 +1217,7 @@ class TestAuthFlow:
oauth_provider.context.token_expiry_time = time.time() + 1800
oauth_provider._initialized = True
test_request = httpx.Request("GET", "https://api.example.com/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/mcp")
auth_flow = oauth_provider.async_auth_flow(test_request)
# Count how many times the request is yielded
@ -1229,7 +1229,7 @@ class TestAuthFlow:
assert request.headers["Authorization"] == "Bearer test_access_token"
# Send a successful 200 response
response = httpx.Response(200, request=request)
response = httpx2.Response(200, request=request)
# In the buggy version, this would yield the request AGAIN unconditionally
# In the fixed version, this should end the generator
@ -1260,7 +1260,7 @@ class TestAuthFlow:
oauth_provider._initialized = True
# Create a test request
test_request = httpx.Request("GET", "https://api.example.com/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/mcp")
# Mock the auth flow
auth_flow = oauth_provider.async_auth_flow(test_request)
@ -1270,7 +1270,7 @@ class TestAuthFlow:
assert "Authorization" not in request.headers
# Send a 401 response to trigger the OAuth flow
response = httpx.Response(
response = httpx2.Response(
401,
headers={
"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
@ -1284,7 +1284,7 @@ class TestAuthFlow:
assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource"
# Send a successful discovery response with minimal protected resource metadata
discovery_response = httpx.Response(
discovery_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
request=discovery_request,
@ -1297,7 +1297,7 @@ class TestAuthFlow:
assert "mcp-protocol-version" in oauth_metadata_request.headers
# Send a successful OAuth metadata response
oauth_metadata_response = httpx.Response(
oauth_metadata_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
@ -1314,7 +1314,7 @@ class TestAuthFlow:
assert str(registration_request.url) == "https://auth.example.com/register"
# Send a successful registration response with 201 status
registration_response = httpx.Response(
registration_response = httpx2.Response(
201,
content=b'{"client_id": "test_client_id", "client_secret": "test_client_secret", "redirect_uris": ["http://localhost:3030/callback"]}',
request=registration_request,
@ -1332,7 +1332,7 @@ class TestAuthFlow:
assert "code=test_auth_code" in token_request.content.decode()
# Send a successful token response with 201 status code (test both 200 and 201 are accepted)
token_response = httpx.Response(
token_response = httpx2.Response(
201,
content=(
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
@ -1348,7 +1348,7 @@ class TestAuthFlow:
assert str(final_request.url) == "https://api.example.com/mcp"
# Send final success response to properly close the generator
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -1405,14 +1405,14 @@ class TestAuthFlow:
oauth_provider.context.callback_handler = mock_callback
test_request = httpx.Request("GET", "https://api.example.com/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/mcp")
auth_flow = oauth_provider.async_auth_flow(test_request)
# First request
request = await auth_flow.__anext__()
# Send 403 with new scope requirement
response_403 = httpx.Response(
response_403 = httpx2.Response(
403,
headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="admin:write admin:delete"'},
request=request,
@ -1426,7 +1426,7 @@ class TestAuthFlow:
assert redirect_captured
# Complete the flow with successful token response
token_response = httpx.Response(
token_response = httpx2.Response(
200,
json={
"access_token": "new_token_with_new_scope",
@ -1441,7 +1441,7 @@ class TestAuthFlow:
final_request = await auth_flow.asend(token_response)
# Send success response - flow should complete
success_response = httpx.Response(200, request=final_request)
success_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(success_response)
pytest.fail("Should have stopped after successful response") # pragma: no cover
@ -1485,9 +1485,9 @@ async def test_403_step_up_preserves_scope_from_stored_token(
oauth_provider.context.redirect_handler = capture_redirect
oauth_provider.context.callback_handler = mock_callback
auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/mcp"))
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/mcp"))
request = await auth_flow.__anext__()
response_403 = httpx.Response(
response_403 = httpx2.Response(
403,
headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="write"'},
request=request,
@ -1497,14 +1497,14 @@ async def test_403_step_up_preserves_scope_from_stored_token(
assert reauthorize_scope == "read write"
# Drive the flow to completion so the context lock is released cleanly
token_response = httpx.Response(
token_response = httpx2.Response(
200,
json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600, "scope": "read write"},
request=token_exchange_request,
)
final_request = await auth_flow.asend(token_response)
try:
await auth_flow.asend(httpx.Response(200, request=final_request))
await auth_flow.asend(httpx2.Response(200, request=final_request))
except StopAsyncIteration:
pass
@ -1619,7 +1619,7 @@ class TestLegacyServerFallback:
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
test_request = httpx.Request("GET", "https://mcp.linear.app/sse")
test_request = httpx2.Request("GET", "https://mcp.linear.app/sse")
auth_flow = provider.async_auth_flow(test_request)
# First request
@ -1627,21 +1627,21 @@ class TestLegacyServerFallback:
assert "Authorization" not in request.headers
# Send 401 without WWW-Authenticate header (typical legacy server)
response = httpx.Response(401, headers={}, request=test_request)
response = httpx2.Response(401, headers={}, request=test_request)
# Should try path-based PRM first
prm_request_1 = await auth_flow.asend(response)
assert str(prm_request_1.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource/sse"
# PRM returns 404
prm_response_1 = httpx.Response(404, request=prm_request_1)
prm_response_1 = httpx2.Response(404, request=prm_request_1)
# Should try root-based PRM
prm_request_2 = await auth_flow.asend(prm_response_1)
assert str(prm_request_2.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource"
# PRM returns 404 again - all PRM URLs failed
prm_response_2 = httpx.Response(404, request=prm_request_2)
prm_response_2 = httpx2.Response(404, request=prm_request_2)
# Should fall back to root OAuth discovery (March 2025 spec behavior)
oauth_metadata_request = await auth_flow.asend(prm_response_2)
@ -1649,7 +1649,7 @@ class TestLegacyServerFallback:
assert oauth_metadata_request.method == "GET"
# Send successful OAuth metadata response
oauth_metadata_response = httpx.Response(
oauth_metadata_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://mcp.linear.app", '
@ -1669,7 +1669,7 @@ class TestLegacyServerFallback:
assert str(token_request.url) == "https://mcp.linear.app/token"
# Send successful token response
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=b'{"access_token": "linear_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
@ -1681,7 +1681,7 @@ class TestLegacyServerFallback:
assert str(final_request.url) == "https://mcp.linear.app/sse"
# Complete flow
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -1716,13 +1716,13 @@ class TestLegacyServerFallback:
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/v1/mcp")
auth_flow = provider.async_auth_flow(test_request)
await auth_flow.__anext__()
# 401 with custom WWW-Authenticate PRM URL
response = httpx.Response(
response = httpx2.Response(
401,
headers={
"WWW-Authenticate": 'Bearer resource_metadata="https://custom.prm.com/.well-known/oauth-protected-resource"'
@ -1735,28 +1735,28 @@ class TestLegacyServerFallback:
assert str(prm_request_1.url) == "https://custom.prm.com/.well-known/oauth-protected-resource"
# Returns 500
prm_response_1 = httpx.Response(500, request=prm_request_1)
prm_response_1 = httpx2.Response(500, request=prm_request_1)
# Try path-based fallback
prm_request_2 = await auth_flow.asend(prm_response_1)
assert str(prm_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
# Returns 404
prm_response_2 = httpx.Response(404, request=prm_request_2)
prm_response_2 = httpx2.Response(404, request=prm_request_2)
# Try root fallback
prm_request_3 = await auth_flow.asend(prm_response_2)
assert str(prm_request_3.url) == "https://api.example.com/.well-known/oauth-protected-resource"
# Also returns 404 - all PRM URLs failed
prm_response_3 = httpx.Response(404, request=prm_request_3)
prm_response_3 = httpx2.Response(404, request=prm_request_3)
# Should fall back to root OAuth discovery
oauth_metadata_request = await auth_flow.asend(prm_response_3)
assert str(oauth_metadata_request.url) == "https://api.example.com/.well-known/oauth-authorization-server"
# Complete the flow
oauth_metadata_response = httpx.Response(
oauth_metadata_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://api.example.com", '
@ -1773,7 +1773,7 @@ class TestLegacyServerFallback:
token_request = await auth_flow.asend(oauth_metadata_response)
assert str(token_request.url) == "https://api.example.com/token"
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
@ -1782,7 +1782,7 @@ class TestLegacyServerFallback:
final_request = await auth_flow.asend(token_response)
assert final_request.headers["Authorization"] == "Bearer test_token"
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -1813,8 +1813,8 @@ class TestSEP985Discovery:
)
# Test with 401 response without WWW-Authenticate header
init_response = httpx.Response(
status_code=401, headers={}, request=httpx.Request("GET", "https://api.example.com/v1/mcp")
init_response = httpx2.Response(
status_code=401, headers={}, request=httpx2.Request("GET", "https://api.example.com/v1/mcp")
)
# Build discovery URLs
@ -1859,7 +1859,7 @@ class TestSEP985Discovery:
)
# Create a test request
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/v1/mcp")
# Mock the auth flow
auth_flow = provider.async_auth_flow(test_request)
@ -1869,7 +1869,7 @@ class TestSEP985Discovery:
assert "Authorization" not in request.headers
# Send a 401 response without WWW-Authenticate header
response = httpx.Response(401, headers={}, request=test_request)
response = httpx2.Response(401, headers={}, request=test_request)
# Next request should be to discover protected resource metadata (path-based)
discovery_request_1 = await auth_flow.asend(response)
@ -1877,7 +1877,7 @@ class TestSEP985Discovery:
assert discovery_request_1.method == "GET"
# Send 404 response for path-based discovery
discovery_response_1 = httpx.Response(404, request=discovery_request_1)
discovery_response_1 = httpx2.Response(404, request=discovery_request_1)
# Next request should be to root-based well-known URI
discovery_request_2 = await auth_flow.asend(discovery_response_1)
@ -1885,7 +1885,7 @@ class TestSEP985Discovery:
assert discovery_request_2.method == "GET"
# Send successful discovery response
discovery_response_2 = httpx.Response(
discovery_response_2 = httpx2.Response(
200,
content=(
b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}'
@ -1901,7 +1901,7 @@ class TestSEP985Discovery:
assert oauth_metadata_request.method == "GET"
# Complete the flow
oauth_metadata_response = httpx.Response(
oauth_metadata_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
@ -1912,7 +1912,7 @@ class TestSEP985Discovery:
)
token_request = await auth_flow.asend(oauth_metadata_response)
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=(
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
@ -1922,7 +1922,7 @@ class TestSEP985Discovery:
)
final_request = await auth_flow.asend(token_response)
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -1949,12 +1949,12 @@ class TestSEP985Discovery:
)
# Test with 401 response with WWW-Authenticate header
init_response = httpx.Response(
init_response = httpx2.Response(
status_code=401,
headers={
"WWW-Authenticate": 'Bearer resource_metadata="https://custom.example.com/.well-known/oauth-protected-resource"'
},
request=httpx.Request("GET", "https://api.example.com/v1/mcp"),
request=httpx2.Request("GET", "https://api.example.com/v1/mcp"),
)
# Build discovery URLs
@ -2028,10 +2028,10 @@ class TestWWWAuthenticate:
):
"""Test extraction of various fields from valid WWW-Authenticate headers."""
init_response = httpx.Response(
init_response = httpx2.Response(
status_code=401,
headers={"WWW-Authenticate": www_auth_header},
request=httpx.Request("GET", "https://api.example.com/test"),
request=httpx2.Request("GET", "https://api.example.com/test"),
)
result = extract_field_from_www_auth(init_response, field_name)
@ -2063,8 +2063,8 @@ class TestWWWAuthenticate:
"""Test extraction returns None for invalid cases."""
headers = {"WWW-Authenticate": www_auth_header} if www_auth_header is not None else {}
init_response = httpx.Response(
status_code=401, headers=headers, request=httpx.Request("GET", "https://api.example.com/test")
init_response = httpx2.Response(
status_code=401, headers=headers, request=httpx2.Request("GET", "https://api.example.com/test")
)
result = extract_field_from_www_auth(init_response, field_name)
@ -2211,7 +2211,7 @@ class TestCIMD:
provider.context.token_expiry_time = None
provider._initialized = True
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/v1/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request
@ -2219,11 +2219,11 @@ class TestCIMD:
assert "Authorization" not in request.headers
# Send 401 response
response = httpx.Response(401, headers={}, request=test_request)
response = httpx2.Response(401, headers={}, request=test_request)
# PRM discovery
prm_request = await auth_flow.asend(response)
prm_response = httpx.Response(
prm_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
request=prm_request,
@ -2231,7 +2231,7 @@ class TestCIMD:
# OAuth metadata discovery
oauth_request = await auth_flow.asend(prm_response)
oauth_response = httpx.Response(
oauth_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
@ -2262,7 +2262,7 @@ class TestCIMD:
assert provider.context.client_info.token_endpoint_auth_method == "none"
# Complete the flow
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
@ -2271,7 +2271,7 @@ class TestCIMD:
final_request = await auth_flow.asend(token_response)
assert final_request.headers["Authorization"] == "Bearer test_token"
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -2302,18 +2302,18 @@ class TestCIMD:
provider.context.token_expiry_time = None
provider._initialized = True
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/v1/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request
await auth_flow.__anext__()
# Send 401 response
response = httpx.Response(401, headers={}, request=test_request)
response = httpx2.Response(401, headers={}, request=test_request)
# PRM discovery
prm_request = await auth_flow.asend(response)
prm_response = httpx.Response(
prm_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
request=prm_request,
@ -2321,7 +2321,7 @@ class TestCIMD:
# OAuth metadata discovery - server does NOT support CIMD
oauth_request = await auth_flow.asend(prm_response)
oauth_response = httpx.Response(
oauth_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
@ -2338,7 +2338,7 @@ class TestCIMD:
assert str(registration_request.url) == "https://auth.example.com/register"
# Complete the flow to avoid generator cleanup issues
registration_response = httpx.Response(
registration_response = httpx2.Response(
201,
content=b'{"client_id": "dcr_client_id", "redirect_uris": ["http://localhost:3030/callback"]}',
request=registration_request,
@ -2350,14 +2350,14 @@ class TestCIMD:
)
token_request = await auth_flow.asend(registration_response)
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
)
final_request = await auth_flow.asend(token_response)
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -2541,7 +2541,7 @@ class TestSEP2207OfflineAccessScope:
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/v1/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request
@ -2549,11 +2549,11 @@ class TestSEP2207OfflineAccessScope:
assert "Authorization" not in request.headers
# Send 401
response = httpx.Response(401, headers={}, request=test_request)
response = httpx2.Response(401, headers={}, request=test_request)
# PRM discovery
prm_request = await auth_flow.asend(response)
prm_response = httpx.Response(
prm_response = httpx2.Response(
200,
content=(
b'{"resource": "https://api.example.com/v1/mcp",'
@ -2565,7 +2565,7 @@ class TestSEP2207OfflineAccessScope:
# OAuth metadata discovery - AS advertises offline_access
oauth_request = await auth_flow.asend(prm_response)
oauth_response = httpx.Response(
oauth_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com",'
@ -2593,7 +2593,7 @@ class TestSEP2207OfflineAccessScope:
assert params["prompt"][0] == "consent"
# Complete the token exchange
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=(
b'{"access_token": "new_access_token", "token_type": "Bearer",'
@ -2606,7 +2606,7 @@ class TestSEP2207OfflineAccessScope:
assert final_request.headers["Authorization"] == "Bearer new_access_token"
# Close the generator
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -2650,18 +2650,18 @@ class TestSEP2207OfflineAccessScope:
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/v1/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request
await auth_flow.__anext__()
# Send 401
response = httpx.Response(401, headers={}, request=test_request)
response = httpx2.Response(401, headers={}, request=test_request)
# PRM discovery
prm_request = await auth_flow.asend(response)
prm_response = httpx.Response(
prm_response = httpx2.Response(
200,
content=(
b'{"resource": "https://api.example.com/v1/mcp",'
@ -2673,7 +2673,7 @@ class TestSEP2207OfflineAccessScope:
# OAuth metadata discovery - AS does NOT advertise offline_access
oauth_request = await auth_flow.asend(prm_response)
oauth_response = httpx.Response(
oauth_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com",'
@ -2701,7 +2701,7 @@ class TestSEP2207OfflineAccessScope:
assert "prompt" not in params
# Complete the token exchange
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
@ -2711,7 +2711,7 @@ class TestSEP2207OfflineAccessScope:
assert final_request.headers["Authorization"] == "Bearer new_access_token"
# Close the generator
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
@ -2846,10 +2846,10 @@ async def test_handle_token_response_backfills_omitted_scope_from_request(
has reverted to its constructor value.
"""
oauth_provider.context.client_metadata.scope = "read admin"
response = httpx.Response(
response = httpx2.Response(
200,
json={"access_token": "t", "token_type": "Bearer", "expires_in": 3600},
request=httpx.Request("POST", "https://auth.example.com/token"),
request=httpx2.Request("POST", "https://auth.example.com/token"),
)
await oauth_provider._handle_token_response(response)
@ -2862,10 +2862,10 @@ async def test_handle_token_response_backfills_omitted_scope_from_request(
@pytest.mark.anyio
async def test_handle_token_response_raises_on_non_2xx_with_body(oauth_provider: OAuthClientProvider):
response = httpx.Response(
response = httpx2.Response(
400,
json={"error": "invalid_grant"},
request=httpx.Request("POST", "https://auth.example.com/token"),
request=httpx2.Request("POST", "https://auth.example.com/token"),
)
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
await oauth_provider._handle_token_response(response)
@ -2884,10 +2884,10 @@ async def test_handle_refresh_response_carries_prior_scope_and_refresh_token_whe
oauth_provider.context.current_tokens = OAuthToken(
access_token="old", scope="read write", refresh_token="prior-refresh"
)
response = httpx.Response(
response = httpx2.Response(
200,
json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600},
request=httpx.Request("POST", "https://auth.example.com/token"),
request=httpx2.Request("POST", "https://auth.example.com/token"),
)
ok = await oauth_provider._handle_refresh_response(response)
@ -2910,10 +2910,10 @@ async def test_handle_refresh_response_adopts_rotated_refresh_token_when_returne
oauth_provider.context.current_tokens = OAuthToken(
access_token="old", scope="read write", refresh_token="prior-refresh"
)
response = httpx.Response(
response = httpx2.Response(
200,
json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rotated"},
request=httpx.Request("POST", "https://auth.example.com/token"),
request=httpx2.Request("POST", "https://auth.example.com/token"),
)
ok = await oauth_provider._handle_refresh_response(response)
@ -2943,20 +2943,20 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed(
issuer="https://old-as.example.com",
)
auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/v1/mcp"))
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
response_401 = httpx.Response(401, request=request)
response_401 = httpx2.Response(401, request=request)
# PRM discovery: path-based then root, both 404.
prm_req = await auth_flow.asend(response_401)
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
prm_req = await auth_flow.asend(httpx.Response(404, request=prm_req))
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource"
# ASM discovery via root fallback (no auth_server_url) succeeds with a different issuer.
asm_req = await auth_flow.asend(httpx.Response(404, request=prm_req))
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
asm_response = httpx.Response(
asm_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://api.example.com", '
@ -2981,12 +2981,12 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed(
"asm_responses",
[
pytest.param(
[httpx.Response(404), httpx.Response(404)],
[httpx2.Response(404), httpx2.Response(404)],
id="asm-discovery-failed",
),
pytest.param(
[
httpx.Response(
httpx2.Response(
200,
content=(
b'{"issuer": "https://new-as.example.com", '
@ -3000,7 +3000,7 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed(
],
)
async def test_issuer_is_not_stamped_when_registration_falls_back_to_the_resource_origin(
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, asm_responses: list[httpx.Response]
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, asm_responses: list[httpx2.Response]
):
"""SEP-2352: a fallback registration is not recorded as bound to the PRM-advertised AS.
@ -3032,9 +3032,9 @@ async def test_issuer_is_not_stamped_when_registration_falls_back_to_the_resourc
oauth_provider.context.redirect_handler = capture_redirect
oauth_provider.context.callback_handler = echo_callback
auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/v1/mcp"))
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
response_401 = httpx.Response(
response_401 = httpx2.Response(
401,
headers={
"WWW-Authenticate": (
@ -3047,7 +3047,7 @@ async def test_issuer_is_not_stamped_when_registration_falls_back_to_the_resourc
# PRM succeeds and advertises a new AS — the discard block fires.
prm_req = await auth_flow.asend(response_401)
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource"
prm_response = httpx.Response(
prm_response = httpx2.Response(
200,
content=(
b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://new-as.example.com"]}'
@ -3069,7 +3069,7 @@ async def test_issuer_is_not_stamped_when_registration_falls_back_to_the_resourc
dcr_req = next_req
assert dcr_req.method == "POST"
assert str(dcr_req.url) == "https://api.example.com/register"
dcr_response = httpx.Response(
dcr_response = httpx2.Response(
201,
json={"client_id": "fallback-client", "redirect_uris": ["http://localhost:3030/callback"]},
request=dcr_req,
@ -3083,12 +3083,12 @@ async def test_issuer_is_not_stamped_when_registration_falls_back_to_the_resourc
assert stored.issuer is None
# Drive the flow to completion so the context lock is released cleanly.
token_response = httpx.Response(
token_response = httpx2.Response(
200, json={"access_token": "t", "token_type": "Bearer", "expires_in": 3600}, request=token_req
)
final_req = await auth_flow.asend(token_response)
try:
await auth_flow.asend(httpx.Response(200, request=final_req))
await auth_flow.asend(httpx2.Response(200, request=final_req))
except StopAsyncIteration:
pass
@ -3121,19 +3121,19 @@ async def test_issuer_is_stamped_when_same_origin_fallback_register_is_on_the_di
oauth_provider.context.redirect_handler = capture_redirect
oauth_provider.context.callback_handler = echo_callback
auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/v1/mcp"))
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
# PRM discovery 404s on both well-known URLs.
prm_req = await auth_flow.asend(httpx.Response(401, request=request))
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
prm_req = await auth_flow.asend(httpx.Response(404, request=prm_req))
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource"
# Root ASM discovery succeeds with the resource origin as issuer and no registration_endpoint.
asm_req = await auth_flow.asend(httpx.Response(404, request=prm_req))
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
asm_response = httpx.Response(
asm_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://api.example.com", '
@ -3147,7 +3147,7 @@ async def test_issuer_is_stamped_when_same_origin_fallback_register_is_on_the_di
dcr_req = await auth_flow.asend(asm_response)
assert dcr_req.method == "POST"
assert str(dcr_req.url) == "https://api.example.com/register"
dcr_response = httpx.Response(
dcr_response = httpx2.Response(
201,
json={"client_id": "embedded-client", "redirect_uris": ["http://localhost:3030/callback"]},
request=dcr_req,
@ -3161,11 +3161,11 @@ async def test_issuer_is_stamped_when_same_origin_fallback_register_is_on_the_di
assert stored.issuer == str(oauth_provider.context.oauth_metadata.issuer)
assert urlparse(stored.issuer).netloc == "api.example.com"
token_response = httpx.Response(
token_response = httpx2.Response(
200, json={"access_token": "t", "token_type": "Bearer", "expires_in": 3600}, request=token_req
)
final_req = await auth_flow.asend(token_response)
try:
await auth_flow.asend(httpx.Response(200, request=final_req))
await auth_flow.asend(httpx2.Response(200, request=final_req))
except StopAsyncIteration:
pass

View file

@ -12,7 +12,7 @@ from typing import Any, Literal
import anyio
import anyio.lowlevel
import httpx
import httpx2
import mcp_types as types
import pytest
from inline_snapshot import snapshot
@ -836,9 +836,9 @@ async def test_a_cache_hit_listing_still_mirrors_x_mcp_headers_on_tools_call() -
server = Server("headers", on_list_tools=list_tools, on_call_tool=call_tool)
posts: list[httpx.Request] = []
posts: list[httpx2.Request] = []
async def on_request(request: httpx.Request) -> None:
async def on_request(request: httpx2.Request) -> None:
posts.append(request)
config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc")

View file

@ -7,7 +7,7 @@ Verifies that Unicode text is correctly transmitted and received in both directi
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
import httpx2
import mcp_types as types
import pytest
from mcp_types import TextContent, Tool
@ -114,7 +114,7 @@ async def unicode_session() -> AsyncIterator[ClientSession]:
session_manager.run(),
# follow_redirects matches the SDK's own client factory; Starlette's Mount 307-redirects
# the bare /mcp path to /mcp/.
httpx.AsyncClient(
httpx2.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True
) as http_client,
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),

View file

@ -6,7 +6,7 @@ that don't follow SDK conventions.
import json
import httpx
import httpx2
import mcp_types as types
import pytest
from mcp_types import RootsListChangedNotification
@ -89,7 +89,7 @@ async def test_non_compliant_notification_response() -> None:
if isinstance(message, Exception):
returned_exception = message
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_non_sdk_server_app())) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_non_sdk_server_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
await session.initialize()
@ -108,7 +108,7 @@ async def test_unexpected_content_type_sends_jsonrpc_error() -> None:
the client should send a JSONRPCError so the pending request resolves immediately
instead of hanging until timeout.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_unexpected_content_type_app())) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_unexpected_content_type_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
@ -142,9 +142,9 @@ async def test_http_error_status_sends_jsonrpc_error() -> None:
When a server returns a non-2xx status code (e.g. 500), the client should
send a JSONRPCError so the pending request resolves immediately instead of
raising an unhandled httpx.HTTPStatusError that causes the caller to hang.
raising an unhandled httpx2.HTTPStatusError that causes the caller to hang.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_http_error_app(500))) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_http_error_app(500))) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
@ -160,7 +160,7 @@ async def test_http_error_on_notification_does_not_hang() -> None:
unblock, so the client should just return without sending a JSONRPCError.
"""
app = _create_http_error_app(500, error_on_notifications=True)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
@ -195,7 +195,7 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
should send a JSONRPCError so the pending request resolves immediately
instead of hanging until timeout.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_invalid_json_response_app())) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_invalid_json_response_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
@ -233,7 +233,7 @@ async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_i
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
).encode()
app = _create_non_2xx_json_body_app(400, body)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
@ -247,7 +247,7 @@ async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
treated as the request's reply."""
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
@ -261,7 +261,7 @@ async def test_client_falls_back_to_session_terminated_when_404_body_is_malforme
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
pending request — the parse failure never propagates."""
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()

View file

@ -16,7 +16,7 @@ from __future__ import annotations
from typing import Any, cast
import anyio
import httpx
import httpx2
import mcp_types as types
import pytest
from mcp_types import (
@ -286,7 +286,7 @@ async def test_any_other_handshake_error_propagates_unchanged() -> None:
@pytest.mark.parametrize(
"exc",
[
pytest.param(httpx.ConnectError("connection refused"), id="httpx-connect-error"),
pytest.param(httpx2.ConnectError("connection refused"), id="httpx2-connect-error"),
pytest.param(anyio.ClosedResourceError(), id="anyio-closed-resource"),
],
)

View file

@ -6,7 +6,7 @@ in the WWW-Authenticate header, the actual scope is used (not the resource_metad
from unittest import mock
import httpx
import httpx2
import pytest
from pydantic import AnyUrl
@ -79,7 +79,7 @@ async def test_401_uses_www_auth_scope_not_resource_metadata_url():
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
test_request = httpx.Request("GET", "https://api.example.com/mcp")
test_request = httpx2.Request("GET", "https://api.example.com/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request (no auth header yet)
@ -90,7 +90,7 @@ async def test_401_uses_www_auth_scope_not_resource_metadata_url():
resource_metadata_url = "https://api.example.com/.well-known/oauth-protected-resource"
expected_scope = "read write"
response_401 = httpx.Response(
response_401 = httpx2.Response(
401,
headers={"WWW-Authenticate": (f'Bearer resource_metadata="{resource_metadata_url}", scope="{expected_scope}"')},
request=test_request,
@ -101,7 +101,7 @@ async def test_401_uses_www_auth_scope_not_resource_metadata_url():
assert ".well-known/oauth-protected-resource" in str(prm_request.url)
# PRM response with scopes_supported (these should be overridden by WWW-Auth scope)
prm_response = httpx.Response(
prm_response = httpx2.Response(
200,
content=(
b'{"resource": "https://api.example.com/mcp", '
@ -116,7 +116,7 @@ async def test_401_uses_www_auth_scope_not_resource_metadata_url():
assert ".well-known/oauth-authorization-server" in str(oauth_metadata_request.url)
# OAuth metadata response
oauth_metadata_response = httpx.Response(
oauth_metadata_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
@ -152,7 +152,7 @@ async def test_401_uses_www_auth_scope_not_resource_metadata_url():
)
# Complete the flow to properly release the lock
token_response = httpx.Response(
token_response = httpx2.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
@ -162,7 +162,7 @@ async def test_401_uses_www_auth_scope_not_resource_metadata_url():
assert final_request.headers["Authorization"] == "Bearer test_token"
# Finish the flow
final_response = httpx.Response(200, request=final_request)
final_response = httpx2.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:

View file

@ -1,7 +1,7 @@
import contextlib
from unittest import mock
import httpx
import httpx2
import mcp_types as types
import pytest
@ -380,7 +380,7 @@ async def test_client_session_group_establish_session_parameterized(
call_args = mock_specific_client_func.call_args
assert call_args.kwargs["url"] == server_params_instance.url
assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close
assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient)
assert isinstance(call_args.kwargs["http_client"], httpx2.AsyncClient)
mock_client_cm_instance.__aenter__.assert_awaited_once()

View file

@ -12,7 +12,7 @@ from collections.abc import AsyncIterator, Callable, Mapping
from typing import Any
import anyio
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import (
@ -85,16 +85,16 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field
async def test_post_request_merges_per_message_metadata_headers() -> None:
"""`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers
(SDK-defined: the headers sidecar is the path the session uses to reach the transport)."""
recorded: list[httpx.Request] = []
recorded: list[httpx2.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
recorded.append(request)
body = json.loads(request.content)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
@ -117,12 +117,12 @@ async def test_pre_session_bare_404_maps_to_method_not_found() -> None:
"Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404)
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(404)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={})))
@ -145,18 +145,18 @@ async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_
passes through the session's stamp) then reads the cache and carries the
negotiated version — the spec MUST for all post-initialization HTTP requests.
"""
recorded: list[httpx.Request] = []
recorded: list[httpx2.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
recorded.append(request)
body = json.loads(request.content)
if "id" not in body or "result" in body:
return httpx.Response(202)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
return httpx2.Response(202)
return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
@ -185,11 +185,11 @@ async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_
assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
class _ParkedSSEStream(httpx.AsyncByteStream):
class _ParkedSSEStream(httpx2.AsyncByteStream):
"""An SSE response body that emits one comment line, then parks until closed.
`opened` fires once the transport is iterating the body (the POST is truly in
flight); `closed` fires when httpx tears the body down — the observable proof
flight); `closed` fires when httpx2 tears the body down — the observable proof
that an abort, not a response, ended the stream.
"""
@ -210,16 +210,16 @@ class _ParkedSSEStream(httpx.AsyncByteStream):
def _sse_or_ack_handler(
parked: _ParkedSSEStream, posted: list[dict[str, Any]], frame_posted: anyio.Event
) -> Callable[[httpx.Request], httpx.Response]:
) -> Callable[[httpx2.Request], httpx2.Response]:
"""Requests get the parked SSE body; notifications get 202 and set `frame_posted`."""
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
body = json.loads(request.content)
posted.append(body)
if "id" in body:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
frame_posted.set()
return httpx.Response(202)
return httpx2.Response(202)
return handler
@ -232,13 +232,13 @@ async def test_modern_cancelled_frame_aborts_the_matching_in_flight_post() -> No
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
posted.append(json.loads(request.content))
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
await write.send(
@ -272,7 +272,7 @@ async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_v
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
metadata = (
@ -316,16 +316,16 @@ async def test_modern_cancelled_frames_matching_no_post_are_swallowed(params: di
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
body = json.loads(request.content)
posted.append(body)
if body.get("id") == 1:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
@ -362,7 +362,7 @@ async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() ->
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
await write.send(
@ -395,19 +395,19 @@ async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_ado
posted: list[dict[str, Any]] = []
frame_posted = anyio.Event()
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
body = json.loads(request.content)
posted.append(body)
if body.get("id") == 1:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
if "id" in body:
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
frame_posted.set()
return httpx.Response(202)
return httpx2.Response(202)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
@ -472,7 +472,7 @@ async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None:
posted_methods: list[str] = []
async def record_request(request: httpx.Request) -> None:
async def record_request(request: httpx2.Request) -> None:
posted_methods.append(json.loads(request.content)["method"])
acked = anyio.Event()
@ -485,7 +485,7 @@ async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None:
with anyio.fail_after(15):
async with (
httpx.AsyncClient(
httpx2.AsyncClient(
transport=StreamingASGITransport(app),
base_url="http://testserver",
event_hooks={"request": [record_request]},
@ -526,7 +526,7 @@ async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None:
assert posted_methods == ["subscriptions/listen"]
class _CompletingSSEStream(httpx.AsyncByteStream):
class _CompletingSSEStream(httpx2.AsyncByteStream):
"""An SSE body that delivers one JSON-RPC response, then parks in `aclose`.
Holding `aclose` keeps the finished POST task alive past its response, so a
@ -554,13 +554,13 @@ async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration
posted: list[dict[str, Any]] = []
streams = [completing, parked]
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
posted.append(json.loads(request.content))
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
modern = ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
@ -593,7 +593,7 @@ async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration
assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"]
class _DyingSSEStream(httpx.AsyncByteStream):
class _DyingSSEStream(httpx2.AsyncByteStream):
"""Emits one id-less comment then breaks - a non-resumable stream dropping."""
def __init__(self) -> None:
@ -602,7 +602,7 @@ class _DyingSSEStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
yield b": hello\n\n"
raise httpx.ReadError("connection reset")
raise httpx2.ReadError("connection reset")
async def aclose(self) -> None:
pass
@ -614,12 +614,12 @@ async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() ->
response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever."""
dying = _DyingSSEStream()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
@ -632,7 +632,7 @@ async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() ->
assert reply.message.error.code == CONNECTION_CLOSED
class _DeliverOnCommandSSEStream(httpx.AsyncByteStream):
class _DeliverOnCommandSSEStream(httpx2.AsyncByteStream):
"""Parks after opening, then delivers one JSON-RPC response when told."""
def __init__(self, response_body: dict[str, Any]) -> None:
@ -656,14 +656,14 @@ async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor
the reused id's waiter; only the successor's own response arrives."""
stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}})
succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}})
streams: list[httpx.AsyncByteStream] = [stale, succeeding]
streams: list[httpx2.AsyncByteStream] = [stale, succeeding]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
@ -685,12 +685,12 @@ async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None:
response will follow (the spec requires SSE or JSON for requests); the transport
resolves the waiter with INVALID_REQUEST instead of parking the caller forever."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(202)
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(202)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
@ -704,7 +704,7 @@ async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None:
def _abandoned_request_context(
http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception]
http: httpx2.AsyncClient, send: ContextSendStream[SessionMessage | Exception]
) -> RequestContext:
return RequestContext(
client=http,
@ -722,7 +722,7 @@ async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error
"""An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED."""
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
async with httpx.AsyncClient() as http:
async with httpx2.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
@ -742,7 +742,7 @@ async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contain
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
receive.close()
async with httpx.AsyncClient() as http:
async with httpx2.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS

View file

@ -16,7 +16,7 @@ import sys
from collections.abc import Iterator
from contextlib import contextmanager
import httpx
import httpx2
import pytest
from mcp.client.sse import sse_client
@ -64,7 +64,7 @@ async def test_sse_client_closes_all_streams_on_connection_error(free_tcp_port:
closed in the finally block.
"""
with _assert_no_memory_stream_leak():
with pytest.raises(httpx.ConnectError):
with pytest.raises(httpx2.ConnectError):
async with sse_client(f"http://127.0.0.1:{free_tcp_port}/sse"):
pytest.fail("should not reach here") # pragma: no cover
@ -76,18 +76,18 @@ async def test_sse_client_closes_all_streams_on_http_error() -> None:
ExceptionGroup) with nothing to leak — the task group is never entered.
"""
def return_403(request: httpx.Request) -> httpx.Response:
return httpx.Response(403)
def return_403(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(403)
def mock_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(return_403))
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
return httpx2.AsyncClient(transport=httpx2.MockTransport(return_403))
with _assert_no_memory_stream_leak():
with pytest.raises(httpx.HTTPStatusError):
with pytest.raises(httpx2.HTTPStatusError):
async with sse_client("http://test/sse", httpx_client_factory=mock_factory):
pytest.fail("should not reach here") # pragma: no cover

View file

@ -2,7 +2,7 @@
import inspect
import httpx
import httpx2
import pytest
from mcp_types import TextContent
from starlette.applications import Starlette
@ -50,8 +50,8 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None:
async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
"""The `!!! check`: nothing starts the session manager except its lifespan."""
transport = httpx.ASGITransport(app=tutorial001.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=tutorial001.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
await http.post("/mcp")
@ -76,12 +76,12 @@ async def test_a_root_mount_swallows_routes_listed_after_it() -> None:
listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)])
listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)])
transport = httpx.ASGITransport(app=listed_after)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=listed_after)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 404
transport = httpx.ASGITransport(app=listed_before)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=listed_before)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 200
@ -127,8 +127,8 @@ async def test_cors_exposes_the_session_id_header() -> None:
"""tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`."""
(middleware,) = tutorial005.app.user_middleware
assert middleware.cls is CORSMiddleware
transport = httpx.ASGITransport(app=tutorial005.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=tutorial005.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
preflight = await http.options(
"/mcp",
headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"},
@ -152,8 +152,8 @@ async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None:
async def test_the_health_check_answers_outside_the_protocol() -> None:
"""tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP."""
transport = httpx.ASGITransport(app=tutorial006.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
transport = httpx2.ASGITransport(app=tutorial006.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
response = await http.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@ -174,11 +174,11 @@ async def test_the_default_app_is_localhost_only() -> None:
before any MCP code runs."""
bare = MCPServer("Notes")
app = bare.streamable_http_app()
transport = httpx.ASGITransport(app=app)
transport = httpx2.ASGITransport(app=app)
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
async with httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
wrong_origin = await http.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
)
@ -189,9 +189,9 @@ async def test_the_default_app_is_localhost_only() -> None:
async def test_the_documented_browser_origin_works_end_to_end() -> None:
"""tutorial005: the page's scenario for real. The public hostname, the browser origin, a
realistic preflight naming the `Mcp-*` headers, then the actual request."""
transport = httpx.ASGITransport(app=tutorial005.app)
transport = httpx2.ASGITransport(app=tutorial005.app)
async with tutorial005.lifespan(tutorial005.app):
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
preflight = await http.options(
"/mcp",
headers={

View file

@ -1,6 +1,6 @@
"""`docs/run/authorization.md`: every claim the page makes, proved against the real SDK."""
import httpx
import httpx2
import pytest
from inline_snapshot import snapshot
from mcp_types import TextContent
@ -40,8 +40,8 @@ async def test_the_app_grows_a_protected_resource_metadata_route() -> None:
async def test_the_metadata_document_is_built_from_auth_settings() -> None:
"""tutorial001: `GET` on the well-known route returns the Protected Resource Metadata the page shows."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
assert response.json() == snapshot(
@ -56,8 +56,8 @@ async def test_the_metadata_document_is_built_from_auth_settings() -> None:
async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
"""The `!!! check`: no `Authorization` header means a 401 that points at the metadata document."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.post("/mcp", json={})
assert response.status_code == 401
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
@ -69,8 +69,8 @@ async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None:
"""tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller."""
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app())
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"})
assert response.status_code == 401
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
@ -86,11 +86,11 @@ async def test_get_access_token_is_none_outside_an_authenticated_request() -> No
async def test_get_access_token_is_the_callers_access_token() -> None:
"""tutorial002: over Streamable HTTP a valid bearer token reaches the tool as an `AccessToken`."""
url = "http://127.0.0.1:8000/mcp"
transport = httpx.ASGITransport(app=tutorial002.mcp.streamable_http_app())
transport = httpx2.ASGITransport(app=tutorial002.mcp.streamable_http_app())
headers = {"Authorization": "Bearer alice-token"}
async with tutorial002.mcp.session_manager.run():
async with (
httpx.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client,
httpx2.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client,
Client(streamable_http_client(url, http_client=http_client)) as client,
):
result = await client.call_tool("whoami", {})

View file

@ -1,7 +1,7 @@
"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""
import anyio
import httpx
import httpx2
import pytest
from mcp_types import (
INVALID_PARAMS,
@ -44,18 +44,18 @@ async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None
bare = MCPServer("Notes")
app = bare.streamable_http_app()
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="https://api.example.com") as h:
response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert (response.status_code, response.text) == (421, "Invalid Host header")
async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
"""tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
transport = httpx.ASGITransport(app=tutorial001.app)
transport = httpx2.ASGITransport(app=tutorial001.app)
async with tutorial001.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
async with httpx2.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert allowed.status_code == 200
assert allowed.headers["mcp-session-id"]

View file

@ -3,7 +3,7 @@
import inspect
from urllib.parse import parse_qsl
import httpx
import httpx2
import jwt
import pytest
from inline_snapshot import snapshot
@ -27,21 +27,21 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDepre
MCP_SERVER_URL = "http://localhost:8001/mcp"
class RecordingASGITransport(httpx.ASGITransport):
"""An `httpx.ASGITransport` that appends every (method, path, body) it carries to a shared log."""
class RecordingASGITransport(httpx2.ASGITransport):
"""An `httpx2.ASGITransport` that appends every (method, path, body) it carries to a shared log."""
def __init__(self, app: Starlette, log: list[tuple[str, str, bytes]]) -> None:
super().__init__(app=app)
self.log = log
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
self.log.append((request.method, request.url.path, request.content))
return await super().handle_async_request(request)
async def test_the_provider_is_an_httpx_auth_but_not_an_oauth_client_provider() -> None:
"""tutorial001: same `auth=` slot as the rest of OAuth clients, but nothing is discovered or registered."""
assert isinstance(tutorial001.oauth, httpx.Auth)
assert isinstance(tutorial001.oauth, httpx2.Auth)
assert not isinstance(tutorial001.oauth, OAuthClientProvider)
@ -132,8 +132,8 @@ async def test_a_replayed_assertion_is_rejected() -> None:
async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() -> None:
"""tutorial002: the flag turns on both the `jwt-bearer` grant type and the grant-profile advertisement."""
transport = httpx.ASGITransport(app=tutorial002.auth_app)
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client:
transport = httpx2.ASGITransport(app=tutorial002.auth_app)
async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client:
response = await http_client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
metadata = response.json()
@ -167,7 +167,7 @@ async def test_the_whole_grant_is_one_token_request() -> None:
mounts = {"https://auth.example.com": RecordingASGITransport(tutorial002.auth_app, log)}
async with mcp.session_manager.run():
async with (
httpx.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client,
httpx2.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client,
Client(streamable_http_client(MCP_SERVER_URL, http_client=http_client)) as client,
):
result = await client.call_tool("whoami", {})

View file

@ -2,7 +2,7 @@
import inspect
import httpx
import httpx2
import pytest
from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent
@ -64,8 +64,8 @@ async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a
app = MCPServer("Bookshop").streamable_http_app()
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
httpx2.ASGITransport(app) as transport,
httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
@ -80,8 +80,8 @@ async def test_stateless_http_never_mints_a_session() -> None:
app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
httpx2.ASGITransport(app) as transport,
httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
@ -93,8 +93,8 @@ async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_
the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes."""
async with (
tutorial002.app.router.lifespan_context(tutorial002.app),
httpx.ASGITransport(tutorial002.app) as transport,
httpx.AsyncClient(transport=transport) as http,
httpx2.ASGITransport(tutorial002.app) as transport,
httpx2.AsyncClient(transport=transport) as http,
):
modern_target = streamable_http_client(URL, http_client=http)
async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern:

View file

@ -2,7 +2,7 @@
import inspect
import httpx
import httpx2
import pytest
from pydantic import AnyUrl, ValidationError
@ -42,8 +42,8 @@ async def test_storage_round_trips_tokens_and_client_info() -> None:
async def test_the_provider_is_an_httpx_auth() -> None:
"""tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP."""
assert isinstance(tutorial001.oauth, httpx.Auth)
"""tutorial001: `OAuthClientProvider` plugs into httpx2, not into MCP."""
assert isinstance(tutorial001.oauth, httpx2.Auth)
async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None:
@ -66,9 +66,9 @@ async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytes
async def test_client_credentials_provider_has_no_human_in_the_loop() -> None:
"""tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers."""
"""tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the handlers."""
assert isinstance(tutorial002.oauth, OAuthClientProvider)
assert isinstance(tutorial002.oauth, httpx.Auth)
assert isinstance(tutorial002.oauth, httpx2.Auth)
assert tutorial002.oauth.context.redirect_handler is None
assert tutorial002.oauth.context.callback_handler is None
@ -92,7 +92,7 @@ async def test_the_three_remaining_keyword_arguments_have_defaults() -> None:
async def test_the_one_more_provider_is_private_key_jwt() -> None:
"""The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way."""
"""The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx2.Auth`, built the same way."""
provider = PrivateKeyJWTOAuthProvider(
server_url="http://localhost:8001/mcp",
storage=tutorial002.InMemoryTokenStorage(),
@ -100,7 +100,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None:
assertion_provider=static_assertion_provider("a.prebuilt.jwt"),
)
assert isinstance(provider, OAuthClientProvider)
assert isinstance(provider, httpx.Auth)
assert isinstance(provider, httpx2.Auth)
assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt"

View file

@ -3,7 +3,7 @@
import logging
from typing import Any
import httpx
import httpx2
import pytest
from mcp_types import (
INVALID_PARAMS,
@ -125,10 +125,10 @@ async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
caplog: pytest.LogCaptureFixture,
) -> None:
"""tutorial003: one 421, three spellings. The page presents all three as the same event."""
transport = httpx.ASGITransport(app=tutorial003.app)
transport = httpx2.ASGITransport(app=tutorial003.app)
async with tutorial003.mcp.session_manager.run():
# What curl (or the reverse proxy's access log) shows: the status and the plain-text body.
async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
async with httpx2.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"):
response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert (response.status_code, response.text) == (421, "Invalid Host header")
@ -137,7 +137,7 @@ async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
# What the server operator finds by grepping the server log.
assert "Invalid Host header: mcp.example.com" in caplog.messages
# What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
async with httpx.AsyncClient(transport=transport) as http_client:
async with httpx2.AsyncClient(transport=transport) as http_client:
client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client))
with pytest.raises(Exception) as exc_info: # pragma: no branch
await client.__aenter__() # the connection attempt itself is what fails
@ -147,9 +147,9 @@ async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
"""tutorial004: `transport_security=` names the deployed hostname, and the same client connects."""
transport = httpx.ASGITransport(app=tutorial004.app)
transport = httpx2.ASGITransport(app=tutorial004.app)
async with tutorial004.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
async with httpx2.AsyncClient(transport=transport) as http_client:
allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)
async with Client(allowed) as c: # pragma: no branch
assert c.protocol_version == "2026-07-28"
@ -159,8 +159,8 @@ async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None:
"""tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager."""
transport = httpx.ASGITransport(app=tutorial005.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http:
transport = httpx2.ASGITransport(app=tutorial005.app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http:
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
await http.post("/mcp")
@ -170,7 +170,7 @@ async def test_a_session_id_the_server_never_issued_gets_a_404_session_not_found
mcp = MCPServer("Weather")
app = mcp.streamable_http_app()
async with mcp.session_manager.run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
response = await h.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
@ -243,9 +243,9 @@ async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_suppo
async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
"""tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`."""
transport = httpx.ASGITransport(app=tutorial008.app)
transport = httpx2.ASGITransport(app=tutorial008.app)
async with tutorial008.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
async with httpx2.AsyncClient(transport=transport) as http_client:
stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)
async with Client(stateless) as c: # pragma: no branch
with pytest.raises(MCPError) as exc_info: # pragma: no branch

View file

@ -19,7 +19,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
import httpx2
import pytest
import stories
from mcp_types.version import LATEST_MODERN_VERSION
@ -125,12 +125,12 @@ class Hosted:
``targets`` yields a fresh connection target against that single instance on
every call, so state observed by one connection is visible to the next.
``http`` is the shared raw ``httpx.AsyncClient`` bound to the same ASGI app,
``http`` is the shared raw ``httpx2.AsyncClient`` bound to the same ASGI app,
or ``None`` on the in-memory leg.
"""
targets: TargetFactory
http: httpx.AsyncClient | None
http: httpx2.AsyncClient | None
@pytest.fixture
@ -140,7 +140,7 @@ async def hosted(
"""Build the leg's server/app once and keep it running for the test.
The story's ``main`` owns the ``Client(target, mode=...)`` construction; this
fixture only decides what ``target`` is. Auth stories thread an ``httpx.Auth``
fixture only decides what ``target`` is. Auth stories thread an ``httpx2.Auth``
onto the bridge client via a module-level ``build_auth(http)`` export.
"""
for key, value in cfg["env"].items():
@ -163,7 +163,7 @@ async def hosted(
build_auth: AuthBuilder | None = getattr(client_module, "build_auth", None)
async with (
app.router.lifespan_context(app),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
):
if build_auth is not None:
http_client.auth = build_auth(http_client)

View file

@ -32,7 +32,7 @@ pytestmark = [
]
_REPO_ROOT = Path(__file__).parents[2]
# httpx in the spawned client honours these and tries to mount a SOCKS transport even for
# httpx2 in the spawned client honours these and tries to mount a SOCKS transport even for
# 127.0.0.1; strip them so the smoke run is hermetic regardless of the caller's shell.
_PROXY_VARS = {v for base in ("all_proxy", "http_proxy", "https_proxy", "ftp_proxy") for v in (base, base.upper())}
_ENV = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS}

View file

@ -70,7 +70,7 @@ real-clock timeout tests (the timeout machinery is transport-independent and mus
transport latency), and everything under `transports/`, which pins behaviour only observable on
that transport.
A transport conformance test in `transports/` speaks raw `httpx` against the mounted ASGI app
A transport conformance test in `transports/` speaks raw `httpx2` against the mounted ASGI app
**only** when its assertion is about HTTP semantics that `Client` cannot observe — status codes,
response headers, SSE event fields, which stream a message travels on. Any other behaviour is
asserted through a `Client`, connected to the mounted app via `client_via_http(http)` so several

Some files were not shown because too many files have changed in this diff Show more