Replace httpx and httpx-sse with httpx2 (#2972)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
1216c53693
commit
2713b53b12
51 changed files with 1029 additions and 948 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue