diff --git a/pyproject.toml b/pyproject.toml index d88869da1c..459042417a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,6 +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`; 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'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", ] [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..f8ab67b5d6 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 httpx from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, 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..5d52519d12 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 httpx from mcp.types import Annotations, Icon diff --git a/src/mcp/shared/_httpx.py b/src/mcp/shared/_httpx.py new file mode 100644 index 0000000000..03fd1e5359 --- /dev/null +++ b/src/mcp/shared/_httpx.py @@ -0,0 +1,37 @@ +"""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). + +`mcp` declares `httpx` (not `httpx2`) as a dependency, so unless the user installs `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. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +from mcp.shared.exceptions import MCPDeprecationWarning + +__all__ = ["httpx"] + + +if TYPE_CHECKING: + import httpx as httpx +else: + try: + import httpx2 as httpx + except ImportError: + import httpx + + warnings.warn( + "Using `httpx` with `mcp` is deprecated; install `httpx2` instead.", + MCPDeprecationWarning, + stacklevel=2, + ) diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 251469eaa1..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 -import httpx +from mcp.shared._httpx import httpx __all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index f153ea319d..b3f4213cf9 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -41,6 +41,16 @@ def __str__(self) -> str: return self.message +class MCPDeprecationWarning(UserWarning): + """A custom deprecation warning for MCP. + + 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 + """ + + 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 new file mode 100644 index 0000000000..6c4b06fdf0 --- /dev/null +++ b/tests/shared/test_httpx_shim.py @@ -0,0 +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. The lockfile pins `httpx` (not `httpx2`), so the canonical state of +the shim is the fallback path. +""" + +from __future__ import annotations + +import importlib +import warnings +from unittest import mock + +import pytest + +import mcp.shared._httpx +from mcp.shared.exceptions import MCPDeprecationWarning + + +@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() -> None: + with mock.patch.dict("sys.modules", {"httpx2": None}): + with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"): + importlib.reload(mcp.shared._httpx) + + +def test_httpx2_present_is_silent() -> None: + import httpx + + 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