chore(mcp): migrate client integration to SDK v2

This commit is contained in:
chengyongru 2026-07-30 17:07:24 +08:00
parent 606ac56e8f
commit c2349a0bfc
8 changed files with 166 additions and 85 deletions

View File

@ -12,7 +12,7 @@ from contextlib import AsyncExitStack, suppress
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
from weakref import WeakKeyDictionary
import httpx
import httpx2 as httpx
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
@ -25,9 +25,9 @@ from nanobot.bus.events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.security.network import (
PinnedDNSAsyncTransport,
Httpx2PinnedDNSAsyncTransport,
env_proxy_applies_to_url,
httpx_env_proxy_mounts,
httpx2_env_proxy_mounts,
resolve_url_target,
validate_url_target,
)
@ -194,7 +194,7 @@ def _is_session_terminated(exc: BaseException) -> bool:
messages.append(str(getattr(error, "message", "")))
return any(
marker in message.lower()
for marker in ("session terminated", "connection closed")
for marker in ("session terminated", "session not found", "connection closed")
for message in messages
)
@ -252,8 +252,8 @@ def _redact_url(url: str) -> str:
def _pinned_transport_kwargs() -> dict[str, Any]:
kwargs: dict[str, Any] = {"transport": PinnedDNSAsyncTransport()}
mounts = httpx_env_proxy_mounts()
kwargs: dict[str, Any] = {"transport": Httpx2PinnedDNSAsyncTransport()}
mounts = httpx2_env_proxy_mounts()
if mounts:
kwargs["mounts"] = mounts
return kwargs
@ -518,7 +518,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
"""
image_cls = getattr(types, "ImageContent", None)
if image_cls is not None and isinstance(block, image_cls):
mime = getattr(block, "mimeType", None) or "image/png"
mime = getattr(block, "mime_type", None) or "image/png"
return f"data:{mime};base64,{block.data}"
embedded_cls = getattr(types, "EmbeddedResource", None)
@ -527,7 +527,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
resource = getattr(block, "resource", None)
if blob_cls is not None and isinstance(resource, blob_cls):
blob_resource = cast(Any, resource)
mime = getattr(blob_resource, "mimeType", None) or ""
mime = getattr(blob_resource, "mime_type", None) or ""
if isinstance(mime, str) and mime.startswith("image/"):
return f"data:{mime};base64,{blob_resource.blob}"
return None
@ -571,7 +571,7 @@ class MCPToolWrapper(_MCPWrapperBase):
self._original_name = tool_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
self._description = tool_def.description or tool_def.name
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
raw_schema = tool_def.input_schema or {"type": "object", "properties": {}}
self._parameters = _normalize_schema_for_openai(raw_schema)
self._tool_timeout = tool_timeout
@ -650,7 +650,7 @@ class MCPToolWrapper(_MCPWrapperBase):
# Success — extract text and persist any image content as artifacts.
try:
rendered = self._render_call_result(result.content, kwargs)
if getattr(result, "isError", False):
if getattr(result, "is_error", False):
return ToolResult.error(rendered)
return rendered
except Exception as exc:
@ -876,8 +876,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
return True
async def execute(self, **kwargs: Any) -> str:
from mcp import types
from mcp.shared.exceptions import McpError
from mcp import MCPError, types
retried_transient = False
refreshed_session = False
@ -897,7 +896,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
raise
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
except McpError as exc:
except MCPError as exc:
if await self._refresh_session_after_termination(
exc,
refreshed_session,
@ -1062,7 +1061,7 @@ async def connect_mcp_servers(
**_pinned_transport_kwargs(),
)
)
read, write, _ = await server_stack.enter_async_context(
read, write = await server_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else:

View File

@ -12,6 +12,7 @@ from urllib.parse import urlparse
from urllib.request import getproxies, proxy_bypass
import httpx
import httpx2
_BLOCKED_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
@ -29,6 +30,7 @@ _BLOCKED_NETWORKS = [
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
_DNS_PIN_RESOLVER_LOCK = asyncio.Lock()
def is_loopback_host(host: str) -> bool:
@ -195,6 +197,30 @@ def httpx_env_proxy_mounts() -> dict[str, httpx.AsyncBaseTransport | None]:
return mounts
def httpx2_env_proxy_mounts() -> dict[str, httpx2.AsyncBaseTransport | None]:
"""Build HTTPX2 proxy mounts while leaving direct routes to the base transport."""
proxies = getproxies()
mounts: dict[str, httpx2.AsyncBaseTransport | None] = {}
for scheme in ("http", "https", "all"):
proxy_url = proxies.get(scheme)
if proxy_url:
if "://" not in proxy_url:
proxy_url = f"http://{proxy_url}"
mounts[f"{scheme}://"] = httpx2.AsyncHTTPTransport(proxy=httpx2.Proxy(proxy_url))
if not mounts:
return {}
no_proxy = proxies.get("no", "")
if no_proxy == "*":
return {}
for entry in no_proxy.split(","):
pattern = _no_proxy_mount_pattern(entry.strip())
if pattern:
mounts[pattern] = None
return mounts
def _no_proxy_mount_pattern(hostname: str) -> str | None:
if not hostname:
return None
@ -264,7 +290,7 @@ class UnsafeURLRequestError(httpx.RequestError):
class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
"""HTTPX transport that pins each request to the IPs validated for its URL."""
_resolver_lock = asyncio.Lock()
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
def __init__(
self,
@ -288,6 +314,37 @@ class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
await self._inner.aclose()
class Httpx2UnsafeURLRequestError(httpx2.RequestError):
"""Raised when an HTTPX2 request is rejected by URL safety validation."""
class Httpx2PinnedDNSAsyncTransport(httpx2.AsyncBaseTransport):
"""HTTPX2 transport that pins each request to the IPs validated for its URL."""
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
def __init__(
self,
*,
allow_loopback: bool = False,
inner: httpx2.AsyncBaseTransport | None = None,
) -> None:
self._allow_loopback = allow_loopback
self._inner = inner or httpx2.AsyncHTTPTransport()
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
ok, error, resolved_ips = resolve_url_target(url, allow_loopback=self._allow_loopback)
if not ok:
raise Httpx2UnsafeURLRequestError(error, request=request)
async with self._resolver_lock:
with pin_resolved_url_dns(url, resolved_ips):
return await self._inner.handle_async_request(request)
async def aclose(self) -> None:
await self._inner.aclose()
def validate_resolved_url(url: str) -> tuple[bool, str]:
"""Validate an already-fetched URL (e.g. after redirect). Only checks the IP, skips DNS."""
try:

View File

@ -31,6 +31,8 @@ dependencies = [
"websockets>=15.0,<17.0",
"websocket-client>=1.9.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
# MCP v2 uses the independently versioned httpx2 package for HTTP transports.
"httpx2>=2.5.0,<3.0.0",
"ddgs>=9.5.5,<10.0.0",
"oauth-cli-kit>=0.1.6,<1.0.0",
"loguru>=0.7.3,<1.0.0",
@ -40,7 +42,7 @@ dependencies = [
"croniter>=6.0.0,<7.0.0",
"prompt-toolkit>=3.0.50,<4.0.0",
"questionary>=2.0.0,<3.0.0",
"mcp>=1.26.0,<2.0.0",
"mcp>=2.0.0,<3.0.0",
"json-repair>=0.57.0,<1.0.0",
"chardet>=3.0.2,<6.0.0",
"openai>=2.8.0",

View File

@ -10,10 +10,9 @@ from unittest.mock import MagicMock
import anyio
import pytest
from mcp import MCPError
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from mcp.shared.message import SessionMessage
from mcp.types import ErrorData
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools import mcp as mcp_runtime
@ -26,12 +25,10 @@ from nanobot.config.schema import MCPServerConfig
def _mcp_notification(method: str, params: dict[str, Any] | None = None) -> SessionMessage:
return SessionMessage(
message=mcp_types.JSONRPCMessage(
mcp_types.JSONRPCNotification(
jsonrpc="2.0",
method=method,
params=params,
)
message=mcp_types.JSONRPCNotification(
jsonrpc="2.0",
method=method,
params=params,
)
)
@ -428,7 +425,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
self.call_count += 1
assert arguments == {"symbol": "AAPL"}
if self.index == 1:
raise McpError(ErrorData(code=-32000, message="Session terminated"))
raise MCPError(-32000, "Session terminated")
return SimpleNamespace(
content=[mcp_types.TextContent(type="text", text="recovered")]
)
@ -443,7 +440,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
tool_def = SimpleNamespace(
name="quote",
description="quote tool",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
registry.register(MCPToolWrapper(session, name, tool_def, tool_timeout=5))
stack = AsyncExitStack()
@ -484,7 +481,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
async def call_tool(self, _name: str, arguments: dict[str, Any]) -> Any:
assert arguments == {}
if self.index == 1:
raise McpError(ErrorData(code=-32000, message="Session terminated"))
raise MCPError(-32000, "Session terminated")
return SimpleNamespace(
content=[mcp_types.TextContent(type="text", text="recovered")]
)
@ -497,7 +494,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
tool_def = SimpleNamespace(
name="quote",
description="quote tool",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
registry.register(MCPToolWrapper(_FakeSession(connect_count), name, tool_def))
stack = AsyncExitStack()
@ -532,7 +529,7 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
class _DeadSession:
async def read_resource(self, _uri: str) -> Any:
raise McpError(ErrorData(code=-32000, message="Session terminated"))
raise MCPError(-32000, "Session terminated")
class _LiveSession:
async def read_resource(self, uri: str) -> Any:

View File

@ -17,7 +17,7 @@ import socket
import time
from unittest.mock import MagicMock
import httpx
import httpx2 as httpx
import pytest
from nanobot.agent.loop import AgentLoop
@ -39,31 +39,29 @@ def _free_port() -> int:
def _run_mcp_server(port: int, ready_event: multiprocessing.Event) -> None:
"""FastMCP server target for ``multiprocessing.Process``.
"""MCPServer target for ``multiprocessing.Process``.
The server exposes a single ``greet`` tool and terminates idle sessions
after ``_IDLE_TIMEOUT_SECONDS``.
"""
from mcp.server.fastmcp import FastMCP
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
import uvicorn
from mcp.server import MCPServer
mcp = FastMCP("IdleTimeoutDemo", json_response=True, port=port)
mcp = MCPServer("IdleTimeoutDemo")
@mcp.tool()
def greet(name: str = "World") -> str: # noqa: N802
"""Greet someone."""
return f"Hello, {name}!"
mcp._session_manager = StreamableHTTPSessionManager(
app=mcp._mcp_server,
json_response=mcp.settings.json_response,
stateless=mcp.settings.stateless_http,
security_settings=mcp.settings.transport_security,
session_idle_timeout=_IDLE_TIMEOUT_SECONDS,
app = mcp.streamable_http_app(
json_response=True,
host="127.0.0.1",
)
mcp.session_manager.session_idle_timeout = _IDLE_TIMEOUT_SECONDS
ready_event.set()
mcp.run(transport="streamable-http")
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
async def _wait_for_server(url: str, timeout: float = 10.0) -> bool:
@ -128,10 +126,14 @@ def _make_loop(tmp_path, *, mcp_servers: dict) -> AgentLoop:
@pytest.fixture(autouse=True)
def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
"""The repro server runs on 127.0.0.1; allow nanobot to talk to it."""
class TestPinnedDNSAsyncTransport(security_network.PinnedDNSAsyncTransport):
class TestPinnedDNSAsyncTransport(security_network.Httpx2PinnedDNSAsyncTransport):
_resolver_lock = asyncio.Lock()
monkeypatch.setattr(mcp_module, "PinnedDNSAsyncTransport", TestPinnedDNSAsyncTransport)
monkeypatch.setattr(
mcp_module,
"Httpx2PinnedDNSAsyncTransport",
TestPinnedDNSAsyncTransport,
)
monkeypatch.setattr(
mcp_module,
"validate_url_target",
@ -154,7 +156,7 @@ def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
)
monkeypatch.setattr(
mcp_module,
"httpx_env_proxy_mounts",
"httpx2_env_proxy_mounts",
lambda: {},
)

View File

@ -5,9 +5,8 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from mcp import MCPError
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nanobot.agent.tools.mcp import (
MCPPromptWrapper,
@ -37,12 +36,16 @@ class _FakeEndOfStreamError(Exception):
_FakeEndOfStreamError.__name__ = "EndOfStream"
def _session_terminated_error() -> McpError:
return McpError(ErrorData(code=-32000, message="Session terminated"))
def _session_terminated_error() -> MCPError:
return MCPError(-32000, "Session terminated")
def _connection_closed_error() -> McpError:
return McpError(ErrorData(code=-32000, message="Connection closed"))
def _connection_closed_error() -> MCPError:
return MCPError(-32000, "Connection closed")
def _session_not_found_error() -> MCPError:
return MCPError(-32600, "Session not found")
def test_is_transient_recognizes_closed_resource():
@ -85,6 +88,10 @@ def test_is_session_terminated_recognizes_connection_closed_mcp_error():
assert _is_session_terminated(_connection_closed_error())
def test_is_session_terminated_recognizes_v2_session_not_found_error():
assert _is_session_terminated(_session_not_found_error())
# ---------------------------------------------------------------------------
# MCPToolWrapper retry behaviour
# ---------------------------------------------------------------------------
@ -94,7 +101,7 @@ def _make_tool_def(name="test_tool"):
return SimpleNamespace(
name=name,
description="A test tool",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
@ -415,10 +422,10 @@ async def test_prompt_fails_after_retry_exhausted():
@pytest.mark.asyncio
async def test_prompt_no_retry_on_mcp_error():
"""McpError (application-level) should NOT trigger retry."""
"""MCPError (application-level) should NOT trigger retry."""
session = AsyncMock()
session.get_prompt = AsyncMock(
side_effect=McpError(ErrorData(code=-1, message="not found"))
side_effect=MCPError(-1, "not found")
)
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
@ -443,7 +450,7 @@ async def test_prompt_no_retry_on_non_transient():
@pytest.mark.asyncio
async def test_prompt_reconnects_on_session_terminated():
"""Prompt should reconnect once before falling back to McpError handling."""
"""Prompt should reconnect once before falling back to MCPError handling."""
old_session = AsyncMock()
old_session.get_prompt = AsyncMock(side_effect=_session_terminated_error())
new_session = AsyncMock()

View File

@ -9,9 +9,12 @@ from unittest.mock import patch
import pytest
from nanobot.security.network import (
Httpx2PinnedDNSAsyncTransport,
PinnedDNSAsyncTransport,
configure_ssrf_whitelist,
contains_internal_url,
env_proxy_applies_to_url,
httpx2_env_proxy_mounts,
httpx_env_proxy_mounts,
is_loopback_host,
pin_resolved_url_dns,
@ -264,6 +267,17 @@ def test_env_proxy_helpers_respect_no_proxy(monkeypatch):
assert any(transport is None for transport in mounts.values())
assert any(transport is not None for transport in mounts.values())
httpx2_mounts = httpx2_env_proxy_mounts()
assert any(transport is None for transport in httpx2_mounts.values())
assert any(transport is not None for transport in httpx2_mounts.values())
def test_httpx_transports_share_global_dns_pin_lock():
assert (
Httpx2PinnedDNSAsyncTransport._resolver_lock
is PinnedDNSAsyncTransport._resolver_lock
)
# ---------------------------------------------------------------------------
# contains_internal_url — shell command scanning

View File

@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
from types import ModuleType, SimpleNamespace
import httpx
import httpx2 as httpx
import pytest
import nanobot.agent.tools.mcp as mcp_mod
@ -52,7 +52,7 @@ class _FakeBlobResourceContents:
class _FakeImageContent:
def __init__(self, data: str, mime_type: str = "image/png") -> None:
self.data = data
self.mimeType = mime_type
self.mime_type = mime_type
@pytest.fixture
@ -111,7 +111,7 @@ def _fake_mcp_module(
@asynccontextmanager
async def _fake_streamable_http_client(_url: str, http_client=None):
yield object(), object(), object()
yield object(), object()
mod.ClientSession = _FakeClientSession
mod.StdioServerParameters = _FakeStdioServerParameters
@ -133,12 +133,13 @@ def _fake_mcp_module(
shared_mod = ModuleType("mcp.shared")
exc_mod = ModuleType("mcp.shared.exceptions")
class _FakeMcpError(Exception):
class _FakeMCPError(Exception):
def __init__(self, code: int = -1, message: str = "error"):
self.error = SimpleNamespace(code=code, message=message)
super().__init__(message)
exc_mod.McpError = _FakeMcpError
mod.MCPError = _FakeMCPError
exc_mod.MCPError = _FakeMCPError
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
@ -147,7 +148,7 @@ def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
@ -185,7 +186,7 @@ def test_wrapper_preserves_non_nullable_unions() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
inputSchema={
input_schema={
"type": "object",
"properties": {
"value": {
@ -207,7 +208,7 @@ def test_wrapper_normalizes_nullable_property_type_union() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
inputSchema={
input_schema={
"type": "object",
"properties": {
"name": {"type": ["string", "null"]},
@ -224,7 +225,7 @@ def test_wrapper_normalizes_nullable_property_anyof() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
inputSchema={
input_schema={
"type": "object",
"properties": {
"name": {
@ -249,7 +250,7 @@ def test_wrapper_hoists_recursive_local_refs_into_defs() -> None:
tool_def = SimpleNamespace(
name="search_dataset",
description="search tool",
inputSchema={
input_schema={
"type": "object",
"properties": {
"filter": {
@ -282,7 +283,7 @@ def test_wrapper_hoists_root_self_ref_into_defs() -> None:
tool_def = SimpleNamespace(
name="tree",
description="tree tool",
inputSchema={
input_schema={
"type": "object",
"properties": {
"children": {"type": "array", "items": {"$ref": "#"}},
@ -304,7 +305,7 @@ def test_wrapper_preserves_existing_defs_refs() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
inputSchema={
input_schema={
"type": "object",
"$defs": {"value": {"type": "string"}},
"properties": {"value": {"$ref": "#/$defs/value"}},
@ -321,7 +322,7 @@ def test_wrapper_resolves_uri_encoded_json_pointer() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
inputSchema={
input_schema={
"type": "object",
"properties": {
"space name/value": {"type": "string"},
@ -449,7 +450,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
async def call_tool(_name: str, arguments: dict) -> object:
return SimpleNamespace(
content=[_FakeTextContent("Error: server-side MCP failure")],
isError=True,
is_error=True,
)
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
@ -494,7 +495,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
async def call_tool(_name: str, arguments: dict) -> object:
return SimpleNamespace(
content=[_FakeTextContent("Error: generated report successfully")],
isError=False,
is_error=False,
)
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
@ -622,7 +623,7 @@ def _make_tool_def(name: str) -> SimpleNamespace:
return SimpleNamespace(
name=name,
description=f"{name} tool",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
@ -936,7 +937,7 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
@asynccontextmanager
async def _capturing_streamable_http_client(_url: str, http_client=None):
assert http_client is not None
yield object(), object(), object()
yield object(), object()
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
@ -944,11 +945,11 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
"Httpx2PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
"nanobot.security.network.httpx.AsyncHTTPTransport",
"nanobot.security.network.httpx2.AsyncHTTPTransport",
lambda **_kwargs: httpx.MockTransport(
lambda request: httpx.Response(200, request=request)
),
@ -976,11 +977,11 @@ def test_mcp_http_clients_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
monkeypatch.setenv("NO_PROXY", "mcp.example.com")
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
"Httpx2PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
"nanobot.security.network.httpx.AsyncHTTPTransport",
"nanobot.security.network.httpx2.AsyncHTTPTransport",
lambda **_kwargs: httpx.MockTransport(
lambda request: httpx.Response(200, request=request)
),
@ -1050,13 +1051,15 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
assert http_client is not None
used_transports.append("streamableHttp")
await http_client.get("https://example.com/start")
yield object(), object(), object()
yield object(), object()
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
# Keep the redirect exercise isolated from host-level proxy settings.
monkeypatch.setattr(mcp_mod, "httpx2_env_proxy_mounts", lambda: {})
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
"Httpx2PinnedDNSAsyncTransport",
lambda **_kwargs: httpx.MockTransport(_handler),
)
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", _async_client_with_mock_transport)
@ -1138,13 +1141,13 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
@asynccontextmanager
async def _capturing_streamable_http_client(_url: str, http_client=None):
captured["timeout"] = http_client.timeout
yield object(), object(), object()
yield object(), object()
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
"Httpx2PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
@ -1385,10 +1388,10 @@ async def test_prompt_wrapper_execute_handles_timeout() -> None:
@pytest.mark.asyncio
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
from mcp.shared.exceptions import McpError
from mcp import MCPError
async def get_prompt(name: str, arguments: dict | None = None) -> object:
raise McpError(code=42, message="invalid argument")
raise MCPError(code=42, message="invalid argument")
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
result = await wrapper.execute()
@ -1510,7 +1513,7 @@ def test_tool_wrapper_sanitizes_name() -> None:
tool_def = SimpleNamespace(
name="My Tool",
description="tool with spaces",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
assert wrapper.name == "mcp_srv_My_Tool"
@ -1541,7 +1544,7 @@ def test_tool_wrapper_preserves_original_name_for_mcp_call() -> None:
tool_def = SimpleNamespace(
name="My Tool",
description="tool with spaces",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
# The sanitized API-facing name differs from the original MCP name
@ -1619,12 +1622,12 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
tool_def = SimpleNamespace(
name="search",
description="search tool",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
other_tool_def = SimpleNamespace(
name="search",
description="other search tool",
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), server_name, tool_def)
other_wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "other", other_tool_def)