Skip to content

Replace httpx and httpx-sse with httpx2#2972

Open
Kludex wants to merge 1 commit into
mainfrom
httpx2-migration
Open

Replace httpx and httpx-sse with httpx2#2972
Kludex wants to merge 1 commit into
mainfrom
httpx2-migration

Conversation

@Kludex

@Kludex Kludex commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Swaps the httpx + httpx-sse dependencies for httpx2 >=2.5.0. httpx2 is the next-generation httpx fork with server-sent events support built in, so the separate httpx-sse dependency is no longer needed.

Changes

  • Replace httpx>=0.27.1,<1.0.0 + httpx-sse>=0.4 with httpx2>=2.5.0 in the SDK and the example projects (lockfile regenerated).
  • 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; update docs/installation.md, README.v2.md, and the example sources.

Notes

  • httpx2 is API-compatible with httpx, so most of the diff is the httpxhttpx2 import rename. Users passing their own http_client only need to change the import.
  • Tests use httpx2's 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-cover clean).

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Review in cubic

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

@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: 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>
Fix with cubic

Comment thread src/mcp/client/sse.py
) 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:

@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: 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>
Suggested change
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:
Fix with cubic

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


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

Comment thread docs/migration.md
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

Comment thread pyproject.toml
Comment on lines 30 to 36
# 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'",

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.

Comment thread src/mcp/client/sse.py
Comment on lines +56 to +59
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)

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 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_ssehttp.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 httpxhttpx2 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.

Comment on lines +95 to 96
logging.getLogger("httpx2").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)

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).

@maxisbey

Copy link
Copy Markdown
Contributor

.github/actions/conformance/client.py was missed in the rename sweep — it still does import httpx (line 35) and builds httpx.AsyncClient (lines 130, 346). The client-conformance workflow runs it via uv run --frozen against this repo's uv.lock, which no longer contains httpx after this swap, so the job will fail at import with ModuleNotFoundError: No module named 'httpx'. Update the script to import httpx2 / httpx2.AsyncClient like the rest of the codebase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants