diff --git a/README.v2.md b/README.v2.md index 6eb869a8a4..962290962b 100644 --- a/README.v2.md +++ b/README.v2.md @@ -2319,7 +2319,7 @@ cd to the `examples/snippets` directory and run: import asyncio from urllib.parse import parse_qs, urlparse -import httpx +import httpx2 from pydantic import AnyUrl from mcp import ClientSession @@ -2382,7 +2382,7 @@ async def main(): callback_handler=handle_callback, ) - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): async with ClientSession(read, write) as session: await session.initialize() diff --git a/docs/installation.md b/docs/installation.md index f398462353..6a9977fe80 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -15,8 +15,7 @@ The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so The following dependencies are automatically installed: -- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports. -- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport. +- [`httpx2`](https://pypi.org/project/httpx2/): HTTP client to handle HTTP Streamable and SSE transports. - [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/). - [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. - [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. diff --git a/docs/migration.md b/docs/migration.md index bf06690c45..b2b86de04a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -8,6 +8,39 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t ## Breaking Changes +### `httpx` replaced by `httpx2` + +The SDK now depends on [`httpx2`](https://pypi.org/project/httpx2/) instead of +`httpx` and `httpx-sse`. `httpx2` is the next-generation HTTP client (a fork of +`httpx`) with server-sent events support built in, so the separate `httpx-sse` +dependency is gone. + +The public API surface is unchanged in shape - `streamable_http_client` and +`sse_client` still accept the same arguments - but the client type they expect +is now `httpx2.AsyncClient`. If you construct your own client to pass as +`http_client` (or build an `httpx.Auth` subclass for `auth`), import from +`httpx2`: + +**Before (v1):** + +```python +import httpx + +http_client = httpx.AsyncClient(follow_redirects=True) +``` + +**After (v2):** + +```python +import httpx2 + +http_client = httpx2.AsyncClient(follow_redirects=True) +``` + +`httpx2` is API-compatible with `httpx`, so usually only the import name +changes. To consume SSE directly, use `httpx2.EventSource` (or +`AsyncClient.sse()`) instead of the `httpx-sse` helpers. + ### `MCPServer.call_tool()` returns `CallToolResult` `MCPServer.call_tool()` now always returns a `CallToolResult`. It previously @@ -55,13 +88,13 @@ async with streamablehttp_client( **After (v2):** ```python -import httpx +import httpx2 from mcp.client.streamable_http import streamable_http_client -# Configure headers, timeout, and auth on the httpx.AsyncClient -http_client = httpx.AsyncClient( +# Configure headers, timeout, and auth on the httpx2.AsyncClient +http_client = httpx2.AsyncClient( headers={"Authorization": "Bearer token"}, - timeout=httpx.Timeout(30, read=300), + timeout=httpx2.Timeout(30, read=300), auth=my_auth, follow_redirects=True, ) @@ -74,7 +107,7 @@ async with http_client: ... ``` -v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. +v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior. ### OAuth `callback_handler` returns `AuthorizationCodeResult` @@ -109,7 +142,7 @@ Forward the `iss` query parameter from the redirect so the validation can run: o The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. -If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers: +If you need to capture the session ID (e.g., for session resumption testing), you can use httpx2 event hooks to capture it from the response headers: **Before (v1):** @@ -125,7 +158,7 @@ async with streamable_http_client(url) as (read_stream, write_stream, get_sessio **After (v2):** ```python -import httpx +import httpx2 from mcp.client.streamable_http import streamable_http_client # Option 1: Simply ignore if you don't need the session ID @@ -133,15 +166,15 @@ async with streamable_http_client(url) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() -# Option 2: Capture session ID via httpx event hooks if needed +# Option 2: Capture session ID via httpx2 event hooks if needed captured_session_ids: list[str] = [] -async def capture_session_id(response: httpx.Response) -> None: +async def capture_session_id(response: httpx2.Response) -> None: session_id = response.headers.get("mcp-session-id") if session_id: captured_session_ids.append(session_id) -http_client = httpx.AsyncClient( +http_client = httpx2.AsyncClient( event_hooks={"response": [capture_session_id]}, follow_redirects=True, ) @@ -155,7 +188,7 @@ async with http_client: ### `StreamableHTTPTransport` parameters removed -The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). +The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx2.AsyncClient` instead (see example above). Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 0d461d5d11..a190b89970 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -17,7 +17,7 @@ from typing import Any from urllib.parse import parse_qs, urlparse -import httpx +import httpx2 from mcp.client._transport import ReadStream, WriteStream from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage from mcp.client.session import ClientSession @@ -233,7 +233,7 @@ async def _default_redirect_handler(authorization_url: str) -> None: await self._run_session(read_stream, write_stream) else: print("📡 Opening StreamableHTTP transport connection with auth...") - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: async with streamable_http_client(url=self.server_url, http_client=custom_client) as ( read_stream, write_stream, diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py index 72b1a6f204..991b985ae5 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -8,7 +8,7 @@ from contextlib import AsyncExitStack from typing import Any -import httpx +import httpx2 from dotenv import load_dotenv from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -230,7 +230,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str: The LLM's response as a string. Raises: - httpx.RequestError: If the request to the LLM fails. + httpx2.RequestError: If the request to the LLM fails. """ url = "https://api.groq.com/openai/v1/chat/completions" @@ -249,17 +249,17 @@ def get_response(self, messages: list[dict[str, str]]) -> str: } try: - with httpx.Client() as client: + with httpx2.Client() as client: response = client.post(url, headers=headers, json=payload) response.raise_for_status() data = response.json() return data["choices"][0]["message"]["content"] - except httpx.RequestError as e: + except httpx2.RequestError as e: error_message = f"Error getting LLM response: {str(e)}" logging.error(error_message) - if isinstance(e, httpx.HTTPStatusError): + if isinstance(e, httpx2.HTTPStatusError): status_code = e.response.status_code logging.error(f"Status code: {status_code}") logging.error(f"Response details: {e.response.text}") diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py index e91ed9d527..f99093a6d4 100644 --- a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py @@ -92,7 +92,7 @@ def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) # Suppress noisy HTTP client logging - logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpx2").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) asyncio.run(run_demo(url, items, checkpoint_every)) diff --git a/examples/mcpserver/text_me.py b/examples/mcpserver/text_me.py index 7aeb543621..f6da331ec4 100644 --- a/examples/mcpserver/text_me.py +++ b/examples/mcpserver/text_me.py @@ -19,7 +19,7 @@ from typing import Annotated -import httpx +import httpx2 from pydantic import BeforeValidator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -44,7 +44,7 @@ class SurgeSettings(BaseSettings): @mcp.tool(name="textme", description="Send a text message to me") def text_me(text_content: str) -> str: """Send a text message to a phone number via https://surgemsg.com/""" - with httpx.Client() as client: + with httpx2.Client() as client: response = client.post( "https://api.surgemsg.com/messages", headers={ diff --git a/examples/servers/everything-server/pyproject.toml b/examples/servers/everything-server/pyproject.toml index f68a9d2821..61ddcc63a6 100644 --- a/examples/servers/everything-server/pyproject.toml +++ b/examples/servers/everything-server/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "automation", "conformance", "testing"] license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp", "starlette", "uvicorn"] [project.scripts] mcp-everything-server = "mcp_everything_server.server:main" diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py index 641095a125..933935d6a6 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -33,7 +33,7 @@ def __init__( async def verify_token(self, token: str) -> AccessToken | None: """Verify token via introspection endpoint.""" - import httpx + import httpx2 # Validate URL to prevent SSRF attacks if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): @@ -41,10 +41,10 @@ async def verify_token(self, token: str) -> AccessToken | None: return None # Configure secure HTTP client - timeout = httpx.Timeout(10.0, connect=5.0) - limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + timeout = httpx2.Timeout(10.0, connect=5.0) + limits = httpx2.Limits(max_connections=10, max_keepalive_connections=5) - async with httpx.AsyncClient( + async with httpx2.AsyncClient( timeout=timeout, limits=limits, verify=True, # Enforce SSL verification diff --git a/examples/servers/simple-auth/pyproject.toml b/examples/servers/simple-auth/pyproject.toml index 1ffe3e694b..455db1e735 100644 --- a/examples/servers/simple-auth/pyproject.toml +++ b/examples/servers/simple-auth/pyproject.toml @@ -9,7 +9,7 @@ license = { text = "MIT" } dependencies = [ "anyio>=4.5", "click>=8.2.0", - "httpx>=0.27", + "httpx2>=2.5.0", "mcp", "pydantic>=2.0", "pydantic-settings>=2.5.2", diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml index 2d57d9cccf..c398faf10c 100644 --- a/examples/servers/simple-pagination/pyproject.toml +++ b/examples/servers/simple-pagination/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp"] [project.scripts] mcp-simple-pagination = "mcp_simple_pagination.server:main" diff --git a/examples/servers/simple-prompt/pyproject.toml b/examples/servers/simple-prompt/pyproject.toml index 9d4d8e6a6b..fc9ea70106 100644 --- a/examples/servers/simple-prompt/pyproject.toml +++ b/examples/servers/simple-prompt/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp"] [project.scripts] mcp-simple-prompt = "mcp_simple_prompt.server:main" diff --git a/examples/servers/simple-resource/pyproject.toml b/examples/servers/simple-resource/pyproject.toml index 34fbc8d9de..4e4e409f6f 100644 --- a/examples/servers/simple-resource/pyproject.toml +++ b/examples/servers/simple-resource/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp"] [project.scripts] mcp-simple-resource = "mcp_simple_resource.server:main" diff --git a/examples/servers/simple-streamablehttp-stateless/pyproject.toml b/examples/servers/simple-streamablehttp-stateless/pyproject.toml index 38f7b1b391..6f15a492dc 100644 --- a/examples/servers/simple-streamablehttp-stateless/pyproject.toml +++ b/examples/servers/simple-streamablehttp-stateless/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"] license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp", "starlette", "uvicorn"] [project.scripts] mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main" diff --git a/examples/servers/simple-streamablehttp/pyproject.toml b/examples/servers/simple-streamablehttp/pyproject.toml index 93f7baf41b..2f9fb7a7c4 100644 --- a/examples/servers/simple-streamablehttp/pyproject.toml +++ b/examples/servers/simple-streamablehttp/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"] license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp", "starlette", "uvicorn"] [project.scripts] mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main" diff --git a/examples/servers/simple-tool/pyproject.toml b/examples/servers/simple-tool/pyproject.toml index 022e039e04..5d1ab5852a 100644 --- a/examples/servers/simple-tool/pyproject.toml +++ b/examples/servers/simple-tool/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp"] [project.scripts] mcp-simple-tool = "mcp_simple_tool.server:main" diff --git a/examples/servers/sse-polling-demo/pyproject.toml b/examples/servers/sse-polling-demo/pyproject.toml index 400f6580bc..ef9be0a739 100644 --- a/examples/servers/sse-polling-demo/pyproject.toml +++ b/examples/servers/sse-polling-demo/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "sse", "polling", "streamable", "http"] license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx2>=2.5.0", "mcp", "starlette", "uvicorn"] [project.scripts] mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main" diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index 2085b9a1db..58c542ea43 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -9,7 +9,7 @@ import asyncio from urllib.parse import parse_qs, urlparse -import httpx +import httpx2 from pydantic import AnyUrl from mcp import ClientSession @@ -72,7 +72,7 @@ async def main(): callback_handler=handle_callback, ) - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): async with ClientSession(read, write) as session: await session.initialize() diff --git a/pyproject.toml b/pyproject.toml index 07bfff740e..bd9703117d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,7 @@ dependencies = [ # stderr (agronholm/anyio#816, fixed in 4.10). "anyio>=4.10; python_version >= '3.14'", "anyio>=4.9; python_version < '3.14'", - "httpx>=0.27.1,<1.0.0", - "httpx-sse>=0.4", + "httpx2>=2.5.0", "pydantic>=2.12.0", "starlette>=0.48.0; python_version >= '3.14'", "starlette>=0.27; python_version < '3.14'", diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 5efd596110..723003f7d7 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -13,7 +13,7 @@ from typing import Any, Literal from uuid import uuid4 -import httpx +import httpx2 import jwt from pydantic import BaseModel, Field @@ -82,11 +82,11 @@ async def _initialize(self) -> None: self.context.client_info = self._fixed_client_info self._initialized = True - async def _perform_authorization(self) -> httpx.Request: + async def _perform_authorization(self) -> httpx2.Request: """Perform client_credentials authorization.""" return await self._exchange_token_client_credentials() - async def _exchange_token_client_credentials(self) -> httpx.Request: + async def _exchange_token_client_credentials(self) -> httpx2.Request: """Build token exchange request for client_credentials grant.""" token_data: dict[str, Any] = { "grant_type": "client_credentials", @@ -104,7 +104,7 @@ async def _exchange_token_client_credentials(self) -> httpx.Request: token_data["scope"] = self.context.client_metadata.scope token_url = self._get_token_endpoint() - return httpx.Request("POST", token_url, data=token_data, headers=headers) + return httpx2.Request("POST", token_url, data=token_data, headers=headers) def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: @@ -296,7 +296,7 @@ async def _initialize(self) -> None: self.context.client_info = self._fixed_client_info self._initialized = True - async def _perform_authorization(self) -> httpx.Request: + async def _perform_authorization(self) -> httpx2.Request: """Perform client_credentials authorization with private_key_jwt.""" return await self._exchange_token_client_credentials() @@ -314,7 +314,7 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) -> token_data["client_assertion"] = assertion token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - async def _exchange_token_client_credentials(self) -> httpx.Request: + async def _exchange_token_client_credentials(self) -> httpx2.Request: """Build token exchange request for client_credentials grant with private_key_jwt.""" token_data: dict[str, Any] = { "grant_type": "client_credentials", @@ -332,7 +332,7 @@ async def _exchange_token_client_credentials(self) -> httpx.Request: token_data["scope"] = self.context.client_metadata.scope token_url = self._get_token_endpoint() - return httpx.Request("POST", token_url, data=token_data, headers=headers) + return httpx2.Request("POST", token_url, data=token_data, headers=headers) class JWTParameters(BaseModel): @@ -420,14 +420,14 @@ def __init__( async def _exchange_token_authorization_code( self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None - ) -> httpx.Request: # pragma: no cover + ) -> httpx2.Request: # pragma: no cover """Build token exchange request for authorization_code flow.""" token_data = token_data or {} if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt": self._add_client_authentication_jwt(token_data=token_data) return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data) - async def _perform_authorization(self) -> httpx.Request: # pragma: no cover + async def _perform_authorization(self) -> httpx2.Request: # pragma: no cover """Perform the authorization flow.""" if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types: token_request = await self._exchange_token_jwt_bearer() @@ -454,7 +454,7 @@ def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # prag # it represents the resource server that will validate the token token_data["audience"] = self.context.get_resource_url() - async def _exchange_token_jwt_bearer(self) -> httpx.Request: + async def _exchange_token_jwt_bearer(self) -> httpx2.Request: """Build token exchange request for JWT bearer grant.""" if not self.context.client_info: raise OAuthFlowError("Missing client info") # pragma: no cover @@ -480,6 +480,6 @@ async def _exchange_token_jwt_bearer(self) -> httpx.Request: token_data["scope"] = self.context.client_metadata.scope token_url = self._get_token_endpoint() - return httpx.Request( + return httpx2.Request( "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} ) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 39858cba44..fee0252960 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -15,7 +15,7 @@ from urllib.parse import quote, urlencode, urljoin, urlparse import anyio -import httpx +import httpx2 from pydantic import BaseModel, Field, ValidationError from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError @@ -218,8 +218,8 @@ def prepare_token_auth( return data, headers -class OAuthClientProvider(httpx.Auth): - """OAuth2 authentication for httpx. +class OAuthClientProvider(httpx2.Auth): + """OAuth2 authentication for httpx2. Handles OAuth flow with automatic client registration and token storage. """ @@ -277,7 +277,7 @@ def __init__( self._validate_resource_url_callback = validate_resource_url self._initialized = False - async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: + async def _handle_protected_resource_response(self, response: httpx2.Response) -> bool: """Handle protected resource metadata discovery response. Per SEP-985, supports fallback when discovery fails at one URL. @@ -308,7 +308,7 @@ async def _handle_protected_resource_response(self, response: httpx.Response) -> f"Protected Resource Metadata request failed: {response.status_code}" ) # pragma: no cover - async def _perform_authorization(self) -> httpx.Request: + async def _perform_authorization(self) -> httpx2.Request: """Perform the authorization flow.""" auth_code, code_verifier = await self._perform_authorization_code_grant() token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) @@ -385,7 +385,7 @@ def _get_token_endpoint(self) -> str: async def _exchange_token_authorization_code( self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {} - ) -> httpx.Request: + ) -> httpx2.Request: """Build token exchange request for authorization_code flow.""" if self.context.client_metadata.redirect_uris is None: raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover @@ -412,9 +412,9 @@ async def _exchange_token_authorization_code( headers = {"Content-Type": "application/x-www-form-urlencoded"} token_data, headers = self.context.prepare_token_auth(token_data, headers) - return httpx.Request("POST", token_url, data=token_data, headers=headers) + return httpx2.Request("POST", token_url, data=token_data, headers=headers) - async def _handle_token_response(self, response: httpx.Response) -> None: + async def _handle_token_response(self, response: httpx2.Response) -> None: """Handle token exchange response.""" if response.status_code not in {200, 201}: body = await response.aread() # pragma: no cover @@ -436,7 +436,7 @@ async def _handle_token_response(self, response: httpx.Response) -> None: self.context.update_token_expiry(token_response) await self.context.storage.set_tokens(token_response) - async def _refresh_token(self) -> httpx.Request: + async def _refresh_token(self) -> httpx2.Request: """Build token refresh request.""" if not self.context.current_tokens or not self.context.current_tokens.refresh_token: raise OAuthTokenError("No refresh token available") # pragma: no cover @@ -464,9 +464,9 @@ async def _refresh_token(self) -> httpx.Request: headers = {"Content-Type": "application/x-www-form-urlencoded"} refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers) - return httpx.Request("POST", token_url, data=refresh_data, headers=headers) + return httpx2.Request("POST", token_url, data=refresh_data, headers=headers) - async def _handle_refresh_response(self, response: httpx.Response) -> bool: + async def _handle_refresh_response(self, response: httpx2.Response) -> bool: """Handle token refresh response. Returns True if successful.""" if response.status_code != 200: logger.warning(f"Token refresh failed: {response.status_code}") @@ -503,12 +503,12 @@ async def _initialize(self) -> None: self.context.client_info = await self.context.storage.get_client_info() self._initialized = True - def _add_auth_header(self, request: httpx.Request) -> None: + def _add_auth_header(self, request: httpx2.Request) -> None: """Add authorization header to request if we have valid tokens.""" if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}" - async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None: + async def _handle_oauth_metadata_response(self, response: httpx2.Response) -> None: content = await response.aread() metadata = OAuthMetadata.model_validate_json(content) self.context.oauth_metadata = metadata @@ -527,7 +527,7 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") - async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: """HTTPX auth flow integration.""" async with self.context.lock: if not self._initialized: diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index f10264a330..ddacc76bba 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -1,7 +1,7 @@ import re from urllib.parse import urljoin, urlparse -from httpx import Request, Response +from httpx2 import Request, Response from pydantic import AnyUrl, ValidationError from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 9610212642..864af9012a 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -14,7 +14,7 @@ from typing import Any, TypeAlias import anyio -import httpx +import httpx2 from pydantic import BaseModel, Field from typing_extensions import Self @@ -285,7 +285,7 @@ async def _establish_session( else: httpx_client = create_mcp_http_client( headers=server_params.headers, - timeout=httpx.Timeout( + timeout=httpx2.Timeout( server_params.timeout, read=server_params.sse_read_timeout, ), diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 6a2579f4c0..bfee68b33b 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -5,9 +5,9 @@ from urllib.parse import parse_qs, urljoin, urlparse import anyio -import httpx +import httpx2 from anyio.abc import TaskStatus -from httpx_sse import SSEError, aconnect_sse +from httpx2 import EventSource, SSEError from mcp import types from mcp.shared._compat import resync_tracer @@ -34,7 +34,7 @@ async def sse_client( timeout: float = 5.0, sse_read_timeout: float = 300.0, httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, - auth: httpx.Auth | None = None, + auth: httpx2.Auth | None = None, on_session_created: Callable[[str], None] | None = None, ): """Client transport for SSE. @@ -53,10 +53,11 @@ async def sse_client( """ logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}") async with httpx_client_factory( - headers=headers, auth=auth, timeout=httpx.Timeout(timeout, read=sse_read_timeout) + headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout) ) as client: - async with aconnect_sse(client, "GET", url) as event_source: - event_source.response.raise_for_status() + async with client.stream("GET", url) as response: + event_source = EventSource(response) + response.raise_for_status() logger.debug("SSE connection established") read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) @@ -64,7 +65,7 @@ async def sse_client( async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED): try: - async for sse in event_source.aiter_sse(): # pragma: no branch + async for sse in event_source: # pragma: no branch logger.debug(f"Received SSE event: {sse.event}") match sse.event: case "endpoint": diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index a703a48afb..fe5db7c6ad 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -11,9 +11,9 @@ from dataclasses import dataclass import anyio -import httpx +import httpx2 from anyio.abc import TaskGroup -from httpx_sse import EventSource, ServerSentEvent, aconnect_sse +from httpx2 import EventSource, ServerSentEvent from pydantic import ValidationError from mcp.client._transport import TransportStreams @@ -78,7 +78,7 @@ class ResumptionError(StreamableHTTPError): class RequestContext: """Context for a request operation.""" - client: httpx.AsyncClient + client: httpx2.AsyncClient session_id: str | None session_message: SessionMessage metadata: ClientMessageMetadata | None @@ -127,7 +127,7 @@ def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]: def _prepare_headers(self) -> dict[str, str]: """Build MCP-specific request headers. - These headers will be merged with the httpx.AsyncClient's default headers, + These headers will be merged with the httpx2.AsyncClient's default headers, with these MCP-specific headers taking precedence. """ headers: dict[str, str] = { @@ -149,7 +149,7 @@ def _is_initialized_notification(self, message: JSONRPCMessage) -> bool: """Check if the message is an initialized notification.""" return isinstance(message, JSONRPCNotification) and message.method == "notifications/initialized" - def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> None: + def _maybe_extract_session_id_from_response(self, response: httpx2.Response) -> None: """Extract and store session ID from response headers.""" new_session_id = response.headers.get(MCP_SESSION_ID) if new_session_id: @@ -224,7 +224,7 @@ async def _handle_sse_event( logger.warning(f"Unknown SSE event: {sse.event}") return False - async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: StreamWriter) -> None: + async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer: StreamWriter) -> None: """Handle GET stream for server-initiated messages with auto-reconnect.""" last_event_id: str | None = None retry_interval_ms: int | None = None @@ -239,11 +239,11 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: if last_event_id: headers[LAST_EVENT_ID] = last_event_id - async with aconnect_sse(client, "GET", self.url, headers=headers) as event_source: - event_source.response.raise_for_status() + async with client.stream("GET", self.url, headers=headers) as response: + response.raise_for_status() logger.debug("GET SSE connection established") - async for sse in event_source.aiter_sse(): + async for sse in EventSource(response): # Track last event ID for reconnection if sse.id: last_event_id = sse.id @@ -282,11 +282,11 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch original_request_id = ctx.session_message.message.id - async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source: - event_source.response.raise_for_status() + async with ctx.client.stream("GET", self.url, headers=headers) as response: + response.raise_for_status() logger.debug("Resumption GET SSE connection established") - async for sse in event_source.aiter_sse(): # pragma: no branch + async for sse in EventSource(response): # pragma: no branch is_complete = await self._handle_sse_event( sse, ctx.read_stream_writer, @@ -294,7 +294,7 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: ctx.metadata.on_resumption_token_update if ctx.metadata else None, ) if is_complete: - await event_source.response.aclose() + await response.aclose() break async def _handle_post_request(self, ctx: RequestContext) -> None: @@ -330,7 +330,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error) await ctx.read_stream_writer.send(SessionMessage(reply)) return - except (httpx.StreamError, ValidationError): + except (httpx2.StreamError, ValidationError): pass logger.debug("Non-2xx body was not a JSON-RPC error; using fallback") if response.status_code == 404: @@ -362,7 +362,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: async def _handle_json_response( self, - response: httpx.Response, + response: httpx2.Response, read_stream_writer: StreamWriter, is_initialization: bool = False, *, @@ -379,7 +379,7 @@ async def _handle_json_response( session_message = SessionMessage(message) await read_stream_writer.send(session_message) - except (httpx.StreamError, ValidationError) as exc: + except (httpx2.StreamError, ValidationError) as exc: logger.exception("Error parsing JSON response") error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse JSON response: {exc}") error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data)) @@ -387,7 +387,7 @@ async def _handle_json_response( async def _handle_sse_response( self, - response: httpx.Response, + response: httpx2.Response, ctx: RequestContext, is_initialization: bool = False, ) -> None: @@ -401,8 +401,7 @@ async def _handle_sse_response( original_request_id = ctx.session_message.message.id try: - event_source = EventSource(response) - async for sse in event_source.aiter_sse(): # pragma: no branch + async for sse in EventSource(response): # pragma: no branch # Track last event ID for potential reconnection if sse.id: last_event_id = sse.id @@ -457,15 +456,15 @@ async def _handle_reconnection( original_request_id = ctx.session_message.message.id try: - async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source: - event_source.response.raise_for_status() + async with ctx.client.stream("GET", self.url, headers=headers) as response: + response.raise_for_status() logger.info("Reconnected to SSE stream") # Track for potential further reconnection reconnect_last_event_id: str = last_event_id reconnect_retry_ms = retry_interval_ms - async for sse in event_source.aiter_sse(): + async for sse in EventSource(response): if sse.id: # pragma: no branch reconnect_last_event_id = sse.id if sse.retry is not None: @@ -478,7 +477,7 @@ async def _handle_reconnection( ctx.metadata.on_resumption_token_update if ctx.metadata else None, ) if is_complete: - await event_source.response.aclose() + await response.aclose() return # Stream ended again without response - reconnect again (reset attempt counter) @@ -491,7 +490,7 @@ async def _handle_reconnection( async def post_writer( self, - client: httpx.AsyncClient, + client: httpx2.AsyncClient, write_stream_reader: StreamReader, read_stream_writer: StreamWriter, write_stream: ContextSendStream[SessionMessage], @@ -550,7 +549,7 @@ async def handle_request_async(): except Exception: # pragma: lax no cover logger.exception("Error in post_writer") - async def terminate_session(self, client: httpx.AsyncClient) -> None: + async def terminate_session(self, client: httpx2.AsyncClient) -> None: """Terminate the session by sending a DELETE request.""" if not self.session_id: return # pragma: no cover @@ -579,7 +578,7 @@ def get_session_id(self) -> str | None: async def streamable_http_client( url: str, *, - http_client: httpx.AsyncClient | None = None, + http_client: httpx2.AsyncClient | None = None, terminate_on_close: bool = True, protocol_version: str | None = None, ) -> AsyncGenerator[TransportStreams, None]: @@ -587,9 +586,9 @@ async def streamable_http_client( Args: url: The MCP server endpoint URL. - http_client: Optional pre-configured httpx.AsyncClient. If None, a default + http_client: Optional pre-configured httpx2.AsyncClient. If None, a default client with recommended MCP timeouts will be created. To configure headers, - authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. + authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions. Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`. diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index d9e472e362..a2ba6bb803 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -9,7 +9,7 @@ import anyio import anyio.to_thread -import httpx +import httpx2 import pydantic import pydantic_core from pydantic import Field, ValidationInfo, validate_call @@ -159,7 +159,7 @@ class HttpResource(Resource): async def read(self) -> str | bytes: """Read the HTTP content.""" - async with httpx.AsyncClient() as client: # pragma: no cover + async with httpx2.AsyncClient() as client: # pragma: no cover response = await client.get(self.url) response.raise_for_status() return response.text diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 6a121aff6d..6bb638886a 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -1,8 +1,8 @@ -"""Utilities for creating standardized httpx AsyncClient instances.""" +"""Utilities for creating standardized httpx2 AsyncClient instances.""" from typing import Any, Protocol -import httpx +import httpx2 __all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] @@ -15,28 +15,28 @@ class McpHttpClientFactory(Protocol): # pragma: no branch def __call__( # pragma: no branch self, headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: ... + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: ... def create_mcp_http_client( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, -) -> httpx.AsyncClient: - """Create a standardized httpx AsyncClient with MCP defaults. + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, +) -> httpx2.AsyncClient: + """Create a standardized httpx2 AsyncClient with MCP defaults. Always enables follow_redirects and applies an SSE-friendly default timeout. Args: headers: Optional headers to include with all requests. - timeout: Request timeout as httpx.Timeout object. Defaults to 30s for + timeout: Request timeout as httpx2.Timeout object. Defaults to 30s for connect/write/pool and 300s for read (for long-lived SSE streams). auth: Optional authentication handler. Returns: - Configured httpx.AsyncClient instance with MCP defaults. + Configured httpx2.AsyncClient instance with MCP defaults. Note: The returned AsyncClient must be used as a context manager to ensure @@ -61,7 +61,7 @@ def create_mcp_http_client( With both custom headers and timeout: ```python - timeout = httpx.Timeout(60.0, read=300.0) + timeout = httpx2.Timeout(60.0, read=300.0) async with create_mcp_http_client(headers, timeout) as client: response = await client.get("/long-request") ``` @@ -69,7 +69,7 @@ def create_mcp_http_client( With authentication: ```python - from httpx import BasicAuth + from httpx2 import BasicAuth auth = BasicAuth(username="user", password="pass") async with create_mcp_http_client(headers, timeout, auth) as client: response = await client.get("/protected-endpoint") @@ -80,7 +80,7 @@ def create_mcp_http_client( # Handle timeout if timeout is None: - kwargs["timeout"] = httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) + kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) else: kwargs["timeout"] = timeout @@ -92,4 +92,4 @@ def create_mcp_http_client( if auth is not None: # pragma: no cover kwargs["auth"] = auth - return httpx.AsyncClient(**kwargs) + return httpx2.AsyncClient(**kwargs) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index cdbba1b588..adc5b705c2 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -6,7 +6,7 @@ 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 @@ async def callback_handler() -> AuthorizationCodeResult: @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 @@ async def callback_handler() -> AuthorizationCodeResult: ) # 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl ) # 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl # 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl 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 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl 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 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien "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 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien 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 @@ async def test_prioritize_www_auth_scope_over_prm( 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 @@ async def test_prioritize_prm_scopes_when_no_www_auth_scope( 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 @@ async def test_handle_registration_response_reads_before_accessing_text(self): """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 @@ async def test_auth_flow_with_valid_tokens( 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 @@ async def test_auth_flow_with_valid_tokens( 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 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide 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 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide 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 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide 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 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide 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 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide 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 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide 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 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide 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 @@ async def test_auth_flow_no_unnecessary_retry_after_oauth( 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 @@ async def test_auth_flow_no_unnecessary_retry_after_oauth( 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 @@ async def test_token_exchange_accepts_201_status( 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 @@ async def test_token_exchange_accepts_201_status( 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 @@ async def test_token_exchange_accepts_201_status( 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 @@ async def test_token_exchange_accepts_201_status( 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 @@ async def test_token_exchange_accepts_201_status( 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 @@ async def test_token_exchange_accepts_201_status( 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 @@ async def test_token_exchange_accepts_201_status( 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 @@ async def mock_callback() -> AuthorizationCodeResult: 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 @@ async def mock_callback() -> AuthorizationCodeResult: 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 @@ async def mock_callback() -> AuthorizationCodeResult: 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 mock_callback() -> AuthorizationCodeResult: 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 mock_callback() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: ) # 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 @@ async def callback_handler() -> AuthorizationCodeResult: ) # 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: ) 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 @@ async def callback_handler() -> AuthorizationCodeResult: ) 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 @@ async def callback_handler() -> AuthorizationCodeResult: ) # 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 @@ def test_extract_field_from_www_auth_valid_cases( ): """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 @@ def test_extract_field_from_www_auth_invalid_cases( """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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: # 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: # 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: ) 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: # 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: # 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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 @@ async def callback_handler() -> AuthorizationCodeResult: 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) @@ -2873,10 +2873,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) @@ -2899,10 +2899,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) @@ -2932,20 +2932,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", ' @@ -2970,12 +2970,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", ' @@ -2989,7 +2989,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. @@ -3021,9 +3021,9 @@ async def echo_callback() -> AuthorizationCodeResult: 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": ( @@ -3036,7 +3036,7 @@ async def echo_callback() -> AuthorizationCodeResult: # 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"]}' @@ -3058,7 +3058,7 @@ async def echo_callback() -> AuthorizationCodeResult: 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, @@ -3072,12 +3072,12 @@ async def echo_callback() -> AuthorizationCodeResult: 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 @@ -3110,19 +3110,19 @@ async def echo_callback() -> AuthorizationCodeResult: 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", ' @@ -3136,7 +3136,7 @@ async def echo_callback() -> AuthorizationCodeResult: 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, @@ -3150,11 +3150,11 @@ async def echo_callback() -> AuthorizationCodeResult: 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 diff --git a/tests/client/test_http_unicode.py b/tests/client/test_http_unicode.py index 585a142617..364f2e703a 100644 --- a/tests/client/test_http_unicode.py +++ b/tests/client/test_http_unicode.py @@ -7,7 +7,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 @@ -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), diff --git a/tests/client/test_notification_response.py b/tests/client/test_notification_response.py index 4dbd78dbbe..863643cbf5 100644 --- a/tests/client/test_notification_response.py +++ b/tests/client/test_notification_response.py @@ -6,7 +6,7 @@ import json -import httpx +import httpx2 import pytest from starlette.applications import Starlette from starlette.requests import Request @@ -88,7 +88,7 @@ async def message_handler( # pragma: no cover 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() @@ -107,7 +107,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() @@ -141,9 +141,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() @@ -159,7 +159,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() @@ -194,7 +194,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() @@ -225,7 +225,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() @@ -239,7 +239,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() @@ -253,7 +253,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() diff --git a/tests/client/test_scope_bug_1630.py b/tests/client/test_scope_bug_1630.py index 338755dc68..0782b4a037 100644 --- a/tests/client/test_scope_bug_1630.py +++ b/tests/client/test_scope_bug_1630.py @@ -6,7 +6,7 @@ from unittest import mock -import httpx +import httpx2 import pytest from pydantic import AnyUrl @@ -79,7 +79,7 @@ async def callback_handler() -> AuthorizationCodeResult: 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 callback_handler() -> AuthorizationCodeResult: 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 callback_handler() -> AuthorizationCodeResult: 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 callback_handler() -> AuthorizationCodeResult: 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 callback_handler() -> AuthorizationCodeResult: ) # 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 callback_handler() -> AuthorizationCodeResult: 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: diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 6a58b39f39..c2013e589c 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -1,7 +1,7 @@ import contextlib from unittest import mock -import httpx +import httpx2 import pytest import mcp @@ -363,7 +363,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() diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index bbe3e67fee..7451321afa 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -10,7 +10,7 @@ import json import anyio -import httpx +import httpx2 import pytest from inline_snapshot import snapshot @@ -110,9 +110,9 @@ async def test_pinned_transport_ignores_returned_session_id_and_never_opens_get_ triggers the client's implicit ``tools/list`` output-schema fetch so there is a second POST after the id was offered. """ - 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 body["method"] == "tools/list": @@ -124,13 +124,13 @@ def handler(request: httpx.Request) -> httpx.Response: } else: result = {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} - return httpx.Response( + return httpx2.Response( 200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}, headers={"mcp-session-id": "srv-123"} ) 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, protocol_version="2026-07-28") as (read, write), ClientSession(read, write, protocol_version="2026-07-28") as session, ): diff --git a/tests/client/test_transport_stream_cleanup.py b/tests/client/test_transport_stream_cleanup.py index 40d3b2439d..c3d012bb23 100644 --- a/tests/client/test_transport_stream_cleanup.py +++ b/tests/client/test_transport_stream_cleanup.py @@ -16,7 +16,7 @@ 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 diff --git a/tests/interaction/README.md b/tests/interaction/README.md index 863be7d6c7..693d15faf9 100644 --- a/tests/interaction/README.md +++ b/tests/interaction/README.md @@ -67,7 +67,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 diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index 575a742632..89eedbb1a6 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -12,8 +12,8 @@ from functools import partial from typing import Any, Protocol -import httpx -from httpx_sse import ServerSentEvent, aconnect_sse +import httpx2 +from httpx2 import ServerSentEvent from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response @@ -136,7 +136,7 @@ async def connect_over_streamable_http( ) async with ( server.session_manager.run(), - httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, + httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, Client( streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client, protocol_version=protocol_version), read_timeout_seconds=read_timeout_seconds, @@ -167,17 +167,17 @@ async def mounted_app( event_store: EventStore | None = None, retry_interval: int | None = None, transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION, - on_request: Callable[[httpx.Request], Awaitable[None]] | None = None, - on_response: Callable[[httpx.Response], Awaitable[None]] | None = None, + on_request: Callable[[httpx2.Request], Awaitable[None]] | None = None, + on_response: Callable[[httpx2.Response], Awaitable[None]] | None = None, headers: dict[str, str] | None = None, auth: AuthSettings | None = None, token_verifier: TokenVerifier | None = None, auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, -) -> AsyncIterator[tuple[httpx.AsyncClient, StreamableHTTPSessionManager]]: - """Mount the server's streamable HTTP app on the in-process bridge and yield an httpx client. +) -> AsyncIterator[tuple[httpx2.AsyncClient, StreamableHTTPSessionManager]]: + """Mount the server's streamable HTTP app on the in-process bridge and yield an httpx2 client. - Yields the httpx client (rooted at the in-process origin) and the live session manager. Tests - use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test + Yields the httpx2 client (rooted at the in-process origin) and the live session manager. Tests + use this in two ways: for raw-httpx2 assertions (status codes, headers, SSE bytes) the test speaks HTTP through the yielded client directly; for client-driven assertions the test wraps that client in `client_via_http(http)`, which lets several `Client`s share the one mounted session manager. `on_request` observes every outgoing HTTP request before it leaves the @@ -205,7 +205,7 @@ async def mounted_app( event_hooks["response"] = [on_response] async with ( server.session_manager.run(), - httpx.AsyncClient( + httpx2.AsyncClient( transport=StreamingASGITransport(app), base_url=BASE_URL, event_hooks=event_hooks, headers=headers ) as http_client, ): @@ -214,7 +214,7 @@ async def mounted_app( @asynccontextmanager async def client_via_http( - http_client: httpx.AsyncClient, + http_client: httpx2.AsyncClient, *, logging_callback: LoggingFnT | None = None, message_handler: MessageHandlerFnT | None = None, @@ -223,8 +223,8 @@ async def client_via_http( """Connect a `Client` over an already-mounted streamable HTTP app. Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a - client-driven assertion can sit alongside raw-httpx assertions in the same test. The - underlying `httpx.AsyncClient` is left open when the `Client` exits. + client-driven assertion can sit alongside raw-httpx2 assertions in the same test. The + underlying `httpx2.AsyncClient` is left open when the `Client` exits. """ transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) async with Client( @@ -242,22 +242,22 @@ def parse_sse_messages(events: Iterable[ServerSentEvent]) -> list[JSONRPCMessage async def post_jsonrpc( - http: httpx.AsyncClient, body: dict[str, object], *, session_id: str | None = None -) -> tuple[httpx.Response, list[JSONRPCMessage]]: + http: httpx2.AsyncClient, body: dict[str, object], *, session_id: str | None = None +) -> tuple[httpx2.Response, list[JSONRPCMessage]]: """POST a JSON-RPC body and read its SSE response stream to completion. Returns the HTTP response (for header/status assertions) and the parsed JSON-RPC messages that arrived on the response's SSE stream. Only meaningful for requests the server answers with `text/event-stream`; for error responses or 202 notification acknowledgements, use - `httpx.AsyncClient.post` directly and assert on the response. + `httpx2.AsyncClient.post` directly and assert on the response. """ - async with aconnect_sse(http, "POST", "/mcp", json=body, headers=base_headers(session_id=session_id)) as source: - events = [event async for event in source.aiter_sse()] + async with http.sse("/mcp", method="POST", json=body, headers=base_headers(session_id=session_id)) as source: + events = [event async for event in source] return source.response, parse_sse_messages(events) def base_headers(*, session_id: str | None = None) -> dict[str, str]: - """Standard request headers for raw-httpx streamable-HTTP tests. + """Standard request headers for raw-httpx2 streamable-HTTP tests. Every well-formed request carries these (Accept covering both response representations, Content-Type for POST bodies, MCP-Protocol-Version at the latest revision, and the session @@ -286,16 +286,16 @@ def initialize_body(request_id: int = 1) -> dict[str, object]: ).model_dump(by_alias=True, exclude_none=True) -async def initialize_via_http(http: httpx.AsyncClient) -> str: - """Perform the initialize handshake over a raw `httpx.AsyncClient` and return the session ID. +async def initialize_via_http(http: httpx2.AsyncClient) -> str: + """Perform the initialize handshake over a raw `httpx2.AsyncClient` and return the session ID. Validates the SSE response and sends the `notifications/initialized` follow-up, so the server is fully ready for subsequent feature requests when this returns. """ - async with aconnect_sse(http, "POST", "/mcp", json=initialize_body(), headers=base_headers()) as source: + async with http.sse("/mcp", method="POST", json=initialize_body(), headers=base_headers()) as source: assert source.response.status_code == 200 # An event-store-backed server opens the stream with a priming event (empty data); skip it. - events = [event async for event in source.aiter_sse() if event.data] + events = [event async for event in source if event.data] assert len(events) == 1 assert JSONRPCResponse.model_validate_json(events[0].data).id == 1 session_id = source.response.headers["mcp-session-id"] @@ -352,13 +352,13 @@ async def connect_over_sse( def httpx_client_factory( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: # The SSE server transport's connect_sse runs the entire MCP session inside the GET # request and only releases its streams after that request observes a disconnect, so the # bridge must let the application drain rather than cancelling at close. - return httpx.AsyncClient( + return httpx2.AsyncClient( transport=StreamingASGITransport(app, cancel_on_close=False), base_url=BASE_URL, headers=headers, diff --git a/tests/interaction/_modern_vocab.py b/tests/interaction/_modern_vocab.py index 7531724ee3..d07a676db8 100644 --- a/tests/interaction/_modern_vocab.py +++ b/tests/interaction/_modern_vocab.py @@ -17,7 +17,7 @@ from dataclasses import dataclass -import httpx +import httpx2 from mcp.types import JSONRPCMessage, jsonrpc_message_adapter @@ -52,8 +52,8 @@ class RecordedExchange: the server-to-client body content must be supplied via `frames`. """ - requests: list[httpx.Request] - responses: list[httpx.Response] + requests: list[httpx2.Request] + responses: list[httpx2.Response] frames: list[JSONRPCMessage] diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 9aee73b29b..a66a3a8931 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3151,7 +3151,7 @@ def __post_init__(self) -> None: "including auth flows." ), transports=("streamable-http",), - note="Only observable over HTTP: the httpx client is HTTP-specific.", + note="Only observable over HTTP: the httpx2 client is HTTP-specific.", ), "client-transport:http:custom-headers": Requirement( source="sdk", diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index ab360addd4..242236bb7d 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -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 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 @@ @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 @@ def path(self) -> str: 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 @@ def __init__(self, *, state_override: str | None = None, iss_override: str | Non 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: @@ -403,16 +403,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 @@ -420,7 +420,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). @@ -456,7 +456,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]} @@ -464,7 +464,7 @@ async def hook(request: httpx.Request) -> None: 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 ) ) diff --git a/tests/interaction/auth/test_as_handlers.py b/tests/interaction/auth/test_as_handlers.py index 5cb4e92d86..f59478b49a 100644 --- a/tests/interaction/auth/test_as_handlers.py +++ b/tests/interaction/auth/test_as_handlers.py @@ -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 @@ 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 @@ @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. diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index 341a8e0db9..0ca4f6afb5 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -10,7 +10,7 @@ import time from collections.abc import AsyncIterator -import httpx +import httpx2 import pytest from inline_snapshot import snapshot @@ -44,7 +44,7 @@ @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 diff --git a/tests/interaction/auth/test_discovery.py b/tests/interaction/auth/test_discovery.py index 5038fa8e65..4357a6f84e 100644 --- a/tests/interaction/auth/test_discovery.py +++ b/tests/interaction/auth/test_discovery.py @@ -6,7 +6,7 @@ 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. """ diff --git a/tests/interaction/auth/test_flow.py b/tests/interaction/auth/test_flow.py index ab96185796..0bdea7fd9f 100644 --- a/tests/interaction/auth/test_flow.py +++ b/tests/interaction/auth/test_flow.py @@ -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 urllib.parse import parse_qs, urlsplit import anyio -import httpx +import httpx2 import pytest from inline_snapshot import snapshot from pydantic import AnyUrl @@ -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 ` → 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" diff --git a/tests/interaction/transports/_bridge.py b/tests/interaction/transports/_bridge.py index 25b7618ffb..4138309b1f 100644 --- a/tests/interaction/transports/_bridge.py +++ b/tests/interaction/transports/_bridge.py @@ -1,6 +1,6 @@ -"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx. +"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx2. -`httpx.ASGITransport` runs the application to completion and only then hands the buffered +`httpx2.ASGITransport` runs the application to completion and only then hands the buffered response to the caller, so a server that streams its response — the streamable HTTP transport's SSE responses — can never converse with the client mid-request: a server-initiated request nested inside a still-open call deadlocks. `StreamingASGITransport` removes that limitation by @@ -20,7 +20,7 @@ server over a real socket would give. The transport owns an anyio task group for the application tasks; it is opened and closed by -`httpx.AsyncClient`'s own context manager, so use the client as a context manager (the suite +`httpx2.AsyncClient`'s own context manager, so use the client as a context manager (the suite always does). Closing the transport cancels every running application task by default; set `cancel_on_close=False` to wait for the application's own disconnect handling instead. """ @@ -31,14 +31,14 @@ import anyio import anyio.abc -import httpx +import httpx2 from anyio.streams.memory import MemoryObjectReceiveStream from starlette.types import ASGIApp, Message, Scope from mcp.shared._compat import resync_tracer -class _StreamingResponseBody(httpx.AsyncByteStream): +class _StreamingResponseBody(httpx2.AsyncByteStream): """A response body that yields chunks as the application produces them. Closing it tells the application the client has gone away (`http.disconnect`), mirroring a @@ -58,7 +58,7 @@ async def aclose(self) -> None: await self._chunks.aclose() -class StreamingASGITransport(httpx.AsyncBaseTransport): +class StreamingASGITransport(httpx2.AsyncBaseTransport): """Drive an ASGI application in-process, streaming each response as it is produced. With `cancel_on_close` (the default), closing the transport cancels every application task @@ -84,7 +84,7 @@ async def __aexit__( exc_value: BaseException | None = None, traceback: TracebackType | None = None, ) -> None: - # httpx closes every streamed response before closing the transport, so by now each + # httpx2 closes every streamed response before closing the transport, so by now each # application task has been delivered `http.disconnect`. Either cancel immediately, or wait # for the application's own disconnect handling to unwind. if self._cancel_on_close: @@ -92,8 +92,8 @@ async def __aexit__( await self._task_group.__aexit__(exc_type, exc_value, traceback) await resync_tracer() - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - assert isinstance(request.stream, httpx.AsyncByteStream) + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: + assert isinstance(request.stream, httpx2.AsyncByteStream) request_body = b"".join([chunk async for chunk in request.stream]) scope: Scope = { @@ -164,7 +164,7 @@ async def run_application() -> None: client_disconnected.set() await chunk_reader.aclose() raise - return httpx.Response( + return httpx2.Response( status_code=response_status, headers=response_headers, stream=_StreamingResponseBody(chunk_reader, client_disconnected), diff --git a/tests/interaction/transports/test_bridge.py b/tests/interaction/transports/test_bridge.py index 7420b9d902..a8b229b613 100644 --- a/tests/interaction/transports/test_bridge.py +++ b/tests/interaction/transports/test_bridge.py @@ -8,7 +8,7 @@ """ import anyio -import httpx +import httpx2 import pytest from starlette.types import Message, Receive, Scope, Send @@ -29,7 +29,7 @@ async def chunked_app(scope: Scope, receive: Receive, send: Send) -> None: await send({"type": "http.response.body", "body": b"second", "more_body": False}) async with ( - httpx.AsyncClient(transport=StreamingASGITransport(chunked_app), base_url="http://bridge") as http, + httpx2.AsyncClient(transport=StreamingASGITransport(chunked_app), base_url="http://bridge") as http, http.stream("GET", "/chunks") as response, ): with anyio.fail_after(5): @@ -52,7 +52,7 @@ async def waiting_app(scope: Scope, receive: Receive, send: Send) -> None: seen_after_request.append(await receive()) disconnect_seen.set() - async with httpx.AsyncClient(transport=StreamingASGITransport(waiting_app), base_url="http://bridge") as http: + async with httpx2.AsyncClient(transport=StreamingASGITransport(waiting_app), base_url="http://bridge") as http: async with http.stream("GET", "/wait") as response: assert response.status_code == 200 # Leaving the stream block closes the response while the application is still mid-response. @@ -68,7 +68,7 @@ async def test_an_application_failure_before_the_response_starts_fails_the_reque async def broken_app(scope: Scope, receive: Receive, send: Send) -> None: raise RuntimeError("the demo application is broken") - async with httpx.AsyncClient(transport=StreamingASGITransport(broken_app), base_url="http://bridge") as http: + async with httpx2.AsyncClient(transport=StreamingASGITransport(broken_app), base_url="http://bridge") as http: with pytest.raises(RuntimeError, match="the demo application is broken"): await http.get("/broken") @@ -87,7 +87,7 @@ async def lingering_app(scope: Scope, receive: Receive, send: Send) -> None: transport = StreamingASGITransport(lingering_app, cancel_on_close=False) with anyio.fail_after(5): - async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http: + async with httpx2.AsyncClient(transport=transport, base_url="http://bridge") as http: async with http.stream("GET", "/linger") as response: assert response.status_code == 200 assert not cleanup_ran.is_set() diff --git a/tests/interaction/transports/test_client_transport_http.py b/tests/interaction/transports/test_client_transport_http.py index 65ed03f1e4..43874adf00 100644 --- a/tests/interaction/transports/test_client_transport_http.py +++ b/tests/interaction/transports/test_client_transport_http.py @@ -9,7 +9,7 @@ from collections.abc import AsyncIterator import anyio -import httpx +import httpx2 import pytest from inline_snapshot import snapshot from starlette.types import Receive, Scope, Send @@ -42,16 +42,16 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @pytest.fixture -async def recorded() -> AsyncIterator[list[httpx.Request]]: +async def recorded() -> AsyncIterator[list[httpx2.Request]]: """Connect a `Client` over a recording HTTP client, list tools, exit, and yield every request sent. The HTTP client carries one caller-supplied header (`x-trace`) so its propagation can be asserted; the recording captures the closing DELETE because it is read after the `Client` has fully exited. """ - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - async def record(request: httpx.Request) -> None: + async def record(request: httpx2.Request) -> None: requests.append(request) async with mounted_app(_tooled_server(), on_request=record, headers={"x-trace": "abc"}) as (http, _): @@ -62,7 +62,7 @@ async def record(request: httpx.Request) -> None: yield requests -def _after_initialize(recorded: list[httpx.Request]) -> list[httpx.Request]: +def _after_initialize(recorded: list[httpx2.Request]) -> list[httpx2.Request]: """Every recorded request after the initialize POST (which carries no session yet).""" assert recorded[0].method == "POST" assert "mcp-session-id" not in recorded[0].headers @@ -72,9 +72,9 @@ def _after_initialize(recorded: list[httpx.Request]) -> list[httpx.Request]: @requirement("client-transport:http:custom-client") @requirement("client-transport:http:custom-headers") async def test_the_client_uses_the_supplied_http_client_and_propagates_its_headers( - recorded: list[httpx.Request], + recorded: list[httpx2.Request], ) -> None: - """A caller-supplied `httpx.AsyncClient` is used for every request and carries its own headers. + """A caller-supplied `httpx2.AsyncClient` is used for every request and carries its own headers. The recording itself proves the supplied client is the one in use; the propagated header proves the SDK transport does not replace the caller's client configuration. @@ -86,7 +86,7 @@ async def test_the_client_uses_the_supplied_http_client_and_propagates_its_heade @requirement("client-transport:http:session-stored") -async def test_every_request_after_initialize_carries_the_issued_session_id(recorded: list[httpx.Request]) -> None: +async def test_every_request_after_initialize_carries_the_issued_session_id(recorded: list[httpx2.Request]) -> None: """The session id from the initialize response is sent on every subsequent request.""" session_ids = {request.headers["mcp-session-id"] for request in _after_initialize(recorded)} assert len(session_ids) == 1 @@ -97,7 +97,7 @@ async def test_every_request_after_initialize_carries_the_issued_session_id(reco @requirement("client-transport:http:protocol-version-stored") @requirement("client-transport:http:protocol-version-header") async def test_every_request_after_initialize_carries_the_negotiated_protocol_version( - recorded: list[httpx.Request], + recorded: list[httpx2.Request], ) -> None: """The negotiated protocol version is sent on every subsequent request (and not on initialize).""" assert "mcp-protocol-version" not in recorded[0].headers @@ -108,7 +108,7 @@ async def test_every_request_after_initialize_carries_the_negotiated_protocol_ve @requirement("client-transport:http:accept-header-post") @requirement("client-transport:http:accept-header-get") async def test_accept_headers_cover_the_response_representations_the_transport_handles( - recorded: list[httpx.Request], + recorded: list[httpx2.Request], ) -> None: """POSTs accept both JSON and SSE; the standalone GET stream accepts SSE.""" for request in recorded: @@ -120,7 +120,7 @@ async def test_accept_headers_cover_the_response_representations_the_transport_h @requirement("client-transport:http:no-reconnect-after-close") -async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded: list[httpx.Request]) -> None: +async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded: list[httpx2.Request]) -> None: """Client teardown sends DELETE and issues no further requests (no resumption GET).""" assert recorded[-1].method == "DELETE" assert all("last-event-id" not in request.headers for request in recorded) @@ -129,10 +129,10 @@ async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded: @requirement("client-transport:http:concurrent-streams") async def test_concurrent_tool_calls_each_open_a_post_stream_and_receive_their_own_response() -> None: """Three tool calls issued at once each open their own POST stream and get the right answer.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] results: dict[int, CallToolResult] = {} - async def record(request: httpx.Request) -> None: + async def record(request: httpx2.Request) -> None: requests.append(request) async with mounted_app(_tooled_server(), on_request=record) as (http, _), client_via_http(http) as client: @@ -177,7 +177,7 @@ async def filter_methods(scope: Scope, receive: Receive, send: Send) -> None: async with ( server.session_manager.run(), - httpx.AsyncClient(transport=StreamingASGITransport(filter_methods), base_url=BASE_URL) as http_client, + httpx2.AsyncClient(transport=StreamingASGITransport(filter_methods), base_url=BASE_URL) as http_client, ): transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) with anyio.fail_after(5): # pragma: no branch @@ -195,9 +195,9 @@ async def test_a_completed_post_stream_is_not_reconnected() -> None: Last-Event-ID it could resume from -- the test proves it does not, because the response arrived and the stream completed normally. """ - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - async def record(request: httpx.Request) -> None: + async def record(request: httpx2.Request) -> None: requests.append(request) server = _tooled_server() @@ -236,7 +236,7 @@ async def first_post_then_404(scope: Scope, receive: Receive, send: Send) -> Non async with ( server.session_manager.run(), - httpx.AsyncClient(transport=StreamingASGITransport(first_post_then_404), base_url=BASE_URL) as http_client, + httpx2.AsyncClient(transport=StreamingASGITransport(first_post_then_404), base_url=BASE_URL) as http_client, ): transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) with anyio.fail_after(5): # pragma: no branch diff --git a/tests/interaction/transports/test_flows.py b/tests/interaction/transports/test_flows.py index e8081a7a1d..26d128cb6a 100644 --- a/tests/interaction/transports/test_flows.py +++ b/tests/interaction/transports/test_flows.py @@ -6,7 +6,7 @@ """ import anyio -import httpx +import httpx2 import pytest from inline_snapshot import snapshot @@ -76,7 +76,7 @@ def echo(text: str) -> str: session_ids: list[str] = [] - async def record(request: httpx.Request) -> None: + async def record(request: httpx2.Request) -> None: session_id = request.headers.get("mcp-session-id") if session_id is not None: session_ids.append(session_id) diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index 5b5c7085b3..ec4727cf59 100644 --- a/tests/interaction/transports/test_hosting_http.py +++ b/tests/interaction/transports/test_hosting_http.py @@ -9,7 +9,7 @@ import anyio import pytest from anyio.lowlevel import checkpoint -from httpx_sse import ServerSentEvent, aconnect_sse +from httpx2 import ServerSentEvent from inline_snapshot import snapshot from mcp.server import Server, ServerRequestContext @@ -260,7 +260,7 @@ async def test_a_second_standalone_get_stream_on_the_same_session_returns_409() async with mounted_app(_server()) as (http, _): session_id = await initialize_via_http(http) - async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as first: + async with http.sse("/mcp", headers=base_headers(session_id=session_id)) as first: assert first.response.status_code == 200 # The standalone-stream writer registers its key as its first action, then parks # awaiting messages; one yield to the loop lets that registration complete before the @@ -296,10 +296,10 @@ async def test_messages_are_routed_to_exactly_one_stream() -> None: get_events: list[ServerSentEvent] = [] async def read_standalone_stream() -> None: - async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as get: + async with http.sse("/mcp", headers=base_headers(session_id=session_id)) as get: assert get.response.status_code == 200 standalone_ready.set() - async for event in get.aiter_sse(): + async for event in get: get_events.append(event) seen_on_standalone.set() @@ -312,16 +312,15 @@ async def read_standalone_stream() -> None: params = CallToolRequestParams(name="narrate", arguments={}) body = JSONRPCRequest(jsonrpc="2.0", id=5, method="tools/call", params=params.model_dump()) - async with aconnect_sse( - http, - "POST", + async with http.sse( "/mcp", + method="POST", json=body.model_dump(by_alias=True, exclude_none=True), headers=base_headers(session_id=session_id), ) as post: assert post.response.status_code == 200 # The POST stream iterator ends when the server closes the stream after the response. - post_events = [event async for event in post.aiter_sse()] + post_events = [event async for event in post] await seen_on_standalone.wait() tg.cancel_scope.cancel() @@ -360,11 +359,14 @@ async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> No "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"} ) bad_host = await http.post("/mcp", json=initialize_body(), headers=base_headers() | {"host": "evil.example"}) - async with aconnect_sse( - http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://127.0.0.1:8000"} + async with http.sse( + "/mcp", + method="POST", + json=initialize_body(), + headers=base_headers() | {"origin": "http://127.0.0.1:8000"}, ) as ok: assert ok.response.status_code == 200 - assert [event async for event in ok.aiter_sse()] + assert [event async for event in ok] assert (bad_origin.status_code, bad_origin.text) == snapshot((403, "Invalid Origin header")) assert (bad_host.status_code, bad_host.text) == snapshot((421, "Invalid Host header")) @@ -372,10 +374,10 @@ async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> No async with mounted_app( Server("unguarded"), transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False) ) as (http, _): - async with aconnect_sse( - http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"} + async with http.sse( + "/mcp", method="POST", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"} ) as unguarded: status = unguarded.response.status_code - assert [event async for event in unguarded.aiter_sse()] + assert [event async for event in unguarded] assert status == 200 diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index f943f9e89e..82017481d9 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -12,7 +12,7 @@ from typing import Any import anyio -import httpx +import httpx2 import pytest from inline_snapshot import snapshot @@ -72,7 +72,7 @@ def _meta_envelope() -> dict[str, object]: def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server: - """A low-level server with one ``add`` tool for the raw-httpx tests below.""" + """A low-level server with one ``add`` tool for the raw-httpx2 tests below.""" async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: tool = Tool(name="add", input_schema={"type": "object"}) @@ -306,7 +306,7 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern plus the three-key ``io.modelcontextprotocol/*`` ``_meta`` envelope. The caller passes a ``custom-key`` under ``meta=`` and the server handler captures the incoming ``ctx.meta``, proving the envelope merge is additive: the caller's key sits alongside the three envelope keys - on the wire and inside the handler. Asserted at the wire via the ``mounted_app`` httpx event + on the wire and inside the handler. Asserted at the wire via the ``mounted_app`` httpx2 event hooks because none of the headers, the envelope, or the handshake-absence is observable through the public client API. The recorded log shows two POSTs: the ``tools/call`` itself and the client's implicit ``tools/list`` output-schema fetch (see ``client:output-schema:auto-list``), @@ -315,13 +315,13 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern observed_metas: list[dict[str, Any]] = [] server = _server(on_meta=observed_metas.append) - requests: list[httpx.Request] = [] - responses: list[httpx.Response] = [] + requests: list[httpx2.Request] = [] + responses: list[httpx2.Response] = [] - async def on_request(request: httpx.Request) -> None: + async def on_request(request: httpx2.Request) -> None: requests.append(request) - async def on_response(response: httpx.Response) -> None: + async def on_response(response: httpx2.Response) -> None: responses.append(response) client_info = Implementation(name="e2e-client", version="1.0.0") diff --git a/tests/interaction/transports/test_hosting_resume.py b/tests/interaction/transports/test_hosting_resume.py index f88521dbb0..4de6ab166b 100644 --- a/tests/interaction/transports/test_hosting_resume.py +++ b/tests/interaction/transports/test_hosting_resume.py @@ -2,7 +2,7 @@ These tests configure the server with an event store, so every SSE event is stamped with an ID and a client that loses its connection can resume by sending `Last-Event-ID`. The wire-level -tests (`mounted_app` + raw httpx) assert exactly what travels on the wire; the end-to-end test +tests (`mounted_app` + raw httpx2) assert exactly what travels on the wire; the end-to-end test drives the SDK client through a server-initiated stream close and proves the call still completes. The bridge's `aclose()` delivers `http.disconnect` to the running application, so closing a streaming response mid-read is a deterministic in-process disconnect -- no sockets, @@ -12,9 +12,9 @@ import json import anyio -import httpx +import httpx2 import pytest -from httpx_sse import EventSource, ServerSentEvent +from httpx2 import EventSource, ServerSentEvent from inline_snapshot import snapshot from mcp.client.session import ClientSession @@ -69,9 +69,9 @@ def _tools_call(request_id: int, name: str, arguments: dict[str, object]) -> str ).model_dump_json(by_alias=True, exclude_none=True) -async def _read_events(response: httpx.Response, count: int) -> list[ServerSentEvent]: +async def _read_events(response: httpx2.Response, count: int) -> list[ServerSentEvent]: """Read exactly `count` SSE events from a streaming response without closing it.""" - source = EventSource(response).aiter_sse() + source = aiter(EventSource(response)) return [await anext(source) for _ in range(count)] @@ -273,7 +273,7 @@ async def test_an_unknown_last_event_id_yields_an_empty_replay_stream() -> None: async with http.stream("GET", "/mcp", headers=headers) as replay: assert replay.status_code == 200 assert replay.headers["content-type"].startswith("text/event-stream") - events = [event async for event in EventSource(replay).aiter_sse()] + events = [event async for event in EventSource(replay)] assert events == [] diff --git a/tests/interaction/transports/test_hosting_session.py b/tests/interaction/transports/test_hosting_session.py index a926c3e8a2..b0648970c2 100644 --- a/tests/interaction/transports/test_hosting_session.py +++ b/tests/interaction/transports/test_hosting_session.py @@ -9,7 +9,7 @@ import re import anyio -import httpx +import httpx2 import pytest from inline_snapshot import snapshot @@ -167,9 +167,9 @@ async def test_stateless_mode_never_issues_a_session_id() -> None: cannot have issued one, or the client would echo it); the empty instance map proves the manager kept no transport between requests. """ - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - async def record(request: httpx.Request) -> None: + async def record(request: httpx2.Request) -> None: requests.append(request) async with mounted_app(_server(), stateless_http=True, on_request=record) as (http, manager): diff --git a/tests/interaction/transports/test_legacy_wire.py b/tests/interaction/transports/test_legacy_wire.py index b65a50759d..4a3dee9c4f 100644 --- a/tests/interaction/transports/test_legacy_wire.py +++ b/tests/interaction/transports/test_legacy_wire.py @@ -1,13 +1,13 @@ """Legacy-wire protection: a 2025-era streamable-HTTP exchange stays free of 2026 vocabulary. Records a full SDK client -> SDK server round trip at both seams (HTTP request/response headers -via httpx event hooks; JSON-RPC frames in both directions via the recording transport) and runs +via httpx2 event hooks; JSON-RPC frames in both directions via the recording transport) and runs the result through :func:`tests.interaction._modern_vocab.assert_no_modern_vocabulary`. The test pins today's wire so any future 2026-07-28 work that leaks new fields, `_meta` keys, or headers onto a connection negotiated at the current protocol version fails here. """ -import httpx +import httpx2 import pytest from inline_snapshot import snapshot @@ -58,10 +58,10 @@ async def test_legacy_streamable_http_exchange_carries_no_modern_protocol_vocabu """ recorded = RecordedExchange(requests=[], responses=[], frames=[]) - async def on_request(request: httpx.Request) -> None: + async def on_request(request: httpx2.Request) -> None: recorded.requests.append(request) - async def on_response(response: httpx.Response) -> None: + async def on_response(response: httpx2.Response) -> None: recorded.responses.append(response) async with mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _): diff --git a/tests/interaction/transports/test_sse.py b/tests/interaction/transports/test_sse.py index 9c7353dda5..0e748c7301 100644 --- a/tests/interaction/transports/test_sse.py +++ b/tests/interaction/transports/test_sse.py @@ -10,7 +10,7 @@ from uuid import UUID, uuid4 import anyio -import httpx +import httpx2 import pytest from inline_snapshot import snapshot @@ -36,10 +36,10 @@ async def test_endpoint_event_names_the_message_endpoint_with_a_fresh_session_id def httpx_client_factory( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: - return httpx.AsyncClient( + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: + return httpx2.AsyncClient( transport=StreamingASGITransport(app, cancel_on_close=False), base_url=BASE_URL, headers=headers, @@ -63,7 +63,7 @@ def httpx_client_factory( async def test_post_without_a_session_id_is_rejected() -> None: """A POST to the message endpoint with no session_id query parameter is answered 400.""" app, _ = build_sse_app(Server("legacy")) - 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: response = await http.post("/messages/", json={"jsonrpc": "2.0", "method": "ping", "id": 1}) assert (response.status_code, response.text) == snapshot((400, "session_id is required")) @@ -72,7 +72,7 @@ async def test_post_without_a_session_id_is_rejected() -> None: async def test_post_with_a_malformed_session_id_is_rejected() -> None: """A POST whose session_id query parameter is not a UUID is answered 400.""" app, _ = build_sse_app(Server("legacy")) - 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: response = await http.post( "/messages/", params={"session_id": "not-a-uuid"}, json={"jsonrpc": "2.0", "method": "ping", "id": 1} ) @@ -83,7 +83,7 @@ async def test_post_with_a_malformed_session_id_is_rejected() -> None: async def test_post_for_an_unknown_session_is_rejected() -> None: """A POST naming a well-formed session_id that no SSE stream owns is answered 404.""" app, _ = build_sse_app(Server("legacy")) - 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: response = await http.post( "/messages/", params={"session_id": uuid4().hex}, json={"jsonrpc": "2.0", "method": "ping", "id": 1} ) diff --git a/tests/issues/test_1363_race_condition_streamable_http.py b/tests/issues/test_1363_race_condition_streamable_http.py index a5021ac414..f98194b7b5 100644 --- a/tests/issues/test_1363_race_condition_streamable_http.py +++ b/tests/issues/test_1363_race_condition_streamable_http.py @@ -22,7 +22,7 @@ import anyio import anyio.to_thread -import httpx +import httpx2 import pytest from starlette.applications import Starlette from starlette.routing import Mount @@ -147,8 +147,8 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi # Suppress WARNING logs (expected validation errors) and capture ERROR logs with caplog.at_level(logging.ERROR): # Test with missing text/event-stream in Accept header - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as client: response = await client.post( "/", @@ -162,8 +162,8 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi assert response.status_code == 406 # Test with missing application/json in Accept header - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as client: response = await client.post( "/", @@ -177,8 +177,8 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi assert response.status_code == 406 # Test with completely invalid Accept header - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as client: response = await client.post( "/", @@ -218,8 +218,8 @@ async def test_race_condition_invalid_content_type(caplog: pytest.LogCaptureFixt # Suppress WARNING logs (expected validation errors) and capture ERROR logs with caplog.at_level(logging.ERROR): # Test with invalid Content-Type - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as client: response = await client.post( "/", @@ -257,9 +257,9 @@ async def test_race_condition_message_router_async_for(caplog: pytest.LogCapture # Suppress WARNING logs (expected validation errors) and capture ERROR logs with caplog.at_level(logging.ERROR): - # Use httpx.ASGITransport to test the ASGI app directly - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + # Use httpx2.ASGITransport to test the ASGI app directly + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as client: # Send a valid initialize request response = await client.post( diff --git a/tests/server/auth/test_error_handling.py b/tests/server/auth/test_error_handling.py index 7c5c435825..cdd9caa16b 100644 --- a/tests/server/auth/test_error_handling.py +++ b/tests/server/auth/test_error_handling.py @@ -7,9 +7,9 @@ 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], diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 413a80276e..5b4f69d9d5 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -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 diff --git a/tests/server/mcpserver/auth/test_auth_integration.py b/tests/server/mcpserver/auth/test_auth_integration.py index 35fec1c57e..e9c1df8465 100644 --- a/tests/server/mcpserver/auth/test_auth_integration.py +++ b/tests/server/mcpserver/auth/test_auth_integration.py @@ -8,7 +8,7 @@ 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 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): 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 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient): @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 @@ async def test_token_invalid_client_secret_returns_invalid_client( @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 @@ async def test_token_invalid_auth_code( @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 @@ async def test_token_expired_auth_code( ) 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 @@ async def test_token_redirect_uri_mismatch( @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 @@ async def test_token_code_verifier_mismatch( 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 @@ async def test_token_invalid_refresh_token(self, test_client: httpx.AsyncClient, @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 @@ async def test_token_expired_refresh_token( @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 @@ async def test_token_invalid_scope( 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 @@ async def test_client_registration(self, test_client: httpx.AsyncClient, mock_oa # ) 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 @@ async def test_client_registration_missing_required_fields(self, test_client: ht 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 @@ async def test_client_registration_invalid_uri(self, test_client: httpx.AsyncCli ) @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 @@ async def test_client_registration_empty_redirect_uris(self, test_client: httpx. ) @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 @@ async def test_authorize_form_post(self, test_client: httpx.AsyncClient, pkce_ch @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 @@ async def test_authorization_get( 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 @@ async def test_revoke_invalid_token(self, test_client: httpx.AsyncClient, regist 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 @@ async def test_revoke_with_malformed_token(self, test_client: httpx.AsyncClient, 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 @@ async def test_client_registration_disallowed_scopes(self, test_client: httpx.As @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 @@ async def test_client_registration_default_scopes( 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 @@ async def test_client_registration_with_authorization_code_only(self, test_clien 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 @@ async def test_client_registration_missing_authorization_code(self, test_client: 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 @@ async def test_client_registration_with_additional_grant_type(self, test_client: @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 @@ async def test_client_registration_with_additional_response_types( 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 @@ async def test_client_registration_response_types_without_code(self, test_client @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 @@ async def test_client_registration_default_response_types( @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 @@ async def test_client_secret_basic_authentication( @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 @@ async def test_wrong_auth_method_without_valid_credentials_fails( @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 @@ async def test_basic_auth_without_header_fails( @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 @@ async def test_basic_auth_invalid_base64_fails( @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 @@ async def test_basic_auth_no_colon_fails( @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 @@ async def test_basic_auth_client_id_mismatch_fails( @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 @@ async def test_authorize_missing_client_id(self, test_client: httpx.AsyncClient, 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 @@ async def test_authorize_invalid_client_id(self, test_client: httpx.AsyncClient, @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 @@ async def test_authorize_missing_redirect_uri( @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 @@ async def test_authorize_invalid_redirect_uri( 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 @@ async def test_authorize_missing_redirect_uri_multiple_registered( @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 @@ async def test_authorize_unsupported_response_type( @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 @@ async def test_authorize_missing_response_type( @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 @@ async def test_authorize_missing_pkce_challenge( @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. diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index e77bd5e2c2..877c8b2376 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -4,7 +4,7 @@ import re import anyio -import httpx +import httpx2 import pytest import sse_starlette.sse from starlette.applications import Starlette @@ -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 @@ async def handle_sse(request: Request) -> Response: # 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 diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 0e8afed509..2dfa65bfd1 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch import anyio -import httpx +import httpx2 import pytest from starlette.types import Message, Scope @@ -331,8 +331,8 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP 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)) as client, ): await client.list_tools() diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 35ee17f3d6..e913e3a020 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -10,7 +10,7 @@ from typing import Any import anyio -import httpx +import httpx2 import pytest from starlette.types import Receive, Scope, Send @@ -48,13 +48,13 @@ async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: assert await dctx.progress(0.5, total=1.0, message="half") is None -def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient: +def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> 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, lifespan_state, scope, receive, send) - return httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), + return httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://testserver", headers={MCP_PROTOCOL_VERSION_HEADER: MODERN_PROTOCOL_VERSIONS[0]}, ) diff --git a/tests/server/test_streamable_http_security.py b/tests/server/test_streamable_http_security.py index f13bb4a9bb..e83289ee34 100644 --- a/tests/server/test_streamable_http_security.py +++ b/tests/server/test_streamable_http_security.py @@ -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 @@ @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 diff --git a/tests/shared/test_httpx_utils.py b/tests/shared/test_httpx_utils.py index dcc6fd003c..a94d7c9299 100644 --- a/tests/shared/test_httpx_utils.py +++ b/tests/shared/test_httpx_utils.py @@ -1,6 +1,6 @@ -"""Tests for httpx utility functions.""" +"""Tests for httpx2 utility functions.""" -import httpx +import httpx2 from mcp.shared._httpx_utils import create_mcp_http_client @@ -16,7 +16,7 @@ def test_default_settings(): def test_custom_parameters(): """Test custom headers and timeout are set correctly.""" headers = {"Authorization": "Bearer token"} - timeout = httpx.Timeout(60.0) + timeout = httpx2.Timeout(60.0) client = create_mcp_http_client(headers, timeout) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 675a4acb16..ddbae1769f 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -7,9 +7,9 @@ from urllib.parse import urlparse import anyio -import httpx +import httpx2 import pytest -from httpx_sse import ServerSentEvent +from httpx2 import ServerSentEvent from inline_snapshot import snapshot from starlette.applications import Starlette from starlette.requests import Request @@ -54,13 +54,13 @@ def in_process_client_factory(app: Starlette) -> McpHttpClientFactory: def factory( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: # The SSE GET runs until it observes a disconnect, so the bridge must let the # application drain on close rather than cancelling it. follow_redirects matches # create_mcp_http_client, the factory this one stands in for. - return httpx.AsyncClient( + return httpx2.AsyncClient( transport=StreamingASGITransport(app, cancel_on_close=False), base_url=BASE_URL, headers=headers, @@ -112,7 +112,7 @@ def make_server_app() -> Starlette: async def test_raw_sse_connection() -> None: """The SSE GET responds 200 with an event-stream content type, announcing the session endpoint as its first event.""" - http_client = httpx.AsyncClient( + http_client = httpx2.AsyncClient( transport=StreamingASGITransport(make_server_app(), cancel_on_close=False), base_url=BASE_URL ) @@ -416,7 +416,7 @@ async def test_sse_client_handles_empty_keepalive_pings() -> None: ) response_json = response.model_dump_json(by_alias=True, exclude_none=True) - # Create mock SSE events using httpx_sse's ServerSentEvent + # Create mock SSE events using httpx2's ServerSentEvent async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]: # First: endpoint event yield ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123") @@ -425,25 +425,28 @@ async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]: # Real JSON-RPC response yield ServerSentEvent(event="message", data=response_json) - mock_event_source = MagicMock() - mock_event_source.aiter_sse.return_value = mock_aiter_sse() - mock_event_source.response = MagicMock() - mock_event_source.response.raise_for_status = MagicMock() + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() - mock_aconnect_sse = MagicMock() - mock_aconnect_sse.__aenter__ = AsyncMock(return_value=mock_event_source) - mock_aconnect_sse.__aexit__ = AsyncMock(return_value=None) + mock_stream = MagicMock() + mock_stream.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream.__aexit__ = AsyncMock(return_value=None) mock_client = MagicMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.stream = MagicMock(return_value=mock_stream) mock_client.post = AsyncMock(return_value=MagicMock(status_code=200, raise_for_status=MagicMock())) - with ( - patch("mcp.client.sse.create_mcp_http_client", return_value=mock_client), - patch("mcp.client.sse.aconnect_sse", return_value=mock_aconnect_sse), - ): - async with sse_client("http://test/sse") as (read_stream, _): + def mock_factory( + headers: dict[str, str] | None = None, + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: + return mock_client + + with patch("mcp.client.sse.EventSource", return_value=mock_aiter_sse()): + async with sse_client("http://test/sse", httpx_client_factory=mock_factory) as (read_stream, _): # Read the message - should skip the empty one and get the real response msg = await read_stream.receive() # If we get here without error, the empty message was skipped successfully diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 5360e56ff6..e418578cc2 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -16,10 +16,10 @@ from urllib.parse import urlparse import anyio -import httpx +import httpx2 import pytest from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from httpx_sse import ServerSentEvent +from httpx2 import ServerSentEvent from starlette.applications import Starlette from starlette.requests import Request from starlette.routing import Mount @@ -84,7 +84,7 @@ # Helper functions -def first_sse_data(response: httpx.Response) -> dict[str, Any]: +def first_sse_data(response: httpx2.Response) -> dict[str, Any]: """Return the first SSE `data:` payload of a response, parsed as JSON.""" assert response.headers.get("Content-Type") == "text/event-stream" for line in response.text.splitlines(): @@ -93,7 +93,7 @@ def first_sse_data(response: httpx.Response) -> dict[str, Any]: raise ValueError("No data event in SSE response") # pragma: no cover -def extract_protocol_version_from_sse(response: httpx.Response) -> str: +def extract_protocol_version_from_sse(response: httpx2.Response) -> str: """Extract the negotiated protocol version from an SSE initialization response.""" return first_sse_data(response)["result"]["protocolVersion"] @@ -355,13 +355,13 @@ async def running_app( yield app -def make_client(app: Starlette, headers: dict[str, str] | None = None) -> httpx.AsyncClient: - """An httpx client served in process by `app`, with create_mcp_http_client's redirect default. +def make_client(app: Starlette, headers: dict[str, str] | None = None) -> httpx2.AsyncClient: + """An httpx2 client served in process by `app`, with create_mcp_http_client's redirect default. (Starlette's Mount 307-redirects the bare /mcp path to /mcp/, which the SDK's own client factory follows.) """ - return httpx.AsyncClient( + return httpx2.AsyncClient( transport=StreamingASGITransport(app), base_url=BASE_URL, headers=headers, follow_redirects=True ) @@ -399,7 +399,7 @@ async def event_app(event_store: SimpleEventStore) -> AsyncIterator[tuple[Simple async def test_accept_header_validation(basic_app: Starlette) -> None: """A POST without an Accept header is rejected with 406.""" async with make_client(basic_app) as client: - # Suppress the httpx client default Accept: */* header + # Suppress the httpx2 client default Accept: */* header del client.headers["accept"] response = await client.post( "/mcp", @@ -715,7 +715,7 @@ async def test_json_response_accept_json_only(json_app: Starlette) -> None: async def test_json_response_missing_accept_header(json_app: Starlette) -> None: """JSON response mode still rejects requests without an Accept header.""" async with make_client(json_app) as client: - # Suppress the httpx client default Accept: */* header + # Suppress the httpx2 client default Accept: */* header del client.headers["accept"] response = await client.post( "/mcp", @@ -828,7 +828,7 @@ async def test_get_validation(basic_app: Starlette) -> None: assert session_id is not None negotiated_version = extract_protocol_version_from_sse(init_response) - # Test without Accept header (suppress the httpx client default Accept: */*) + # Test without Accept header (suppress the httpx2 client default Accept: */*) del client.headers["accept"] response = await client.get( "/mcp", @@ -998,16 +998,16 @@ async def message_handler( # pragma: no branch assert resource_update_found, "ResourceUpdatedNotification not received via GET stream" -def create_session_id_capturing_client(app: Starlette) -> tuple[httpx.AsyncClient, list[str]]: - """Create an in-process httpx client that captures the session ID from responses.""" +def create_session_id_capturing_client(app: Starlette) -> tuple[httpx2.AsyncClient, list[str]]: + """Create an in-process httpx2 client that captures the session ID from responses.""" captured_ids: list[str] = [] - async def capture_session_id(response: httpx.Response) -> None: + async def capture_session_id(response: httpx2.Response) -> None: session_id = response.headers.get(MCP_SESSION_ID_HEADER) if session_id: captured_ids.append(session_id) - client = httpx.AsyncClient( + client = httpx2.AsyncClient( transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True, @@ -1019,7 +1019,7 @@ async def capture_session_id(response: httpx.Response) -> None: @pytest.mark.anyio async def test_streamable_http_client_session_termination(basic_app: Starlette) -> None: """After the client terminates its session on close, a new connection with that session ID fails.""" - # Use httpx client with event hooks to capture session ID + # Use httpx2 client with event hooks to capture session ID httpx_client, captured_ids = create_session_id_capturing_client(basic_app) async with httpx_client: @@ -1059,19 +1059,19 @@ async def test_streamable_http_client_session_termination_204( ) -> None: """Session termination also succeeds when the server answers the DELETE with 204. - This test patches the httpx client to return a 204 response for DELETEs. + This test patches the httpx2 client to return a 204 response for DELETEs. """ # Save the original delete method to restore later - original_delete = httpx.AsyncClient.delete + original_delete = httpx2.AsyncClient.delete # Mock the client's delete method to return a 204 - async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response: + async def mock_delete(self: httpx2.AsyncClient, *args: Any, **kwargs: Any) -> httpx2.Response: # Call the original method to get the real response response = await original_delete(self, *args, **kwargs) # Create a new response with 204 status code but same headers - mocked_response = httpx.Response( + mocked_response = httpx2.Response( 204, headers=response.headers, content=response.content, @@ -1079,10 +1079,10 @@ async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> htt ) return mocked_response - # Apply the patch to the httpx client - monkeypatch.setattr(httpx.AsyncClient, "delete", mock_delete) + # Apply the patch to the httpx2 client + monkeypatch.setattr(httpx2.AsyncClient, "delete", mock_delete) - # Use httpx client with event hooks to capture session ID + # Use httpx2 client with event hooks to capture session ID httpx_client, captured_ids = create_session_id_capturing_client(basic_app) async with httpx_client: @@ -1142,7 +1142,7 @@ async def on_resumption_token_update(token: str) -> None: captured_resumption_token = token resumption_token_received.set() - # Use httpx client with event hooks to capture session ID + # Use httpx2 client with event hooks to capture session ID httpx_client, captured_ids = create_session_id_capturing_client(app) # First, start the client session and begin the tool that waits on lock @@ -1613,7 +1613,7 @@ async def test_handle_sse_event_skips_empty_data() -> None: transport = StreamableHTTPTransport(url="http://localhost:8000/mcp") # Create a mock SSE event with empty data (keep-alive ping) - mock_sse = ServerSentEvent(event="message", data="", id=None, retry=None) + mock_sse = ServerSentEvent(event="message", data="") # Create a context-aware stream writer (matches StreamWriter type alias) write_stream, read_stream = create_context_streams[SessionMessage | Exception](1) @@ -2080,7 +2080,7 @@ async def message_handler( @pytest.mark.anyio async def test_streamable_http_client_does_not_mutate_provided_client(basic_app: Starlette) -> None: - """streamable_http_client does not mutate the provided httpx client's headers.""" + """streamable_http_client does not mutate the provided httpx2 client's headers.""" # Create a client with custom headers original_headers = { "X-Custom-Header": "custom-value", @@ -2098,7 +2098,7 @@ async def test_streamable_http_client_does_not_mutate_provided_client(basic_app: assert isinstance(result, InitializeResult) # Verify client headers were not mutated with MCP protocol headers - # If accept header exists, it should still be httpx default, not MCP's + # If accept header exists, it should still be httpx2 default, not MCP's if "accept" in custom_client.headers: # pragma: no branch assert custom_client.headers.get("accept") == "*/*" # MCP content-type should not have been added @@ -2111,8 +2111,8 @@ async def test_streamable_http_client_does_not_mutate_provided_client(basic_app: @pytest.mark.anyio async def test_streamable_http_client_mcp_headers_override_defaults(context_app: Starlette) -> None: - """MCP protocol headers override the httpx client's default headers in actual requests.""" - # httpx.AsyncClient has default "accept: */*" header + """MCP protocol headers override the httpx2 client's default headers in actual requests.""" + # httpx2.AsyncClient has default "accept: */*" header # We need to verify that our MCP accept header overrides it in actual requests async with make_client(context_app) as client: @@ -2129,7 +2129,7 @@ async def test_streamable_http_client_mcp_headers_override_defaults(context_app: assert isinstance(tool_result.content[0], TextContent) headers_data = json.loads(tool_result.content[0].text) - # Verify MCP protocol headers were sent (not httpx defaults) + # Verify MCP protocol headers were sent (not httpx2 defaults) assert "accept" in headers_data assert "application/json" in headers_data["accept"] assert "text/event-stream" in headers_data["accept"] diff --git a/uv.lock b/uv.lock index 7970d1cc2d..7894a1b0ae 100644 --- a/uv.lock +++ b/uv.lock @@ -660,49 +660,41 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore2" }, { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -906,8 +898,7 @@ name = "mcp" source = { editable = "." } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, { name = "opentelemetry-api" }, { name = "pydantic" }, @@ -966,8 +957,7 @@ docs = [ requires-dist = [ { name = "anyio", marker = "python_full_version < '3.14'", specifier = ">=4.9" }, { name = "anyio", marker = "python_full_version >= '3.14'", specifier = ">=4.10" }, - { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, - { name = "httpx-sse", specifier = ">=0.4" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "jsonschema", specifier = ">=4.20.0" }, { name = "opentelemetry-api", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.12.0" }, @@ -1023,7 +1013,7 @@ source = { editable = "examples/servers/everything-server" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1040,7 +1030,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, { name = "starlette" }, { name = "uvicorn" }, @@ -1060,7 +1050,7 @@ source = { editable = "examples/servers/simple-auth" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1079,7 +1069,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, @@ -1161,7 +1151,7 @@ source = { editable = "examples/servers/simple-pagination" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1176,7 +1166,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, ] @@ -1194,7 +1184,7 @@ source = { editable = "examples/servers/simple-prompt" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1209,7 +1199,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, ] @@ -1227,7 +1217,7 @@ source = { editable = "examples/servers/simple-resource" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1242,7 +1232,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, ] @@ -1260,7 +1250,7 @@ source = { editable = "examples/servers/simple-streamablehttp" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1277,7 +1267,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, { name = "starlette" }, { name = "uvicorn" }, @@ -1297,7 +1287,7 @@ source = { editable = "examples/servers/simple-streamablehttp-stateless" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1314,7 +1304,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, { name = "starlette" }, { name = "uvicorn" }, @@ -1334,7 +1324,7 @@ source = { editable = "examples/servers/simple-tool" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1349,7 +1339,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, ] @@ -1407,7 +1397,7 @@ source = { editable = "examples/servers/sse-polling-demo" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1424,7 +1414,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.5.0" }, { name = "mcp", editable = "." }, { name = "starlette" }, { name = "uvicorn" }, @@ -2728,6 +2718,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/5b/94237a3485620dbff9741df02ff6d8acaa5fdec67d81ab3f62e4d8511bf7/trio-0.31.0-py3-none-any.whl", hash = "sha256:b5d14cd6293d79298b49c3485ffd9c07e3ce03a6da8c7dfbe0cb3dd7dc9a4774", size = 512679, upload-time = "2025-09-09T15:17:13.821Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typeguard" version = "4.5.2"