Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -2319,7 +2319,7 @@ cd to the `examples/snippets` directory and run:
import asyncio
from urllib.parse import parse_qs, urlparse

import httpx
import httpx2
from pydantic import AnyUrl

from mcp import ClientSession
Expand Down Expand Up @@ -2382,7 +2382,7 @@ async def main():
callback_handler=handle_callback,
)

async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
Expand Down
3 changes: 1 addition & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so

The following dependencies are automatically installed:

- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports.
- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport.
- [`httpx2`](https://pypi.org/project/httpx2/): HTTP client to handle HTTP Streamable and SSE transports.
- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/).
- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints.
- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing.
Expand Down
55 changes: 44 additions & 11 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Migration text names the old auth base class (httpx.Auth) in v2 instructions. Use httpx2.Auth to keep guidance consistent and avoid incorrect imports during migration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 21:

<comment>Migration text names the old auth base class (`httpx.Auth`) in v2 instructions. Use `httpx2.Auth` to keep guidance consistent and avoid incorrect imports during migration.</comment>

<file context>
@@ -8,6 +8,39 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t
+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`:
+
</file context>
Suggested change
`http_client` (or build an `httpx.Auth` subclass for `auth`), import from
`http_client` (or build an `httpx2.Auth` subclass for `auth`), import from
Fix with cubic

`httpx2`:

**Before (v1):**

```python
import httpx

http_client = httpx.AsyncClient(follow_redirects=True)
```

**After (v2):**

```python
import httpx2

http_client = httpx2.AsyncClient(follow_redirects=True)
```

`httpx2` is API-compatible with `httpx`, so usually only the import name
changes. To consume SSE directly, use `httpx2.EventSource` (or
`AsyncClient.sse()`) instead of the `httpx-sse` helpers.

### `MCPServer.call_tool()` returns `CallToolResult`

`MCPServer.call_tool()` now always returns a `CallToolResult`. It previously
Expand Down Expand Up @@ -55,13 +88,13 @@ async with streamablehttp_client(
**After (v2):**

```python
import httpx
import httpx2
from mcp.client.streamable_http import streamable_http_client

# Configure headers, timeout, and auth on the httpx.AsyncClient
http_client = httpx.AsyncClient(
# Configure headers, timeout, and auth on the httpx2.AsyncClient
http_client = httpx2.AsyncClient(
headers={"Authorization": "Bearer token"},
timeout=httpx.Timeout(30, read=300),
timeout=httpx2.Timeout(30, read=300),
auth=my_auth,
follow_redirects=True,
)
Expand All @@ -74,7 +107,7 @@ async with http_client:
...
```

v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior.

### OAuth `callback_handler` returns `AuthorizationCodeResult`

Expand Down Expand Up @@ -109,7 +142,7 @@ Forward the `iss` query parameter from the redirect so the validation can run: o

The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.

If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers:
If you need to capture the session ID (e.g., for session resumption testing), you can use httpx2 event hooks to capture it from the response headers:

**Before (v1):**

Expand All @@ -125,23 +158,23 @@ 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
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,
)
Expand All @@ -155,7 +188,7 @@ async with http_client:

### `StreamableHTTPTransport` parameters removed

The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above).
The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx2.AsyncClient` instead (see example above).

Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions examples/clients/simple-chatbot/mcp_simple_chatbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Docstring states this method raises httpx2.RequestError, but it handles and returns an error message instead. Update the Raises entry to match actual behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/clients/simple-chatbot/mcp_simple_chatbot/main.py, line 233:

<comment>Docstring states this method raises `httpx2.RequestError`, but it handles and returns an error message instead. Update the Raises entry to match actual behavior.</comment>

<file context>
@@ -230,7 +230,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str:
 
         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"
</file context>
Fix with cubic

"""
url = "https://api.groq.com/openai/v1/chat/completions"

Expand All @@ -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:

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: except httpx2.RequestError does not catch HTTPStatusError raised by response.raise_for_status(), so HTTP 4xx/5xx errors bypass this error handling path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/clients/simple-chatbot/mcp_simple_chatbot/main.py, line 258:

<comment>`except httpx2.RequestError` does not catch `HTTPStatusError` raised by `response.raise_for_status()`, so HTTP 4xx/5xx errors bypass this error handling path.</comment>

<file context>
@@ -249,17 +249,17 @@ def get_response(self, messages: list[dict[str, str]]) -> str:
                 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)
</file context>
Suggested change
except httpx2.RequestError as e:
except httpx2.HTTPError as e:
Fix with cubic

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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@
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)

Check warning on line 96 in examples/clients/sse-polling-client/mcp_sse_polling_client/main.py

View check run for this annotation

Claude / Claude Code Review

Example still suppresses the old httpcore logger instead of httpcore2

The adjacent line was updated to suppress the `httpx2` logger, but this one still targets `httpcore` — with the dependency swap the transport stack is now `httpcore2`, so this suppression is a no-op and the example will emit the noisy transport DEBUG logging it was meant to silence when run with `--log-level DEBUG`. Update it to `logging.getLogger("httpcore2")`.
Comment on lines +95 to 96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The adjacent line was updated to suppress the httpx2 logger, but this one still targets httpcore — with the dependency swap the transport stack is now httpcore2, so this suppression is a no-op and the example will emit the noisy transport DEBUG logging it was meant to silence when run with --log-level DEBUG. Update it to logging.getLogger("httpcore2").

Extended reasoning...

What the bug is. This PR replaces the httpx/httpx-sse dependencies with httpx2, and the example's log-suppression block was partially updated: the line above was changed from logging.getLogger("httpx") to logging.getLogger("httpx2"), but the companion line still calls logging.getLogger("httpcore").setLevel(logging.WARNING). Per the regenerated uv.lock, httpx2 depends on httpcore2httpcore is no longer anywhere in the dependency tree.

Why the existing line no longer does anything. Python library loggers are named after the module path (logging.getLogger(__name__)), so a transport library packaged as httpcore2 emits its records under httpcore2.http11, httpcore2.connection, etc. Setting the level on the \"httpcore\" logger configures a logger hierarchy that no installed package ever logs to, so the call is a silent no-op. Note the two adjacent lines cannot both be correct as written: either the fork's loggers follow the new package name (in which case this line is dead) or the fork kept the old logger names (in which case the httpxhttpx2 edit on the previous line would be the wrong one). Given the rest of the PR consistently treats httpx2/httpcore2 as the live module names, this line is the stale one.

Code path / proof. Step through running the example with verbose logging:

  1. uv run mcp-sse-polling-client --log-level DEBUGmain() calls logging.basicConfig(level=DEBUG), so the root logger handles DEBUG records.
  2. logging.getLogger(\"httpx2\").setLevel(WARNING) correctly silences httpx2's request/response logs.
  3. logging.getLogger(\"httpcore\") configures a logger no library uses; httpcore2.* loggers keep the default NOTSET level and delegate to root, which is at DEBUG.
  4. Every connection open, send/receive, and SSE chunk read by the transport produces a httpcore2.* DEBUG record that propagates to root and is printed — exactly the noise this block exists to suppress, drowning out the demo's own progress/checkpoint output.

Impact. Limited: this is an example client, and httpcore-style logging is DEBUG-level, so nothing changes at the default --log-level INFO. It only manifests when a user passes --log-level DEBUG, where the demo output becomes cluttered with low-level transport chatter.

Fix. One-word change: logging.getLogger(\"httpcore2\").setLevel(logging.WARNING) (or drop the line if httpcore2's logging is deemed quiet enough).


asyncio.run(run_demo(url, items, checkpoint_every))

Expand Down
4 changes: 2 additions & 2 deletions examples/mcpserver/text_me.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from typing import Annotated

import httpx
import httpx2
from pydantic import BeforeValidator
from pydantic_settings import BaseSettings, SettingsConfigDict

Expand All @@ -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={
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/everything-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,18 @@ 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")):
logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}")
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
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/simple-auth/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/simple-pagination/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/simple-prompt/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/simple-resource/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/simple-streamablehttp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/simple-tool/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/servers/sse-polling-demo/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions examples/snippets/clients/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,13 @@
dependencies = [
# anyio < 4.10 triggers a compile-time SyntaxWarning on Python 3.14 (PEP 765,
# "'return' in a 'finally' block"); for stdio servers it lands on the child's
# stderr (agronholm/anyio#816, fixed in 4.10).
"anyio>=4.10; python_version >= '3.14'",
"anyio>=4.9; python_version < '3.14'",
"httpx>=0.27.1,<1.0.0",
"httpx-sse>=0.4",
"httpx2>=2.5.0",
"pydantic>=2.12.0",
"starlette>=0.48.0; python_version >= '3.14'",
"starlette>=0.27; python_version < '3.14'",

Check failure on line 36 in pyproject.toml

View check run for this annotation

Claude / Claude Code Review

httpx2/httpcore2 are brand-new packages published the same day as the PR; certifi→truststore TLS change undocumented

Making the just-published `httpx2`/`httpcore2` packages (uploaded to PyPI ~45 minutes before this PR was opened, per the lockfile timestamps) the SDK's sole HTTP dependency deserves explicit provenance/maturity vetting before merge, and the swap also silently changes TLS verification: `httpcore2`/`httpx2` drop `certifi` for `truststore`, so certificate validation moves from the certifi CA bundle to the OS trust store for every SDK user. At minimum, document the certifi→truststore TLS behaviour c
Comment on lines 30 to 36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Making the just-published httpx2/httpcore2 packages (uploaded to PyPI ~45 minutes before this PR was opened, per the lockfile timestamps) the SDK's sole HTTP dependency deserves explicit provenance/maturity vetting before merge, and the swap also silently changes TLS verification: httpcore2/httpx2 drop certifi for truststore, so certificate validation moves from the certifi CA bundle to the OS trust store for every SDK user. At minimum, document the certifi→truststore TLS behaviour change in docs/migration.md (which currently only covers the import rename) and confirm the fork's publisher before pinning the SDK to an hours-old 2.5.0 release.

Extended reasoning...

What the change does. The PR replaces httpx>=0.27.1,<1.0.0 + httpx-sse>=0.4 with httpx2>=2.5.0 as the SDK's only HTTP stack (pyproject.toml lines 30–36), and the regenerated uv.lock shows the transport swap underneath it: httpcore (which depends on certifi + h11) is replaced by httpcore2 (which depends on h11 + truststore), and httpx2 itself also pulls in truststore instead of certifi.

Maturity/provenance concern. The lockfile records the upload times of the new packages: httpcore2-2.5.0 at 2026-06-25T14:16:53–56Z and httpx2-2.5.0 at 2026-06-25T14:16:55–57Z, while this PR was opened at 2026-06-25T15:03:08Z. In other words, the SDK would pin its entire HTTP/SSE/OAuth transport to packages that were published to PyPI less than an hour before the PR — zero deployment track record, no ecosystem usage, and the PR description asserts "next-generation httpx fork" without substantiating who publishes it. The wheel metadata does list Tom Christie as author and Pydantic Services Inc. as maintainer with a github.com/pydantic/httpx2 homepage, which softens the worst-case typosquat scenario, but that metadata is self-declared and should be verified by maintainers (confirm the PyPI publisher, the GitHub org, and that this is the intended successor to httpx) before the SDK adopts it as its sole HTTP dependency.

The undocumented TLS behaviour change. This is the independently actionable part regardless of how the provenance question resolves. With httpx <1.0, default TLS verification used the certifi CA bundle. httpx2 2.5.0's create_ssl_context() instead defaults to truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) — the operating system trust store — unless SSL_CERT_FILE/SSL_CERT_DIR are set. Every SDK user's HTTP, SSE, and OAuth requests therefore start validating server certificates against a different trust root the moment they upgrade.

Concrete walk-through of how this manifests. Take a user running an MCP client in a corporate environment behind a TLS-intercepting proxy whose root CA is installed in the OS trust store but (deliberately) not in certifi. On v1, streamable_http_client(...)create_mcp_http_client()httpx.AsyncClient() → certifi bundle → the proxy's certificate is rejected with SSLCertVerificationError (the behaviour they have built tooling/expectations around, e.g. mounting a custom bundle via SSL_CERT_FILE). After this PR, the same call path goes httpx2.AsyncClient() → truststore → OS store → the connection now succeeds. The inverse failure also exists: a minimal container image (e.g. python:slim derivatives without ca-certificates installed, or distroless variants) that previously worked because certifi ships its own bundle will now fail every TLS handshake because the OS store is empty. Neither direction is hinted at anywhere — docs/migration.md documents only the httpxhttpx2 import rename and the SSE helper change.

Why nothing else in the PR mitigates this. The migration guide explicitly claims "httpx2 is API-compatible with httpx, so usually only the import name changes", which actively tells users there is no behavioural change to look for. The test suite runs entirely against in-process ASGI transports and MockTransport, so no test exercises real TLS and the change is invisible in CI.

How to fix. (1) Add a section to docs/migration.md (and ideally docs/installation.md) stating that TLS verification now uses the system trust store via truststore instead of the certifi bundle, and how to restore the old behaviour (set SSL_CERT_FILE to certifi's bundle, or pass an explicit verify= SSL context to a user-supplied httpx2.AsyncClient). (2) Before merging, have a maintainer verify the httpx2/httpcore2 PyPI publisher and decide whether pinning to a release that is hours old (>=2.5.0 with no upper bound) is acceptable for the SDK, or whether to wait for the fork to accumulate a track record / add a stricter version constraint.

"python-multipart>=0.0.9",
"sse-starlette>=3.0.0",
"pydantic-settings>=2.5.2",
Expand Down
Loading
Loading