From c2aebfb70414b066276987c5139f838aac59a844 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 25 Jun 2026 16:52:41 +0200 Subject: [PATCH 1/7] Replace httpx and httpx-sse with httpx2 httpx2 (2.5.0) is the next-generation httpx fork with server-sent events support built in, so the separate httpx-sse dependency is no longer needed. - Swap the httpx/httpx-sse dependencies for httpx2>=2.5.0 in the SDK and the example projects. - Rewrite the SSE transports against httpx2's API: aconnect_sse(...) -> client.stream(...)/client.sse(...) wrapped in EventSource, and iterate the EventSource directly instead of .aiter_sse(). - Document the swap as a v2 breaking change in docs/migration.md and update docs/installation.md, README.v2.md, and the example sources. Verified: ruff, pyright, and the full test suite pass at 100% coverage. --- docs/installation.md | 2 +- docs/migration.md | 55 +++- .../mcp_simple_auth_client/main.py | 4 +- .../simple-chatbot/mcp_simple_chatbot/main.py | 10 +- .../mcp_sse_polling_client/main.py | 2 +- examples/mcpserver/text_me.py | 4 +- .../servers/everything-server/pyproject.toml | 2 +- .../mcp_simple_auth/token_verifier.py | 8 +- examples/servers/simple-auth/pyproject.toml | 2 +- .../servers/simple-pagination/pyproject.toml | 2 +- examples/servers/simple-prompt/pyproject.toml | 2 +- .../servers/simple-resource/pyproject.toml | 2 +- .../pyproject.toml | 2 +- .../simple-streamablehttp/pyproject.toml | 2 +- examples/servers/simple-tool/pyproject.toml | 2 +- .../servers/sse-polling-demo/pyproject.toml | 2 +- examples/snippets/clients/oauth_client.py | 4 +- pyproject.toml | 3 +- .../auth/extensions/client_credentials.py | 22 +- src/mcp/client/auth/oauth2.py | 28 +- src/mcp/client/auth/utils.py | 2 +- src/mcp/client/session_group.py | 4 +- src/mcp/client/sse.py | 15 +- src/mcp/client/streamable_http.py | 52 ++-- src/mcp/server/mcpserver/resources/types.py | 4 +- src/mcp/shared/_httpx_utils.py | 30 +- tests/client/test_auth.py | 270 +++++++++--------- tests/client/test_http_unicode.py | 4 +- tests/client/test_notification_response.py | 20 +- tests/client/test_scope_bug_1630.py | 14 +- tests/client/test_session_group.py | 4 +- tests/client/test_streamable_http.py | 26 +- tests/client/test_transport_stream_cleanup.py | 18 +- tests/interaction/README.md | 2 +- tests/interaction/_connect.py | 54 ++-- tests/interaction/_modern_vocab.py | 6 +- tests/interaction/_requirements.py | 2 +- tests/interaction/auth/_harness.py | 30 +- tests/interaction/auth/test_as_handlers.py | 28 +- tests/interaction/auth/test_bearer.py | 20 +- tests/interaction/auth/test_discovery.py | 2 +- tests/interaction/auth/test_flow.py | 12 +- tests/interaction/transports/_bridge.py | 20 +- tests/interaction/transports/test_bridge.py | 10 +- .../transports/test_client_transport_http.py | 34 +-- tests/interaction/transports/test_flows.py | 4 +- .../transports/test_hosting_http.py | 30 +- .../transports/test_hosting_http_modern.py | 14 +- .../transports/test_hosting_resume.py | 12 +- .../transports/test_hosting_session.py | 6 +- .../transports/test_legacy_wire.py | 8 +- tests/interaction/transports/test_sse.py | 16 +- ...est_1363_race_condition_streamable_http.py | 24 +- tests/server/auth/test_error_handling.py | 16 +- tests/server/auth/test_protected_resource.py | 16 +- .../mcpserver/auth/test_auth_integration.py | 98 ++++--- tests/server/test_sse_security.py | 8 +- tests/server/test_streamable_http_manager.py | 6 +- tests/server/test_streamable_http_modern.py | 6 +- tests/server/test_streamable_http_security.py | 8 +- tests/shared/test_httpx_utils.py | 6 +- tests/shared/test_sse.py | 43 +-- tests/shared/test_streamable_http.py | 56 ++-- uv.lock | 90 +++--- 64 files changed, 677 insertions(+), 633 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index bc2a8281cf..bda1fa29cd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -35,7 +35,7 @@ You don't need to know any of this to use the SDK, but if you're wondering what * [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`. * [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation. * [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files. -* [`httpx`](https://www.python-httpx.org/) and [`httpx-sse`](https://pypi.org/project/httpx-sse/): the HTTP client behind the Streamable HTTP and SSE *client* transports. +* [`httpx2`](https://pypi.org/project/httpx2/): the HTTP client behind the Streamable HTTP and SSE *client* transports, with server-sent events support built in. * [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), and [`python-multipart`](https://pypi.org/project/python-multipart/): the HTTP *server* transports. * [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema. * [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization. diff --git a/docs/migration.md b/docs/migration.md index 68155560d9..acf4711fa6 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 returns a `CallToolResult` (or an @@ -56,13 +89,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, ) @@ -75,7 +108,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` @@ -110,7 +143,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):** @@ -126,7 +159,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 @@ -134,15 +167,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, ) @@ -156,7 +189,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 7b947588fe..e7bc7fe615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,8 +104,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", "mcp-types=={{ version }}", "pydantic>=2.12.0", "starlette>=0.48.0; 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 1daf55c1c5..c05cc55b36 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 @@ -83,11 +83,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", @@ -105,7 +105,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]]: @@ -297,7 +297,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() @@ -315,7 +315,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", @@ -333,7 +333,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): @@ -421,14 +421,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() @@ -455,7 +455,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 @@ -481,6 +481,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 711848d724..17b925f5b9 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 mcp_types.version import is_version_at_least from pydantic import BaseModel, Field, ValidationError @@ -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() @@ -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 d6b05e0667..fc87c9e469 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 mcp_types import LATEST_PROTOCOL_VERSION from pydantic import AnyUrl, ValidationError diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 40f0232594..5f26a43365 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -14,7 +14,7 @@ from typing import Any, Literal, TypeAlias, overload import anyio -import httpx +import httpx2 import mcp_types as types from pydantic import BaseModel, Field from typing_extensions import Self @@ -324,7 +324,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 8b482932aa..8a91411ac4 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -5,10 +5,10 @@ from urllib.parse import parse_qs, urljoin, urlparse import anyio -import httpx +import httpx2 import mcp_types as types from anyio.abc import TaskStatus -from httpx_sse import SSEError, aconnect_sse +from httpx2 import EventSource, SSEError from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import create_context_streams @@ -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 f28eb7c7ab..ba0c161dcb 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -9,9 +9,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 mcp_types import ( INTERNAL_ERROR, INVALID_REQUEST, @@ -63,7 +63,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 @@ -90,7 +90,7 @@ def __init__(self, url: str) -> None: def _prepare_headers(self) -> dict[str, str]: """Build MCP-specific request headers for any outbound HTTP request. - These are merged with the ``httpx.AsyncClient`` defaults (these take + These are merged with the ``httpx2.AsyncClient`` defaults (these take precedence). The cached ``MCP-Protocol-Version`` is included whenever present so messages that don't pass through the session's stamp — response/error/cancel POSTs, transport-internal GET/DELETE — still @@ -115,7 +115,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: @@ -172,7 +172,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 @@ -187,11 +187,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 @@ -230,11 +230,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, @@ -242,7 +242,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: @@ -285,7 +285,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: @@ -321,7 +321,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, *, request_id: RequestId, @@ -332,7 +332,7 @@ async def _handle_json_response( message = jsonrpc_message_adapter.validate_json(content, by_name=False) 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)) @@ -340,7 +340,7 @@ async def _handle_json_response( async def _handle_sse_response( self, - response: httpx.Response, + response: httpx2.Response, ctx: RequestContext, ) -> None: """Handle SSE response from the server.""" @@ -354,7 +354,7 @@ async def _handle_sse_response( try: event_source = EventSource(response) - async for sse in event_source.aiter_sse(): # pragma: no branch + async for sse in event_source: # pragma: no branch # Track last event ID for potential reconnection if sse.id: last_event_id = sse.id @@ -408,15 +408,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: @@ -442,7 +442,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], @@ -501,7 +501,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 @@ -530,16 +530,16 @@ 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, ) -> AsyncGenerator[TransportStreams, None]: """Client transport for StreamableHTTP. 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. Yields: diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index e295e21e02..f62b187bd4 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 mcp_types import Annotations, Icon @@ -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 1ec38ccf6f..eaf9fb265d 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) @@ -2884,10 +2884,10 @@ async def test_handle_refresh_response_carries_prior_scope_and_refresh_token_whe oauth_provider.context.current_tokens = OAuthToken( access_token="old", scope="read write", refresh_token="prior-refresh" ) - response = httpx.Response( + response = httpx2.Response( 200, json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600}, - request=httpx.Request("POST", "https://auth.example.com/token"), + request=httpx2.Request("POST", "https://auth.example.com/token"), ) ok = await oauth_provider._handle_refresh_response(response) @@ -2910,10 +2910,10 @@ async def test_handle_refresh_response_adopts_rotated_refresh_token_when_returne oauth_provider.context.current_tokens = OAuthToken( access_token="old", scope="read write", refresh_token="prior-refresh" ) - response = httpx.Response( + response = httpx2.Response( 200, json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rotated"}, - request=httpx.Request("POST", "https://auth.example.com/token"), + request=httpx2.Request("POST", "https://auth.example.com/token"), ) ok = await oauth_provider._handle_refresh_response(response) @@ -2943,20 +2943,20 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( issuer="https://old-as.example.com", ) - auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/v1/mcp")) + auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) request = await auth_flow.__anext__() - response_401 = httpx.Response(401, request=request) + response_401 = httpx2.Response(401, request=request) # PRM discovery: path-based then root, both 404. prm_req = await auth_flow.asend(response_401) assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp" - prm_req = await auth_flow.asend(httpx.Response(404, request=prm_req)) + prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource" # ASM discovery via root fallback (no auth_server_url) succeeds with a different issuer. - asm_req = await auth_flow.asend(httpx.Response(404, request=prm_req)) + asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server" - asm_response = httpx.Response( + asm_response = httpx2.Response( 200, content=( b'{"issuer": "https://api.example.com", ' @@ -2981,12 +2981,12 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( "asm_responses", [ pytest.param( - [httpx.Response(404), httpx.Response(404)], + [httpx2.Response(404), httpx2.Response(404)], id="asm-discovery-failed", ), pytest.param( [ - httpx.Response( + httpx2.Response( 200, content=( b'{"issuer": "https://new-as.example.com", ' @@ -3000,7 +3000,7 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( ], ) async def test_issuer_is_not_stamped_when_registration_falls_back_to_the_resource_origin( - oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, asm_responses: list[httpx.Response] + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, asm_responses: list[httpx2.Response] ): """SEP-2352: a fallback registration is not recorded as bound to the PRM-advertised AS. @@ -3032,9 +3032,9 @@ async def 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": ( @@ -3047,7 +3047,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"]}' @@ -3069,7 +3069,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, @@ -3083,12 +3083,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 @@ -3121,19 +3121,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", ' @@ -3147,7 +3147,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, @@ -3161,11 +3161,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 387fa4b48e..ef9511fbe0 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 mcp_types as types import pytest from mcp_types import TextContent, Tool @@ -114,7 +114,7 @@ async def unicode_session() -> AsyncIterator[ClientSession]: session_manager.run(), # follow_redirects matches the SDK's own client factory; Starlette's Mount 307-redirects # the bare /mcp path to /mcp/. - httpx.AsyncClient( + httpx2.AsyncClient( transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True ) as http_client, streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream), diff --git a/tests/client/test_notification_response.py b/tests/client/test_notification_response.py index 418a6bc54b..6724dfaf1b 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 mcp_types as types import pytest from mcp_types import RootsListChangedNotification @@ -89,7 +89,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() @@ -108,7 +108,7 @@ async def test_unexpected_content_type_sends_jsonrpc_error() -> None: the client should send a JSONRPCError so the pending request resolves immediately instead of hanging until timeout. """ - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_unexpected_content_type_app())) as client: + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_unexpected_content_type_app())) as client: async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch await session.initialize() @@ -142,9 +142,9 @@ async def test_http_error_status_sends_jsonrpc_error() -> None: When a server returns a non-2xx status code (e.g. 500), the client should send a JSONRPCError so the pending request resolves immediately instead of - raising an unhandled httpx.HTTPStatusError that causes the caller to hang. + raising an unhandled httpx2.HTTPStatusError that causes the caller to hang. """ - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_http_error_app(500))) as client: + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_http_error_app(500))) as client: async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch await session.initialize() @@ -160,7 +160,7 @@ async def test_http_error_on_notification_does_not_hang() -> None: unblock, so the client should just return without sending a JSONRPCError. """ app = _create_http_error_app(500, error_on_notifications=True) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client: + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client: async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch await session.initialize() @@ -195,7 +195,7 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None: should send a JSONRPCError so the pending request resolves immediately instead of hanging until timeout. """ - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_invalid_json_response_app())) as client: + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_invalid_json_response_app())) as client: async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch await session.initialize() @@ -233,7 +233,7 @@ async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_i {"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}} ).encode() app = _create_non_2xx_json_body_app(400, body) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client: + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client: async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch await session.initialize() @@ -247,7 +247,7 @@ async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being treated as the request's reply.""" app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}') - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client: + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client: async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch await session.initialize() @@ -261,7 +261,7 @@ async def test_client_falls_back_to_session_terminated_when_404_body_is_malforme and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the pending request — the parse failure never propagates.""" app = _create_non_2xx_json_body_app(404, b"not valid json{{{") - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client: + async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as client: async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # pragma: no branch await session.initialize() 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 dae0766168..b75d22b7a0 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 mcp_types as types import pytest @@ -380,7 +380,7 @@ async def test_client_session_group_establish_session_parameterized( call_args = mock_specific_client_func.call_args assert call_args.kwargs["url"] == server_params_instance.url assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close - assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient) + assert isinstance(call_args.kwargs["http_client"], httpx2.AsyncClient) mock_client_cm_instance.__aenter__.assert_awaited_once() diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 99ff6f03e5..abbbd1cda7 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 from mcp_types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse @@ -56,16 +56,16 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field async def test_post_request_merges_per_message_metadata_headers() -> None: """`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers (SDK-defined: the headers sidecar is the path the session uses to reach the transport).""" - recorded: list[httpx.Request] = [] + recorded: list[httpx2.Request] = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: recorded.append(request) body = json.loads(request.content) - return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) with anyio.fail_after(5): async with ( - httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, streamable_http_client("http://test/mcp", http_client=http) as (read, write), ): await write.send( @@ -88,12 +88,12 @@ async def test_pre_session_bare_404_maps_to_method_not_found() -> None: "Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601. """ - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(404) + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(404) with anyio.fail_after(5): async with ( - httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, streamable_http_client("http://test/mcp", http_client=http) as (read, write), ): await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}))) @@ -116,18 +116,18 @@ async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_ passes through the session's stamp) then reads the cache and carries the negotiated version — the spec MUST for all post-initialization HTTP requests. """ - recorded: list[httpx.Request] = [] + recorded: list[httpx2.Request] = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: recorded.append(request) body = json.loads(request.content) if "id" not in body or "result" in body: - return httpx.Response(202) - return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + return httpx2.Response(202) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) with anyio.fail_after(5): async with ( - httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http, streamable_http_client("http://test/mcp", http_client=http) as (read, write), ): await write.send( 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 feb5ca5d15..bc6c960861 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 05b2d2277b..531bc0d44a 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 mcp_types import ( ClientCapabilities, Implementation, @@ -145,7 +145,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), mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy", @@ -176,17 +176,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 @@ -214,7 +214,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, ): @@ -223,7 +223,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, @@ -232,8 +232,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( @@ -254,22 +254,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 newest handshake revision, and the session @@ -298,16 +298,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"] @@ -364,13 +364,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 2f69763419..3519decd5d 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 #: Substrings that must not appear anywhere in a request body or JSON-RPC frame on a legacy @@ -51,8 +51,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 d376f0b9f0..93e6004564 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3206,7 +3206,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 4fd1110c9b..856a1fe9a8 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: @@ -411,16 +411,16 @@ async def connect_with_oauth( client_metadata: OAuthClientMetadata | None = None, client_metadata_url: str | None = None, headless: HeadlessOAuth | None = None, - auth: httpx.Auth | None = None, + auth: httpx2.Auth | None = None, verify_tokens: bool = True, app_shim: Callable[[ASGIApp], ASGIApp] | None = None, - on_request: Callable[[httpx.Request], None] | None = None, + on_request: Callable[[httpx2.Request], None] | None = None, ) -> AsyncIterator[tuple[Client, HeadlessOAuth]]: """Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process. Yields the connected `Client` and the `HeadlessOAuth` whose `authorize_url` records what the SDK put on the authorize request. `on_request` records every HTTP request the underlying - `httpx.AsyncClient` issues, including those yielded from inside the auth flow. + `httpx2.AsyncClient` issues, including those yielded from inside the auth flow. `headless`: supply a pre-configured `HeadlessOAuth` to override the callback behaviour (state mismatch, error redirects). `verify_tokens=False` mounts the MCP endpoint without @@ -428,7 +428,7 @@ async def connect_with_oauth( scopes. `app_shim` wraps the built Starlette app before it reaches the bridge transport, for tests that need to intercept or rewrite specific server responses. - `auth`: supply a pre-built `httpx.Auth` (such as `ClientCredentialsOAuthProvider`) to use + `auth`: supply a pre-built `httpx2.Auth` (such as `ClientCredentialsOAuthProvider`) to use instead of constructing the default `OAuthClientProvider`; in that case `storage`, `client_metadata`, `client_metadata_url`, and `headless` are unused (the yielded `HeadlessOAuth` is never invoked and its `authorize_url` stays None). @@ -464,7 +464,7 @@ async def connect_with_oauth( if on_request is not None: record = on_request - async def hook(request: httpx.Request) -> None: + async def hook(request: httpx2.Request) -> None: record(request) event_hooks = {"request": [hook]} @@ -472,7 +472,7 @@ async def 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 55029c9f43..c70a27c52e 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 from mcp_types import JSONRPCResponse @@ -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 1317fd19de..dc9f3794af 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 e98735abf8..f579969d59 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 mcp_types as types import pytest from inline_snapshot import snapshot @@ -64,7 +64,7 @@ async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow 6. POST /token (authorization-code exchange). 7. Retry POST /mcp with `Authorization: Bearer ` → 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 5508d3e8f9..7f1e522de0 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 mcp_types as types import pytest from inline_snapshot import snapshot @@ -43,16 +43,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, _): @@ -63,7 +63,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 @@ -73,9 +73,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. @@ -87,7 +87,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 @@ -98,7 +98,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 @@ -109,7 +109,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: @@ -121,7 +121,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) @@ -130,10 +130,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: @@ -178,7 +178,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 @@ -196,9 +196,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() @@ -237,7 +237,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 13979b3f1a..7364c5a7c9 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 from mcp_types import CallToolResult, LoggingMessageNotificationParams, TextContent @@ -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 6331c2dae1..ff9ac2ed05 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_types import ( CLIENT_CAPABILITIES_META_KEY, @@ -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 a8f1f53c7b..7f69809da4 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 from mcp_types import ( @@ -74,7 +74,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"}) @@ -308,7 +308,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``), @@ -317,13 +317,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 78492ccb7e..2699ab32f1 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_types import ( CallToolRequest, @@ -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 1b41a4bee4..23c8da1580 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 from mcp_types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool @@ -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 d097ded692..4653696cf9 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 from mcp_types import ( @@ -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 6e492a35ba..ffa5c5728e 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 from mcp_types import EmptyResult @@ -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 ca16d33541..824bd16aba 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 mcp_types import JSONRPCRequest, JSONRPCResponse @@ -40,8 +40,8 @@ def reset_sse_starlette_exit_event() -> None: app_status.should_exit_event = None -def sse_security_client(security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient: - """An httpx client whose requests are served in process by an SSE app with the given settings.""" +def sse_security_client(security_settings: TransportSecuritySettings | None = None) -> httpx2.AsyncClient: + """An httpx2 client whose requests are served in process by an SSE app with the given settings.""" server = Server(SERVER_NAME) sse_transport = SseServerTransport("/messages/", security_settings) @@ -65,7 +65,7 @@ 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 0b3a280832..c825ffb207 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 mcp_types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams 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), mode="legacy") 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 6e8df458d1..393537a998 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -11,7 +11,7 @@ from typing import Any import anyio -import httpx +import httpx2 import pytest from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, @@ -72,12 +72,12 @@ def _asgi_client( *, json_response: bool = True, accept: str = "application/json, text/event-stream", -) -> httpx.AsyncClient: +) -> httpx2.AsyncClient: async def app(scope: Scope, receive: Receive, send: Send) -> None: async with server.lifespan(server) as lifespan_state: await handle_modern_request(server, security_settings, json_response, lifespan_state, scope, receive, send) - return httpx.AsyncClient( + return httpx2.AsyncClient( transport=StreamingASGITransport(app), base_url="http://testserver", headers={ 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 6427fb21a6..124e1e9c19 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -7,10 +7,10 @@ from urllib.parse import urlparse import anyio -import httpx +import httpx2 import mcp_types as types import pytest -from httpx_sse import ServerSentEvent +from httpx2 import ServerSentEvent from inline_snapshot import snapshot from mcp_types import ( CallToolRequestParams, @@ -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 cbce222eca..c98118d927 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -16,11 +16,11 @@ from urllib.parse import urlparse import anyio -import httpx +import httpx2 import mcp_types as types import pytest from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from httpx_sse import ServerSentEvent +from httpx2 import ServerSentEvent from mcp_types import ( DEFAULT_NEGOTIATED_VERSION, INVALID_PARAMS, @@ -85,7 +85,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(): @@ -94,7 +94,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"] @@ -356,13 +356,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 ) @@ -400,7 +400,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", @@ -716,7 +716,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", @@ -829,7 +829,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", @@ -999,16 +999,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, @@ -1020,7 +1020,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: @@ -1060,19 +1060,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, @@ -1080,10 +1080,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: @@ -1143,7 +1143,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 @@ -2081,7 +2081,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", @@ -2099,7 +2099,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 @@ -2112,8 +2112,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: @@ -2130,7 +2130,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 a1e8a7e356..373ce169da 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,6 @@ build-constraints = [ { name = "trove-classifiers", specifier = "==2026.1.14.14" }, { name = "uv-dynamic-versioning", specifier = "==0.14.0" }, ] - [[package]] name = "annotated-types" version = "0.7.0" @@ -662,49 +661,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" } +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/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" } -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]] @@ -908,8 +899,7 @@ name = "mcp" source = { editable = "." } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, { name = "mcp-types" }, { name = "opentelemetry-api" }, @@ -971,8 +961,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 = "mcp-types", editable = "src/mcp-types" }, { name = "opentelemetry-api", specifier = ">=1.28.0" }, @@ -1031,7 +1020,7 @@ source = { editable = "examples/servers/everything-server" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1048,7 +1037,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" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1083,7 +1072,7 @@ source = { editable = "examples/servers/simple-auth" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1102,7 +1091,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" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, @@ -1184,7 +1173,7 @@ source = { editable = "examples/servers/simple-pagination" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1199,7 +1188,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" }, ] @@ -1217,7 +1206,7 @@ source = { editable = "examples/servers/simple-prompt" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1232,7 +1221,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" }, ] @@ -1250,7 +1239,7 @@ source = { editable = "examples/servers/simple-resource" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1265,7 +1254,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" }, ] @@ -1283,7 +1272,7 @@ source = { editable = "examples/servers/simple-streamablehttp" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1300,7 +1289,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" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1320,7 +1309,7 @@ source = { editable = "examples/servers/simple-streamablehttp-stateless" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1337,7 +1326,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" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1357,7 +1346,7 @@ source = { editable = "examples/servers/simple-tool" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, ] @@ -1372,7 +1361,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" }, ] @@ -1430,7 +1419,7 @@ source = { editable = "examples/servers/sse-polling-demo" } dependencies = [ { name = "anyio" }, { name = "click" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1447,7 +1436,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" }, { name = "starlette" }, { name = "uvicorn" }, @@ -2765,6 +2754,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" From f0af3b97d84e35b3f859d430e211e25691dbc320 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:18:03 +0000 Subject: [PATCH 2/7] Update conformance client to httpx2 --- .github/actions/conformance/client.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 39150add5d..0784ef190d 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -38,7 +38,7 @@ from typing import Any, cast from urllib.parse import parse_qs, urlparse -import httpx +import httpx2 import mcp_types as types from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import AnyUrl @@ -151,7 +151,7 @@ async def handle_redirect(self, authorization_url: str) -> None: """Fetch the authorization URL and extract the auth code from the redirect.""" logger.debug(f"Fetching authorization URL: {authorization_url}") - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: response = await client.get( authorization_url, follow_redirects=False, @@ -486,13 +486,13 @@ async def run_enterprise_managed_authorization(server_url: str) -> None: # learn it from the harness's PRM document (RFC 9728); production # deployments would supply it as static configuration instead. prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0] - async with httpx.AsyncClient(timeout=30.0) as http: + async with httpx2.AsyncClient(timeout=30.0) as http: prm = (await http.get(prm_url)).raise_for_status().json() as_issuer = prm["authorization_servers"][0] async def fetch_id_jag(audience: str, resource: str) -> str: """Leg 1 - RFC 8693 token-exchange at the enterprise IdP.""" - async with httpx.AsyncClient(timeout=30.0) as http: + async with httpx2.AsyncClient(timeout=30.0) as http: resp = await http.post( idp_token_endpoint, data={ @@ -563,9 +563,9 @@ async def run_auth_code_client(server_url: str) -> None: await _run_auth_session(server_url, oauth_auth) -async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None: +async def _run_auth_session(server_url: str, oauth_auth: httpx2.Auth) -> None: """Common session logic for all OAuth flows.""" - http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) + http_client = httpx2.AsyncClient(auth=oauth_auth, timeout=30.0) transport = streamable_http_client(url=server_url, http_client=http_client) async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client: logger.debug("Initialized successfully") From c5d54b238e0fced0abed85852f329e6e58731ac5 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:34:06 +0000 Subject: [PATCH 3/7] Convert httpx usage added on main to httpx2 The rebase onto main picked up files added since the swap (stories examples, identity-assertion client/docs, client probe, docs_src tutorials) that still imported httpx. Apply the same httpx -> httpx2 rename to them, update the migration guide's mcp-types and identity-assertion sections to name httpx2, and document the certifi -> truststore TLS verification change. --- docs/advanced/identity-assertion.md | 4 +- docs/advanced/oauth-clients.md | 14 +-- docs/client/transports.md | 19 ++-- docs/migration.md | 13 ++- docs_src/client_transports/tutorial003.py | 6 +- docs_src/identity_assertion/tutorial001.py | 4 +- docs_src/oauth_clients/tutorial001.py | 4 +- docs_src/oauth_clients/tutorial002.py | 4 +- .../clients/identity_assertion_client.py | 4 +- examples/stories/_harness.py | 16 ++-- examples/stories/_hosting.py | 2 +- examples/stories/_shared/auth.py | 8 +- examples/stories/bearer_auth/README.md | 8 +- examples/stories/bearer_auth/client.py | 14 +-- examples/stories/identity_assertion/README.md | 4 +- examples/stories/identity_assertion/client.py | 6 +- examples/stories/json_response/README.md | 4 +- examples/stories/json_response/client.py | 4 +- examples/stories/legacy_routing/README.md | 2 +- examples/stories/manifest.toml | 2 +- examples/stories/oauth/README.md | 10 +-- examples/stories/oauth/client.py | 6 +- .../oauth_client_credentials/README.md | 6 +- .../oauth_client_credentials/client.py | 8 +- examples/stories/sse_polling/README.md | 2 +- examples/stories/standalone_get/README.md | 2 +- examples/stories/stateless_legacy/README.md | 2 +- src/mcp/client/_probe.py | 2 +- .../auth/extensions/identity_assertion.py | 12 +-- .../extensions/test_identity_assertion.py | 86 +++++++++---------- tests/client/test_auth.py | 4 +- tests/client/test_probe.py | 4 +- tests/docs_src/test_asgi.py | 32 +++---- tests/docs_src/test_authorization.py | 18 ++-- tests/docs_src/test_identity_assertion.py | 16 ++-- tests/docs_src/test_oauth_clients.py | 14 +-- tests/examples/conftest.py | 10 +-- tests/examples/test_stories_smoke.py | 2 +- .../lowlevel/test_client_connect.py | 26 +++--- .../transports/test_hosting_http_modern.py | 12 +-- tests/server/auth/test_identity_assertion.py | 24 +++--- 41 files changed, 224 insertions(+), 216 deletions(-) diff --git a/docs/advanced/identity-assertion.md b/docs/advanced/identity-assertion.md index 7e73183616..d375c1b2bf 100644 --- a/docs/advanced/identity-assertion.md +++ b/docs/advanced/identity-assertion.md @@ -19,7 +19,7 @@ Everything below is the second request: the client that sends it and the authori ## The client -**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport. +**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx2.Auth`: construct one, put it on `auth=`, hand the `httpx2.AsyncClient` to the transport. ```python title="client.py" hl_lines="49-50 53-61" --8<-- "docs_src/identity_assertion/tutorial001.py" @@ -139,7 +139,7 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I * [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**. * Obtaining the ID-JAG is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant, and the SDK does both sides of that. -* `IdentityAssertionOAuthProvider` is another `httpx.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token. +* `IdentityAssertionOAuthProvider` is another `httpx2.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token. * The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character. * Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's. diff --git a/docs/advanced/oauth-clients.md b/docs/advanced/oauth-clients.md index 698a08f4f1..322ac46fdc 100644 --- a/docs/advanced/oauth-clients.md +++ b/docs/advanced/oauth-clients.md @@ -2,7 +2,7 @@ Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`. -**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it. +**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx2.Auth`, the standard httpx2 hook for "do something to every request". You attach it to an `httpx2.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it. This chapter is the client side. Making your own server demand a token is **[Authorization](authorization.md)**. @@ -68,9 +68,9 @@ A real client runs a small local HTTP server on the redirect URI instead of call ### Into the `Client` -Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. +Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. -`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](../client/transports.md)**. +`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx2.AsyncClient` you bring. That layering is **[Client transports](../client/transports.md)**. ## What the provider does for you @@ -95,7 +95,7 @@ The repository ships the live version. `examples/servers/simple-auth/` runs a st A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow. -`ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the human: +`ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the human: ```python title="client.py" hl_lines="4 27-33" --8<-- "docs_src/oauth_clients/tutorial002.py" @@ -105,7 +105,7 @@ What changed: * No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely. * `scopes` is a space-separated string, the OAuth wire format. -* Everything downstream is identical: the same `TokenStorage`, the same `httpx.AsyncClient(auth=...)`, the same `streamable_http_client`. +* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`. By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two. @@ -125,11 +125,11 @@ There is one more no-human situation: the client belongs to an enterprise whose When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange. -Not everything is a flow error. The network can still fail; those are ordinary `httpx` exceptions and pass through untouched. +Not everything is a flow error. The network can still fail; those are ordinary `httpx2` exceptions and pass through untouched. ## Recap -* `OAuthClientProvider` is an `httpx.Auth`. Put it on an `httpx.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened. +* `OAuthClientProvider` is an `httpx2.Auth`. Put it on an `httpx2.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened. * You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair. * `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens. * Discovery, dynamic registration, PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours. diff --git a/docs/client/transports.md b/docs/client/transports.md index 1503979a3a..15884e4396 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi --8<-- "docs_src/client_transports/tutorial002.py" ``` -That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. +That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. !!! check A `Client` you have constructed is **not** connected. Construction only picks the transport; @@ -41,9 +41,9 @@ That is the whole production client. `Client` wraps the URL in `streamable_http_ Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free. -### Bring your own `httpx.AsyncClient` +### Bring your own `httpx2.AsyncClient` -The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`: +The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx2.AsyncClient` yourself and hand it to `streamable_http_client`: ```python title="client.py" hl_lines="8-14" --8<-- "docs_src/client_transports/tutorial003.py" @@ -51,7 +51,7 @@ The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a dif Two things to notice: -* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create. +* You own the `httpx2.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create. * `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else. !!! warning @@ -63,12 +63,13 @@ Two things to notice: TypeError: streamable_http_client() got an unexpected keyword argument 'headers' ``` - Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in. + Everything HTTP-shaped now lives on the one `httpx2.AsyncClient` you pass in. !!! info - If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection - limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: - `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](../advanced/oauth-clients.md)**. + `httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth, + proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes + nothing away. It is also where OAuth plugs in: + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](../advanced/oauth-clients.md)**. ## stdio @@ -106,7 +107,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair * `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. * `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. -* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. +* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. * stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone. * The subprocess gets an allow-listed environment, not yours; `env=` adds to it. * A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol. diff --git a/docs/migration.md b/docs/migration.md index acf4711fa6..3759cb9f66 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -18,7 +18,7 @@ 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 +`http_client` (or build an `httpx2.Auth` subclass for `auth`), import from `httpx2`: **Before (v1):** @@ -41,6 +41,13 @@ http_client = httpx2.AsyncClient(follow_redirects=True) changes. To consume SSE directly, use `httpx2.EventSource` (or `AsyncClient.sse()`) instead of the `httpx-sse` helpers. +TLS verification also changes: `httpx` validated certificates against the +bundled `certifi` CA list, while `httpx2` validates against the operating +system trust store via [`truststore`](https://pypi.org/project/truststore/). +If your environment has no usable system CA store (some minimal containers), +or you relied on certifi's bundle specifically, pass an explicit +`verify=ssl_context` to your `httpx2.AsyncClient`. + ### `MCPServer.call_tool()` returns `CallToolResult` `MCPServer.call_tool()` now returns a `CallToolResult` (or an @@ -253,7 +260,7 @@ unchanged. Only the `mcp.types` submodule and `mcp.shared.version` were removed. package's API reference is at [`mcp_types`](api/mcp_types/index.md). **Why:** keeping the wire types in their own package lets tooling and lightweight clients -depend on the protocol schema without pulling in `httpx`, `starlette`, `uvicorn`, and the +depend on the protocol schema without pulling in `httpx2`, `starlette`, `uvicorn`, and the rest of the server/transport stack. **Before (v1):** @@ -1604,7 +1611,7 @@ Under OIDC, omitting `application_type` defaults to `"web"`, which an authorizat The SDK now supports [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)'s enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) jwt-bearer grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged. -On the client, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) is an `httpx.Auth` that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async `assertion_provider(audience, resource)` callback - `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's identifier (the ID-JAG `resource` claim): +On the client, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) is an `httpx2.Auth` that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async `assertion_provider(audience, resource)` callback - `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's identifier (the ID-JAG `resource` claim): ```python from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider diff --git a/docs_src/client_transports/tutorial003.py b/docs_src/client_transports/tutorial003.py index 0134a72561..4df055e229 100644 --- a/docs_src/client_transports/tutorial003.py +++ b/docs_src/client_transports/tutorial003.py @@ -1,13 +1,13 @@ -import httpx +import httpx2 from mcp import Client from mcp.client.streamable_http import streamable_http_client async def main() -> None: - async with httpx.AsyncClient( + async with httpx2.AsyncClient( headers={"Authorization": "Bearer ..."}, - timeout=httpx.Timeout(30.0, read=300.0), + timeout=httpx2.Timeout(30.0, read=300.0), follow_redirects=True, ) as http_client: transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client) diff --git a/docs_src/identity_assertion/tutorial001.py b/docs_src/identity_assertion/tutorial001.py index 8a7e9a050b..3012f1ed17 100644 --- a/docs_src/identity_assertion/tutorial001.py +++ b/docs_src/identity_assertion/tutorial001.py @@ -1,7 +1,7 @@ import time import uuid -import httpx +import httpx2 import jwt from mcp import Client @@ -62,7 +62,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str: async def main() -> None: - async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client: transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) async with Client(transport) as client: result = await client.list_tools() diff --git a/docs_src/oauth_clients/tutorial001.py b/docs_src/oauth_clients/tutorial001.py index 4360379a29..d150dc5da6 100644 --- a/docs_src/oauth_clients/tutorial001.py +++ b/docs_src/oauth_clients/tutorial001.py @@ -1,6 +1,6 @@ from urllib.parse import parse_qs, urlparse -import httpx +import httpx2 from pydantic import AnyUrl from mcp import Client @@ -55,7 +55,7 @@ async def wait_for_callback() -> AuthorizationCodeResult: async def main() -> None: - async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client: transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) async with Client(transport) as client: result = await client.list_tools() diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py index b5b052c962..99865c6aea 100644 --- a/docs_src/oauth_clients/tutorial002.py +++ b/docs_src/oauth_clients/tutorial002.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from mcp import Client from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider @@ -34,7 +34,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None async def main() -> None: - async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client: transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) async with Client(transport) as client: result = await client.list_tools() diff --git a/examples/snippets/clients/identity_assertion_client.py b/examples/snippets/clients/identity_assertion_client.py index 218df4bcfc..19cde274c5 100644 --- a/examples/snippets/clients/identity_assertion_client.py +++ b/examples/snippets/clients/identity_assertion_client.py @@ -16,7 +16,7 @@ import asyncio -import httpx +import httpx2 from mcp import ClientSession from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider @@ -66,7 +66,7 @@ async def main() -> None: scope="user", ) - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client: + async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client: async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write): async with ClientSession(read, write) as session: await session.initialize() diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index c7036acd68..3ef52b3239 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -18,7 +18,7 @@ from urllib.parse import urlsplit import anyio -import httpx +import httpx2 from mcp_types.version import LATEST_MODERN_VERSION from mcp import StdioServerParameters, stdio_client @@ -38,8 +38,8 @@ TargetFactory = Callable[[], Target] """Yields a FRESH target against the same server/app on every call (``multi_connection`` stories).""" -AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth] -"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam).""" +AuthBuilder = Callable[[httpx2.AsyncClient], httpx2.Auth] +"""Builds an ``httpx2.Auth`` bound to the in-process HTTP client (auth-story harness seam).""" def argv_after(flag: str, *, default: str | None = None) -> str: @@ -127,8 +127,8 @@ def _story_cfg(name: str) -> dict[str, Any]: return manifest["defaults"] | manifest["story"].get(name, {}) -def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory: - """Fresh streamable-HTTP transports over an already-authed ``httpx`` client.""" +def _authed_targets(url: str, http: httpx2.AsyncClient) -> TargetFactory: + """Fresh streamable-HTTP transports over an already-authed ``httpx2`` client.""" return lambda: streamable_http_client(url, http_client=http) @@ -176,13 +176,13 @@ async def _run() -> None: if url is None or (build_auth is None and not cfg["needs_http"]): await main(targets if cfg["multi_connection"] else targets(), mode=mode) return - # Auth and needs_http stories want the raw httpx client underneath the transport: - # build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist + # Auth and needs_http stories want the raw httpx2 client underneath the transport: + # build_auth threads an httpx2.Auth onto it (Client(url, auth=...) doesn't exist # yet), and needs_http stories assert on raw responses, so root the client at the # server origin and relative paths like "/mcp" resolve. parts = urlsplit(url) base = f"{parts.scheme}://{parts.netloc}" - http = await stack.enter_async_context(httpx.AsyncClient(base_url=base)) + http = await stack.enter_async_context(httpx2.AsyncClient(base_url=base)) make = targets if build_auth is not None: http.auth = build_auth(http) diff --git a/examples/stories/_hosting.py b/examples/stories/_hosting.py index 041778677d..f7ef1ad56d 100644 --- a/examples/stories/_hosting.py +++ b/examples/stories/_hosting.py @@ -25,7 +25,7 @@ AppFactory = Callable[[], Starlette] NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False) -"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header.""" +"""Harness servers bind 127.0.0.1 and the in-process httpx2 client sends no Origin header.""" def argv_after(flag: str, *, default: str | None = None) -> str: diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py index 3bedcd3ab9..35e997242f 100644 --- a/examples/stories/_shared/auth.py +++ b/examples/stories/_shared/auth.py @@ -10,7 +10,7 @@ import time from urllib.parse import parse_qs, urlsplit -import httpx +import httpx2 from pydantic import AnyHttpUrl from mcp.server.auth.provider import ( @@ -49,14 +49,14 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None class HeadlessOAuth: - """Completes the authorize redirect in-process via the bound ``httpx`` client.""" + """Completes the authorize redirect in-process via the bound ``httpx2`` client.""" def __init__(self) -> None: self.authorize_url: str | None = None - self._http: httpx.AsyncClient | None = None + self._http: httpx2.AsyncClient | None = None self._result = AuthorizationCodeResult(code="", state=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: diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md index d1d556f94f..9e809fad05 100644 --- a/examples/stories/bearer_auth/README.md +++ b/examples/stories/bearer_auth/README.md @@ -31,7 +31,7 @@ uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp `Client(url)` has no `auth=` passthrough, so a target built from a bare URL can't carry the token. Both runners close that gap the same way: `run_client` (above) and the pytest harness thread the module's `build_auth` export onto the -`httpx.AsyncClient` underneath the transport and hand `main` a target that is +`httpx2.AsyncClient` underneath the transport and hand `main` a target that is already routed through it. ## Try it without the SDK client @@ -56,9 +56,9 @@ kill "$SERVER_PID" transport that already carries the bearer token; nothing in the body knows auth exists. - `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is - five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK + five lines of `httpx2.Auth`. `Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands, the auth has to be threaded onto the - `httpx.AsyncClient` underneath the transport, outside `main`. + `httpx2.AsyncClient` underneath the transport, outside `main`. - `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the whole recipe; `streamable_http_app()` reads those constructor kwargs and mounts the bearer gate + PRM route. @@ -73,7 +73,7 @@ kill "$SERVER_PID" ## Caveats - `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default - for localhost binds; the harness disables it because the in-process httpx + for localhost binds; the harness disables it because the in-process httpx2 client sends no `Origin` header. Drop the kwarg for a real deployment. - `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin). If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's diff --git a/examples/stories/bearer_auth/client.py b/examples/stories/bearer_auth/client.py index 5c419a0716..df39cd7c7b 100644 --- a/examples/stories/bearer_auth/client.py +++ b/examples/stories/bearer_auth/client.py @@ -2,7 +2,7 @@ from collections.abc import Generator -import httpx +import httpx2 from mcp.client import Client from stories._harness import Target, run_client @@ -10,22 +10,22 @@ from .server import DEMO_TOKEN, REQUIRED_SCOPE -class StaticBearerAuth(httpx.Auth): - """``httpx.Auth`` that attaches a fixed ``Authorization: Bearer `` to every request.""" +class StaticBearerAuth(httpx2.Auth): + """``httpx2.Auth`` that attaches a fixed ``Authorization: Bearer `` to every request.""" def __init__(self, token: str) -> None: self.token = token - def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]: request.headers["Authorization"] = f"Bearer {self.token}" yield request -def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: - """The demo bearer token as an ``httpx.Auth``. +def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: + """The demo bearer token as an ``httpx2.Auth``. ``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying - ``httpx.AsyncClient`` and the target ``main`` receives is already routed through it. + ``httpx2.AsyncClient`` and the target ``main`` receives is already routed through it. """ return StaticBearerAuth(DEMO_TOKEN) diff --git a/examples/stories/identity_assertion/README.md b/examples/stories/identity_assertion/README.md index 34746ec09c..1002a5f757 100644 --- a/examples/stories/identity_assertion/README.md +++ b/examples/stories/identity_assertion/README.md @@ -30,7 +30,7 @@ uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/ `Client(url)` has no `auth=` passthrough, so both runners thread the module's `build_auth` export (an `IdentityAssertionOAuthProvider`) onto the -`httpx.AsyncClient` underneath the transport and hand `main` a target that is +`httpx2.AsyncClient` underneath the transport and hand `main` a target that is already routed through it. ## Try it without the SDK client @@ -57,7 +57,7 @@ kill "$SERVER_PID" return a fresh ID-JAG. In production this is an RFC 8693 token exchange against your IdP; here it calls the stand-in signer in `idp.py`. - `client.py` `build_auth` — `IdentityAssertionOAuthProvider` is the same - `httpx.Auth` shape as every other provider. Note `issuer=ISSUER` with the + `httpx2.Auth` shape as every other provider. Note `issuer=ISSUER` with the trailing slash: the provider compares it to the metadata document's `issuer` by simple string comparison and refuses a mismatch before sending anything. - `server.py` `exchange_identity_assertion` — the whole authorization-server diff --git a/examples/stories/identity_assertion/client.py b/examples/stories/identity_assertion/client.py index bd13909801..fa089da243 100644 --- a/examples/stories/identity_assertion/client.py +++ b/examples/stories/identity_assertion/client.py @@ -1,6 +1,6 @@ """HTTP-only SEP-990: `build_auth` presents an IdP-issued ID-JAG (jwt-bearer grant); `whoami` proves the subject.""" -import httpx +import httpx2 from mcp.client import Client from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider @@ -27,14 +27,14 @@ async def fetch_id_jag(audience: str, resource: str) -> str: ) -def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: +def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: """An `IdentityAssertionOAuthProvider` for the pre-registered confidential client. `issuer` is configuration, not discovery: the provider fetches metadata from this issuer's well-known and never asks the MCP server which authorization server to use. The string must equal the `issuer` its metadata serves byte for byte (note the trailing slash). `Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying - `httpx.AsyncClient` and hands `main` a target that is already routed through it. + `httpx2.AsyncClient` and hands `main` a target that is already routed through it. """ return IdentityAssertionOAuthProvider( server_url=MCP_URL, diff --git a/examples/stories/json_response/README.md b/examples/stories/json_response/README.md index e7dff00f8c..5acd72a586 100644 --- a/examples/stories/json_response/README.md +++ b/examples/stories/json_response/README.md @@ -34,7 +34,7 @@ kill "$SERVER_PID" - `client.py` `main` — `async with Client(target, mode=mode) as client:` is an ordinary high-level client; nothing about JSON mode is visible from this side. - The same `main` also takes the raw `httpx.AsyncClient` so it can prove what + The same `main` also takes the raw `httpx2.AsyncClient` so it can prove what the wire looks like underneath. - `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize @@ -52,7 +52,7 @@ kill "$SERVER_PID" ## Caveats - DNS-rebinding protection is on by default; the harness disables it via - `NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header. + `NO_DNS_REBIND` because the in-process httpx2 client sends no `Origin` header. - The `streamable_http_app()` call shape here will move when the free-function entry lands (see `_hosting.py`). - `Mcp-Name` is omitted for `tools/list` because the SDK only emits it on diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py index 08af5ef914..8cbfed3fce 100644 --- a/examples/stories/json_response/client.py +++ b/examples/stories/json_response/client.py @@ -5,7 +5,7 @@ asserts the response is a single ``application/json`` body with no session id. """ -import httpx +import httpx2 from mcp_types import TextContent from mcp_types.version import LATEST_MODERN_VERSION @@ -37,7 +37,7 @@ } -async def main(target: Target, *, mode: str = "auto", http: httpx.AsyncClient) -> None: +async def main(target: Target, *, mode: str = "auto", http: httpx2.AsyncClient) -> None: async with Client(target, mode=mode) as client: assert client.protocol_version == LATEST_MODERN_VERSION diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md index 66f839c0a2..857b3270d1 100644 --- a/examples/stories/legacy_routing/README.md +++ b/examples/stories/legacy_routing/README.md @@ -92,7 +92,7 @@ eras need different auth, rate limits, or scaling. - `ctx.request_context.protocol_version` is the interim 2-hop reach; a later release will shorten it. - DNS-rebinding protection is on by default; the harness disables it - (`NO_DNS_REBIND`) because the in-process httpx client sends no `Origin`. + (`NO_DNS_REBIND`) because the in-process httpx2 client sends no `Origin`. Drop the kwarg for a real deployment. - `mcp.shared.inbound` is a deep import path — a shorter re-export is planned before beta. diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 1ba2fe862a..54fe3da949 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -9,7 +9,7 @@ status = "current" # "current" | "legacy" | "deprec lowlevel = true # also run main against server_lowlevel.build_server()/build_app() server_export = "factory" # "factory" -> build_server() | "app" -> build_app() multi_connection = false # main(target, ...) vs main(targets, ...); targets() -> fresh target per call -needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only) +needs_http = false # main(..., http=) gets the raw httpx2.AsyncClient (http-asgi only) timeout_s = 30 mcp_path = "/mcp" fixed_port = 0 # `client --http` self-host port; 0 = an OS-assigned free port diff --git a/examples/stories/oauth/README.md b/examples/stories/oauth/README.md index a773a7851c..e34dce7ce3 100644 --- a/examples/stories/oauth/README.md +++ b/examples/stories/oauth/README.md @@ -6,7 +6,7 @@ auth_server_provider=...)` constructor call co-hosts the RFC 9728 protected-resource metadata route, the AS routes (`/register`, `/authorize`, `/token`, `/.well-known/oauth-authorization-server`) and the bearer-gated `/mcp` endpoint on a single Starlette app. On the **client** side: -`OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by +`OAuthClientProvider` is an `httpx2.Auth` that reacts to the first `401` by walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange → bearer retry — all inside the first awaited request, with no user-visible `UnauthorizedError`. @@ -39,7 +39,7 @@ straight back with `?code=...`; without it the authorize step returns `Client(url)` has no `auth=` passthrough, so a target built from a bare URL can't carry the flow. Both runners close that gap the same way: `run_client` -(above) and the pytest harness build an authed `httpx.AsyncClient` from +(above) and the pytest harness build an authed `httpx2.AsyncClient` from this module's `build_auth` export and hand `main` targets that are already routed through it. @@ -57,9 +57,9 @@ routed through it. request — no second `/authorize`, no second `/register`. The demo AS mints a fresh `client_id` per DCR call, so `whoami` returning the *same* `client_id` is the reuse proof. -- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx.Auth`. +- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx2.Auth`. `Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands - the auth has to be threaded onto the underlying `httpx.AsyncClient` by hand. + the auth has to be threaded onto the underlying `httpx2.AsyncClient` by hand. - **`server.py` — `MCPServer(auth=..., auth_server_provider=...)`.** The constructor wires everything; `streamable_http_app()` reads it back. (Don't also pass `token_verifier=` — `auth_server_provider` and `token_verifier` are @@ -74,7 +74,7 @@ routed through it. ## Caveats - `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default - and the in-process httpx bridge sends no `Origin` header. Drop the kwarg for a + and the in-process httpx2 bridge sends no `Origin` header. Drop the kwarg for a real deployment. - `HeadlessOAuth` only works because the demo AS auto-consents; a real `redirect_handler` would open a browser and a real `callback_handler` would diff --git a/examples/stories/oauth/client.py b/examples/stories/oauth/client.py index c55307f633..18ff8c6ff8 100644 --- a/examples/stories/oauth/client.py +++ b/examples/stories/oauth/client.py @@ -1,6 +1,6 @@ """HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`.""" -import httpx +import httpx2 from pydantic import AnyUrl from mcp.client import Client @@ -14,11 +14,11 @@ from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage -def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth: +def build_auth(http_client: httpx2.AsyncClient) -> httpx2.Auth: """An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly. `Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying - `httpx.AsyncClient` and every target `main` receives is already routed through it. + `httpx2.AsyncClient` and every target `main` receives is already routed through it. """ headless = HeadlessOAuth() headless.bind(http_client) diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md index 8cd5a5b82c..d58b004c90 100644 --- a/examples/stories/oauth_client_credentials/README.md +++ b/examples/stories/oauth_client_credentials/README.md @@ -32,7 +32,7 @@ the client and server side. - `client.py` `main` — opens with `async with Client(target, mode=mode) as client:` and that's the whole program. `target` is a transport that already - carries the OAuth `httpx.Auth`; the body never touches a token. + carries the OAuth `httpx2.Auth`; the body never touches a token. - `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider` config is all the caller writes; the SDK does RFC 9728 PRM → RFC 8414 AS-metadata discovery and token exchange on the first 401. @@ -50,13 +50,13 @@ the client and server side. - `Client(url, auth=build_auth(http))` is the ergonomic the SDK is missing — `Client(url)` has no `auth=` passthrough. Until it lands, the authed - `httpx.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has + `httpx2.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has to be built *outside* `main` and handed in as `target`; both `run_client` (the standalone `--http` run) and the test harness do that from the `build_auth` export. - `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default for localhost binds; the harness disables it because the in-process - httpx client sends no `Origin` header. Drop the kwarg for a real deployment. + httpx2 client sends no `Origin` header. Drop the kwarg for a real deployment. - `OAuthMetadata.authorization_endpoint` is a required field even though a `client_credentials`-only AS has no authorize endpoint; the server sets a dummy URL. diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py index 318523ee70..86d4057dc1 100644 --- a/examples/stories/oauth_client_credentials/client.py +++ b/examples/stories/oauth_client_credentials/client.py @@ -1,6 +1,6 @@ """HTTP-only: ``build_auth`` returns a ``ClientCredentialsOAuthProvider``; ``whoami`` round-trips client_id + scopes.""" -import httpx +import httpx2 from mcp.client import Client from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider @@ -13,12 +13,12 @@ from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE -def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: - """The ``httpx.Auth`` for the ``client_credentials`` grant — five lines of provider config. +def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: + """The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config. The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST → Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the - harness threads this onto the transport's ``httpx.AsyncClient`` and hands ``main`` the + harness threads this onto the transport's ``httpx2.AsyncClient`` and hands ``main`` the already-authed ``target``. """ return ClientCredentialsOAuthProvider( diff --git a/examples/stories/sse_polling/README.md b/examples/stories/sse_polling/README.md index ddd1b61884..026ba3ce07 100644 --- a/examples/stories/sse_polling/README.md +++ b/examples/stories/sse_polling/README.md @@ -57,7 +57,7 @@ kill "$SERVER_PID" release; this story calls it directly because the event-store and retry-interval kwargs are the point. - DNS-rebinding protection is disabled (`transport_security=NO_DNS_REBIND`) - because the in-process httpx client sends no `Origin` header. Drop the kwarg + because the in-process httpx2 client sends no `Origin` header. Drop the kwarg for a real deployment. - `event_store.py` here is example-grade only (sequential IDs, no eviction). A production server would back the `EventStore` interface with persistent diff --git a/examples/stories/standalone_get/README.md b/examples/stories/standalone_get/README.md index c460e14911..5eba92a3f4 100644 --- a/examples/stories/standalone_get/README.md +++ b/examples/stories/standalone_get/README.md @@ -45,7 +45,7 @@ kill "$SERVER_PID" ## Caveats - DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND` - because the in-process httpx client sends no `Origin` header. Drop the kwarg + because the in-process httpx2 client sends no `Origin` header. Drop the kwarg for a real deployment. - Neither `MCPServer` nor lowlevel `Server` auto-advertises `resources.listChanged: true` in capabilities, and `MCPServer` exposes no knob diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md index 7fc1630ce9..73dce711c3 100644 --- a/examples/stories/stateless_legacy/README.md +++ b/examples/stories/stateless_legacy/README.md @@ -41,7 +41,7 @@ kill "$SERVER_PID" ## Caveats - `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default - for localhost binds; the harness disables it because the in-process httpx + for localhost binds; the harness disables it because the in-process httpx2 client sends no `Origin` header. Drop the kwarg for a real deployment. - `streamable_http_app()` reshapes in a later release; the call is isolated in `build_app()` so the change touches one line per server file. diff --git a/src/mcp/client/_probe.py b/src/mcp/client/_probe.py index 39a5c52964..9459891bb0 100644 --- a/src/mcp/client/_probe.py +++ b/src/mcp/client/_probe.py @@ -67,7 +67,7 @@ async def negotiate_auto(session: ClientSession) -> None: raise # server is modern-only and disjoint — real incompatibility await session.initialize() # every other rpc-error → legacy (the denylist) return - # any other exception (httpx.TransportError, ConnectionError, anyio errors, + # any other exception (httpx2.TransportError, ConnectionError, anyio errors, # RuntimeError from adopt) → propagate try: result = types.DiscoverResult.model_validate(raw) diff --git a/src/mcp/client/auth/extensions/identity_assertion.py b/src/mcp/client/auth/extensions/identity_assertion.py index 2d97e5eff2..17738a70bb 100644 --- a/src/mcp/client/auth/extensions/identity_assertion.py +++ b/src/mcp/client/auth/extensions/identity_assertion.py @@ -26,7 +26,7 @@ from urllib.parse import quote, urlsplit import anyio -import httpx +import httpx2 from mcp.client.auth import OAuthFlowError, OAuthTokenError, TokenStorage from mcp.client.auth.utils import ( @@ -56,8 +56,8 @@ def _origin(url: str) -> tuple[str, str, int | None]: return (parsed.scheme, parsed.hostname or "", port) -class IdentityAssertionOAuthProvider(httpx.Auth): - """`httpx.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS. +class IdentityAssertionOAuthProvider(httpx2.Auth): + """`httpx2.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS. The authorization server `issuer` is fixed at construction; metadata is fetched from its RFC 8414 well-known and the ID-JAG and client secret are sent only to that issuer's token @@ -136,7 +136,7 @@ def __init__( self._lock = anyio.Lock() self._initialized = False - def _build_token_request(self, scope: str | None, assertion: str) -> httpx.Request: + def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Request: """Build the RFC 7523 jwt-bearer token request, applying confidential-client auth.""" assert self._token_endpoint is not None assert self._client.client_id is not None and self._client.client_secret is not None @@ -157,9 +157,9 @@ def _build_token_request(self, scope: str | None, assertion: str) -> httpx.Reque headers["Authorization"] = f"Basic {credentials}" else: data["client_secret"] = self._client.client_secret - return httpx.Request("POST", self._token_endpoint, data=data, headers=headers) + return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers) - 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]: async with self._lock: if not self._initialized: self._tokens = await self._storage.get_tokens() diff --git a/tests/client/auth/extensions/test_identity_assertion.py b/tests/client/auth/extensions/test_identity_assertion.py index 1bc63a1173..d4c965edc5 100644 --- a/tests/client/auth/extensions/test_identity_assertion.py +++ b/tests/client/auth/extensions/test_identity_assertion.py @@ -1,4 +1,4 @@ -"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`. +"""Unit tests for the standalone SEP-990 jwt-bearer `httpx2.Auth`. The provider's authorization server is configuration; these tests assert that authorization-server metadata is fetched only from the configured issuer, that the resource server is never consulted for @@ -9,7 +9,7 @@ import json import urllib.parse -import httpx +import httpx2 import pytest from mcp.client.auth import OAuthFlowError, OAuthTokenError @@ -81,13 +81,13 @@ async def assertion_provider(audience: str, resource: str) -> str: def mock_transport( - requests: list[httpx.Request], + requests: list[httpx2.Request], *, asm: bytes | int = 200, token: bytes | int = 200, rs_first_status: int = 401, rs_first_headers: dict[str, str] | None = None, -) -> httpx.MockTransport: +) -> httpx2.MockTransport: """Build a `MockTransport` that records every request and serves the configured ASM and token. `asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body). @@ -96,41 +96,41 @@ def mock_transport( """ rs_hits = 0 - def handle(request: httpx.Request) -> httpx.Response: + def handle(request: httpx2.Request) -> httpx2.Response: nonlocal rs_hits requests.append(request) host, path = request.url.host, request.url.path if host == "mcp.example.com": rs_hits += 1 if rs_hits == 1: - return httpx.Response(rs_first_status, headers=rs_first_headers or {}) - return httpx.Response(200, json={"ok": True}) + return httpx2.Response(rs_first_status, headers=rs_first_headers or {}) + return httpx2.Response(200, json={"ok": True}) if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH): if isinstance(asm, int): - return httpx.Response(asm) - return httpx.Response(200, content=asm, headers={"content-type": "application/json"}) + return httpx2.Response(asm) + return httpx2.Response(200, content=asm, headers={"content-type": "application/json"}) if host == "auth.example.com" and path == "/token": if isinstance(token, int): - return httpx.Response(token, json={"error": "invalid_grant"}) - return httpx.Response(200, content=token, headers={"content-type": "application/json"}) + return httpx2.Response(token, json={"error": "invalid_grant"}) + return httpx2.Response(200, content=token, headers={"content-type": "application/json"}) raise AssertionError(f"unexpected request: {request.method} {request.url}") # pragma: no cover - return httpx.MockTransport(handle) + return httpx2.MockTransport(handle) -def form(request: httpx.Request) -> dict[str, str]: +def form(request: httpx2.Request) -> dict[str, str]: return dict(urllib.parse.parse_qsl(request.content.decode())) @pytest.mark.anyio async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None: """A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] record: list[tuple[str, str]] = [] storage = InMemoryStorage() auth = make_provider(storage, record=record) - async with httpx.AsyncClient( + async with httpx2.AsyncClient( transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth ) as http: response = await http.post(f"{RS}/mcp") @@ -165,10 +165,10 @@ async def test_resource_server_metadata_is_never_consulted() -> None: This is the by-construction property: the AS is configuration, so the resource server has no input into where the ID-JAG or client secret go. Any GET to the RS host fails the test. """ - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] auth = make_provider() - async with httpx.AsyncClient( + async with httpx2.AsyncClient( transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth ) as http: await http.post(f"{RS}/mcp") @@ -183,11 +183,11 @@ async def test_resource_server_metadata_is_never_consulted() -> None: @pytest.mark.anyio async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None: """If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] record: list[tuple[str, str]] = [] auth = make_provider(record=record) - async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http: + async with httpx2.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http: with pytest.raises(OAuthFlowError, match="No authorization server metadata"): await http.post(f"{RS}/mcp") @@ -200,10 +200,10 @@ async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> @pytest.mark.anyio async def test_asm_5xx_stops_discovery_and_raises() -> None: """A 5xx at the issuer's well-known stops discovery without trying further URLs.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] auth = make_provider() - async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http: + async with httpx2.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http: with pytest.raises(OAuthFlowError, match="No authorization server metadata"): await http.post(f"{RS}/mcp") @@ -213,11 +213,11 @@ async def test_asm_5xx_stops_discovery_and_raises() -> None: @pytest.mark.anyio async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None: """RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] record: list[tuple[str, str]] = [] auth = make_provider(record=record) - async with httpx.AsyncClient( + async with httpx2.AsyncClient( transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth ) as http: with pytest.raises(OAuthFlowError, match="issuer mismatch"): @@ -230,11 +230,11 @@ async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> N @pytest.mark.anyio async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None: """A `token_endpoint` off the configured issuer's origin is refused before any credential is sent.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] record: list[tuple[str, str]] = [] auth = make_provider(record=record) - async with httpx.AsyncClient( + async with httpx2.AsyncClient( transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth ) as http: with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"): @@ -247,7 +247,7 @@ async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_ass @pytest.mark.anyio async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None: """A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] auth = make_provider(scope="mcp") transport = mock_transport( @@ -257,7 +257,7 @@ async def test_403_insufficient_scope_unions_challenged_scope_with_configured() rs_first_status=403, rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'}, ) - async with httpx.AsyncClient(transport=transport, auth=auth) as http: + async with httpx2.AsyncClient(transport=transport, auth=auth) as http: response = await http.post(f"{RS}/mcp") [token_req] = [r for r in requests if r.url.path == "/token"] @@ -268,12 +268,12 @@ async def test_403_insufficient_scope_unions_challenged_scope_with_configured() @pytest.mark.anyio async def test_403_without_insufficient_scope_does_not_reauthorize() -> None: """A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] record: list[tuple[str, str]] = [] auth = make_provider(record=record) transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"}) - async with httpx.AsyncClient(transport=transport, auth=auth) as http: + async with httpx2.AsyncClient(transport=transport, auth=auth) as http: response = await http.post(f"{RS}/mcp") assert response.status_code == 403 @@ -283,20 +283,20 @@ async def test_403_without_insufficient_scope_does_not_reauthorize() -> None: @pytest.mark.anyio async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None: - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] auth = make_provider() - async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http: + async with httpx2.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http: with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"): await http.post(f"{RS}/mcp") @pytest.mark.anyio async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None: - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] auth = make_provider(token_endpoint_auth_method="client_secret_basic") - async with httpx.AsyncClient( + async with httpx2.AsyncClient( transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth ) as http: await http.post(f"{RS}/mcp") @@ -310,12 +310,12 @@ async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None: @pytest.mark.anyio async def test_stored_token_is_reused_without_reauthorizing() -> None: """A valid stored token is sent on the first request; on success no ASM or /token is fetched.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600)) auth = make_provider(storage) transport = mock_transport(requests, rs_first_status=200) - async with httpx.AsyncClient(transport=transport, auth=auth) as http: + async with httpx2.AsyncClient(transport=transport, auth=auth) as http: response = await http.post(f"{RS}/mcp") assert response.status_code == 200 @@ -326,25 +326,25 @@ async def test_stored_token_is_reused_without_reauthorizing() -> None: @pytest.mark.anyio async def test_second_401_re_exchanges_without_refetching_asm() -> None: """ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] record: list[tuple[str, str]] = [] auth = make_provider(record=record) rs_hits = 0 - def handle(request: httpx.Request) -> httpx.Response: + def handle(request: httpx2.Request) -> httpx2.Response: nonlocal rs_hits requests.append(request) host, path = request.url.host, request.url.path if host == "mcp.example.com": rs_hits += 1 # First and third RS hits draw a 401; second and fourth succeed. - return httpx.Response(401 if rs_hits in (1, 3) else 200) + return httpx2.Response(401 if rs_hits in (1, 3) else 200) if host == "auth.example.com" and path == ASM_PATH: - return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"}) + return httpx2.Response(200, content=asm_body(), headers={"content-type": "application/json"}) assert host == "auth.example.com" and path == "/token" - return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"}) + return httpx2.Response(200, content=token_body(), headers={"content-type": "application/json"}) - async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handle), auth=auth) as http: await http.post(f"{RS}/mcp") await http.post(f"{RS}/mcp") @@ -358,11 +358,11 @@ def handle(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None: """With no configured scope and no scope in the token response, the stored token records None.""" - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] storage = InMemoryStorage() auth = make_provider(storage, scope=None) - async with httpx.AsyncClient( + async with httpx2.AsyncClient( transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth ) as http: await http.post(f"{RS}/mcp") diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index eaf9fb265d..9e9599f86c 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2862,10 +2862,10 @@ async def test_handle_token_response_backfills_omitted_scope_from_request( @pytest.mark.anyio async def test_handle_token_response_raises_on_non_2xx_with_body(oauth_provider: OAuthClientProvider): - response = httpx.Response( + response = httpx2.Response( 400, json={"error": "invalid_grant"}, - request=httpx.Request("POST", "https://auth.example.com/token"), + request=httpx2.Request("POST", "https://auth.example.com/token"), ) with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"): await oauth_provider._handle_token_response(response) diff --git a/tests/client/test_probe.py b/tests/client/test_probe.py index 34a347fa7d..480294563e 100644 --- a/tests/client/test_probe.py +++ b/tests/client/test_probe.py @@ -16,7 +16,7 @@ from typing import Any, cast import anyio -import httpx +import httpx2 import mcp_types as types import pytest from mcp_types import ( @@ -207,7 +207,7 @@ async def test_a_second_unsupported_version_after_the_corrective_retry_does_not_ @pytest.mark.parametrize( "exc", [ - pytest.param(httpx.ConnectError("connection refused"), id="httpx-connect-error"), + pytest.param(httpx2.ConnectError("connection refused"), id="httpx2-connect-error"), pytest.param(anyio.ClosedResourceError(), id="anyio-closed-resource"), ], ) diff --git a/tests/docs_src/test_asgi.py b/tests/docs_src/test_asgi.py index 93aa502428..f10ee2e22c 100644 --- a/tests/docs_src/test_asgi.py +++ b/tests/docs_src/test_asgi.py @@ -2,7 +2,7 @@ import inspect -import httpx +import httpx2 import pytest from mcp_types import TextContent from starlette.applications import Starlette @@ -50,8 +50,8 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None: async def test_a_request_before_the_session_manager_runs_is_rejected() -> None: """The `!!! check`: nothing starts the session manager except its lifespan.""" - transport = httpx.ASGITransport(app=tutorial001.app) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + transport = httpx2.ASGITransport(app=tutorial001.app) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."): await http.post("/mcp") @@ -76,12 +76,12 @@ async def about(request: Request) -> Response: listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)]) listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)]) - transport = httpx.ASGITransport(app=listed_after) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + transport = httpx2.ASGITransport(app=listed_after) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: assert (await http.get("/about")).status_code == 404 - transport = httpx.ASGITransport(app=listed_before) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + transport = httpx2.ASGITransport(app=listed_before) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: assert (await http.get("/about")).status_code == 200 @@ -127,8 +127,8 @@ async def test_cors_exposes_the_session_id_header() -> None: """tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`.""" (middleware,) = tutorial005.app.user_middleware assert middleware.cls is CORSMiddleware - transport = httpx.ASGITransport(app=tutorial005.app) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + transport = httpx2.ASGITransport(app=tutorial005.app) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: preflight = await http.options( "/mcp", headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"}, @@ -152,8 +152,8 @@ async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None: async def test_the_health_check_answers_outside_the_protocol() -> None: """tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP.""" - transport = httpx.ASGITransport(app=tutorial006.app) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + transport = httpx2.ASGITransport(app=tutorial006.app) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: response = await http.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} @@ -174,11 +174,11 @@ async def test_the_default_app_is_localhost_only() -> None: before any MCP code runs.""" bare = MCPServer("Notes") app = bare.streamable_http_app() - transport = httpx.ASGITransport(app=app) + transport = httpx2.ASGITransport(app=app) async with bare.session_manager.run(): - async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: + async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) - async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http: + async with httpx2.AsyncClient(transport=transport, base_url="http://localhost:8000") as http: wrong_origin = await http.post( "/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"} ) @@ -189,9 +189,9 @@ async def test_the_default_app_is_localhost_only() -> None: async def test_the_documented_browser_origin_works_end_to_end() -> None: """tutorial005: the page's scenario for real. The public hostname, the browser origin, a realistic preflight naming the `Mcp-*` headers, then the actual request.""" - transport = httpx.ASGITransport(app=tutorial005.app) + transport = httpx2.ASGITransport(app=tutorial005.app) async with tutorial005.lifespan(tutorial005.app): - async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: + async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: preflight = await http.options( "/mcp", headers={ diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py index 4c7554ed75..4ad92d914e 100644 --- a/tests/docs_src/test_authorization.py +++ b/tests/docs_src/test_authorization.py @@ -1,6 +1,6 @@ """`docs/advanced/authorization.md`: every claim the page makes, proved against the real SDK.""" -import httpx +import httpx2 import pytest from inline_snapshot import snapshot from mcp_types import TextContent @@ -40,8 +40,8 @@ async def test_the_app_grows_a_protected_resource_metadata_route() -> None: async def test_the_metadata_document_is_built_from_auth_settings() -> None: """tutorial001: `GET` on the well-known route returns the Protected Resource Metadata the page shows.""" - transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: response = await http_client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 assert response.json() == snapshot( @@ -56,8 +56,8 @@ async def test_the_metadata_document_is_built_from_auth_settings() -> None: async def test_a_request_without_a_token_never_reaches_the_protocol() -> None: """The `!!! check`: no `Authorization` header means a 401 that points at the metadata document.""" - transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: response = await http_client.post("/mcp", json={}) assert response.status_code == 401 assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"} @@ -69,8 +69,8 @@ async def test_a_request_without_a_token_never_reaches_the_protocol() -> None: async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None: """tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller.""" - transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"}) assert response.status_code == 401 assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"} @@ -86,11 +86,11 @@ async def test_get_access_token_is_none_outside_an_authenticated_request() -> No async def test_get_access_token_is_the_callers_access_token() -> None: """tutorial002: over Streamable HTTP a valid bearer token reaches the tool as an `AccessToken`.""" url = "http://127.0.0.1:8000/mcp" - transport = httpx.ASGITransport(app=tutorial002.mcp.streamable_http_app()) + transport = httpx2.ASGITransport(app=tutorial002.mcp.streamable_http_app()) headers = {"Authorization": "Bearer alice-token"} async with tutorial002.mcp.session_manager.run(): async with ( - httpx.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client, + httpx2.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client, Client(streamable_http_client(url, http_client=http_client)) as client, ): result = await client.call_tool("whoami", {}) diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py index afcfd83290..ac593b0b77 100644 --- a/tests/docs_src/test_identity_assertion.py +++ b/tests/docs_src/test_identity_assertion.py @@ -3,7 +3,7 @@ import inspect from urllib.parse import parse_qsl -import httpx +import httpx2 import jwt import pytest from inline_snapshot import snapshot @@ -27,21 +27,21 @@ MCP_SERVER_URL = "http://localhost:8001/mcp" -class RecordingASGITransport(httpx.ASGITransport): - """An `httpx.ASGITransport` that appends every (method, path, body) it carries to a shared log.""" +class RecordingASGITransport(httpx2.ASGITransport): + """An `httpx2.ASGITransport` that appends every (method, path, body) it carries to a shared log.""" def __init__(self, app: Starlette, log: list[tuple[str, str, bytes]]) -> None: super().__init__(app=app) self.log = log - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: self.log.append((request.method, request.url.path, request.content)) return await super().handle_async_request(request) async def test_the_provider_is_an_httpx_auth_but_not_an_oauth_client_provider() -> None: """tutorial001: same `auth=` slot as the rest of OAuth clients, but nothing is discovered or registered.""" - assert isinstance(tutorial001.oauth, httpx.Auth) + assert isinstance(tutorial001.oauth, httpx2.Auth) assert not isinstance(tutorial001.oauth, OAuthClientProvider) @@ -132,8 +132,8 @@ async def test_a_replayed_assertion_is_rejected() -> None: async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() -> None: """tutorial002: the flag turns on both the `jwt-bearer` grant type and the grant-profile advertisement.""" - transport = httpx.ASGITransport(app=tutorial002.auth_app) - async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client: + transport = httpx2.ASGITransport(app=tutorial002.auth_app) + async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client: response = await http_client.get("/.well-known/oauth-authorization-server") assert response.status_code == 200 metadata = response.json() @@ -167,7 +167,7 @@ def whoami() -> str: mounts = {"https://auth.example.com": RecordingASGITransport(tutorial002.auth_app, log)} async with mcp.session_manager.run(): async with ( - httpx.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client, + httpx2.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client, Client(streamable_http_client(MCP_SERVER_URL, http_client=http_client)) as client, ): result = await client.call_tool("whoami", {}) diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py index a85eab388f..ec624006d7 100644 --- a/tests/docs_src/test_oauth_clients.py +++ b/tests/docs_src/test_oauth_clients.py @@ -2,7 +2,7 @@ import inspect -import httpx +import httpx2 import pytest from pydantic import AnyUrl, ValidationError @@ -42,8 +42,8 @@ async def test_storage_round_trips_tokens_and_client_info() -> None: async def test_the_provider_is_an_httpx_auth() -> None: - """tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP.""" - assert isinstance(tutorial001.oauth, httpx.Auth) + """tutorial001: `OAuthClientProvider` plugs into httpx2, not into MCP.""" + assert isinstance(tutorial001.oauth, httpx2.Auth) async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None: @@ -66,9 +66,9 @@ async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytes async def test_client_credentials_provider_has_no_human_in_the_loop() -> None: - """tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers.""" + """tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the handlers.""" assert isinstance(tutorial002.oauth, OAuthClientProvider) - assert isinstance(tutorial002.oauth, httpx.Auth) + assert isinstance(tutorial002.oauth, httpx2.Auth) assert tutorial002.oauth.context.redirect_handler is None assert tutorial002.oauth.context.callback_handler is None @@ -92,7 +92,7 @@ async def test_the_three_remaining_keyword_arguments_have_defaults() -> None: async def test_the_one_more_provider_is_private_key_jwt() -> None: - """The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way.""" + """The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx2.Auth`, built the same way.""" provider = PrivateKeyJWTOAuthProvider( server_url="http://localhost:8001/mcp", storage=tutorial002.InMemoryTokenStorage(), @@ -100,7 +100,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None: assertion_provider=static_assertion_provider("a.prebuilt.jwt"), ) assert isinstance(provider, OAuthClientProvider) - assert isinstance(provider, httpx.Auth) + assert isinstance(provider, httpx2.Auth) assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt" diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index 48c5bffa5c..8874654eda 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Any -import httpx +import httpx2 import pytest import stories from mcp_types.version import LATEST_MODERN_VERSION @@ -125,12 +125,12 @@ class Hosted: ``targets`` yields a fresh connection target against that single instance on every call, so state observed by one connection is visible to the next. - ``http`` is the shared raw ``httpx.AsyncClient`` bound to the same ASGI app, + ``http`` is the shared raw ``httpx2.AsyncClient`` bound to the same ASGI app, or ``None`` on the in-memory leg. """ targets: TargetFactory - http: httpx.AsyncClient | None + http: httpx2.AsyncClient | None @pytest.fixture @@ -140,7 +140,7 @@ async def hosted( """Build the leg's server/app once and keep it running for the test. The story's ``main`` owns the ``Client(target, mode=...)`` construction; this - fixture only decides what ``target`` is. Auth stories thread an ``httpx.Auth`` + fixture only decides what ``target`` is. Auth stories thread an ``httpx2.Auth`` onto the bridge client via a module-level ``build_auth(http)`` export. """ for key, value in cfg["env"].items(): @@ -163,7 +163,7 @@ async def hosted( build_auth: AuthBuilder | None = getattr(client_module, "build_auth", None) async with ( app.router.lifespan_context(app), - httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, + httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, ): if build_auth is not None: http_client.auth = build_auth(http_client) diff --git a/tests/examples/test_stories_smoke.py b/tests/examples/test_stories_smoke.py index ed8a26ea44..a2bec46885 100644 --- a/tests/examples/test_stories_smoke.py +++ b/tests/examples/test_stories_smoke.py @@ -32,7 +32,7 @@ ] _REPO_ROOT = Path(__file__).parents[2] -# httpx in the spawned client honours these and tries to mount a SOCKS transport even for +# httpx2 in the spawned client honours these and tries to mount a SOCKS transport even for # 127.0.0.1; strip them so the smoke run is hermetic regardless of the caller's shell. _PROXY_VARS = {v for base in ("all_proxy", "http_proxy", "https_proxy", "ftp_proxy") for v in (base, base.upper())} _ENV = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS} diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py index 69fd5c4e85..eda1b8423c 100644 --- a/tests/interaction/lowlevel/test_client_connect.py +++ b/tests/interaction/lowlevel/test_client_connect.py @@ -5,7 +5,7 @@ that a modern-negotiated session stamps the three-key `io.modelcontextprotocol/*` `_meta` envelope on every subsequent request. Each test drives the highest public surface (`Client`) and observes traffic at a recording seam: `RecordingTransport` for the legacy stream pair, and -`mounted_app`'s httpx event hook for the in-process streamable-HTTP transport. +`mounted_app`'s httpx2 event hook for the in-process streamable-HTTP transport. The fallback test alone hand-plays the server's side of the wire, because no real `Server` answers `server/discover` with -32601. @@ -16,7 +16,7 @@ from contextlib import asynccontextmanager import anyio -import httpx +import httpx2 import mcp_types as types import pytest from mcp_types import ( @@ -64,11 +64,11 @@ async def list_tools( return Server(name, on_list_tools=list_tools) -def _request_recorder() -> tuple[list[httpx.Request], Callable[[httpx.Request], Awaitable[None]]]: - """Return a list and an `on_request` hook that appends each outgoing httpx request to it.""" - captured: list[httpx.Request] = [] +def _request_recorder() -> tuple[list[httpx2.Request], Callable[[httpx2.Request], Awaitable[None]]]: + """Return a list and an `on_request` hook that appends each outgoing httpx2 request to it.""" + captured: list[httpx2.Request] = [] - async def on_request(request: httpx.Request) -> None: + async def on_request(request: httpx2.Request) -> None: captured.append(request) return captured, on_request @@ -100,7 +100,7 @@ async def test_pinned_mode_sends_no_connect_time_traffic() -> None: Requirement `lifecycle:mode:pin-never-handshakes` (sdk-defined): a version pin adopts a synthesized DiscoverResult locally, so no `initialize` and no `server/discover` ever cross - the wire. Asserted at the in-process streamable-HTTP seam via the httpx event hook. + the wire. Asserted at the in-process streamable-HTTP seam via the httpx2 event hook. """ requests, on_request = _request_recorder() @@ -223,21 +223,21 @@ async def test_auto_mode_propagates_a_network_error_from_discover_without_initia Requirement `lifecycle:discover:network-error-raises` (sdk-defined): under the denylist policy every server-sent rpc-error and every transport-layer 4xx falls back to `initialize()`; the only probe failures that reach the caller are real outages — network errors, anyio resource - errors, and the disjoint-modern -32022 case. Exercised here as an `httpx.ConnectError` from + errors, and the disjoint-modern -32022 case. Exercised here as an `httpx2.ConnectError` from the underlying transport, which the policy must not classify as an era verdict. The error reaches the test wrapped in the streamable-http transport's task-group teardown, so `pytest.RaisesGroup` flattens before matching. The probe POST is recorded before the transport raises, so the `initialize` fallback observably did not happen. """ - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: requests.append(request) - raise httpx.ConnectError("connection refused") + raise httpx2.ConnectError("connection refused") with anyio.fail_after(5): - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: - with pytest.RaisesGroup(httpx.ConnectError, flatten_subgroups=True): # pragma: no branch + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + with pytest.RaisesGroup(httpx2.ConnectError, flatten_subgroups=True): # pragma: no branch async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"): raise NotImplementedError("entering the Client should have raised") # pragma: no cover diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 7f69809da4..b136f1a52f 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -429,9 +429,9 @@ async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers() `verbose`-sibling stay out of the headers, and every mirrored value remains in the request body. Asserted at the wire because the client never surfaces the outgoing headers. """ - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - async def on_request(request: httpx.Request) -> None: + async def on_request(request: httpx2.Request) -> None: requests.append(request) discover = DiscoverResult( @@ -474,9 +474,9 @@ async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> No The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header. """ - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - async def on_request(request: httpx.Request) -> None: + async def on_request(request: httpx2.Request) -> None: if json.loads(request.content)["method"] == "tools/call": requests.append(request) @@ -521,9 +521,9 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> server = Server("evict", on_list_tools=list_tools, on_call_tool=call_tool) - tool_calls: list[httpx.Request] = [] + tool_calls: list[httpx2.Request] = [] - async def on_request(request: httpx.Request) -> None: + async def on_request(request: httpx2.Request) -> None: if json.loads(request.content)["method"] == "tools/call": tool_calls.append(request) diff --git a/tests/server/auth/test_identity_assertion.py b/tests/server/auth/test_identity_assertion.py index c83bbe1e84..bef1095a6f 100644 --- a/tests/server/auth/test_identity_assertion.py +++ b/tests/server/auth/test_identity_assertion.py @@ -3,9 +3,9 @@ import secrets import time -import httpx +import httpx2 import pytest -from httpx import ASGITransport +from httpx2 import ASGITransport from pydantic import AnyHttpUrl from starlette.applications import Starlette @@ -121,7 +121,7 @@ def app(provider: IdentityAssertionProvider) -> Starlette: @pytest.fixture async def client(app: Starlette): transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http: + async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http: yield http @@ -160,7 +160,7 @@ def test_build_metadata_advertises_id_jag_profile_when_enabled(): @pytest.mark.anyio -async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient): +async def test_metadata_endpoint_lists_id_jag_profile(client: httpx2.AsyncClient): response = await client.get("/.well-known/oauth-authorization-server") assert response.status_code == 200 body = response.json() @@ -169,7 +169,7 @@ async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient) @pytest.mark.anyio -async def test_identity_assertion_success(client: httpx.AsyncClient, provider: IdentityAssertionProvider): +async def test_identity_assertion_success(client: httpx2.AsyncClient, provider: IdentityAssertionProvider): response = await client.post("/token", data=assertion_form(scope="mcp", resource="https://mcp.example.com/mcp")) assert response.status_code == 200, response.content @@ -189,7 +189,7 @@ async def test_identity_assertion_success(client: httpx.AsyncClient, provider: I @pytest.mark.anyio -async def test_identity_assertion_invalid_assertion(client: httpx.AsyncClient): +async def test_identity_assertion_invalid_assertion(client: httpx2.AsyncClient): response = await client.post("/token", data=assertion_form(assertion="forged")) assert response.status_code == 400 @@ -207,7 +207,7 @@ async def test_identity_assertion_rejected_when_disabled(provider: IdentityAsser ) app = Starlette(routes=routes) transport = ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http: + async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http: response = await http.post("/token", data=assertion_form()) assert response.status_code == 400 @@ -216,7 +216,7 @@ async def test_identity_assertion_rejected_when_disabled(provider: IdentityAsser @pytest.mark.anyio -async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClient, provider: IdentityAssertionProvider): +async def test_identity_assertion_rejects_public_client(client: httpx2.AsyncClient, provider: IdentityAssertionProvider): """A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion.""" provider.clients["public-client"] = OAuthClientInformationFull( client_id="public-client", @@ -238,7 +238,7 @@ async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClien @pytest.mark.anyio async def test_identity_assertion_rejects_secretless_confidential_client( - client: httpx.AsyncClient, provider: IdentityAssertionProvider + client: httpx2.AsyncClient, provider: IdentityAssertionProvider ): """A client registered with a secret-based method but no stored secret fails authentication. @@ -267,7 +267,7 @@ async def test_identity_assertion_rejects_secretless_confidential_client( @pytest.mark.anyio -async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx.AsyncClient): +async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx2.AsyncClient): """A jwt-bearer request without the required `assertion` fails validation with invalid_request.""" response = await client.post( "/token", @@ -284,7 +284,7 @@ async def test_malformed_request_missing_assertion_is_invalid_request(client: ht @pytest.mark.anyio async def test_client_without_the_grant_registered_is_rejected( - client: httpx.AsyncClient, provider: IdentityAssertionProvider + client: httpx2.AsyncClient, provider: IdentityAssertionProvider ): """A confidential client whose registration omits the jwt-bearer grant is refused the grant.""" provider.clients["no-grant-client"] = OAuthClientInformationFull( @@ -313,7 +313,7 @@ async def test_client_without_the_grant_registered_is_rejected( @pytest.mark.anyio async def test_dcr_refuses_to_register_the_jwt_bearer_grant( - client: httpx.AsyncClient, provider: IdentityAssertionProvider + client: httpx2.AsyncClient, provider: IdentityAssertionProvider ): """Dynamic client registration rejects the jwt-bearer grant; the ID-JAG flow needs pre-registration.""" response = await client.post( From 5db71c12a59cebcee92a9b4fe7ea24f6f6cdfea6 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:38:09 +0000 Subject: [PATCH 4/7] Restore SSE request headers via AsyncClient.sse(); fix example fallout httpx-sse's aconnect_sse() always sent Accept: text/event-stream and Cache-Control: no-store; the swap to bare client.stream() dropped both. Open the legacy SSE GET and the streamable HTTP GET/resumption/ reconnection streams with AsyncClient.sse(), which injects those headers (explicit caller headers still take precedence), and update the mocked sse_client test to drive the new call. Example fixes from the same review pass: - simple-chatbot: catch httpx2.HTTPError instead of RequestError so raise_for_status() failures take the handled path (the HTTPStatusError isinstance branch was unreachable), and drop the Raises section the method never honoured. - sse-polling-client: suppress the httpcore2 logger; httpcore is no longer in the dependency tree, so the old suppression was a no-op. --- .../simple-chatbot/mcp_simple_chatbot/main.py | 7 +-- .../mcp_sse_polling_client/main.py | 2 +- src/mcp/client/sse.py | 7 ++- src/mcp/client/streamable_http.py | 20 ++++----- tests/shared/test_sse.py | 45 +++++++++---------- 5 files changed, 37 insertions(+), 44 deletions(-) diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py index 991b985ae5..57670dc17d 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -227,10 +227,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str: messages: A list of message dictionaries. Returns: - The LLM's response as a string. - - Raises: - httpx2.RequestError: If the request to the LLM fails. + The LLM's response as a string, or an error message if the request fails. """ url = "https://api.groq.com/openai/v1/chat/completions" @@ -255,7 +252,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str: data = response.json() return data["choices"][0]["message"]["content"] - except httpx2.RequestError as e: + except httpx2.HTTPError as e: error_message = f"Error getting LLM response: {str(e)}" logging.error(error_message) 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 f99093a6d4..526d266338 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 @@ -93,7 +93,7 @@ def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: ) # Suppress noisy HTTP client logging logging.getLogger("httpx2").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("httpcore2").setLevel(logging.WARNING) asyncio.run(run_demo(url, items, checkpoint_every)) diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 8a91411ac4..522d26d689 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -8,7 +8,7 @@ import httpx2 import mcp_types as types from anyio.abc import TaskStatus -from httpx2 import EventSource, SSEError +from httpx2 import SSEError from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import create_context_streams @@ -55,9 +55,8 @@ async def sse_client( async with httpx_client_factory( headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout) ) as client: - async with client.stream("GET", url) as response: - event_source = EventSource(response) - response.raise_for_status() + async with client.sse(url) as event_source: + event_source.response.raise_for_status() logger.debug("SSE connection established") read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index ba0c161dcb..7a4f4427d6 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -187,11 +187,11 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer if last_event_id: headers[LAST_EVENT_ID] = last_event_id - async with client.stream("GET", self.url, headers=headers) as response: - response.raise_for_status() + async with client.sse(self.url, headers=headers) as event_source: + event_source.response.raise_for_status() logger.debug("GET SSE connection established") - async for sse in EventSource(response): + async for sse in event_source: # Track last event ID for reconnection if sse.id: last_event_id = sse.id @@ -230,11 +230,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 ctx.client.stream("GET", self.url, headers=headers) as response: - response.raise_for_status() + async with ctx.client.sse(self.url, headers=headers) as event_source: + event_source.response.raise_for_status() logger.debug("Resumption GET SSE connection established") - async for sse in EventSource(response): # pragma: no branch + async for sse in event_source: # pragma: no branch is_complete = await self._handle_sse_event( sse, ctx.read_stream_writer, @@ -242,7 +242,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 response.aclose() + await event_source.response.aclose() break async def _handle_post_request(self, ctx: RequestContext) -> None: @@ -408,15 +408,15 @@ async def _handle_reconnection( original_request_id = ctx.session_message.message.id try: - async with ctx.client.stream("GET", self.url, headers=headers) as response: - response.raise_for_status() + async with ctx.client.sse(self.url, headers=headers) as event_source: + event_source.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 EventSource(response): + async for sse in event_source: if sse.id: # pragma: no branch reconnect_last_event_id = sse.id if sse.retry is not None: diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 124e1e9c19..c27dd69db3 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -3,7 +3,7 @@ import json from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock from urllib.parse import urlparse import anyio @@ -416,26 +416,24 @@ 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 httpx2's ServerSentEvent - async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]: - # First: endpoint event - yield ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123") - # Empty data keep-alive ping - this is what we're testing - yield ServerSentEvent(event="message", data="") - # Real JSON-RPC response - yield ServerSentEvent(event="message", data=response_json) + # Mock SSE events using httpx2's ServerSentEvent: an endpoint event, an + # empty keep-alive ping (the case under test), then a real response. + mock_event_source = MagicMock() + mock_event_source.__aiter__.return_value = [ + ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123"), + ServerSentEvent(event="message", data=""), + ServerSentEvent(event="message", data=response_json), + ] + mock_event_source.response.raise_for_status = MagicMock() - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=mock_response) - mock_stream.__aexit__ = AsyncMock(return_value=None) + mock_sse = MagicMock() + mock_sse.__aenter__ = AsyncMock(return_value=mock_event_source) + mock_sse.__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.sse = MagicMock(return_value=mock_sse) mock_client.post = AsyncMock(return_value=MagicMock(status_code=200, raise_for_status=MagicMock())) def mock_factory( @@ -445,14 +443,13 @@ def mock_factory( ) -> 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 - assert not isinstance(msg, Exception) - assert isinstance(msg.message, types.JSONRPCResponse) - assert msg.message.id == 1 + 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 + assert not isinstance(msg, Exception) + assert isinstance(msg.message, types.JSONRPCResponse) + assert msg.message.id == 1 @pytest.mark.anyio From 8e717222f90e29d312c1147401c1201530dd8e06 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:38:57 +0000 Subject: [PATCH 5/7] Wrap test signature that exceeded the line limit after the rename --- tests/server/auth/test_identity_assertion.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/server/auth/test_identity_assertion.py b/tests/server/auth/test_identity_assertion.py index bef1095a6f..9ce12cb8fb 100644 --- a/tests/server/auth/test_identity_assertion.py +++ b/tests/server/auth/test_identity_assertion.py @@ -216,7 +216,9 @@ async def test_identity_assertion_rejected_when_disabled(provider: IdentityAsser @pytest.mark.anyio -async def test_identity_assertion_rejects_public_client(client: httpx2.AsyncClient, provider: IdentityAssertionProvider): +async def test_identity_assertion_rejects_public_client( + client: httpx2.AsyncClient, provider: IdentityAssertionProvider +): """A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion.""" provider.clients["public-client"] = OAuthClientInformationFull( client_id="public-client", From d4b56119b43faf7919ff54e18b75f31e4956180e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:44:12 +0000 Subject: [PATCH 6/7] Restore blank line uv's serializer emits after the manifest block --- uv.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/uv.lock b/uv.lock index 373ce169da..5b2ac755d9 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,7 @@ build-constraints = [ { name = "trove-classifiers", specifier = "==2026.1.14.14" }, { name = "uv-dynamic-versioning", specifier = "==0.14.0" }, ] + [[package]] name = "annotated-types" version = "0.7.0" From 53622d5eac1da1b5b51ffd458509fd4174fd12a0 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:12:29 +0000 Subject: [PATCH 7/7] Drop None args no longer accepted by ServerSentEvent httpx2's ServerSentEvent declares id as str defaulting to empty, where httpx-sse allowed str | None. The handler under test checks truthiness, so the default is behaviourally identical. --- tests/shared/test_streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index c98118d927..d7eeccdfdb 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1614,7 +1614,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)