Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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 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]
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
38 changes: 38 additions & 0 deletions src/mcp/shared/_httpx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""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. `MCPDeprecationWarning` lives in `mcp.shared._warnings` so pytest's
`filterwarnings` parser can resolve the category symbol without importing this shim.
"""

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING

from mcp.shared._warnings import MCPDeprecationWarning

__all__ = ["MCPDeprecationWarning", "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
19 changes: 19 additions & 0 deletions src/mcp/shared/_warnings.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.

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

Original file line number Diff line number Diff line change
@@ -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.
"""
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._warnings 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