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
8 changed files with 1029 additions and 948 deletions
|
|
@ -7,9 +7,9 @@ import unittest.mock
|
|||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from httpx import ASGITransport
|
||||
from httpx2 import ASGITransport
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ def app(oauth_provider: MockOAuthProvider):
|
|||
def client(app: Starlette):
|
||||
transport = ASGITransport(app=app)
|
||||
# Use base_url without a path since routes are directly on the app
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://localhost")
|
||||
return httpx2.AsyncClient(transport=transport, base_url="http://localhost")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -65,7 +65,7 @@ def pkce_challenge():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
async def registered_client(client: httpx2.AsyncClient) -> dict[str, Any]:
|
||||
"""Create and register a test client."""
|
||||
# Default client metadata
|
||||
client_metadata = {
|
||||
|
|
@ -84,7 +84,7 @@ async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]:
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_registration_error_handling(client: httpx.AsyncClient, oauth_provider: MockOAuthProvider):
|
||||
async def test_registration_error_handling(client: httpx2.AsyncClient, oauth_provider: MockOAuthProvider):
|
||||
# Mock the register_client method to raise a registration error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
|
|
@ -118,7 +118,7 @@ async def test_registration_error_handling(client: httpx.AsyncClient, oauth_prov
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_error_handling(
|
||||
client: httpx.AsyncClient,
|
||||
client: httpx2.AsyncClient,
|
||||
oauth_provider: MockOAuthProvider,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
|
|
@ -159,7 +159,7 @@ async def test_authorize_error_handling(
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_auth_code(
|
||||
client: httpx.AsyncClient,
|
||||
client: httpx2.AsyncClient,
|
||||
oauth_provider: MockOAuthProvider,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
|
|
@ -218,7 +218,7 @@ async def test_token_error_handling_auth_code(
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_refresh_token(
|
||||
client: httpx.AsyncClient,
|
||||
client: httpx2.AsyncClient,
|
||||
oauth_provider: MockOAuthProvider,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
import secrets
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from httpx import ASGITransport
|
||||
from httpx2 import ASGITransport
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ def app(provider: IdentityAssertionProvider) -> Starlette:
|
|||
@pytest.fixture
|
||||
async def client(app: Starlette):
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
yield http
|
||||
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ def test_build_metadata_advertises_id_jag_profile_when_enabled():
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient):
|
||||
async def test_metadata_endpoint_lists_id_jag_profile(client: httpx2.AsyncClient):
|
||||
response = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
|
@ -169,7 +169,7 @@ async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient)
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_success(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
||||
async def test_identity_assertion_success(client: httpx2.AsyncClient, provider: IdentityAssertionProvider):
|
||||
response = await client.post("/token", data=assertion_form(scope="mcp", resource="https://mcp.example.com/mcp"))
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
|
|
@ -189,7 +189,7 @@ async def test_identity_assertion_success(client: httpx.AsyncClient, provider: I
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_invalid_assertion(client: httpx.AsyncClient):
|
||||
async def test_identity_assertion_invalid_assertion(client: httpx2.AsyncClient):
|
||||
response = await client.post("/token", data=assertion_form(assertion="forged"))
|
||||
|
||||
assert response.status_code == 400
|
||||
|
|
@ -207,7 +207,7 @@ async def test_identity_assertion_rejected_when_disabled(provider: IdentityAsser
|
|||
)
|
||||
app = Starlette(routes=routes)
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
||||
response = await http.post("/token", data=assertion_form())
|
||||
|
||||
assert response.status_code == 400
|
||||
|
|
@ -216,7 +216,9 @@ async def test_identity_assertion_rejected_when_disabled(provider: IdentityAsser
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
||||
async def test_identity_assertion_rejects_public_client(
|
||||
client: httpx2.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion."""
|
||||
provider.clients["public-client"] = OAuthClientInformationFull(
|
||||
client_id="public-client",
|
||||
|
|
@ -238,7 +240,7 @@ async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClien
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_assertion_rejects_secretless_confidential_client(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
client: httpx2.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""A client registered with a secret-based method but no stored secret fails authentication.
|
||||
|
||||
|
|
@ -267,7 +269,7 @@ async def test_identity_assertion_rejects_secretless_confidential_client(
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx.AsyncClient):
|
||||
async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx2.AsyncClient):
|
||||
"""A jwt-bearer request without the required `assertion` fails validation with invalid_request."""
|
||||
response = await client.post(
|
||||
"/token",
|
||||
|
|
@ -284,7 +286,7 @@ async def test_malformed_request_missing_assertion_is_invalid_request(client: ht
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_without_the_grant_registered_is_rejected(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
client: httpx2.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""A confidential client whose registration omits the jwt-bearer grant is refused the grant."""
|
||||
provider.clients["no-grant-client"] = OAuthClientInformationFull(
|
||||
|
|
@ -313,7 +315,7 @@ async def test_client_without_the_grant_registered_is_rejected(
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_dcr_refuses_to_register_the_jwt_bearer_grant(
|
||||
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
||||
client: httpx2.AsyncClient, provider: IdentityAssertionProvider
|
||||
):
|
||||
"""Dynamic client registration rejects the jwt-bearer grant; the ID-JAG flow needs pre-registration."""
|
||||
response = await client.post(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import AnyHttpUrl
|
||||
|
|
@ -31,12 +31,14 @@ def test_app():
|
|||
@pytest.fixture
|
||||
async def test_client(test_app: Starlette):
|
||||
"""Fixture to create an HTTP client for the protected resource app."""
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=test_app), base_url="https://mcptest.com") as client:
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=test_app), base_url="https://mcptest.com"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient):
|
||||
async def test_metadata_endpoint_with_path(test_client: httpx2.AsyncClient):
|
||||
"""Test the OAuth 2.0 Protected Resource metadata endpoint for path-based resource."""
|
||||
|
||||
# For resource with path "/resource", metadata should be accessible at the path-aware location
|
||||
|
|
@ -54,7 +56,7 @@ async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient):
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_root_path_returns_404(test_client: httpx.AsyncClient):
|
||||
async def test_metadata_endpoint_root_path_returns_404(test_client: httpx2.AsyncClient):
|
||||
"""Test that root path returns 404 for path-based resource."""
|
||||
|
||||
# Root path should return 404 for path-based resources
|
||||
|
|
@ -81,14 +83,14 @@ def root_resource_app():
|
|||
@pytest.fixture
|
||||
async def root_resource_client(root_resource_app: Starlette):
|
||||
"""Fixture to create an HTTP client for the root resource app."""
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=root_resource_app), base_url="https://mcptest.com"
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=root_resource_app), base_url="https://mcptest.com"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncClient):
|
||||
async def test_metadata_endpoint_without_path(root_resource_client: httpx2.AsyncClient):
|
||||
"""Test metadata endpoint for root-level resource."""
|
||||
|
||||
# For root resource, metadata should be at standard location
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import unittest.mock
|
|||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
from starlette.applications import Starlette
|
||||
|
|
@ -220,13 +220,15 @@ def auth_app(mock_oauth_provider: MockOAuthProvider):
|
|||
|
||||
@pytest.fixture
|
||||
async def test_client(auth_app: Starlette):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com") as client:
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=auth_app), base_url="https://mcptest.com"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def registered_client(
|
||||
test_client: httpx.AsyncClient, request: pytest.FixtureRequest
|
||||
test_client: httpx2.AsyncClient, request: pytest.FixtureRequest
|
||||
) -> OAuthClientInformationFull:
|
||||
"""Create and register a test client.
|
||||
|
||||
|
|
@ -264,7 +266,7 @@ def pkce_challenge():
|
|||
|
||||
@pytest.fixture
|
||||
async def auth_code(
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
request: pytest.FixtureRequest,
|
||||
|
|
@ -310,7 +312,7 @@ async def auth_code(
|
|||
|
||||
class TestAuthEndpoints:
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
|
||||
async def test_metadata_endpoint(self, test_client: httpx2.AsyncClient):
|
||||
"""Test the OAuth 2.0 metadata endpoint."""
|
||||
|
||||
response = await test_client.get("/.well-known/oauth-authorization-server")
|
||||
|
|
@ -332,7 +334,7 @@ class TestAuthEndpoints:
|
|||
assert metadata["service_documentation"] == "https://docs.example.com/"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_validation_error(self, test_client: httpx.AsyncClient):
|
||||
async def test_token_validation_error(self, test_client: httpx2.AsyncClient):
|
||||
"""Test token endpoint error - validation error."""
|
||||
# Missing required fields
|
||||
response = await test_client.post(
|
||||
|
|
@ -351,7 +353,7 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_token_invalid_client_secret_returns_invalid_client(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
mock_oauth_provider: MockOAuthProvider,
|
||||
|
|
@ -399,7 +401,7 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_token_invalid_auth_code(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
|
|
@ -425,7 +427,7 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_token_expired_auth_code(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
|
|
@ -480,7 +482,7 @@ class TestAuthEndpoints:
|
|||
)
|
||||
async def test_token_redirect_uri_mismatch(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
|
|
@ -506,7 +508,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_code_verifier_mismatch(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], auth_code: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any], auth_code: dict[str, str]
|
||||
):
|
||||
"""Test token endpoint error - PKCE code verifier mismatch."""
|
||||
# Try to use the code with an incorrect code verifier
|
||||
|
|
@ -528,7 +530,9 @@ class TestAuthEndpoints:
|
|||
assert "incorrect code_verifier" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_refresh_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]):
|
||||
async def test_token_invalid_refresh_token(
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any]
|
||||
):
|
||||
"""Test token endpoint error - refresh token does not exist."""
|
||||
# Try to use a non-existent refresh token
|
||||
response = await test_client.post(
|
||||
|
|
@ -548,7 +552,7 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_token_expired_refresh_token(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
|
|
@ -596,7 +600,7 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_token_invalid_scope(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
|
|
@ -636,7 +640,7 @@ class TestAuthEndpoints:
|
|||
assert "cannot request scope" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration(self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider):
|
||||
async def test_client_registration(self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider):
|
||||
"""Test client registration."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
|
|
@ -662,7 +666,7 @@ class TestAuthEndpoints:
|
|||
# ) is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_missing_required_fields(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_missing_required_fields(self, test_client: httpx2.AsyncClient):
|
||||
"""Test client registration with missing required fields."""
|
||||
# Missing redirect_uris which is a required field
|
||||
client_metadata = {
|
||||
|
|
@ -681,7 +685,7 @@ class TestAuthEndpoints:
|
|||
assert error_data["error_description"] == "redirect_uris: Field required"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_invalid_uri(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_invalid_uri(self, test_client: httpx2.AsyncClient):
|
||||
"""Test client registration with invalid URIs."""
|
||||
# Invalid redirect_uri format
|
||||
client_metadata = {
|
||||
|
|
@ -702,7 +706,7 @@ class TestAuthEndpoints:
|
|||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_empty_redirect_uris(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_empty_redirect_uris(self, test_client: httpx2.AsyncClient):
|
||||
"""Test client registration with empty redirect_uris array."""
|
||||
redirect_uris: list[str] = []
|
||||
client_metadata = {
|
||||
|
|
@ -723,7 +727,7 @@ class TestAuthEndpoints:
|
|||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_form_post(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
async def test_authorize_form_post(self, test_client: httpx2.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
"""Test the authorization endpoint using POST with form-encoded data."""
|
||||
# Register a client
|
||||
client_metadata = {
|
||||
|
|
@ -764,7 +768,7 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_authorization_get(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
test_client: httpx2.AsyncClient,
|
||||
mock_oauth_provider: MockOAuthProvider,
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
|
|
@ -877,7 +881,7 @@ class TestAuthEndpoints:
|
|||
assert await mock_oauth_provider.load_access_token(new_token_response["access_token"]) is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoke_invalid_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]):
|
||||
async def test_revoke_invalid_token(self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any]):
|
||||
"""Test revoking an invalid token."""
|
||||
response = await test_client.post(
|
||||
"/revoke",
|
||||
|
|
@ -891,7 +895,9 @@ class TestAuthEndpoints:
|
|||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoke_with_malformed_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]):
|
||||
async def test_revoke_with_malformed_token(
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any]
|
||||
):
|
||||
response = await test_client.post(
|
||||
"/revoke",
|
||||
data={
|
||||
|
|
@ -907,7 +913,7 @@ class TestAuthEndpoints:
|
|||
assert "token_type_hint" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_disallowed_scopes(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_disallowed_scopes(self, test_client: httpx2.AsyncClient):
|
||||
"""Test client registration with scopes that are not allowed."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
|
|
@ -925,7 +931,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_default_scopes(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
):
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
|
|
@ -948,7 +954,7 @@ class TestAuthEndpoints:
|
|||
assert registered_client.scope == "read write"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_with_authorization_code_only(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_with_authorization_code_only(self, test_client: httpx2.AsyncClient):
|
||||
"""Test that registration succeeds with only authorization_code (refresh_token is optional per RFC 7591)."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
|
|
@ -963,7 +969,7 @@ class TestAuthEndpoints:
|
|||
assert client_info["grant_types"] == ["authorization_code"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_missing_authorization_code(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_missing_authorization_code(self, test_client: httpx2.AsyncClient):
|
||||
"""Test that registration fails when authorization_code grant type is missing."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
|
|
@ -979,7 +985,7 @@ class TestAuthEndpoints:
|
|||
assert error_data["error_description"] == "grant_types must include 'authorization_code'"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_with_additional_grant_type(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_with_additional_grant_type(self, test_client: httpx2.AsyncClient):
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"client_name": "Test Client",
|
||||
|
|
@ -997,7 +1003,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_with_additional_response_types(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
):
|
||||
"""Test that registration accepts additional response_types values alongside 'code'."""
|
||||
client_metadata = {
|
||||
|
|
@ -1016,7 +1022,7 @@ class TestAuthEndpoints:
|
|||
assert "code" in client.response_types
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_response_types_without_code(self, test_client: httpx.AsyncClient):
|
||||
async def test_client_registration_response_types_without_code(self, test_client: httpx2.AsyncClient):
|
||||
"""Test that registration rejects response_types that don't include 'code'."""
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
|
|
@ -1034,7 +1040,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_registration_default_response_types(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider
|
||||
):
|
||||
"""Test that registration uses default response_types of ['code'] when not specified."""
|
||||
client_metadata = {
|
||||
|
|
@ -1053,7 +1059,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_secret_basic_authentication(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that client_secret_basic authentication works correctly."""
|
||||
client_metadata = {
|
||||
|
|
@ -1099,7 +1105,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_wrong_auth_method_without_valid_credentials_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that using the wrong authentication method fails when credentials are missing."""
|
||||
client_metadata = {
|
||||
|
|
@ -1151,7 +1157,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_without_header_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that omitting Basic auth when client_secret_basic is registered fails."""
|
||||
client_metadata = {
|
||||
|
|
@ -1196,7 +1202,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_invalid_base64_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that invalid base64 in Basic auth header fails."""
|
||||
client_metadata = {
|
||||
|
|
@ -1241,7 +1247,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_no_colon_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that Basic auth without colon separator fails."""
|
||||
client_metadata = {
|
||||
|
|
@ -1287,7 +1293,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_auth_client_id_mismatch_fails(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that client_id mismatch between body and Basic auth fails."""
|
||||
client_metadata = {
|
||||
|
|
@ -1333,7 +1339,7 @@ class TestAuthEndpoints:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_none_auth_method_public_client(
|
||||
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test that 'none' authentication method works for public clients."""
|
||||
client_metadata = {
|
||||
|
|
@ -1381,7 +1387,7 @@ class TestAuthorizeEndpointErrors:
|
|||
"""Test error handling in the OAuth authorization endpoint."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_client_id(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
async def test_authorize_missing_client_id(self, test_client: httpx2.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
"""Test authorization endpoint with missing client_id.
|
||||
|
||||
According to the OAuth2.0 spec, if client_id is missing, the server should
|
||||
|
|
@ -1405,7 +1411,7 @@ class TestAuthorizeEndpointErrors:
|
|||
assert "client_id" in response.text.lower()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_client_id(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
async def test_authorize_invalid_client_id(self, test_client: httpx2.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
"""Test authorization endpoint with invalid client_id.
|
||||
|
||||
According to the OAuth2.0 spec, if client_id is invalid, the server should
|
||||
|
|
@ -1430,7 +1436,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_redirect_uri(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with missing redirect_uri.
|
||||
|
||||
|
|
@ -1456,7 +1462,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_redirect_uri(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with invalid redirect_uri.
|
||||
|
||||
|
|
@ -1496,7 +1502,7 @@ class TestAuthorizeEndpointErrors:
|
|||
indirect=True,
|
||||
)
|
||||
async def test_authorize_missing_redirect_uri_multiple_registered(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test endpoint with missing redirect_uri with multiple registered URIs.
|
||||
|
||||
|
|
@ -1522,7 +1528,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_unsupported_response_type(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with unsupported response_type.
|
||||
|
||||
|
|
@ -1556,7 +1562,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_response_type(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with missing response_type.
|
||||
|
||||
|
|
@ -1589,7 +1595,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_pkce_challenge(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any]
|
||||
):
|
||||
"""Test authorization endpoint with missing PKCE code_challenge.
|
||||
|
||||
|
|
@ -1620,7 +1626,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_scope(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
self, test_client: httpx2.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with invalid scope.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import logging
|
|||
import re
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
import sse_starlette.sse
|
||||
from mcp_types import JSONRPCRequest, JSONRPCResponse
|
||||
|
|
@ -40,8 +40,8 @@ def reset_sse_starlette_exit_event() -> None:
|
|||
app_status.should_exit_event = None
|
||||
|
||||
|
||||
def sse_security_client(security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient:
|
||||
"""An httpx client whose requests are served in process by an SSE app with the given settings."""
|
||||
def sse_security_client(security_settings: TransportSecuritySettings | None = None) -> httpx2.AsyncClient:
|
||||
"""An httpx2 client whose requests are served in process by an SSE app with the given settings."""
|
||||
server = Server(SERVER_NAME)
|
||||
sse_transport = SseServerTransport("/messages/", security_settings)
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ def sse_security_client(security_settings: TransportSecuritySettings | None = No
|
|||
# The SSE GET runs until it observes a disconnect, so the bridge must let the application
|
||||
# drain on close rather than cancelling it.
|
||||
transport = StreamingASGITransport(app, cancel_on_close=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url=BASE_URL)
|
||||
return httpx2.AsyncClient(transport=transport, base_url=BASE_URL)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from mcp_types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams
|
||||
from starlette.types import Message, Scope
|
||||
|
|
@ -331,8 +331,8 @@ async def test_e2e_streamable_http_server_cleanup():
|
|||
mcp_app = app.streamable_http_app(host=host)
|
||||
async with (
|
||||
mcp_app.router.lifespan_context(mcp_app),
|
||||
httpx.ASGITransport(mcp_app) as transport,
|
||||
httpx.AsyncClient(transport=transport) as http_client,
|
||||
httpx2.ASGITransport(mcp_app) as transport,
|
||||
httpx2.AsyncClient(transport=transport) as http_client,
|
||||
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client), mode="legacy") as client,
|
||||
):
|
||||
await client.list_tools()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from collections.abc import Callable
|
|||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
|
|
@ -81,12 +81,12 @@ def _asgi_client(
|
|||
*,
|
||||
json_response: bool = True,
|
||||
accept: str = "application/json, text/event-stream",
|
||||
) -> httpx.AsyncClient:
|
||||
) -> httpx2.AsyncClient:
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
async with server.lifespan(server) as lifespan_state:
|
||||
await handle_modern_request(server, security_settings, json_response, lifespan_state, scope, receive, send)
|
||||
|
||||
return httpx.AsyncClient(
|
||||
return httpx2.AsyncClient(
|
||||
transport=StreamingASGITransport(app),
|
||||
base_url="http://testserver",
|
||||
headers={
|
||||
|
|
@ -894,8 +894,8 @@ async def test_modern_tools_call_leaves_mis_shaped_name_and_arguments_to_dispatc
|
|||
|
||||
async def test_modern_tools_call_rejects_a_duplicated_mcp_param_header() -> None:
|
||||
"""A duplicated recognized header is rejected even if one copy matches: readers may disagree on which wins."""
|
||||
# An httpx header list with a repeated name reaches the ASGI scope as two raw header lines.
|
||||
duplicated = httpx.Headers(
|
||||
# An httpx2 header list with a repeated name reaches the ASGI scope as two raw header lines.
|
||||
duplicated = httpx2.Headers(
|
||||
[*_TOOL_CALL_HEADERS.items(), ("mcp-param-region", "spoofed"), ("mcp-param-region", "eu")]
|
||||
)
|
||||
async with _asgi_client(_x_mcp_server()) as http:
|
||||
|
|
@ -928,7 +928,7 @@ async def test_modern_synthetic_listing_does_not_replay_caller_meta_extras() ->
|
|||
|
||||
async def test_modern_post_rejects_a_duplicated_routing_header() -> None:
|
||||
"""A duplicated routing header (`Mcp-Name`) is unverifiable and rejected before the validation ladder runs."""
|
||||
duplicated = httpx.Headers(
|
||||
duplicated = httpx2.Headers(
|
||||
[(MCP_METHOD_HEADER, "tools/call"), (MCP_NAME_HEADER, "search"), (MCP_NAME_HEADER, "admin-tool")]
|
||||
)
|
||||
async with _asgi_client(_x_mcp_server()) as http:
|
||||
|
|
@ -1066,7 +1066,7 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None:
|
|||
server = Server("test", on_subscriptions_listen=handler)
|
||||
body = _listen_body()
|
||||
|
||||
responses: list[httpx.Response] = []
|
||||
responses: list[httpx2.Response] = []
|
||||
async with _asgi_client(server, json_response=True) as http:
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
|
@ -23,13 +23,13 @@ BASE_URL = "http://127.0.0.1:8000"
|
|||
@asynccontextmanager
|
||||
async def streamable_http_security_client(
|
||||
security_settings: TransportSecuritySettings | None = None,
|
||||
) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""Yield an httpx client served in process by a StreamableHTTP app with the given settings."""
|
||||
) -> AsyncIterator[httpx2.AsyncClient]:
|
||||
"""Yield an httpx2 client served in process by a StreamableHTTP app with the given settings."""
|
||||
session_manager = StreamableHTTPSessionManager(app=Server(SERVER_NAME), security_settings=security_settings)
|
||||
app = Starlette(routes=[Mount("/", app=session_manager.handle_request)])
|
||||
|
||||
async with session_manager.run():
|
||||
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as client:
|
||||
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as client:
|
||||
yield client
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue