Skip to content

Commit 3de2aa1

Browse files
dsfacciniclaude
andcommitted
Simplify shim per @Kludex: emit warning at module import, drop the lazy helper
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) <noreply@anthropic.com>
1 parent 786ee07 commit 3de2aa1

7 files changed

Lines changed: 75 additions & 107 deletions

File tree

pyproject.toml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,14 @@ filterwarnings = [
206206
"ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning",
207207
# pywin32 internal deprecation warning
208208
"ignore:getargs.*The 'u' format is deprecated:DeprecationWarning",
209-
# `mcp` prefers `httpx2`; HTTP surfaces warn when falling back to `httpx`. The lockfile
210-
# pins `httpx` (not `httpx2`), so CI always exercises the fallback and every HTTP-touching
211-
# test would trip the warning. The dedicated test in `tests/shared/test_httpx_shim.py`
212-
# covers emission. Remove this entry once `httpx2` is the dependency and the fallback is
213-
# dropped.
214-
"ignore:Using `httpx` with `mcp` is deprecated:mcp.shared._httpx.MCPDeprecationWarning",
209+
# `mcp` prefers `httpx2`; importing `mcp.shared._httpx` warns when falling back to
210+
# `httpx`. The lockfile pins `httpx`, so CI always exercises the fallback. We match on
211+
# the message string only — naming the category would force pytest to import
212+
# `mcp.shared._warnings`, which cascades through `mcp/__init__.py` and triggers the
213+
# very warning we're trying to filter (the same trap pydantic-ai documents). The
214+
# dedicated test in `tests/shared/test_httpx_shim.py` covers emission. Remove this entry
215+
# once `httpx2` is the dependency and the fallback is dropped.
216+
"ignore:Using `httpx` with `mcp` is deprecated",
215217
]
216218

217219
[tool.markdown.lint]

src/mcp/client/auth/oauth2.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
should_use_client_metadata_url,
3737
)
3838
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
39-
from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx
39+
from mcp.shared._httpx import httpx
4040
from mcp.shared.auth import (
4141
OAuthClientInformationFull,
4242
OAuthClientMetadata,
@@ -255,8 +255,6 @@ def __init__(
255255
ValueError: If client_metadata_url is provided but not a valid HTTPS URL
256256
with a non-root pathname.
257257
"""
258-
emit_httpx_deprecation_warning()
259-
260258
# Validate client_metadata_url if provided
261259
if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url):
262260
raise ValueError(

src/mcp/server/mcpserver/resources/types.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from mcp.server.mcpserver.resources.base import Resource
1717
from mcp.shared._callable_inspection import is_async_callable
18-
from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx
18+
from mcp.shared._httpx import httpx
1919
from mcp.types import Annotations, Icon
2020

2121

@@ -159,7 +159,6 @@ class HttpResource(Resource):
159159

160160
async def read(self) -> str | bytes:
161161
"""Read the HTTP content."""
162-
emit_httpx_deprecation_warning() # pragma: no cover
163162
async with httpx.AsyncClient() as client: # pragma: no cover
164163
response = await client.get(self.url)
165164
response.raise_for_status()

src/mcp/shared/_httpx.py

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,52 +5,34 @@
55
and [pydantic/pydantic-ai#5664](https://github.com/pydantic/pydantic-ai/pull/5664).
66
77
`mcp` declares `httpx` (not `httpx2`) as a dependency, so unless the user installs `httpx2`
8-
explicitly the fallback path is exercised. The warning is emitted lazily on first use (not at
9-
module import) to avoid breaking pytest's filter parser during collection. The MCP v2 cut will
10-
drop the fallback and bump the dependency to `httpx2`.
8+
explicitly the fallback path is exercised. The MCP v2 cut will drop the fallback and bump the
9+
dependency to `httpx2`.
10+
11+
The warning is emitted at module-import time and fires at most once per process via Python's
12+
module cache. `MCPDeprecationWarning` lives in `mcp.shared._warnings` so pytest's
13+
`filterwarnings` parser can resolve the category symbol without importing this shim.
1114
"""
1215

1316
from __future__ import annotations
1417

1518
import warnings
1619
from typing import TYPE_CHECKING
1720

18-
__all__ = ["MCPDeprecationWarning", "emit_httpx_deprecation_warning", "httpx"]
19-
20-
21-
class MCPDeprecationWarning(UserWarning):
22-
"""Deprecation warning emitted by the `mcp` package.
21+
from mcp.shared._warnings import MCPDeprecationWarning
2322

24-
Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default —
25-
`DeprecationWarning` is silenced at the Python level for non-`__main__` callers.
26-
"""
23+
__all__ = ["MCPDeprecationWarning", "httpx"]
2724

2825

2926
if TYPE_CHECKING:
3027
import httpx as httpx
31-
32-
_HTTPX_IS_DEPRECATED = False
3328
else:
3429
try:
3530
import httpx2 as httpx
36-
37-
_HTTPX_IS_DEPRECATED = False
3831
except ImportError:
3932
import httpx
4033

41-
_HTTPX_IS_DEPRECATED = True
42-
43-
44-
_warning_emitted = False
45-
46-
47-
def emit_httpx_deprecation_warning() -> None:
48-
"""Emit the `httpx` → `httpx2` deprecation warning at most once per process."""
49-
global _warning_emitted
50-
if _HTTPX_IS_DEPRECATED and not _warning_emitted:
51-
_warning_emitted = True
5234
warnings.warn(
5335
"Using `httpx` with `mcp` is deprecated; install `httpx2` instead.",
5436
MCPDeprecationWarning,
55-
stacklevel=3,
37+
stacklevel=2,
5638
)

src/mcp/shared/_httpx_utils.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Protocol
44

5-
from mcp.shared._httpx import emit_httpx_deprecation_warning, httpx
5+
from mcp.shared._httpx import httpx
66

77
__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"]
88

@@ -77,8 +77,6 @@ def create_mcp_http_client(
7777
response = await client.get("/protected-endpoint")
7878
```
7979
"""
80-
emit_httpx_deprecation_warning()
81-
8280
# Set MCP defaults
8381
kwargs: dict[str, Any] = {"follow_redirects": True}
8482

src/mcp/shared/_warnings.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Warning categories emitted by the `mcp` package."""
2+
3+
from __future__ import annotations
4+
5+
__all__ = ["MCPDeprecationWarning"]
6+
7+
8+
class MCPDeprecationWarning(UserWarning):
9+
"""Deprecation warning emitted by the `mcp` package.
10+
11+
Subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default —
12+
`DeprecationWarning` is silenced at the Python level for non-`__main__` callers.
13+
14+
Defined in its own module so that pytest's `filterwarnings` parser can resolve the
15+
category symbol without importing any side-effecting module — e.g.
16+
`mcp.shared._httpx` emits a `MCPDeprecationWarning` at import time when only `httpx`
17+
is installed, and resolving the symbol through that module would fire the warning
18+
before pytest finishes registering the filter.
19+
"""

tests/shared/test_httpx_shim.py

Lines changed: 36 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,61 @@
11
"""Tests for the `httpx` → `httpx2` migration shim in `mcp.shared._httpx`.
22
3-
`mcp` prefers `httpx2` and falls back to `httpx` with an `MCPDeprecationWarning`. The
4-
fallback is exercised when only `httpx` is installed (today this is always — `httpx2` is
5-
not yet on PyPI). The warning is emitted once per process from HTTP-touching surfaces.
3+
`mcp` prefers `httpx2` and falls back to `httpx` with an `MCPDeprecationWarning` emitted at
4+
the shim's import time. Today the lockfile pins `httpx` (not `httpx2`), so importing the shim
5+
exercises the fallback.
66
"""
77

88
from __future__ import annotations
99

10+
import importlib
11+
import sys
1012
import warnings
1113

1214
import pytest
1315

14-
from mcp.shared import _httpx as httpx_shim
15-
from mcp.shared._httpx import MCPDeprecationWarning, emit_httpx_deprecation_warning
16-
from mcp.shared._httpx_utils import create_mcp_http_client
16+
from mcp.shared._warnings import MCPDeprecationWarning
1717

18-
pytestmark = pytest.mark.anyio
1918

19+
def _force_reimport_shim() -> None:
20+
"""Drop the cached shim module so the next import re-runs its top-level code."""
21+
sys.modules.pop("mcp.shared._httpx", None)
2022

21-
@pytest.fixture
22-
def reset_warning_flag(monkeypatch: pytest.MonkeyPatch):
23-
"""Reset the once-per-process flag so each test gets a fresh emission state."""
24-
monkeypatch.setattr(httpx_shim, "_warning_emitted", False)
2523

24+
def test_fallback_emits_warning_at_import(monkeypatch: pytest.MonkeyPatch) -> None:
25+
"""With only `httpx` installed, importing the shim emits `MCPDeprecationWarning`."""
26+
monkeypatch.delitem(sys.modules, "httpx2", raising=False)
27+
_force_reimport_shim()
2628

27-
def test_emit_warns_when_httpx_is_deprecated(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None:
28-
monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True)
29-
with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"):
30-
emit_httpx_deprecation_warning()
31-
32-
33-
def test_emit_silent_when_httpx2_is_used(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None:
34-
monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", False)
35-
with warnings.catch_warnings(record=True) as caught:
36-
warnings.simplefilter("always", MCPDeprecationWarning)
37-
emit_httpx_deprecation_warning()
38-
assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == []
29+
from collections.abc import Mapping, Sequence
3930

31+
real_import = __import__
4032

41-
def test_emit_only_warns_once_per_process(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None:
42-
monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True)
43-
with warnings.catch_warnings(record=True) as caught:
44-
warnings.simplefilter("always", MCPDeprecationWarning)
45-
emit_httpx_deprecation_warning()
46-
emit_httpx_deprecation_warning()
47-
emit_httpx_deprecation_warning()
48-
matching = [w for w in caught if issubclass(w.category, MCPDeprecationWarning)]
49-
assert len(matching) == 1
50-
33+
def fake_import(
34+
name: str,
35+
globals: Mapping[str, object] | None = None,
36+
locals: Mapping[str, object] | None = None,
37+
fromlist: Sequence[str] = (),
38+
level: int = 0,
39+
) -> object:
40+
if name == "httpx2":
41+
raise ImportError("simulated: httpx2 not installed")
42+
return real_import(name, globals, locals, fromlist, level)
5143

52-
async def test_create_mcp_http_client_emits_warning(monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None) -> None:
53-
monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", True)
44+
monkeypatch.setattr("builtins.__import__", fake_import)
5445
with pytest.warns(MCPDeprecationWarning, match=r"install `httpx2` instead"):
55-
async with create_mcp_http_client():
56-
pass
57-
58-
59-
async def test_create_mcp_http_client_silent_with_httpx2(
60-
monkeypatch: pytest.MonkeyPatch, reset_warning_flag: None
61-
) -> None:
62-
monkeypatch.setattr(httpx_shim, "_HTTPX_IS_DEPRECATED", False)
63-
with warnings.catch_warnings(record=True) as caught:
64-
warnings.simplefilter("always", MCPDeprecationWarning)
65-
async with create_mcp_http_client():
66-
pass
67-
assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == []
68-
69-
70-
def test_shim_picks_up_httpx2_when_present(monkeypatch: pytest.MonkeyPatch) -> None:
71-
"""Aliasing `httpx2` to the installed `httpx` module exercises the preferred import path.
46+
importlib.import_module("mcp.shared._httpx")
7247

73-
`mcp`'s lockfile pins `httpx` (not `httpx2`), so the `import httpx2 as httpx` branch is
74-
otherwise uncovered in CI. This test injects `httpx2` into `sys.modules` and reloads the
75-
shim to cover that branch deterministically.
76-
"""
77-
import importlib
78-
import sys
7948

49+
def test_httpx2_present_is_silent(monkeypatch: pytest.MonkeyPatch) -> None:
50+
"""When `httpx2` is importable, the shim selects it and emits no warning."""
8051
import httpx as real_httpx
8152

8253
monkeypatch.setitem(sys.modules, "httpx2", real_httpx)
83-
monkeypatch.delitem(sys.modules, "mcp.shared._httpx", raising=False)
84-
try:
54+
_force_reimport_shim()
55+
56+
with warnings.catch_warnings(record=True) as caught:
57+
warnings.simplefilter("always", MCPDeprecationWarning)
8558
reloaded = importlib.import_module("mcp.shared._httpx")
86-
assert reloaded._HTTPX_IS_DEPRECATED is False
87-
assert reloaded.httpx is real_httpx
88-
finally:
89-
# Restore the canonical shim module so subsequent tests see the real state.
90-
sys.modules.pop("mcp.shared._httpx", None)
91-
importlib.import_module("mcp.shared._httpx")
59+
60+
assert reloaded.httpx is real_httpx
61+
assert [w for w in caught if issubclass(w.category, MCPDeprecationWarning)] == []

0 commit comments

Comments
 (0)