Skip to content

Deprecate httpx in favor of httpx2#2693

Open
dsfaccini wants to merge 6 commits into
modelcontextprotocol:mainfrom
dsfaccini:httpx2-deprecation
Open

Deprecate httpx in favor of httpx2#2693
dsfaccini wants to merge 6 commits into
modelcontextprotocol:mainfrom
dsfaccini:httpx2-deprecation

Conversation

@dsfaccini
Copy link
Copy Markdown

@dsfaccini dsfaccini commented May 26, 2026

David's AICA here:

Mirrors Kludex/starlette@508023b and the matching pydantic/pydantic-ai#5664: prefer httpx2 at import time, fall back to httpx with an MCPDeprecationWarning emitted on first use of an HTTP-touching surface. The v2-cut PR will drop the httpx fallback. CC @Kludex.

Summary

  • New src/mcp/shared/_httpx.py shim:
    • MCPDeprecationWarning(UserWarning)UserWarning (not DeprecationWarning) so it isn't silenced by the default Python filter.
    • try: import httpx2 as httpx / except ImportError: import httpx, with an _HTTPX_IS_DEPRECATED flag and an if TYPE_CHECKING: import httpx as httpx block so pyright resolves types regardless of which package is installed at runtime.
    • emit_httpx_deprecation_warning() fires the warning at most once per process, lazily — never at module import time (avoids tripping pytest's parse_warning_filter during collection, the same pitfall pydantic-ai hit).
  • All src/mcp/ import httpx / from httpx import … sites now route through the shim (src/mcp/shared/_httpx_utils.py, src/mcp/client/session_group.py, src/mcp/client/sse.py, src/mcp/client/streamable_http.py, src/mcp/client/auth/oauth2.py, src/mcp/client/auth/utils.py, src/mcp/client/auth/extensions/client_credentials.py, src/mcp/server/mcpserver/resources/types.py). httpx_sse imports are intentionally left alone (out of scope).
  • Warning emitted at three surfaces covering all HTTP client/server entry points:
    • create_mcp_http_client() — covers SSE, streamable HTTP, session group, anything that goes through the standard factory.
    • OAuthClientProvider.__init__ — covers OAuth flows directly, plus the ClientCredentialsOAuthProvider / PrivateKeyJWTOAuthProvider / RFC7523OAuthClientProvider subclasses.
    • HttpResource.read() — covers the server-side resource that fetches a URL directly.
  • tests/shared/test_httpx_shim.py: 6 focused tests — emission, silence under simulated httpx2, once-per-process semantics, real create_mcp_http_client integration, and a sys.modules reload test that exercises the preferred import httpx2 as httpx branch (the lockfile pins httpx, so without this test that branch would be uncovered in CI).
  • pyproject.toml: [tool.pytest.ini_options].filterwarnings ignore entry pinned to the exact message text + mcp.shared._httpx.MCPDeprecationWarning category, with a comment explaining when to remove it. Required because pytest is configured with filterwarnings = ["error"] and every HTTP-touching test would otherwise trip the new warning under the current lockfile.

httpx>=0.27.1,<1.0.0 in pyproject.toml is left as-is. httpx2 exists on PyPI (v2.2.0), but mcp doesn't declare it as a dependency and the lockfile only pins httpx, so users who want the new package install it explicitly. The eventual v2-cut PR will bump the dependency to httpx2 and remove the shim entirely.

Out of scope (intentional)

  • No restructuring of _httpx_utils.py or renames in passing.
  • No changes to httpx_sse imports — those are a separate upstream story.
  • No docs/migration.md entry — this PR is non-breaking; the v2-cut PR that removes the fallback gets the migration note.

Test plan

  • ./scripts/test green (1177 passed, 100% branch coverage, strict-no-cover clean).
  • uv run --frozen pyright clean on all modified files.
  • uv run --frozen ruff check / ruff format clean.
  • CI: full matrix (Python 3.10–3.14 × {ubuntu, windows} × {locked, lowest-direct}).

🤖 Generated with Claude Code

dsfaccini and others added 3 commits May 26, 2026 11:13
Mirrors Kludex/starlette@508023b and pydantic/pydantic-ai#5664: prefer
`httpx2` at import time and fall back to `httpx` with an
`MCPDeprecationWarning` emitted lazily on first use of an HTTP-touching
surface (`create_mcp_http_client`, `OAuthClientProvider`, `HttpResource`).
The v2-cut PR will drop the fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se the lockfile pins `httpx`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Kludex
Copy link
Copy Markdown
Member

Kludex commented May 26, 2026

I think we don't need the emit warning function. Just the:

# mcp.shared._httpx
try:
    import httpx2 as httpx
except ImportError:
    import httpx

    # emit the warning here.

In other modules:

from mcp.shared._httpx import httpx

If I'm wrong, and it does emit multiple warnings, we can just have a boolean:

# mcp.shared._httpx

_displayed_deprecation = False
try:
    import httpx2 as httpx
except ImportError:
    import httpx
    
    if not _displayed_deprecation:
        # emit the warning here.

…zy helper

Module-level `warnings.warn(...)` inside the `except ImportError` branch fires once per
process via Python's module cache — no flag or helper function needed. `MCPDeprecationWarning`
moves to `mcp.shared._warnings` so the class symbol exists independently of the shim, and the
pytest `filterwarnings` entry matches on the message string only. Naming the category would
force pytest's filter parser to import `mcp.shared._warnings`, which cascades through
`mcp/__init__.py` and triggers the very warning we're filtering (the pydantic-ai pitfall).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dsfaccini
Copy link
Copy Markdown
Author

David's AICA here:

Adopted in 3de2aa1warnings.warn now fires from the except ImportError branch at module-import time, no flag, no helper function. Two adjustments fell out of trying your snippet here:

  1. MCPDeprecationWarning moved to mcp.shared._warnings (a side-effect-free module). The bug from pydantic-ai's PR body (pytest's filter parser tripping on a top-level warnings.warn) reproduces here: pytest resolves the category symbol in filterwarnings via __import__("mcp.shared._httpx", ...), which runs the shim's top-level code and fires the warning before the filter is registered, so it escalates to an exception. Putting the class in a separate module breaks the cycle.

  2. The filterwarnings entry matches by message only, no category. Even with the class in _warnings.py, naming mcp.shared._warnings.MCPDeprecationWarning makes pytest do __import__("mcp.shared._warnings", ..., ["MCPDeprecationWarning"]), which runs mcp/__init__.py first — that eagerly imports Client, which cascades into mcp.shared._httpx, which fires the warning. (Pydantic-ai dodges this because pydantic_ai/__init__.py doesn't eagerly import mcp.py.) Matching by the unique message string sidesteps the import entirely.

Net diff: −107 lines, +75. Full suite green (1177 passed, 100% branch coverage). I left a short comment on the _warnings.py class docstring explaining why it lives separately, since the reasoning is non-obvious from the structure alone.

Comment thread src/mcp/shared/_warnings.py Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't we have an _exceptions.py module? If we do, we should put this there.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

yeahp that turned out to be the case

Drops the dedicated _warnings.py — the side-effect-free-module constraint that
justified it is moot now that the filter matches by message only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/mcp/shared/exceptions.py Outdated
Comment on lines +47 to +49
Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default —
`DeprecationWarning` is silenced at the Python level for non-`__main__` callers.
"""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it should say the same thing as the starlette one, so we have the reference as why we have this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Check how we test integrations in logfire. It's a bit more cleaner than this.

…tch.dict style

Test diff: -33 / +12. Replaces the bespoke `builtins.__import__` monkeypatch with
`mock.patch.dict('sys.modules', {'httpx2': None | <fake>})` + `importlib.reload`,
matching `logfire/tests/otel_integrations/test_httpx.py::test_missing_opentelemetry_dependency`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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