Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/auth/extensions/client_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
24 changes: 12 additions & 12 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/session_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from typing import Any, TypeAlias

import anyio
import httpx
from pydantic import BaseModel, Field
from typing_extensions import Self

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/mcpserver/resources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
37 changes: 37 additions & 0 deletions src/mcp/shared/_httpx.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 1 addition & 1 deletion src/mcp/shared/_httpx_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
10 changes: 10 additions & 0 deletions src/mcp/shared/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions tests/shared/test_httpx_shim.py
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.

Original file line number Diff line number Diff line change
@@ -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
Loading