Replace httpx and httpx-sse with httpx2 (#2972)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-07-14 18:05:08 +02:00 • committed by GitHub
commit 2713b53b12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1029 additions and 948 deletions

View file

@ -17,7 +17,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
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 @@ class SimpleAuthClient:
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,

View file

@ -8,7 +8,7 @@ import shutil
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
@ -227,10 +227,7 @@ class LLMClient:
messages: A list of message dictionaries.
Returns:
The LLM's response as a string.
Raises:
httpx.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"
@ -249,17 +246,17 @@ class LLMClient:
}
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.HTTPError 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}")

View file

@ -92,8 +92,8 @@ 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("httpcore").setLevel(logging.WARNING)
logging.getLogger("httpx2").setLevel(logging.WARNING)
logging.getLogger("httpcore2").setLevel(logging.WARNING)
asyncio.run(run_demo(url, items, checkpoint_every))

View file

@ -19,7 +19,7 @@ Visit https://surgemsg.com/ and click "Get Started" to obtain these values.
from typing import Annotated
import httpx
import httpx2
from pydantic import BeforeValidator
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -44,7 +44,7 @@ surge_settings = SurgeSettings() # type: ignore
@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={

View file

@ -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"

View file

@ -33,7 +33,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
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 @@ class IntrospectionTokenVerifier(TokenVerifier):
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

View file

@ -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",

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -16,7 +16,7 @@ to use.
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()

View file

@ -9,7 +9,7 @@ cd to the `examples/snippets` directory and run:
import asyncio
from urllib.parse import parse_qs, urlparse
import httpx
import httpx2
from pydantic import AnyUrl
from mcp import ClientSession
@ -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()

View file

@ -18,7 +18,7 @@ from typing import Any, TypeAlias
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 @@ Target: TypeAlias = "Server[Any] | MCPServer | Transport | str"
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 @@ def run_client(main: Callable[..., Awaitable[None]]) -> 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)

View file

@ -25,7 +25,7 @@ ServerFactory = Callable[[], AnyServer]
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:

View file

@ -10,7 +10,7 @@ import secrets
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 @@ class InMemoryTokenStorage:
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:

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more