Skip to content

Replace httpx and httpx-sse with httpx2#2972

Merged
maxisbey merged 11 commits into
mainfrom
httpx2-migration
Jul 14, 2026
Merged

Replace httpx and httpx-sse with httpx2#2972
maxisbey merged 11 commits 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 and docs/whats-new.md; update the docs, README, and example sources.

Intentional behavior changes

  • SSE GET streams send Accept: application/json, text/event-stream (previously exactly text/event-stream): httpx2's client.sse() lets the transport's headers win where httpx_sse force-overwrote them. Cache-Control: no-store is unchanged.
  • TLS verification uses the operating system trust store via truststore instead of certifi's bundle; SSL_CERT_FILE/SSL_CERT_DIR are honored first. Documented in the migration guide.
  • Loggers rename: httpxhttpx2, httpcore.*httpcore2.*; the default User-Agent becomes python-httpx2/<version>.
  • simple-chatbot example: except httpx.RequestErrorexcept httpx2.HTTPError, so HTTP status errors are reported instead of propagating (the old handler's status-error branch was unreachable).

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.
  • The httpx2 requirement deliberately has no upper bound, unlike v1's httpx<1.0.0 cap (Restrict httpx version to <1.0.0 #2345): httpx2 is maintained in lockstep with this SDK, so a breaking httpx2 major would land alongside a coordinated SDK release rather than being fenced off ahead of time.

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

@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

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

Comment thread src/mcp/client/streamable_http.py Outdated
Comment thread src/mcp/client/sse.py Outdated
Comment thread examples/clients/simple-chatbot/mcp_simple_chatbot/main.py Outdated
Comment thread examples/clients/simple-chatbot/mcp_simple_chatbot/main.py Outdated
Comment thread docs/migration.md Outdated
Comment thread pyproject.toml Outdated
Comment thread src/mcp/client/sse.py Outdated
Comment thread examples/clients/sse-polling-client/mcp_sse_polling_client/main.py Outdated
@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.

@thomaschristory

Copy link
Copy Markdown

is anyone working on this ? moving to httpx2 would trigger a lot of downstream repos to move to the maintained library and I think security wise it's not a bad investment.

@Kludex

Kludex commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

@maxisbey we need to merge this now, it's time.

Kludex and others added 9 commits July 14, 2026 12:43
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.
The rebase onto main picked up files added since the swap (stories
examples, identity-assertion client/docs, client probe, docs_src
tutorials) that still imported httpx. Apply the same httpx -> httpx2
rename to them, update the migration guide's mcp-types and
identity-assertion sections to name httpx2, and document the
certifi -> truststore TLS verification change.
httpx-sse's aconnect_sse() always sent Accept: text/event-stream and
Cache-Control: no-store; the swap to bare client.stream() dropped both.
Open the legacy SSE GET and the streamable HTTP GET/resumption/
reconnection streams with AsyncClient.sse(), which injects those
headers (explicit caller headers still take precedence), and update
the mocked sse_client test to drive the new call.

Example fixes from the same review pass:
- simple-chatbot: catch httpx2.HTTPError instead of RequestError so
  raise_for_status() failures take the handled path (the
  HTTPStatusError isinstance branch was unreachable), and drop the
  Raises section the method never honoured.
- sse-polling-client: suppress the httpcore2 logger; httpcore is no
  longer in the dependency tree, so the old suppression was a no-op.
httpx2's ServerSentEvent declares id as str defaulting to empty, where
httpx-sse allowed str | None. The handler under test checks truthiness,
so the default is behaviourally identical.
New test files and comments picked up httpx imports on main; convert
them to httpx2 and re-apply the dependency swap to uv.lock (httpx,
httpx-sse, and httpcore out; httpx2, httpcore2, and truststore in;
idna raised to the 3.18 floor httpx2 requires).

No-Verification-Needed: test-only conversions plus lockfile, no runtime surface
The migration guide and client docs were reorganized on main after the
swap branched: re-add the httpx2 section under Packaging with rows in
the dependency table, convert the After-v2 examples that arrived with
the reorganization (Before-v1 blocks keep httpx), and surface the
system-trust-store TLS note on the transports page.

No-Verification-Needed: documentation-only change
The TLS notes now cover the zero-code SSL_CERT_FILE/SSL_CERT_DIR route
httpx2 honors before falling back to the system trust store, and warn
that verify="ca.pem" and cert= are deprecated there. A handful of
docstrings still branded the client HTTPX; they now say httpx2.

No-Verification-Needed: docstring- and documentation-only change
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-2972.mcp-python-docs.pages.dev
Deployment https://9673ac9e.mcp-python-docs.pages.dev
Commit 0fc6043
Triggered by @maxisbey
Updated 2026-07-14 15:56:06 UTC

Old except httpx.X handlers fail silently after the swap because httpx
usually stays co-installed transitively, so the migration guide now
calls out exception handlers, isinstance checks, and that httpx client
objects are not interchangeable at runtime. The whats-new silent-changes
list gains the certifi to system-trust-store entry.

No-Verification-Needed: documentation-only change

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

1 issue found across 3 files (changes from recent commits).

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="docs/whats-new.md">

<violation number="1" location="docs/whats-new.md:141">
P3: The new TLS warning describes an impossible private-CA case: certifi's bundled Mozilla root store does not include private CAs. This can misdirect users who actually need to preserve a public root that is in certifi but absent from their system store (or who need to configure their private CA separately). Consider describing it as a CA/root present in certifi's bundle but absent from the system store.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread docs/whats-new.md Outdated
* **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**.
* **The streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**.
* **`mcp dev` and `mcp install` pin the environment they spawn** to your installed SDK version. Both commands run your server in a fresh `uv run --with ...` environment, which used to resolve `mcp` to the newest stable release rather than the version you are developing against. **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**.
* **TLS certificates verify against the operating system trust store, not certifi.** The SDK's HTTP client is now `httpx2`, which validates through `truststore` instead of certifi's bundled CA list. Most environments never notice; a minimal container with no system CA store, or a private CA that only certifi's bundle knew about, starts failing the TLS handshake. Set `SSL_CERT_FILE`/`SSL_CERT_DIR` or pass `verify=ssl_context` to your client. **[Migration Guide](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**.

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: The new TLS warning describes an impossible private-CA case: certifi's bundled Mozilla root store does not include private CAs. This can misdirect users who actually need to preserve a public root that is in certifi but absent from their system store (or who need to configure their private CA separately). Consider describing it as a CA/root present in certifi's bundle but absent from the system store.

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

<comment>The new TLS warning describes an impossible private-CA case: certifi's bundled Mozilla root store does not include private CAs. This can misdirect users who actually need to preserve a public root that is in certifi but absent from their system store (or who need to configure their private CA separately). Consider describing it as a CA/root present in certifi's bundle but absent from the system store.</comment>

<file context>
@@ -138,6 +138,7 @@ The renames announce themselves. These do not:
 * **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**.
 * **The streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**.
 * **`mcp dev` and `mcp install` pin the environment they spawn** to your installed SDK version. Both commands run your server in a fresh `uv run --with ...` environment, which used to resolve `mcp` to the newest stable release rather than the version you are developing against. **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**.
+* **TLS certificates verify against the operating system trust store, not certifi.** The SDK's HTTP client is now `httpx2`, which validates through `truststore` instead of certifi's bundled CA list. Most environments never notice; a minimal container with no system CA store, or a private CA that only certifi's bundle knew about, starts failing the TLS handshake. Set `SSL_CERT_FILE`/`SSL_CERT_DIR` or pass `verify=ssl_context` to your client. **[Migration Guide](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**.
 
 ### Removed outright
</file context>
Suggested change
* **TLS certificates verify against the operating system trust store, not certifi.** The SDK's HTTP client is now `httpx2`, which validates through `truststore` instead of certifi's bundled CA list. Most environments never notice; a minimal container with no system CA store, or a private CA that only certifi's bundle knew about, starts failing the TLS handshake. Set `SSL_CERT_FILE`/`SSL_CERT_DIR` or pass `verify=ssl_context` to your client. **[Migration Guide](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**.
* **TLS certificates verify against the operating system trust store, not certifi.** The SDK's HTTP client is now `httpx2`, which validates through `truststore` instead of certifi's bundled CA list. Most environments never notice; a minimal container with no system CA store, or a CA root present in certifi's bundle but absent from the system store, starts failing the TLS handshake. Set `SSL_CERT_FILE`/`SSL_CERT_DIR` or pass `verify=ssl_context` to your client. **[Migration Guide](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**.

…ames

The whats-new bullet now headlines the dependency swap itself with TLS
as one of its consequences, and the migration guide covers the renamed
loggers, the new User-Agent, and module-keyed telemetry integrations
going quiet.

No-Verification-Needed: documentation-only change
@maxisbey maxisbey merged commit 2713b53 into main Jul 14, 2026
37 checks passed
@maxisbey maxisbey deleted the httpx2-migration branch July 14, 2026 16:05
from typing import Any

import httpx
import httpx2

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 simple-chatbot example now does import httpx2 directly (main.py line 11), but neither mcp_simple_chatbot/requirements.txt (the README's documented pip install -r requirements.txt path) nor the example's pyproject.toml declares httpx2 — with mcp>=1.0.0 resolving to the published 1.x line (which depends on httpx, not httpx2), following the README fails at import with ModuleNotFoundError: No module named 'httpx2'. Add httpx2>=2.5.0 to requirements.txt (or the example's pyproject) to match the convention this PR applied to every other example.

Extended reasoning...

What happened. The import-rename sweep converted examples/clients/simple-chatbot/mcp_simple_chatbot/main.py from import httpx to import httpx2 (and the code genuinely uses it: httpx2.Client(), httpx2.HTTPError, httpx2.HTTPStatusError in LLMClient.get_response), but the example's install metadata was not updated. mcp_simple_chatbot/requirements.txt still lists only python-dotenv>=1.0.0, requests>=2.31.0, mcp>=1.0.0, uvicorn>=0.32.1, and the example's pyproject.toml dependencies are python-dotenv, mcp, uvicorn — httpx2 appears in neither.

How it manifests. The example's README documents pip install -r requirements.txt as the setup path. Step by step: (1) a user runs that command in a fresh venv; (2) pip resolves mcp>=1.0.0 to the newest published release, currently the 1.x line, whose dependencies include httpx>=0.27.1 — not httpx2; (3) nothing else in the requirements pulls httpx2 in; (4) the user runs the chatbot and line 11 executes import httpx2ModuleNotFoundError: No module named 'httpx2'. The example never starts.

Why this was missed. The PR's own convention was to update dependency declarations alongside the import rename — it bumped httpx>=0.27 to httpx2>=2.5.0 in every server-example pyproject that declared httpx (simple-auth, simple-tool, simple-prompt, simple-resource, simple-pagination, both simple-streamablehttp variants, everything-server, sse-polling-demo). simple-chatbot was skipped only because it never declared httpx explicitly; its old import httpx was satisfied transitively through mcp. This is the same class of miss maxisbey flagged for .github/actions/conformance/client.py (since fixed).

Why nit rather than blocking — the refutation's points are fair mitigations. One verifier argued this doesn't regress anything, and two of its points hold up on inspection: (1) the undeclared-direct-import pattern is pre-existing — before this PR the example imported httpx without declaring it either, relying on mcp's transitive dependency; (2) the example already requires mcp v2 independent of httpx2 — main.py:115 reads tool.input_schema, a v2 snake_case attribute (v1 types use inputSchema), so the example on this main branch is broken against a pip-installed mcp 1.x regardless of this PR; (3) inside the repo's uv workspace, the example depends on the editable mcp v2, which now depends on httpx2, so CI/workspace runs work; and (4) once mcp v2 is published, mcp>=1.0.0 resolves to 2.x and httpx2 arrives transitively again.

Why it's still worth a one-line fix. A package a module imports directly should be declared directly — relying on a transitive dependency of mcp is exactly the fragility that produced this gap, and the PR fixed that same fragility in every other example. Declaring httpx2>=2.5.0 in requirements.txt (and/or the example pyproject) makes the example robust regardless of which mcp version the loose >=1.0.0 pin resolves to. While touching the file, the stale mcp>=1.0.0 pin (the example needs v2 APIs) and the unused requests entry are also worth cleaning up, though those are pre-existing.

Fix. Add httpx2>=2.5.0 to examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt and to the example's pyproject.toml dependencies.

@thomaschristory

Copy link
Copy Markdown

following the merge of this (thank you!), when is the new release targeted for ?

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.

3 participants