From fe8afee9708701613adb040b71d05234a2cbe75e Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 26 May 2026 11:13:27 -0500 Subject: [PATCH 1/6] Deprecate `httpx` in favor of `httpx2` 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) --- pyproject.toml | 5 + .../auth/extensions/client_credentials.py | 2 +- src/mcp/client/auth/oauth2.py | 4 +- src/mcp/client/auth/utils.py | 24 ++--- src/mcp/client/session_group.py | 2 +- src/mcp/client/sse.py | 2 +- src/mcp/client/streamable_http.py | 2 +- src/mcp/server/mcpserver/resources/types.py | 3 +- src/mcp/shared/_httpx.py | 55 +++++++++++ src/mcp/shared/_httpx_utils.py | 4 +- tests/shared/test_httpx_shim.py | 92 +++++++++++++++++++ 11 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 src/mcp/shared/_httpx.py create mode 100644 tests/shared/test_httpx_shim.py diff --git a/pyproject.toml b/pyproject.toml index d88869da1c..71a03c4b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,6 +206,11 @@ filterwarnings = [ "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", # pywin32 internal deprecation warning "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", + # `mcp` prefers `httpx2`; HTTP surfaces warn when falling back to `httpx`. `httpx2` is + # not yet on PyPI, so every HTTP-touching test would trip the warning. The dedicated + # test in `tests/shared/test_httpx_shim.py` covers emission. Remove this entry once + # `httpx2` is the default and the fallback is dropped. + "ignore:Using `httpx` with `mcp` is deprecated:mcp.shared._httpx.MCPDeprecationWarning", ] [tool.markdown.lint] diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index cb6dafb407..c8eefded8a 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -13,11 +13,11 @@ from typing import Any, Literal from uuid import uuid4 -import httpx import jwt from pydantic import BaseModel, Field from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage +from mcp.shared._httpx import httpx from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 72309f5775..7d636676e0 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -15,7 +15,6 @@ from urllib.parse import quote, urlencode, urljoin, urlparse import anyio -import httpx from pydantic import BaseModel, Field, ValidationError from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError @@ -37,6 +36,7 @@ should_use_client_metadata_url, ) from mcp.client.streamable_http import MCP_PROTOCOL_VERSION +from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -255,6 +255,8 @@ def __init__( ValueError: If client_metadata_url is provided but not a valid HTTPS URL with a non-root pathname. """ + emit_httpx_deprecation_warning() + # Validate client_metadata_url if provided if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url): raise ValueError( diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index d75324f2f0..0261057da2 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -1,11 +1,11 @@ import re from urllib.parse import urljoin, urlparse -from httpx import Request, Response from pydantic import AnyUrl, ValidationError from mcp.client.auth import OAuthRegistrationError, OAuthTokenError from mcp.client.streamable_http import MCP_PROTOCOL_VERSION +from mcp.shared._httpx import httpx from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -16,7 +16,7 @@ from mcp.types import LATEST_PROTOCOL_VERSION -def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: +def extract_field_from_www_auth(response: httpx.Response, field_name: str) -> str | None: """Extract field from WWW-Authenticate header. Returns: @@ -37,7 +37,7 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No return None -def extract_scope_from_www_auth(response: Response) -> str | None: +def extract_scope_from_www_auth(response: httpx.Response) -> str | None: """Extract scope parameter from WWW-Authenticate header as per RFC 6750. Returns: @@ -46,7 +46,7 @@ def extract_scope_from_www_auth(response: Response) -> str | None: return extract_field_from_www_auth(response, "scope") -def extract_resource_metadata_from_www_auth(response: Response) -> str | None: +def extract_resource_metadata_from_www_auth(response: httpx.Response) -> str | None: """Extract protected resource metadata URL from WWW-Authenticate header as per RFC 9728. Returns: @@ -175,7 +175,7 @@ def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: st async def handle_protected_resource_response( - response: Response, + response: httpx.Response, ) -> ProtectedResourceMetadata | None: """Handle protected resource metadata discovery response. @@ -198,7 +198,7 @@ async def handle_protected_resource_response( return None -async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuthMetadata | None]: +async def handle_auth_metadata_response(response: httpx.Response) -> tuple[bool, OAuthMetadata | None]: if response.status_code == 200: try: content = await response.aread() @@ -211,13 +211,13 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth return True, None -def create_oauth_metadata_request(url: str) -> Request: - return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}) +def create_oauth_metadata_request(url: str) -> httpx.Request: + return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}) def create_client_registration_request( auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str -) -> Request: +) -> httpx.Request: """Build a client registration request.""" if auth_server_metadata and auth_server_metadata.registration_endpoint: @@ -227,10 +227,10 @@ def create_client_registration_request( registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True) - return Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}) + return httpx.Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}) -async def handle_registration_response(response: Response) -> OAuthClientInformationFull: +async def handle_registration_response(response: httpx.Response) -> OAuthClientInformationFull: """Handle registration response.""" if response.status_code not in (200, 201): await response.aread() @@ -316,7 +316,7 @@ def create_client_info_from_metadata_url( async def handle_token_response_scopes( - response: Response, + response: httpx.Response, ) -> OAuthToken: """Parse and validate a token response. diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 9610212642..5fa44ca2fb 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -14,7 +14,6 @@ from typing import Any, TypeAlias import anyio -import httpx from pydantic import BaseModel, Field from typing_extensions import Self @@ -24,6 +23,7 @@ from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters from mcp.client.streamable_http import streamable_http_client +from mcp.shared._httpx import httpx from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.exceptions import MCPError from mcp.shared.session import ProgressFnT diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 74e5ba8062..5e51939a62 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -5,12 +5,12 @@ from urllib.parse import parse_qs, urljoin, urlparse import anyio -import httpx from anyio.abc import TaskStatus from httpx_sse import SSEError, aconnect_sse from mcp import types from mcp.shared._context_streams import create_context_streams +from mcp.shared._httpx import httpx from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client from mcp.shared.message import SessionMessage diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 9a119c6338..c71c3f7790 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -9,13 +9,13 @@ from dataclasses import dataclass import anyio -import httpx from anyio.abc import TaskGroup from httpx_sse import EventSource, ServerSentEvent, aconnect_sse from pydantic import ValidationError from mcp.client._transport import TransportStreams from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams +from mcp.shared._httpx import httpx from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index d9e472e362..ea38313931 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -9,13 +9,13 @@ import anyio import anyio.to_thread -import httpx import pydantic import pydantic_core from pydantic import Field, ValidationInfo, validate_call from mcp.server.mcpserver.resources.base import Resource from mcp.shared._callable_inspection import is_async_callable +from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx from mcp.types import Annotations, Icon @@ -159,6 +159,7 @@ class HttpResource(Resource): async def read(self) -> str | bytes: """Read the HTTP content.""" + emit_httpx_deprecation_warning() # pragma: no cover async with httpx.AsyncClient() as client: # pragma: no cover response = await client.get(self.url) response.raise_for_status() diff --git a/src/mcp/shared/_httpx.py b/src/mcp/shared/_httpx.py new file mode 100644 index 0000000000..cca3287946 --- /dev/null +++ b/src/mcp/shared/_httpx.py @@ -0,0 +1,55 @@ +"""Compatibility shim: prefer `httpx2`, fall back to `httpx` with a deprecation warning. + +Mirrors the pattern from +[Kludex/starlette@508023b](https://github.com/Kludex/starlette/commit/508023b488b649d97c091eb60da1d8ef3636ee06) +and [pydantic/pydantic-ai#5664](https://github.com/pydantic/pydantic-ai/pull/5664). + +`httpx2` is not yet on PyPI, so every install today exercises the fallback path. The warning +is emitted lazily on first use (not at module import) to avoid breaking pytest's filter +parser during collection. The MCP v2 cut will drop the fallback and require `httpx2`. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +__all__ = ["MCPDeprecationWarning", "emit_httpx_deprecation_warning", "httpx"] + + +class MCPDeprecationWarning(UserWarning): + """Deprecation warning emitted by the `mcp` package. + + Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default — + `DeprecationWarning` is silenced at the Python level for non-`__main__` callers. + """ + + +if TYPE_CHECKING: + import httpx as httpx + + _HTTPX_IS_DEPRECATED = False +else: + try: + import httpx2 as httpx + + _HTTPX_IS_DEPRECATED = False + except ImportError: + import httpx + + _HTTPX_IS_DEPRECATED = True + + +_warning_emitted = False + + +def emit_httpx_deprecation_warning() -> None: + """Emit the `httpx` → `httpx2` deprecation warning at most once per process.""" + global _warning_emitted + if _HTTPX_IS_DEPRECATED and not _warning_emitted: + _warning_emitted = True + warnings.warn( + "Using `httpx` with `mcp` is deprecated; install `httpx2` instead.", + MCPDeprecationWarning, + stacklevel=3, + ) diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 251469eaa1..ed1cbbb832 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -2,7 +2,7 @@ from typing import Any, Protocol -import httpx +from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx __all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] @@ -77,6 +77,8 @@ def create_mcp_http_client( response = await client.get("/protected-endpoint") ``` """ + emit_httpx_deprecation_warning() + # Set MCP defaults kwargs: dict[str, Any] = {"follow_redirects": True} diff --git a/tests/shared/test_httpx_shim.py b/tests/shared/test_httpx_shim.py new file mode 100644 index 0000000000..c530dfa891 --- /dev/null +++ b/tests/shared/test_httpx_shim.py @@ -0,0 +1,92 @@ +"""Tests for the `httpx` → `httpx2` migration shim in `mcp.shared._httpx`. + +`mcp` prefers `httpx2` and falls back to `httpx` with an `MCPDeprecationWarning`. The +fallback is exercised when only `httpx` is installed (today this is always — `httpx2` is +not yet on PyPI). The warning is emitted once per process from HTTP-touching surfaces. +""" + +from __future__ import annotations + +import warnings + +import pytest + +from mcp.shared import _httpx as httpx_shim +from mcp.shared._httpx import MCPDeprecationWarning, emit_httpx_deprecation_warning +from mcp.shared._httpx_utils import create_mcp_http_client + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def reset_warning_flag(monkeypatch: pytest.MonkeyPatch): + """Reset the once-per-process flag so each test gets a fresh emission state.""" + monkeypatch.setattr(httpx_shim, "_warning_emitted", False) + + +def test_emit_warns_when_httpx_is_deprecated(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: + monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True) + with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): + emit_httpx_deprecation_warning() + + +def test_emit_silent_when_httpx2_is_used(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: + monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", False) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MCPDeprecationWarning) + emit_httpx_deprecation_warning() + assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == [] + + +def test_emit_only_warns_once_per_process(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: + monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MCPDeprecationWarning) + emit_httpx_deprecation_warning() + emit_httpx_deprecation_warning() + emit_httpx_deprecation_warning() + matching = [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] + assert len(matching) == 1 + + +async def test_create_mcp_http_client_emits_warning( + monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None +) -> None: + monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True) + with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): + async with create_mcp_http_client(): + pass + + +async def test_create_mcp_http_client_silent_with_httpx2( + monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None +) -> None: + monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", False) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MCPDeprecationWarning) + async with create_mcp_http_client(): + pass + assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == [] + + +def test_shim_picks_up_httpx2_when_present(monkeypatch: pytest.MonkeyPatch) -> None: + """Aliasing `httpx2` to the installed `httpx` module exercises the preferred import path. + + Without this test, the `import httpx2 as httpx` branch is uncovered (since `httpx2` is not + yet on PyPI). Once `httpx2` is real, this test will continue to pass naturally. + """ + import importlib + import sys + + import httpx as real_httpx + + monkeypatch.setitem(sys.modules, "httpx2", real_httpx) + monkeypatch.delitem(sys.modules, "mcp.shared._httpx", raising=False) + try: + reloaded = importlib.import_module("mcp.shared._httpx") + assert reloaded._HTTPX_IS_DEPRECATED is False + assert reloaded.httpx is real_httpx + finally: + # Restore the canonical shim module so subsequent tests see the real state. + sys.modules.pop("mcp.shared._httpx", None) + importlib.import_module("mcp.shared._httpx") From faa52983c0019019ba75211eeab8a045d7ee7f7b Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 26 May 2026 11:17:11 -0500 Subject: [PATCH 2/6] Apply ruff-format Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/shared/test_httpx_shim.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/shared/test_httpx_shim.py b/tests/shared/test_httpx_shim.py index c530dfa891..91abac17f3 100644 --- a/tests/shared/test_httpx_shim.py +++ b/tests/shared/test_httpx_shim.py @@ -49,9 +49,7 @@ def test_emit_only_warns_once_per_process(monkeypatch: pytest.MonkeyPatch, reset assert len(matching) == 1 -async def test_create_mcp_http_client_emits_warning( - monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None -) -> None: +async def test_create_mcp_http_client_emits_warning(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True) with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): async with create_mcp_http_client(): From 786ee070269bbec84ff44b916eb688dd72c74485 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 26 May 2026 11:18:35 -0500 Subject: [PATCH 3/6] Correct framing: `httpx2` is on PyPI; CI exercises the fallback because the lockfile pins `httpx` Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 9 +++++---- src/mcp/shared/_httpx.py | 7 ++++--- tests/shared/test_httpx_shim.py | 5 +++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 71a03c4b33..829bbb030b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,10 +206,11 @@ filterwarnings = [ "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", # pywin32 internal deprecation warning "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", - # `mcp` prefers `httpx2`; HTTP surfaces warn when falling back to `httpx`. `httpx2` is - # not yet on PyPI, so every HTTP-touching test would trip the warning. The dedicated - # test in `tests/shared/test_httpx_shim.py` covers emission. Remove this entry once - # `httpx2` is the default and the fallback is dropped. + # `mcp` prefers `httpx2`; HTTP surfaces warn when falling back to `httpx`. The lockfile + # pins `httpx` (not `httpx2`), so CI always exercises the fallback and every HTTP-touching + # test would trip the warning. The dedicated test in `tests/shared/test_httpx_shim.py` + # covers emission. Remove this entry once `httpx2` is the dependency and the fallback is + # dropped. "ignore:Using `httpx` with `mcp` is deprecated:mcp.shared._httpx.MCPDeprecationWarning", ] diff --git a/src/mcp/shared/_httpx.py b/src/mcp/shared/_httpx.py index cca3287946..505e7947fa 100644 --- a/src/mcp/shared/_httpx.py +++ b/src/mcp/shared/_httpx.py @@ -4,9 +4,10 @@ [Kludex/starlette@508023b](https://github.com/Kludex/starlette/commit/508023b488b649d97c091eb60da1d8ef3636ee06) and [pydantic/pydantic-ai#5664](https://github.com/pydantic/pydantic-ai/pull/5664). -`httpx2` is not yet on PyPI, so every install today exercises the fallback path. The warning -is emitted lazily on first use (not at module import) to avoid breaking pytest's filter -parser during collection. The MCP v2 cut will drop the fallback and require `httpx2`. +`mcp` declares `httpx` (not `httpx2`) as a dependency, so unless the user installs `httpx2` +explicitly the fallback path is exercised. The warning is emitted lazily on first use (not at +module import) to avoid breaking pytest's filter parser during collection. The MCP v2 cut will +drop the fallback and bump the dependency to `httpx2`. """ from __future__ import annotations diff --git a/tests/shared/test_httpx_shim.py b/tests/shared/test_httpx_shim.py index 91abac17f3..71e5e051cc 100644 --- a/tests/shared/test_httpx_shim.py +++ b/tests/shared/test_httpx_shim.py @@ -70,8 +70,9 @@ async def test_create_mcp_http_client_silent_with_httpx2( def test_shim_picks_up_httpx2_when_present(monkeypatch: pytest.MonkeyPatch) -> None: """Aliasing `httpx2` to the installed `httpx` module exercises the preferred import path. - Without this test, the `import httpx2 as httpx` branch is uncovered (since `httpx2` is not - yet on PyPI). Once `httpx2` is real, this test will continue to pass naturally. + `mcp`'s lockfile pins `httpx` (not `httpx2`), so the `import httpx2 as httpx` branch is + otherwise uncovered in CI. This test injects `httpx2` into `sys.modules` and reloads the + shim to cover that branch deterministically. """ import importlib import sys From 3de2aa12cb305807b9512d4466488101ccba77c3 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 26 May 2026 11:34:34 -0500 Subject: [PATCH 4/6] Simplify shim per @Kludex: emit warning at module import, drop the lazy helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pyproject.toml | 14 +-- src/mcp/client/auth/oauth2.py | 4 +- src/mcp/server/mcpserver/resources/types.py | 3 +- src/mcp/shared/_httpx.py | 36 ++----- src/mcp/shared/_httpx_utils.py | 4 +- src/mcp/shared/_warnings.py | 19 ++++ tests/shared/test_httpx_shim.py | 102 +++++++------------- 7 files changed, 75 insertions(+), 107 deletions(-) create mode 100644 src/mcp/shared/_warnings.py diff --git a/pyproject.toml b/pyproject.toml index 829bbb030b..694be9f039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,12 +206,14 @@ filterwarnings = [ "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", # pywin32 internal deprecation warning "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", - # `mcp` prefers `httpx2`; HTTP surfaces warn when falling back to `httpx`. The lockfile - # pins `httpx` (not `httpx2`), so CI always exercises the fallback and every HTTP-touching - # test would trip the warning. The dedicated test in `tests/shared/test_httpx_shim.py` - # covers emission. Remove this entry once `httpx2` is the dependency and the fallback is - # dropped. - "ignore:Using `httpx` with `mcp` is deprecated:mcp.shared._httpx.MCPDeprecationWarning", + # `mcp` prefers `httpx2`; importing `mcp.shared._httpx` warns when falling back to + # `httpx`. The lockfile pins `httpx`, so CI always exercises the fallback. We match on + # the message string only — naming the category would force pytest to import + # `mcp.shared._warnings`, which cascades through `mcp/__init__.py` and triggers the + # very warning we're trying to filter (the same trap pydantic-ai documents). The + # dedicated test in `tests/shared/test_httpx_shim.py` covers emission. Remove this entry + # once `httpx2` is the dependency and the fallback is dropped. + "ignore:Using `httpx` with `mcp` is deprecated", ] [tool.markdown.lint] diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 7d636676e0..f8ab67b5d6 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -36,7 +36,7 @@ should_use_client_metadata_url, ) from mcp.client.streamable_http import MCP_PROTOCOL_VERSION -from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx +from mcp.shared._httpx import httpx from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -255,8 +255,6 @@ def __init__( ValueError: If client_metadata_url is provided but not a valid HTTPS URL with a non-root pathname. """ - emit_httpx_deprecation_warning() - # Validate client_metadata_url if provided if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url): raise ValueError( diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index ea38313931..5d52519d12 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -15,7 +15,7 @@ from mcp.server.mcpserver.resources.base import Resource from mcp.shared._callable_inspection import is_async_callable -from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx +from mcp.shared._httpx import httpx from mcp.types import Annotations, Icon @@ -159,7 +159,6 @@ class HttpResource(Resource): async def read(self) -> str | bytes: """Read the HTTP content.""" - emit_httpx_deprecation_warning() # pragma: no cover async with httpx.AsyncClient() as client: # pragma: no cover response = await client.get(self.url) response.raise_for_status() diff --git a/src/mcp/shared/_httpx.py b/src/mcp/shared/_httpx.py index 505e7947fa..6dd322a2cd 100644 --- a/src/mcp/shared/_httpx.py +++ b/src/mcp/shared/_httpx.py @@ -5,9 +5,12 @@ and [pydantic/pydantic-ai#5664](https://github.com/pydantic/pydantic-ai/pull/5664). `mcp` declares `httpx` (not `httpx2`) as a dependency, so unless the user installs `httpx2` -explicitly the fallback path is exercised. The warning is emitted lazily on first use (not at -module import) to avoid breaking pytest's filter parser during collection. The MCP v2 cut will -drop the fallback and bump the dependency to `httpx2`. +explicitly the fallback path is exercised. The MCP v2 cut will drop the fallback and bump the +dependency to `httpx2`. + +The warning is emitted at module-import time and fires at most once per process via Python's +module cache. `MCPDeprecationWarning` lives in `mcp.shared._warnings` so pytest's +`filterwarnings` parser can resolve the category symbol without importing this shim. """ from __future__ import annotations @@ -15,42 +18,21 @@ import warnings from typing import TYPE_CHECKING -__all__ = ["MCPDeprecationWarning", "emit_httpx_deprecation_warning", "httpx"] - - -class MCPDeprecationWarning(UserWarning): - """Deprecation warning emitted by the `mcp` package. +from mcp.shared._warnings import MCPDeprecationWarning - Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default — - `DeprecationWarning` is silenced at the Python level for non-`__main__` callers. - """ +__all__ = ["MCPDeprecationWarning", "httpx"] if TYPE_CHECKING: import httpx as httpx - - _HTTPX_IS_DEPRECATED = False else: try: import httpx2 as httpx - - _HTTPX_IS_DEPRECATED = False except ImportError: import httpx - _HTTPX_IS_DEPRECATED = True - - -_warning_emitted = False - - -def emit_httpx_deprecation_warning() -> None: - """Emit the `httpx` → `httpx2` deprecation warning at most once per process.""" - global _warning_emitted - if _HTTPX_IS_DEPRECATED and not _warning_emitted: - _warning_emitted = True warnings.warn( "Using `httpx` with `mcp` is deprecated; install `httpx2` instead.", MCPDeprecationWarning, - stacklevel=3, + stacklevel=2, ) diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index ed1cbbb832..117a196ddc 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -2,7 +2,7 @@ from typing import Any, Protocol -from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx +from mcp.shared._httpx import httpx __all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] @@ -77,8 +77,6 @@ def create_mcp_http_client( response = await client.get("/protected-endpoint") ``` """ - emit_httpx_deprecation_warning() - # Set MCP defaults kwargs: dict[str, Any] = {"follow_redirects": True} diff --git a/src/mcp/shared/_warnings.py b/src/mcp/shared/_warnings.py new file mode 100644 index 0000000000..2afa675a76 --- /dev/null +++ b/src/mcp/shared/_warnings.py @@ -0,0 +1,19 @@ +"""Warning categories emitted by the `mcp` package.""" + +from __future__ import annotations + +__all__ = ["MCPDeprecationWarning"] + + +class MCPDeprecationWarning(UserWarning): + """Deprecation warning emitted by the `mcp` package. + + Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default — + `DeprecationWarning` is silenced at the Python level for non-`__main__` callers. + + Defined in its own module so that pytest's `filterwarnings` parser can resolve the + category symbol without importing any side-effecting module — e.g. + `mcp.shared._httpx` emits a `MCPDeprecationWarning` at import time when only `httpx` + is installed, and resolving the symbol through that module would fire the warning + before pytest finishes registering the filter. + """ diff --git a/tests/shared/test_httpx_shim.py b/tests/shared/test_httpx_shim.py index 71e5e051cc..cef492fca1 100644 --- a/tests/shared/test_httpx_shim.py +++ b/tests/shared/test_httpx_shim.py @@ -1,91 +1,61 @@ """Tests for the `httpx` → `httpx2` migration shim in `mcp.shared._httpx`. -`mcp` prefers `httpx2` and falls back to `httpx` with an `MCPDeprecationWarning`. The -fallback is exercised when only `httpx` is installed (today this is always — `httpx2` is -not yet on PyPI). The warning is emitted once per process from HTTP-touching surfaces. +`mcp` prefers `httpx2` and falls back to `httpx` with an `MCPDeprecationWarning` emitted at +the shim's import time. Today the lockfile pins `httpx` (not `httpx2`), so importing the shim +exercises the fallback. """ from __future__ import annotations +import importlib +import sys import warnings import pytest -from mcp.shared import _httpx as httpx_shim -from mcp.shared._httpx import MCPDeprecationWarning, emit_httpx_deprecation_warning -from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared._warnings import MCPDeprecationWarning -pytestmark = pytest.mark.anyio +def _force_reimport_shim() -> None: + """Drop the cached shim module so the next import re-runs its top-level code.""" + sys.modules.pop("mcp.shared._httpx", None) -@pytest.fixture -def reset_warning_flag(monkeypatch: pytest.MonkeyPatch): - """Reset the once-per-process flag so each test gets a fresh emission state.""" - monkeypatch.setattr(httpx_shim, "_warning_emitted", False) +def test_fallback_emits_warning_at_import(monkeypatch: pytest.MonkeyPatch) -> None: + """With only `httpx` installed, importing the shim emits `MCPDeprecationWarning`.""" + monkeypatch.delitem(sys.modules, "httpx2", raising=False) + _force_reimport_shim() -def test_emit_warns_when_httpx_is_deprecated(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: - monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True) - with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): - emit_httpx_deprecation_warning() - - -def test_emit_silent_when_httpx2_is_used(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: - monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", False) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always", MCPDeprecationWarning) - emit_httpx_deprecation_warning() - assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == [] + from collections.abc import Mapping, Sequence + real_import = __import__ -def test_emit_only_warns_once_per_process(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: - monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always", MCPDeprecationWarning) - emit_httpx_deprecation_warning() - emit_httpx_deprecation_warning() - emit_httpx_deprecation_warning() - matching = [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] - assert len(matching) == 1 - + def fake_import( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: Sequence[str] = (), + level: int = 0, + ) -> object: + if name == "httpx2": + raise ImportError("simulated: httpx2 not installed") + return real_import(name, globals, locals, fromlist, level) -async def test_create_mcp_http_client_emits_warning(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None: - monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True) + monkeypatch.setattr("builtins.__import__", fake_import) with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): - async with create_mcp_http_client(): - pass - - -async def test_create_mcp_http_client_silent_with_httpx2( - monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None -) -> None: - monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", False) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always", MCPDeprecationWarning) - async with create_mcp_http_client(): - pass - assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == [] - - -def test_shim_picks_up_httpx2_when_present(monkeypatch: pytest.MonkeyPatch) -> None: - """Aliasing `httpx2` to the installed `httpx` module exercises the preferred import path. + importlib.import_module("mcp.shared._httpx") - `mcp`'s lockfile pins `httpx` (not `httpx2`), so the `import httpx2 as httpx` branch is - otherwise uncovered in CI. This test injects `httpx2` into `sys.modules` and reloads the - shim to cover that branch deterministically. - """ - import importlib - import sys +def test_httpx2_present_is_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """When `httpx2` is importable, the shim selects it and emits no warning.""" import httpx as real_httpx monkeypatch.setitem(sys.modules, "httpx2", real_httpx) - monkeypatch.delitem(sys.modules, "mcp.shared._httpx", raising=False) - try: + _force_reimport_shim() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MCPDeprecationWarning) reloaded = importlib.import_module("mcp.shared._httpx") - assert reloaded._HTTPX_IS_DEPRECATED is False - assert reloaded.httpx is real_httpx - finally: - # Restore the canonical shim module so subsequent tests see the real state. - sys.modules.pop("mcp.shared._httpx", None) - importlib.import_module("mcp.shared._httpx") + + assert reloaded.httpx is real_httpx + assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == [] From 240b70171e532a5ba3f2d674e779b25e5ac1a592 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 26 May 2026 11:55:59 -0500 Subject: [PATCH 5/6] Move MCPDeprecationWarning to mcp.shared.exceptions per @Kludex review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pyproject.toml | 10 +++++----- src/mcp/shared/_httpx.py | 7 +++---- src/mcp/shared/_warnings.py | 19 ------------------- src/mcp/shared/exceptions.py | 8 ++++++++ tests/shared/test_httpx_shim.py | 2 +- 5 files changed, 17 insertions(+), 29 deletions(-) delete mode 100644 src/mcp/shared/_warnings.py diff --git a/pyproject.toml b/pyproject.toml index 694be9f039..459042417a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -208,11 +208,11 @@ filterwarnings = [ "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", # `mcp` prefers `httpx2`; importing `mcp.shared._httpx` warns when falling back to # `httpx`. The lockfile pins `httpx`, so CI always exercises the fallback. We match on - # the message string only — naming the category would force pytest to import - # `mcp.shared._warnings`, which cascades through `mcp/__init__.py` and triggers the - # very warning we're trying to filter (the same trap pydantic-ai documents). The - # dedicated test in `tests/shared/test_httpx_shim.py` covers emission. Remove this entry - # once `httpx2` is the dependency and the fallback is dropped. + # the message string only — naming the category would force pytest's filter parser to + # `__import__` the module hosting `MCPDeprecationWarning`, which cascades through + # `mcp/__init__.py` and triggers the very warning we're trying to filter (the same trap + # pydantic-ai documents). The dedicated test in `tests/shared/test_httpx_shim.py` covers + # emission. Remove this entry once `httpx2` is the dependency and the fallback is dropped. "ignore:Using `httpx` with `mcp` is deprecated", ] diff --git a/src/mcp/shared/_httpx.py b/src/mcp/shared/_httpx.py index 6dd322a2cd..03fd1e5359 100644 --- a/src/mcp/shared/_httpx.py +++ b/src/mcp/shared/_httpx.py @@ -9,8 +9,7 @@ dependency to `httpx2`. The warning is emitted at module-import time and fires at most once per process via Python's -module cache. `MCPDeprecationWarning` lives in `mcp.shared._warnings` so pytest's -`filterwarnings` parser can resolve the category symbol without importing this shim. +module cache. """ from __future__ import annotations @@ -18,9 +17,9 @@ import warnings from typing import TYPE_CHECKING -from mcp.shared._warnings import MCPDeprecationWarning +from mcp.shared.exceptions import MCPDeprecationWarning -__all__ = ["MCPDeprecationWarning", "httpx"] +__all__ = ["httpx"] if TYPE_CHECKING: diff --git a/src/mcp/shared/_warnings.py b/src/mcp/shared/_warnings.py deleted file mode 100644 index 2afa675a76..0000000000 --- a/src/mcp/shared/_warnings.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Warning categories emitted by the `mcp` package.""" - -from __future__ import annotations - -__all__ = ["MCPDeprecationWarning"] - - -class MCPDeprecationWarning(UserWarning): - """Deprecation warning emitted by the `mcp` package. - - Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default — - `DeprecationWarning` is silenced at the Python level for non-`__main__` callers. - - Defined in its own module so that pytest's `filterwarnings` parser can resolve the - category symbol without importing any side-effecting module — e.g. - `mcp.shared._httpx` emits a `MCPDeprecationWarning` at import time when only `httpx` - is installed, and resolving the symbol through that module would fire the warning - before pytest finishes registering the filter. - """ diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index f153ea319d..f92d081c38 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -41,6 +41,14 @@ def __str__(self) -> str: return self.message +class MCPDeprecationWarning(UserWarning): + """Deprecation warning emitted by the `mcp` package. + + Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default — + `DeprecationWarning` is silenced at the Python level for non-`__main__` callers. + """ + + class StatelessModeNotSupported(RuntimeError): """Raised when attempting to use a method that is not supported in stateless mode. diff --git a/tests/shared/test_httpx_shim.py b/tests/shared/test_httpx_shim.py index cef492fca1..ed6abe83cc 100644 --- a/tests/shared/test_httpx_shim.py +++ b/tests/shared/test_httpx_shim.py @@ -13,7 +13,7 @@ import pytest -from mcp.shared._warnings import MCPDeprecationWarning +from mcp.shared.exceptions import MCPDeprecationWarning def _force_reimport_shim() -> None: From 0ccc72de5d25caf7db469a91116e4d855b507a94 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 26 May 2026 14:17:54 -0500 Subject: [PATCH 6/6] Mirror Starlette docstring; rewrite shim tests in logfire's reload+patch.dict style Test diff: -33 / +12. Replaces the bespoke `builtins.__import__` monkeypatch with `mock.patch.dict('sys.modules', {'httpx2': None | })` + `importlib.reload`, matching `logfire/tests/otel_integrations/test_httpx.py::test_missing_opentelemetry_dependency`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/shared/exceptions.py | 8 +++-- tests/shared/test_httpx_shim.py | 61 +++++++++++---------------------- 2 files changed, 25 insertions(+), 44 deletions(-) diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index f92d081c38..b3f4213cf9 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -42,10 +42,12 @@ def __str__(self) -> str: class MCPDeprecationWarning(UserWarning): - """Deprecation warning emitted by the `mcp` package. + """A custom deprecation warning for MCP. - Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default — - `DeprecationWarning` is silenced at the Python level for non-`__main__` callers. + Unlike the built-in DeprecationWarning, this inherits from UserWarning to ensure it is visible by default, helping + users discover deprecated features without needing to enable warnings explicitly. + + Reference: https://sethmlarson.dev/deprecations-via-warnings-dont-work-for-python-libraries """ diff --git a/tests/shared/test_httpx_shim.py b/tests/shared/test_httpx_shim.py index ed6abe83cc..6c4b06fdf0 100644 --- a/tests/shared/test_httpx_shim.py +++ b/tests/shared/test_httpx_shim.py @@ -1,61 +1,40 @@ """Tests for the `httpx` → `httpx2` migration shim in `mcp.shared._httpx`. `mcp` prefers `httpx2` and falls back to `httpx` with an `MCPDeprecationWarning` emitted at -the shim's import time. Today the lockfile pins `httpx` (not `httpx2`), so importing the shim -exercises the fallback. +the shim's import time. The lockfile pins `httpx` (not `httpx2`), so the canonical state of +the shim is the fallback path. """ from __future__ import annotations import importlib -import sys import warnings +from unittest import mock import pytest +import mcp.shared._httpx from mcp.shared.exceptions import MCPDeprecationWarning -def _force_reimport_shim() -> None: - """Drop the cached shim module so the next import re-runs its top-level code.""" - sys.modules.pop("mcp.shared._httpx", None) +@pytest.fixture(autouse=True) +def _restore_shim_state(): + """Reload the shim after each test so a simulated `httpx2` doesn't leak into later tests.""" + yield + importlib.reload(mcp.shared._httpx) -def test_fallback_emits_warning_at_import(monkeypatch: pytest.MonkeyPatch) -> None: - """With only `httpx` installed, importing the shim emits `MCPDeprecationWarning`.""" - monkeypatch.delitem(sys.modules, "httpx2", raising=False) - _force_reimport_shim() +def test_fallback_emits_warning() -> None: + with mock.patch.dict("sys.modules", {"httpx2": None}): + with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): + importlib.reload(mcp.shared._httpx) - from collections.abc import Mapping, Sequence - real_import = __import__ +def test_httpx2_present_is_silent() -> None: + import httpx - def fake_import( - name: str, - globals: Mapping[str, object] | None = None, - locals: Mapping[str, object] | None = None, - fromlist: Sequence[str] = (), - level: int = 0, - ) -> object: - if name == "httpx2": - raise ImportError("simulated: httpx2 not installed") - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr("builtins.__import__", fake_import) - with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): - importlib.import_module("mcp.shared._httpx") - - -def test_httpx2_present_is_silent(monkeypatch: pytest.MonkeyPatch) -> None: - """When `httpx2` is importable, the shim selects it and emits no warning.""" - import httpx as real_httpx - - monkeypatch.setitem(sys.modules, "httpx2", real_httpx) - _force_reimport_shim() - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always", MCPDeprecationWarning) - reloaded = importlib.import_module("mcp.shared._httpx") - - assert reloaded.httpx is real_httpx - assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == [] + with mock.patch.dict("sys.modules", {"httpx2": httpx}): + with warnings.catch_warnings(): + warnings.simplefilter("error", MCPDeprecationWarning) + importlib.reload(mcp.shared._httpx) + assert mcp.shared._httpx.httpx is httpx