Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
8 changes: 8 additions & 0 deletions src/mcp/shared/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

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.



class StatelessModeNotSupported(RuntimeError):
"""Raised when attempting to use a method that is not supported in stateless mode.

Expand Down
61 changes: 61 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,61 @@
"""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.
"""

from __future__ import annotations

import importlib
import sys
import warnings

import pytest

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)


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()

from collections.abc import Mapping, Sequence

real_import = __import__

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)] == []
Loading