Replace httpx and httpx-sse with httpx2#2972
Conversation
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.
There was a problem hiding this comment.
5 issues found across 65 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="examples/clients/simple-chatbot/mcp_simple_chatbot/main.py">
<violation number="1" location="examples/clients/simple-chatbot/mcp_simple_chatbot/main.py:233">
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.</violation>
<violation number="2" location="examples/clients/simple-chatbot/mcp_simple_chatbot/main.py:258">
P2: `except httpx2.RequestError` does not catch `HTTPStatusError` raised by `response.raise_for_status()`, so HTTP 4xx/5xx errors bypass this error handling path.</violation>
</file>
<file name="docs/migration.md">
<violation number="1" location="docs/migration.md:21">
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.</violation>
</file>
<file name="src/mcp/client/streamable_http.py">
<violation number="1" location="src/mcp/client/streamable_http.py:242">
P2: SSE GET requests no longer send `Cache-Control: no-store` after replacing `aconnect_sse` with raw `client.stream`. Intermediary caching can return stale event streams and break resumable/live updates.</violation>
</file>
<file name="src/mcp/client/sse.py">
<violation number="1" location="src/mcp/client/sse.py:58">
P2: SSE connection lost required SSE headers when replacing `aconnect_sse` with `client.stream`, which can break negotiation/caching behavior.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Partial review: This PR has more than 50 files, so cubic reviewed the highest-priority files first. During the trial, paid plans get a higher file limit.
You can try an ultrareview to bypass the file limit, comment @cubic-dev-ai ultrareview. Learn more.
Re-trigger cubic
|
|
||
| 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: |
There was a problem hiding this comment.
P2: SSE GET requests no longer send Cache-Control: no-store after replacing aconnect_sse with raw client.stream. Intermediary caching can return stale event streams and break resumable/live updates.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/streamable_http.py, line 242:
<comment>SSE GET requests no longer send `Cache-Control: no-store` after replacing `aconnect_sse` with raw `client.stream`. Intermediary caching can return stale event streams and break resumable/live updates.</comment>
<file context>
@@ -239,11 +239,11 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer:
- 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")
</file context>
| ) 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: |
There was a problem hiding this comment.
P2: SSE connection lost required SSE headers when replacing aconnect_sse with client.stream, which can break negotiation/caching behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/sse.py, line 58:
<comment>SSE connection lost required SSE headers when replacing `aconnect_sse` with `client.stream`, which can break negotiation/caching behavior.</comment>
<file context>
@@ -53,18 +53,19 @@ async def sse_client(
) 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()
</file context>
| async with client.stream("GET", url) as response: | |
| async with client.stream( | |
| "GET", | |
| url, | |
| headers={"accept": "text/event-stream", "cache-control": "no-store"}, | |
| ) as response: |
| return data["choices"][0]["message"]["content"] | ||
|
|
||
| except httpx.RequestError as e: | ||
| except httpx2.RequestError as e: |
There was a problem hiding this comment.
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>
| except httpx2.RequestError as e: | |
| except httpx2.HTTPError as e: |
|
|
||
| Raises: | ||
| httpx.RequestError: If the request to the LLM fails. | ||
| httpx2.RequestError: If the request to the LLM fails. |
There was a problem hiding this comment.
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>
| 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 |
There was a problem hiding this comment.
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>
| `http_client` (or build an `httpx.Auth` subclass for `auth`), import from | |
| `http_client` (or build an `httpx2.Auth` subclass for `auth`), import from |
| # 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'", |
There was a problem hiding this comment.
🔴 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 httpx → httpx2 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.
| 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) |
There was a problem hiding this comment.
🔴 The SSE GET in sse_client no longer sends Accept: text/event-stream or Cache-Control: no-store: httpx_sse.aconnect_sse() injected both headers automatically, but the replacement client.stream("GET", url) + EventSource(response) sends only the httpx2 default Accept: */*. Servers/proxies that gate the SSE endpoint on the Accept header or cache un-marked GET responses will misbehave; the streamable-HTTP GET streams similarly lose Cache-Control: no-store (though they keep text/event-stream via _prepare_headers). Fix by using httpx2's client.sse(...) convenience (as the tests do) or passing the headers explicitly.
Extended reasoning...
What the bug is. Before this PR, the legacy SSE client transport opened its GET stream via httpx_sse.aconnect_sse(client, "GET", url). That helper unconditionally merges Accept: text/event-stream and Cache-Control: no-store into the request headers before calling client.stream(). The rewrite in src/mcp/client/sse.py replaces it with a bare client.stream("GET", url) wrapped in httpx2.EventSource(response). EventSource is purely a response wrapper — its __init__ stores the already-sent response and __aiter__ only validates the response content-type — so it cannot retroactively add request headers. create_mcp_http_client() sets no Accept default either, so the SSE GET now goes out with httpx2's default Accept: */* and no Cache-Control at all.\n\nCode path. sse_client() → httpx_client_factory(...) → client.stream("GET", url) (src/mcp/client/sse.py lines 56–59). No headers= argument is passed at this call site beyond whatever the caller supplied to sse_client(headers=...), and the SDK itself never adds the SSE-specific ones. Verifiers inspected httpx2 2.5.0 and confirmed that the SSE-aware injection lives in AsyncClient.sse() — which the PR's own tests use (tests/interaction/_connect.py switched aconnect_sse → http.sse(...)) — but the production transport does not.\n\nStep-by-step proof.\n1. A user calls sse_client("https://example.com/sse").\n2. create_mcp_http_client() builds an httpx2.AsyncClient with no custom Accept header.\n3. client.stream("GET", "https://example.com/sse") issues GET /sse with Accept: */* (httpx2 default) and no Cache-Control.\n4. Pre-PR, the same call through aconnect_sse issued GET /sse with Accept: text/event-stream and Cache-Control: no-store.\n5. An SSE server or gateway that content-negotiates on Accept (returning 406, or a non-SSE representation, when text/event-stream is not advertised — the SDK's own streamable-HTTP server does exactly this for its GET endpoint) now rejects or mis-routes the connection. A caching intermediary may also cache the GET response since no-store is gone.\n\nWhy the test suite doesn't catch it. The SDK's in-process test servers (sse_starlette / the bridge apps) do not enforce the Accept header on the legacy /sse endpoint, so the suite stays green at 100% coverage while the wire behaviour silently changed.\n\nImpact. This is a wire-level interop regression in the legacy SSE client transport, hidden inside what otherwise looks like a mechanical httpx → httpx2 import rename. Real-world SSE endpoints and proxies commonly gate on Accept: text/event-stream (it is the standard EventSource client behaviour), and Cache-Control: no-store exists precisely so intermediaries never cache the stream. The same Cache-Control: no-store loss applies to the streamable-HTTP GET streams (handle_get_stream, _handle_resumption_request, _handle_reconnection in src/mcp/client/streamable_http.py), though those remain spec-compliant on Accept because _prepare_headers() still sends application/json, text/event-stream.\n\nHow to fix. Either use httpx2's client.sse(url) convenience (which injects both headers, mirroring aconnect_sse) for the SSE GET in sse_client, or pass headers={"Accept": "text/event-stream", "Cache-Control": "no-store"} explicitly to client.stream(). For the streamable-HTTP GET streams, adding Cache-Control: no-store to the GET-specific headers restores parity.
| logging.getLogger("httpx2").setLevel(logging.WARNING) | ||
| logging.getLogger("httpcore").setLevel(logging.WARNING) |
There was a problem hiding this comment.
🟡 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 httpcore2 — httpcore 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 httpx → httpx2 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:
uv run mcp-sse-polling-client --log-level DEBUG→main()callslogging.basicConfig(level=DEBUG), so the root logger handles DEBUG records.logging.getLogger(\"httpx2\").setLevel(WARNING)correctly silences httpx2's request/response logs.logging.getLogger(\"httpcore\")configures a logger no library uses;httpcore2.*loggers keep the defaultNOTSETlevel and delegate to root, which is at DEBUG.- 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).
|
|
Summary
Swaps the
httpx+httpx-ssedependencies forhttpx2>=2.5.0.httpx2is the next-generation httpx fork with server-sent events support built in, so the separatehttpx-ssedependency is no longer needed.Changes
httpx>=0.27.1,<1.0.0+httpx-sse>=0.4withhttpx2>=2.5.0in the SDK and the example projects (lockfile regenerated).aconnect_sse(...)→client.stream(...)/client.sse(...)wrapped inEventSource, and iterate theEventSourcedirectly instead of.aiter_sse().docs/migration.md; updatedocs/installation.md,README.v2.md, and the example sources.Notes
httpx2is API-compatible withhttpx, so most of the diff is thehttpx→httpx2import rename. Users passing their ownhttp_clientonly need to change the import.AsyncClient.sse()convenience method for the raw-client SSE call sites.Verification
ruff,pyright, and the full test suite all pass at 100% coverage (strict-no-coverclean).AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.