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
5 changed files with 1029 additions and 948 deletions
|
|
@ -3,7 +3,7 @@
|
|||
Co-hosts the SDK's authorization-server routes, protected-resource metadata route, and the
|
||||
bearer-gated MCP endpoint on one Starlette app via `Server.streamable_http_app(auth=...,
|
||||
token_verifier=..., auth_server_provider=...)`, drives that app through the streaming bridge
|
||||
on a single `httpx.AsyncClient` carrying `auth=OAuthClientProvider(...)`, and completes the
|
||||
on a single `httpx2.AsyncClient` carrying `auth=OAuthClientProvider(...)`, and completes the
|
||||
authorize redirect headlessly by GETing the URL through the same bridge and parsing the code
|
||||
from the 302 `Location`. The whole authorization-code flow runs in one event loop with no
|
||||
sockets, no threads, and no real time.
|
||||
|
|
@ -16,7 +16,7 @@ from dataclasses import dataclass, field
|
|||
from typing import Any
|
||||
from urllib.parse import parse_qs, parse_qsl, urlsplit
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
from pydantic import AnyHttpUrl, AnyUrl, BaseModel
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
|
@ -38,15 +38,15 @@ AppShim = Callable[[ASGIApp], ASGIApp]
|
|||
|
||||
@dataclass
|
||||
class RecordedRequest:
|
||||
"""A snapshot of an `httpx.Request` at the moment it was sent.
|
||||
"""A snapshot of an `httpx2.Request` at the moment it was sent.
|
||||
|
||||
The auth flow re-yields the same `httpx.Request` object after mutating its headers in
|
||||
The auth flow re-yields the same `httpx2.Request` object after mutating its headers in
|
||||
place for the retry, so tests that need to assert on the first attempt's headers must
|
||||
capture a copy rather than a live reference. `record_requests` produces these.
|
||||
"""
|
||||
|
||||
method: str
|
||||
url: httpx.URL
|
||||
url: httpx2.URL
|
||||
headers: dict[str, str]
|
||||
content: bytes
|
||||
|
||||
|
|
@ -55,11 +55,11 @@ class RecordedRequest:
|
|||
return self.url.path
|
||||
|
||||
|
||||
def record_requests() -> tuple[list[RecordedRequest], Callable[[httpx.Request], None]]:
|
||||
def record_requests() -> tuple[list[RecordedRequest], Callable[[httpx2.Request], None]]:
|
||||
"""Build an `on_request` callback that snapshots each request, and the list it appends to."""
|
||||
recorded: list[RecordedRequest] = []
|
||||
|
||||
def on_request(request: httpx.Request) -> None:
|
||||
def on_request(request: httpx2.Request) -> None:
|
||||
recorded.append(
|
||||
RecordedRequest(
|
||||
method=request.method,
|
||||
|
|
@ -147,12 +147,12 @@ class HeadlessOAuth:
|
|||
self.error: str | None = None
|
||||
self._state_override = state_override
|
||||
self._iss_override = iss_override
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._http: httpx2.AsyncClient | None = None
|
||||
self._code: str = ""
|
||||
self._state: str | None = None
|
||||
self._iss: str | None = None
|
||||
|
||||
def bind(self, http_client: httpx.AsyncClient) -> None:
|
||||
def bind(self, http_client: httpx2.AsyncClient) -> None:
|
||||
self._http = http_client
|
||||
|
||||
async def redirect_handler(self, authorization_url: str) -> None:
|
||||
|
|
@ -411,16 +411,16 @@ async def connect_with_oauth(
|
|||
client_metadata: OAuthClientMetadata | None = None,
|
||||
client_metadata_url: str | None = None,
|
||||
headless: HeadlessOAuth | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
auth: httpx2.Auth | None = None,
|
||||
verify_tokens: bool = True,
|
||||
app_shim: Callable[[ASGIApp], ASGIApp] | None = None,
|
||||
on_request: Callable[[httpx.Request], None] | None = None,
|
||||
on_request: Callable[[httpx2.Request], None] | None = None,
|
||||
) -> AsyncIterator[tuple[Client, HeadlessOAuth]]:
|
||||
"""Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process.
|
||||
|
||||
Yields the connected `Client` and the `HeadlessOAuth` whose `authorize_url` records what the
|
||||
SDK put on the authorize request. `on_request` records every HTTP request the underlying
|
||||
`httpx.AsyncClient` issues, including those yielded from inside the auth flow.
|
||||
`httpx2.AsyncClient` issues, including those yielded from inside the auth flow.
|
||||
|
||||
`headless`: supply a pre-configured `HeadlessOAuth` to override the callback behaviour
|
||||
(state mismatch, error redirects). `verify_tokens=False` mounts the MCP endpoint without
|
||||
|
|
@ -428,7 +428,7 @@ async def connect_with_oauth(
|
|||
scopes. `app_shim` wraps the built Starlette app before it reaches the bridge transport,
|
||||
for tests that need to intercept or rewrite specific server responses.
|
||||
|
||||
`auth`: supply a pre-built `httpx.Auth` (such as `ClientCredentialsOAuthProvider`) to use
|
||||
`auth`: supply a pre-built `httpx2.Auth` (such as `ClientCredentialsOAuthProvider`) to use
|
||||
instead of constructing the default `OAuthClientProvider`; in that case `storage`,
|
||||
`client_metadata`, `client_metadata_url`, and `headless` are unused (the yielded
|
||||
`HeadlessOAuth` is never invoked and its `authorize_url` stays None).
|
||||
|
|
@ -464,7 +464,7 @@ async def connect_with_oauth(
|
|||
if on_request is not None:
|
||||
record = on_request
|
||||
|
||||
async def hook(request: httpx.Request) -> None:
|
||||
async def hook(request: httpx2.Request) -> None:
|
||||
record(request)
|
||||
|
||||
event_hooks = {"request": [hook]}
|
||||
|
|
@ -472,7 +472,7 @@ async def connect_with_oauth(
|
|||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(server.session_manager.run())
|
||||
http_client = await stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
httpx2.AsyncClient(
|
||||
transport=StreamingASGITransport(app), base_url=BASE_URL, auth=oauth, event_hooks=event_hooks
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Error-plane behaviour of the SDK's bundled OAuth authorization-server handlers.
|
||||
|
||||
The end-to-end OAuth tests prove the handlers' happy paths; these tests drive the same
|
||||
mounted authorization server directly with raw httpx so the assertions are the HTTP
|
||||
mounted authorization server directly with raw httpx2 so the assertions are the HTTP
|
||||
semantics (status, redirect target, error body, headers) the OAuth RFCs mandate. Almost
|
||||
every behaviour here is enforced by the SDK's own handlers; where the pinned output
|
||||
deviates from the RFC, the manifest entry carries the divergence.
|
||||
|
|
@ -13,7 +13,7 @@ import secrets
|
|||
from collections.abc import AsyncIterator
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
|
|
@ -29,8 +29,8 @@ pytestmark = pytest.mark.anyio
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def as_app() -> AsyncIterator[tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider]]:
|
||||
"""Co-host the SDK's authorization-server routes and yield a raw httpx client against them."""
|
||||
async def as_app() -> AsyncIterator[tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider]]:
|
||||
"""Co-host the SDK's authorization-server routes and yield a raw httpx2 client against them."""
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
settings = auth_settings()
|
||||
async with mounted_app(
|
||||
|
|
@ -49,14 +49,14 @@ def _pkce_pair() -> tuple[str, str]:
|
|||
return verifier, challenge
|
||||
|
||||
|
||||
async def _register_client(http: httpx.AsyncClient) -> OAuthClientInformationFull:
|
||||
async def _register_client(http: httpx2.AsyncClient) -> OAuthClientInformationFull:
|
||||
"""Dynamically register a client and return its full credentials."""
|
||||
response = await http.post("/register", content=oauth_client_metadata().model_dump_json())
|
||||
assert response.status_code == 201
|
||||
return OAuthClientInformationFull.model_validate_json(response.content)
|
||||
|
||||
|
||||
async def _mint_code(http: httpx.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
|
||||
async def _mint_code(http: httpx2.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
|
||||
"""Register a client, complete a valid authorize step, and return (client_info, code, verifier)."""
|
||||
client_info = await _register_client(http)
|
||||
assert client_info.client_id is not None
|
||||
|
|
@ -96,7 +96,7 @@ def _token_form(client_info: OAuthClientInformationFull, **overrides: str) -> di
|
|||
|
||||
@requirement("hosting:auth:as:authorize-requires-pkce")
|
||||
async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_request(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""An authorize request omitting `code_challenge` is redirected back with `error=invalid_request`.
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_reque
|
|||
|
||||
@requirement("hosting:auth:as:verifier-mismatch")
|
||||
async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""A token exchange whose `code_verifier` does not hash to the stored challenge is rejected."""
|
||||
http, _ = as_app
|
||||
|
|
@ -145,7 +145,7 @@ async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
|
|||
|
||||
@requirement("hosting:auth:as:code-single-use")
|
||||
async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""An authorization code can be exchanged exactly once; a second exchange is `invalid_grant`.
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
|
|||
|
||||
@requirement("hosting:auth:as:redirect-uri-binding")
|
||||
async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_endpoint(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected.
|
||||
|
||||
|
|
@ -200,7 +200,7 @@ async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_
|
|||
|
||||
@requirement("hosting:auth:as:token-cache-headers")
|
||||
async def test_token_responses_carry_cache_control_no_store(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""Every token-endpoint response (success and error) carries `Cache-Control: no-store`."""
|
||||
http, _ = as_app
|
||||
|
|
@ -220,7 +220,7 @@ async def test_token_responses_carry_cache_control_no_store(
|
|||
|
||||
@requirement("hosting:auth:as:register-error-response")
|
||||
async def test_registration_with_invalid_metadata_is_rejected_with_400(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""Invalid client metadata at the registration endpoint returns 400 with an RFC 7591 error body."""
|
||||
http, _ = as_app
|
||||
|
|
@ -247,7 +247,7 @@ async def test_registration_with_invalid_metadata_is_rejected_with_400(
|
|||
|
||||
@requirement("hosting:auth:as:redirect-uri-binding")
|
||||
async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""An authorize request naming an unregistered `redirect_uri` returns 400 without redirecting to it.
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
|
|||
|
||||
@requirement("hosting:auth:as:redirect-uri-scheme")
|
||||
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
|
||||
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
) -> None:
|
||||
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flag
|
|||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import JSONRPCResponse
|
||||
|
|
@ -44,7 +44,7 @@ TOKENS = {
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def protected() -> AsyncIterator[httpx.AsyncClient]:
|
||||
async def protected() -> AsyncIterator[httpx2.AsyncClient]:
|
||||
"""A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge."""
|
||||
server = Server("rs")
|
||||
settings = auth_settings(required_scopes=[REQUIRED_SCOPE])
|
||||
|
|
@ -53,8 +53,8 @@ async def protected() -> AsyncIterator[httpx.AsyncClient]:
|
|||
|
||||
|
||||
async def post_mcp(
|
||||
http: httpx.AsyncClient, *, bearer: str | None = None, query: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
http: httpx2.AsyncClient, *, bearer: str | None = None, query: dict[str, str] | None = None
|
||||
) -> httpx2.Response:
|
||||
"""POST an initialize body to `/mcp`, optionally with a bearer token and/or a query string."""
|
||||
headers = base_headers()
|
||||
if bearer is not None:
|
||||
|
|
@ -76,7 +76,7 @@ def parse_www_authenticate(value: str) -> dict[str, str]:
|
|||
|
||||
@requirement("hosting:auth:missing-401")
|
||||
async def test_a_request_with_no_authorization_header_is_challenged_with_resource_metadata(
|
||||
protected: httpx.AsyncClient,
|
||||
protected: httpx2.AsyncClient,
|
||||
) -> None:
|
||||
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata`.
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ async def test_a_request_with_no_authorization_header_is_challenged_with_resourc
|
|||
|
||||
|
||||
@requirement("hosting:auth:invalid-401")
|
||||
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx.AsyncClient) -> None:
|
||||
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx2.AsyncClient) -> None:
|
||||
"""A token the verifier does not recognize is answered 401 `invalid_token`.
|
||||
|
||||
The challenge is identical to the no-header case (the backend returns `None` for both); the
|
||||
|
|
@ -120,7 +120,7 @@ async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protec
|
|||
|
||||
|
||||
@requirement("hosting:auth:expired-401")
|
||||
async def test_an_expired_token_is_answered_401(protected: httpx.AsyncClient) -> None:
|
||||
async def test_an_expired_token_is_answered_401(protected: httpx2.AsyncClient) -> None:
|
||||
"""A token whose `expires_at` is in the past is answered 401 `invalid_token`.
|
||||
|
||||
The expiry check is the bearer backend's, against the wall clock; the test seeds a concrete
|
||||
|
|
@ -135,7 +135,7 @@ async def test_an_expired_token_is_answered_401(protected: httpx.AsyncClient) ->
|
|||
|
||||
@requirement("hosting:auth:scope-403")
|
||||
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param(
|
||||
protected: httpx.AsyncClient,
|
||||
protected: httpx2.AsyncClient,
|
||||
) -> None:
|
||||
"""A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter.
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco
|
|||
|
||||
|
||||
@requirement("hosting:auth:aud-validation")
|
||||
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.AsyncClient) -> None:
|
||||
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx2.AsyncClient) -> None:
|
||||
"""A token whose `resource` does not match the server's resource identifier is accepted.
|
||||
|
||||
The spec mandates the resource server validate the token's audience; the bearer backend
|
||||
|
|
@ -175,7 +175,7 @@ async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.A
|
|||
|
||||
|
||||
@requirement("hosting:auth:query-token-ignored")
|
||||
async def test_an_access_token_in_the_query_string_is_not_accepted(protected: httpx.AsyncClient) -> None:
|
||||
async def test_an_access_token_in_the_query_string_is_not_accepted(protected: httpx2.AsyncClient) -> None:
|
||||
"""A valid token presented in the URI query string is treated as no authentication.
|
||||
|
||||
The bearer backend reads only the `Authorization` header, so `?access_token=...` is never
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ detail `Client` cannot observe directly but the recording can. Tests that need a
|
|||
endpoint to 404 or return alternate content wrap the SDK's app in `shimmed_app` while leaving
|
||||
the real authorize and token endpoints behind it, so the rest of the flow runs unaltered.
|
||||
|
||||
The two server-side tests (#5, #6) drive raw httpx against `mounted_app` because their
|
||||
The two server-side tests (#5, #6) drive raw httpx2 against `mounted_app` because their
|
||||
assertions are the metadata response bodies and headers, which `Client` does not surface.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Auth is HTTP-only so these tests are not transport-parametrized; each connects via
|
||||
`connect_with_oauth`, which co-hosts the SDK's authorization server, protected-resource
|
||||
metadata, and bearer-gated MCP endpoint on one bridge-backed Starlette app and drives the
|
||||
whole flow through one `httpx.AsyncClient` carrying the SDK's `OAuthClientProvider`. The
|
||||
whole flow through one `httpx2.AsyncClient` carrying the SDK's `OAuthClientProvider`. The
|
||||
authorize redirect completes headlessly through the same bridge, so every request the flow
|
||||
makes is observable via `on_request`.
|
||||
"""
|
||||
|
|
@ -13,7 +13,7 @@ from collections import Counter
|
|||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
|
@ -64,7 +64,7 @@ async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow
|
|||
6. POST /token (authorization-code exchange).
|
||||
7. Retry POST /mcp with `Authorization: Bearer <access_token>` → succeeds.
|
||||
"""
|
||||
requests: list[httpx.Request] = []
|
||||
requests: list[httpx2.Request] = []
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
storage = InMemoryTokenStorage()
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
|
|
@ -145,7 +145,7 @@ async def test_a_preregistered_client_skips_registration() -> None:
|
|||
The provider holds the same registration server-side so the authorize and token steps
|
||||
accept it; the recorded requests prove no `/register` call was made.
|
||||
"""
|
||||
requests: list[httpx.Request] = []
|
||||
requests: list[httpx2.Request] = []
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
storage = InMemoryTokenStorage()
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
|
|
@ -180,7 +180,7 @@ async def test_the_dcr_request_carries_the_client_metadata() -> None:
|
|||
scope filled in from server discovery), and the server's issued client_id and secret are
|
||||
persisted to storage and held by the provider.
|
||||
"""
|
||||
requests: list[httpx.Request] = []
|
||||
requests: list[httpx2.Request] = []
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
storage = InMemoryTokenStorage()
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
|
|
@ -227,7 +227,7 @@ async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_w
|
|||
real_app = server.streamable_http_app(auth=auth_settings(), auth_server_provider=provider)
|
||||
app = shimmed_app(real_app, not_found=frozenset({"/missing"}), serve={"/override": b'{"shimmed": true}'})
|
||||
async with server.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
|
||||
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
|
||||
served = await http.get("/override")
|
||||
assert served.status_code == 200
|
||||
assert served.headers["content-type"] == "application/json"
|
||||
|
|
|
|||
Loading…
Reference in a new issue