Replace httpx and httpx-sse with httpx2 (#2972)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
1216c53693
commit
2713b53b12
34 changed files with 1029 additions and 948 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue