From 5e2057d07838ccff433ea4133bfcea9e5c5dae01 Mon Sep 17 00:00:00 2001 From: Oliver Everett Date: Fri, 31 Jul 2026 18:08:57 +0800 Subject: [PATCH 1/2] feat: migrate MCP tooling to mcp 2.x Bump the mcp dependency to >=2.0.0,<3 and adapt the MCP tool integration to the 2.0 SDK surface: - McpError -> MCPError; ProgressFnT now lives in mcp.shared.dispatcher (mcp.shared.session was removed). - Read snake_case model attributes (input_schema, output_schema, is_error, mime_type) instead of the removed camelCase aliases. - ClientSession read_timeout_seconds now takes float seconds, not a timedelta. - streamable_http_client / streamable-http layer: the public McpHttpClientFactory moved to a private module, so declare the equivalent factory Protocol locally. - to_mcp_server: mcp.server.fastmcp was removed; build the server with mcp.server.mcpserver.MCPServer instead. In mcp 2.0 the server-side Context.session is a fresh ServerSession per request rather than per connection, so key the per-connection ADK session map on the shared underlying Connection object (with a fallback to the session) to keep one conversation per MCP connection. Refs #6532 --- pyproject.toml | 6 +-- .../adk/tools/mcp_tool/_agent_to_mcp.py | 39 +++++++++++---- .../adk/tools/mcp_tool/conversion_utils.py | 2 +- .../adk/tools/mcp_tool/mcp_session_manager.py | 18 +++++-- src/google/adk/tools/mcp_tool/mcp_tool.py | 14 +++--- src/google/adk/tools/mcp_tool/mcp_toolset.py | 2 +- .../adk/tools/mcp_tool/session_context.py | 5 +- .../tools/mcp_tool/test_agent_to_mcp.py | 50 ++++++++++++++++--- .../tools/mcp_tool/test_conversion_utils.py | 30 +++++------ .../mcp_tool/test_mcp_session_manager.py | 4 +- .../unittests/tools/mcp_tool/test_mcp_tool.py | 41 ++++++++------- .../tools/mcp_tool/test_mcp_toolset.py | 6 +-- .../tools/mcp_tool/test_session_context.py | 7 +-- 13 files changed, 147 insertions(+), 77 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 21cd6a1f11d..10e3e16984d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ optional-dependencies.all = [ "google-cloud-spanner>=3.56,<4", "google-cloud-speech>=2.30,<3", "google-cloud-storage>=2.18,<4", - "mcp>=1.24,<2", + "mcp>=2.0.0,<3", "opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0", "opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2", "opentelemetry-exporter-gcp-trace>=1.9,<2", @@ -195,7 +195,7 @@ optional-dependencies.gcp = [ ] optional-dependencies.mcp = [ "anyio>=4.9,<5", - "mcp>=1.24,<2", + "mcp>=2.0.0,<3", ] optional-dependencies.oci = [ "oci>=2.126", # OCI Generative AI native SDK (OCIGenAILlm) @@ -243,7 +243,7 @@ optional-dependencies.test = [ "litellm>=1.84", "llama-index-readers-file>=0.4", "lxml>=5.3", - "mcp>=1.24,<2", + "mcp>=2.0.0,<3", "openai>=2.20,<3", "opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0", "opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2", diff --git a/src/google/adk/tools/mcp_tool/_agent_to_mcp.py b/src/google/adk/tools/mcp_tool/_agent_to_mcp.py index 5c521927d56..ccf9bf7a588 100644 --- a/src/google/adk/tools/mcp_tool/_agent_to_mcp.py +++ b/src/google/adk/tools/mcp_tool/_agent_to_mcp.py @@ -23,8 +23,8 @@ from google.genai import types from mcp import types as mcp_types -from mcp.server.fastmcp import Context -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import Context +from mcp.server.mcpserver import MCPServer from ...agents.base_agent import BaseAgent from ...artifacts.in_memory_artifact_service import InMemoryArtifactService @@ -68,18 +68,37 @@ def _part_to_content(part: types.Part) -> Optional[mcp_types.ContentBlock]: data = base64.b64encode(blob.data).decode("ascii") mime = blob.mime_type or "application/octet-stream" if mime.startswith("image/"): - return mcp_types.ImageContent(type="image", data=data, mimeType=mime) + return mcp_types.ImageContent(type="image", data=data, mime_type=mime) if mime.startswith("audio/"): - return mcp_types.AudioContent(type="audio", data=data, mimeType=mime) + return mcp_types.AudioContent(type="audio", data=data, mime_type=mime) return mcp_types.EmbeddedResource( type="resource", resource=mcp_types.BlobResourceContents( - uri=_INLINE_RESOURCE_URI, blob=data, mimeType=mime + uri=_INLINE_RESOURCE_URI, blob=data, mime_type=mime ), ) return None +def _connection_key(ctx: Context) -> object: + """Returns a stable per-connection key for an MCP tool call context. + + In mcp 2.0, ``ctx.session`` is a new ``ServerSession`` object on every + request even over a single connection, so it can no longer key the + per-connection ADK session map. The underlying ``Connection`` object is + shared by every request on one connection, so we use it when available and + fall back to ``ctx.session`` otherwise. + + Args: + ctx: The MCP tool call context. + + Returns: + A hashable object that is stable across all requests on one connection. + """ + connection = getattr(ctx.session, "_connection", None) + return connection if connection is not None else ctx.session + + async def _run_agent( runner: Runner, request: str, @@ -106,14 +125,14 @@ async def _run_agent( """ session_id: Optional[str] = None if ctx is not None and sessions is not None: - session_id = sessions.get(ctx.session) + session_id = sessions.get(_connection_key(ctx)) if session_id is None: session = await runner.session_service.create_session( app_name=runner.app_name, user_id=_MCP_USER_ID ) session_id = session.id if ctx is not None and sessions is not None: - sessions[ctx.session] = session_id + sessions[_connection_key(ctx)] = session_id new_message = types.Content(role="user", parts=[types.Part(text=request)]) final_content: list[mcp_types.ContentBlock] = [] async for event in runner.run_async( @@ -142,7 +161,7 @@ def to_mcp_server( name: Optional[str] = None, instructions: Optional[str] = None, runner: Optional[Runner] = None, -) -> FastMCP: +) -> MCPServer: """Exposes an ADK agent as an MCP server. The returned server registers a single MCP tool that runs the agent: an MCP @@ -166,7 +185,7 @@ def to_mcp_server( services. Returns: - A ``FastMCP`` server exposing the agent as a single tool. + A ``MCPServer`` server exposing the agent as a single tool. Example:: @@ -175,7 +194,7 @@ def to_mcp_server( server.run(transport="stdio") """ tool_name = name or agent.name or "adk_agent" - server = FastMCP(name=tool_name, instructions=instructions) + server = MCPServer(name=tool_name, instructions=instructions) agent_runner = runner if runner is not None else _build_runner(agent) # Maps each MCP connection to its ADK session; WeakKeyDictionary drops the # entry when the connection is garbage-collected. pylint wrongly flags the diff --git a/src/google/adk/tools/mcp_tool/conversion_utils.py b/src/google/adk/tools/mcp_tool/conversion_utils.py index ddf5d3aa34a..0e87023edef 100644 --- a/src/google/adk/tools/mcp_tool/conversion_utils.py +++ b/src/google/adk/tools/mcp_tool/conversion_utils.py @@ -56,7 +56,7 @@ def adk_to_mcp_tool_type(tool: BaseTool) -> mcp_types.Tool: return mcp_types.Tool( name=tool.name, description=tool.description, - inputSchema=input_schema, + input_schema=input_schema, ) diff --git a/src/google/adk/tools/mcp_tool/mcp_session_manager.py b/src/google/adk/tools/mcp_tool/mcp_session_manager.py index 4d130c59bdb..9d11d0f68ce 100644 --- a/src/google/adk/tools/mcp_tool/mcp_session_manager.py +++ b/src/google/adk/tools/mcp_tool/mcp_session_manager.py @@ -64,7 +64,6 @@ class AsyncAuthorizedSession: # pylint: disable=g-bad-classes from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client from mcp.client.streamable_http import create_mcp_http_client as _create_mcp_http_client -from mcp.client.streamable_http import McpHttpClientFactory from mcp.client.streamable_http import streamable_http_client from pydantic import BaseModel from pydantic import ConfigDict @@ -215,8 +214,21 @@ class SseConnectionParams(BaseModel): @runtime_checkable -class CheckableMcpHttpClientFactory(McpHttpClientFactory, Protocol): - pass +class CheckableMcpHttpClientFactory(Protocol): + """Factory protocol for creating custom HTTPX async clients. + + In mcp 2.0 the upstream ``McpHttpClientFactory`` protocol lives in the + private ``mcp.shared._httpx_utils`` module, so we declare the equivalent + shape locally rather than depend on a private import. + """ + + def __call__( + self, + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + ) -> httpx.AsyncClient: + ... class _DebugHttpxClientFactory: diff --git a/src/google/adk/tools/mcp_tool/mcp_tool.py b/src/google/adk/tools/mcp_tool/mcp_tool.py index 3be223af843..213ec23533f 100644 --- a/src/google/adk/tools/mcp_tool/mcp_tool.py +++ b/src/google/adk/tools/mcp_tool/mcp_tool.py @@ -28,8 +28,8 @@ from fastapi.openapi.models import APIKeyIn from google.genai.types import FunctionDeclaration -from mcp.shared.exceptions import McpError -from mcp.shared.session import ProgressFnT +from mcp.shared.dispatcher import ProgressFnT +from mcp.shared.exceptions import MCPError from mcp.types import Tool as McpBaseTool from opentelemetry import propagate from typing_extensions import override @@ -201,8 +201,8 @@ def _get_declaration(self) -> FunctionDeclaration: Returns: FunctionDeclaration: The Gemini function declaration for the tool. """ - input_schema = self._mcp_tool.inputSchema - output_schema = self._mcp_tool.outputSchema + input_schema = self._mcp_tool.input_schema + output_schema = self._mcp_tool.output_schema if is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL): function_decl = FunctionDeclaration( name=self.name, @@ -375,8 +375,8 @@ async def run_async( # any AGW policy) returns a 403 mid-tool-call. try: return await super().run_async(args=args, tool_context=tool_context) - except McpError as e: - logger.warning("MCP tool execution failed with McpError: %s", e) + except MCPError as e: + logger.warning("MCP tool execution failed with MCPError: %s", e) return {"error": f"MCP tool execution failed: {e}"} except Exception as e: # pylint: disable=broad-exception-caught logger.warning( @@ -489,7 +489,7 @@ async def _run_async_impl( def _detect_error_in_response(self, response: Any) -> str | None: """Telemetry hook: returns an error type if the response indicates an error.""" - if isinstance(response, dict) and response.get("isError"): + if isinstance(response, dict) and response.get("is_error"): return "MCP_TOOL_ERROR" return None diff --git a/src/google/adk/tools/mcp_tool/mcp_toolset.py b/src/google/adk/tools/mcp_tool/mcp_toolset.py index e8531fcaa6d..81809258549 100644 --- a/src/google/adk/tools/mcp_tool/mcp_toolset.py +++ b/src/google/adk/tools/mcp_tool/mcp_toolset.py @@ -34,7 +34,7 @@ from mcp import StdioServerParameters from mcp.client.session import ElicitationFnT from mcp.client.session import SamplingFnT -from mcp.shared.session import ProgressFnT +from mcp.shared.dispatcher import ProgressFnT from mcp.types import ListResourcesResult from mcp.types import ListToolsResult from pydantic import model_validator diff --git a/src/google/adk/tools/mcp_tool/session_context.py b/src/google/adk/tools/mcp_tool/session_context.py index f08b03da3c4..af6fa539baf 100644 --- a/src/google/adk/tools/mcp_tool/session_context.py +++ b/src/google/adk/tools/mcp_tool/session_context.py @@ -17,7 +17,6 @@ import asyncio from contextlib import AbstractAsyncContextManager from contextlib import AsyncExitStack -from datetime import timedelta import logging from types import TracebackType from typing import Any @@ -321,7 +320,7 @@ async def _run(self) -> None: session = await exit_stack.enter_async_context( ClientSession( *transports[:2], - read_timeout_seconds=timedelta(seconds=self._timeout) + read_timeout_seconds=float(self._timeout) if self._timeout is not None else None, sampling_callback=self._sampling_callback, @@ -335,7 +334,7 @@ async def _run(self) -> None: session = await exit_stack.enter_async_context( ClientSession( *transports[:2], - read_timeout_seconds=timedelta(seconds=self._sse_read_timeout) + read_timeout_seconds=float(self._sse_read_timeout) if self._sse_read_timeout is not None else None, sampling_callback=self._sampling_callback, diff --git a/tests/unittests/tools/mcp_tool/test_agent_to_mcp.py b/tests/unittests/tools/mcp_tool/test_agent_to_mcp.py index ceefcf1342e..ed8c5cfb01b 100644 --- a/tests/unittests/tools/mcp_tool/test_agent_to_mcp.py +++ b/tests/unittests/tools/mcp_tool/test_agent_to_mcp.py @@ -15,19 +15,57 @@ from __future__ import annotations import base64 +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import AsyncGenerator +import anyio from google.adk.agents.base_agent import BaseAgent from google.adk.agents.invocation_context import InvocationContext from google.adk.events.event import Event from google.adk.tools.mcp_tool._agent_to_mcp import _run_agent from google.adk.tools.mcp_tool._agent_to_mcp import to_mcp_server from google.genai import types -from mcp.shared.memory import create_connected_server_and_client_session +from mcp import ClientSession +from mcp.server.mcpserver import MCPServer +from mcp.shared.memory import create_client_server_memory_streams import pytest +@asynccontextmanager +async def _connected_client_session(server: MCPServer): + """Connects an in-memory ClientSession to an MCPServer for testing. + + This replaces the ``create_connected_server_and_client_session`` helper that + was removed in mcp 2.0. It runs the server's low-level transport on one end + of an in-memory stream pair and yields a connected, initialized + ClientSession on the other. + + Args: + server: The MCPServer to connect to. + + Yields: + An initialized ClientSession connected to the server. + """ + async with create_client_server_memory_streams() as ( + client_streams, + server_streams, + ): + client_read, client_write = client_streams + server_read, server_write = server_streams + lowlevel_server = server._lowlevel_server # pylint: disable=protected-access + async with anyio.create_task_group() as task_group: + task_group.start_soon( + lowlevel_server.run, + server_read, + server_write, + lowlevel_server.create_initialization_options(), + ) + async with ClientSession(client_read, client_write) as session: + await session.initialize() + yield session + + class _EchoAgent(BaseAgent): """Minimal agent that emits a single final text event.""" @@ -111,7 +149,7 @@ async def test_to_mcp_server_registers_agent_as_single_tool(): assert len(tools) == 1 assert tools[0].name == "my_agent" assert tools[0].description == "does useful things" - assert "request" in tools[0].inputSchema["properties"] + assert "request" in tools[0].input_schema["properties"] @pytest.mark.asyncio @@ -129,10 +167,10 @@ async def test_call_tool_runs_agent_end_to_end(): agent = _EchoAgent(name="assistant") server = to_mcp_server(agent) - async with create_connected_server_and_client_session(server) as client: + async with _connected_client_session(server) as client: result = await client.call_tool("assistant", {"request": "hi"}) - assert not result.isError + assert not result.is_error assert "hello from the agent" in result.content[0].text @@ -174,7 +212,7 @@ async def test_run_agent_maps_image_output_to_image_content(): assert len(result) == 1 assert result[0].type == "image" - assert result[0].mimeType == "image/png" + assert result[0].mime_type == "image/png" assert base64.b64decode(result[0].data) == png @@ -209,7 +247,7 @@ async def test_call_tool_reuses_session_across_calls_on_one_connection(): runner = _FakeRunner([_text_event("ok")]) server = to_mcp_server(agent, runner=runner) - async with create_connected_server_and_client_session(server) as client: + async with _connected_client_session(server) as client: await client.call_tool("assistant", {"request": "first"}) await client.call_tool("assistant", {"request": "second"}) diff --git a/tests/unittests/tools/mcp_tool/test_conversion_utils.py b/tests/unittests/tools/mcp_tool/test_conversion_utils.py index d37c7546a71..34a5c2dc345 100644 --- a/tests/unittests/tools/mcp_tool/test_conversion_utils.py +++ b/tests/unittests/tools/mcp_tool/test_conversion_utils.py @@ -39,7 +39,7 @@ def test_tool_with_no_declaration(self): assert isinstance(result, mcp_types.Tool) assert result.name == "test_tool" assert result.description == "Test tool" - assert result.inputSchema == {} + assert result.input_schema == {} def test_tool_with_parameters_schema(self): """Test conversion when tool has parameters Schema object.""" @@ -72,14 +72,14 @@ def test_tool_with_parameters_schema(self): assert isinstance(result, mcp_types.Tool) assert result.name == "get_weather" assert result.description == "Gets weather information" - assert "type" in result.inputSchema - assert result.inputSchema["type"] == "object" - assert "properties" in result.inputSchema - assert "location" in result.inputSchema["properties"] - assert "units" in result.inputSchema["properties"] - assert result.inputSchema["properties"]["location"]["type"] == "string" - assert "required" in result.inputSchema - assert "location" in result.inputSchema["required"] + assert "type" in result.input_schema + assert result.input_schema["type"] == "object" + assert "properties" in result.input_schema + assert "location" in result.input_schema["properties"] + assert "units" in result.input_schema["properties"] + assert result.input_schema["properties"]["location"]["type"] == "string" + assert "required" in result.input_schema + assert "location" in result.input_schema["required"] def test_tool_with_parameters_json_schema(self): """Test conversion when tool has parameters_json_schema.""" @@ -115,7 +115,7 @@ def test_tool_with_parameters_json_schema(self): assert result.name == "search_database" assert result.description == "Searches a database" # Should use the JSON schema directly - assert result.inputSchema == json_schema + assert result.input_schema == json_schema def test_tool_with_no_parameters(self): """Test conversion when tool has declaration but no parameters.""" @@ -134,7 +134,7 @@ def test_tool_with_no_parameters(self): assert isinstance(result, mcp_types.Tool) assert result.name == "get_current_time" assert result.description == "Gets the current time" - assert not result.inputSchema + assert not result.input_schema def test_tool_prefers_json_schema_over_parameters(self): """Test that parameters_json_schema is preferred over parameters.""" @@ -166,9 +166,9 @@ def test_tool_prefers_json_schema_over_parameters(self): result = adk_to_mcp_tool_type(mock_tool) # Should use parameters_json_schema, not parameters - assert result.inputSchema == json_schema - assert "json_param" in result.inputSchema["properties"] - assert "schema_param" not in result.inputSchema["properties"] + assert result.input_schema == json_schema + assert "json_param" in result.input_schema["properties"] + assert "schema_param" not in result.input_schema["properties"] def test_tool_with_complex_nested_schema(self): """Test conversion with complex nested parameters_json_schema.""" @@ -206,4 +206,4 @@ def test_tool_with_complex_nested_schema(self): result = adk_to_mcp_tool_type(mock_tool) assert isinstance(result, mcp_types.Tool) - assert result.inputSchema == json_schema + assert result.input_schema == json_schema diff --git a/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py b/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py index 916f7b52ef5..2ad8fb85a6f 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py @@ -34,6 +34,7 @@ from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams import httpx +import httpx2 from mcp import StdioServerParameters import pytest @@ -244,7 +245,8 @@ def test_init_with_streamable_http_default_httpx_factory( kwargs = mock_streamable_http_client.call_args.kwargs assert kwargs["url"] == "https://example.com/mcp" assert kwargs["terminate_on_close"] is True - assert isinstance(kwargs["http_client"], httpx.AsyncClient) + # mcp 2.0's default create_mcp_http_client builds a vendored httpx2 client. + assert isinstance(kwargs["http_client"], httpx2.AsyncClient) @patch( "google.adk.tools.mcp_tool.mcp_session_manager.HTTPXClientInstrumentor", diff --git a/tests/unittests/tools/mcp_tool/test_mcp_tool.py b/tests/unittests/tools/mcp_tool/test_mcp_tool.py index fefd04f190b..e791f16378c 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_tool.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_tool.py @@ -49,13 +49,13 @@ def __init__( self, name="test_tool", description="Test tool description", - outputSchema=None, + output_schema=None, meta=None, ): self.name = name self.description = description self.meta = meta - self.inputSchema = { + self.input_schema = { "type": "object", "properties": { "param1": {"type": "string", "description": "First parameter"}, @@ -63,7 +63,7 @@ def __init__( }, "required": ["param1"], } - self.outputSchema = outputSchema + self.output_schema = output_schema class TestMCPToolLegacy: @@ -152,7 +152,7 @@ def test_get_declaration_with_output_schema_and_json_schema_for_func_decl_enable } tool = MCPTool( - mcp_tool=MockMCPTool(outputSchema=output_schema), + mcp_tool=MockMCPTool(output_schema=output_schema), mcp_session_manager=self.mock_session_manager, ) @@ -170,7 +170,7 @@ def test_get_declaration_with_empty_output_schema_and_json_schema_for_func_decl_ ): """Test function declaration with an empty output schema and json schema for func decl enabled.""" tool = MCPTool( - mcp_tool=MockMCPTool(outputSchema={}), + mcp_tool=MockMCPTool(output_schema={}), mcp_session_manager=self.mock_session_manager, ) @@ -1384,10 +1384,9 @@ async def mock_call_tool(*args, **kwargs): async def test_run_async_captures_http_debug_info_on_graceful_error( self, mock_is_enabled ): - """Test that run_async captures HTTP debug info when tool call fails gracefully with McpError.""" + """Test that run_async captures HTTP debug info when tool call fails gracefully with MCPError.""" from google.adk.tools.mcp_tool.mcp_session_manager import _http_debug_var - from mcp.shared.exceptions import McpError - from mcp.types import ErrorData + from mcp.shared.exceptions import MCPError tool = MCPTool( mcp_tool=self.mock_mcp_tool, @@ -1400,7 +1399,7 @@ async def mock_call_tool(*args, **kwargs): debug_list.append( {"url": "https://example.com/api", "status_code": 403} ) - raise McpError(ErrorData(code=-32000, message="Forbidden")) + raise MCPError(code=-32000, message="Forbidden") self.mock_session.call_tool = mock_call_tool @@ -1447,17 +1446,19 @@ def setup_method(self): @pytest.mark.asyncio async def test_run_async_returns_dict_on_mcp_error_when_flag_on(self): - """When the flag is on, McpError surfaces as `{"error": "..."}`.""" - from mcp.shared.exceptions import McpError - from mcp.types import ErrorData + """When the flag is on, MCPError surfaces as `{"error": "..."}`.""" + from mcp.shared.exceptions import MCPError tool = MCPTool( mcp_tool=self.mock_mcp_tool, mcp_session_manager=self.mock_session_manager, ) - error_data = ErrorData(code=-32000, message="Client error '403 Forbidden'") - tool._run_async_impl = AsyncMock(side_effect=McpError(error_data)) + tool._run_async_impl = AsyncMock( + side_effect=MCPError( + code=-32000, message="Client error '403 Forbidden'" + ) + ) tool_context = Mock(spec=ToolContext) args = {"param1": "test_value"} @@ -1507,16 +1508,18 @@ async def test_run_async_propagates_mcp_error_when_flag_off(self): This protects downstream consumers that haven't migrated yet from a silent behavior change. """ - from mcp.shared.exceptions import McpError - from mcp.types import ErrorData + from mcp.shared.exceptions import MCPError tool = MCPTool( mcp_tool=self.mock_mcp_tool, mcp_session_manager=self.mock_session_manager, ) - error_data = ErrorData(code=-32000, message="Client error '403 Forbidden'") - tool._run_async_impl = AsyncMock(side_effect=McpError(error_data)) + tool._run_async_impl = AsyncMock( + side_effect=MCPError( + code=-32000, message="Client error '403 Forbidden'" + ) + ) tool_context = Mock(spec=ToolContext) args = {"param1": "test_value"} @@ -1524,7 +1527,7 @@ async def test_run_async_propagates_mcp_error_when_flag_off(self): with temporary_feature_override( FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False ): - with pytest.raises(McpError): + with pytest.raises(MCPError): await tool.run_async(args=args, tool_context=tool_context) @pytest.mark.asyncio diff --git a/tests/unittests/tools/mcp_tool/test_mcp_toolset.py b/tests/unittests/tools/mcp_tool/test_mcp_toolset.py index ceff08918a4..d262f98b312 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_toolset.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_toolset.py @@ -58,7 +58,7 @@ class MockMCPTool: def __init__(self, name, description="Test tool description"): self.name = name self.description = description - self.inputSchema = { + self.input_schema = { "type": "object", "properties": {"param": {"type": "string"}}, } @@ -737,11 +737,11 @@ async def test_read_resource(self, name, mime_type, content, encoding): # Mock read_resource if encoding == "base64": contents = [ - BlobResourceContents(uri=uri, mimeType=mime_type, blob=content) + BlobResourceContents(uri=uri, mime_type=mime_type, blob=content) ] else: contents = [ - TextResourceContents(uri=uri, mimeType=mime_type, text=content) + TextResourceContents(uri=uri, mime_type=mime_type, text=content) ] read_resource_result = ReadResourceResult(contents=contents) diff --git a/tests/unittests/tools/mcp_tool/test_session_context.py b/tests/unittests/tools/mcp_tool/test_session_context.py index bc3391f65e5..fbe5517c9be 100644 --- a/tests/unittests/tools/mcp_tool/test_session_context.py +++ b/tests/unittests/tools/mcp_tool/test_session_context.py @@ -16,7 +16,6 @@ import asyncio from contextlib import AsyncExitStack -from datetime import timedelta from unittest.mock import AsyncMock from unittest.mock import Mock from unittest.mock import patch @@ -420,7 +419,7 @@ async def test_stdio_client_with_read_timeout(self): # Verify ClientSession was called with read_timeout_seconds for stdio call_args = mock_session_class.call_args assert 'read_timeout_seconds' in call_args.kwargs - assert call_args.kwargs['read_timeout_seconds'] == timedelta(seconds=5.0) + assert call_args.kwargs['read_timeout_seconds'] == 5.0 await session_context.close() @@ -469,9 +468,7 @@ async def test_sse_read_timeout_passed_to_client_session(self): # Verify ClientSession was called with sse_read_timeout call_args = mock_session_class.call_args assert 'read_timeout_seconds' in call_args.kwargs - assert call_args.kwargs['read_timeout_seconds'] == timedelta( - seconds=300.0 - ) + assert call_args.kwargs['read_timeout_seconds'] == 300.0 await session_context.close() From 569b761463d9ecc589aec9ab56f1fe5b78ddd4ee Mon Sep 17 00:00:00 2001 From: Oliver Everett Date: Fri, 21 Aug 2026 18:55:35 +0800 Subject: [PATCH 2/2] fix(mcp): keep 2.x payloads on the camelCase wire shape; adopt httpx2 Follow-ups to the mcp 2.x migration surfaced while reconciling with upstream main (review notes by @zeishr on #6532): - Serialize tool results with model_dump(by_alias=True) so the payload keeps the 1.x camelCase keys ('isError', 'structuredContent'). Without it the 2.x snake_case field names leak to the model, and the 'isError' lookup in _detect_error_in_response silently stops detecting tool errors. Note 'resultType' is a new key with no 1.x equivalent. - mcp 2.x builds its HTTP layer on httpx2 (httpx is gone from its dependency tree) and streamable_http_client(http_client=...) requires an httpx2.AsyncClient. The client factory, the debug factory and the google-auth mTLS bridge (_GoogleAuthAsyncTransport / _SharedAsyncTransport) now produce httpx2 objects, and httpx2 is declared in the mcp extra. opentelemetry-instrumentation-httpx cannot instrument httpx2 clients (it fails with 'httpx must be installed'), so MCP HTTP client spans are dropped until a real httpx2 instrumentor exists; _meta trace propagation in mcp_tool.py is unaffected. - load_mcp_resource_tool read the camelCase 'mimeType' attribute, which no longer exists on 2.x models, so binary resources failed to decode. --- .../adk/tools/load_mcp_resource_tool.py | 2 +- .../adk/tools/mcp_tool/mcp_session_manager.py | 92 ++++++++--------- src/google/adk/tools/mcp_tool/mcp_tool.py | 8 +- src/google/adk/tools/mcp_tool/mcp_toolset.py | 4 +- .../mcp_tool/test_mcp_session_manager.py | 98 ++++++++----------- .../unittests/tools/mcp_tool/test_mcp_tool.py | 28 +++--- .../tools/mcp_tool/test_mcp_toolset.py | 4 +- 7 files changed, 113 insertions(+), 123 deletions(-) diff --git a/src/google/adk/tools/load_mcp_resource_tool.py b/src/google/adk/tools/load_mcp_resource_tool.py index 86eff9182cd..b57b64387fe 100644 --- a/src/google/adk/tools/load_mcp_resource_tool.py +++ b/src/google/adk/tools/load_mcp_resource_tool.py @@ -160,7 +160,7 @@ def _mcp_content_to_part( try: data = base64.b64decode(content.blob) # Basic check for mime type or default - mime_type = content.mimeType or "application/octet-stream" + mime_type = content.mime_type or "application/octet-stream" return types.Part.from_bytes(data=data, mime_type=mime_type) except Exception: return types.Part.from_text( diff --git a/src/google/adk/tools/mcp_tool/mcp_session_manager.py b/src/google/adk/tools/mcp_tool/mcp_session_manager.py index 1d2aa57b30e..ccafc1e411c 100644 --- a/src/google/adk/tools/mcp_tool/mcp_session_manager.py +++ b/src/google/adk/tools/mcp_tool/mcp_session_manager.py @@ -40,7 +40,7 @@ import google.auth import google.auth.credentials from google.auth.transport.requests import Request -import httpx +import httpx2 try: from google.auth.aio.credentials import Credentials as AsyncCredentials @@ -69,12 +69,11 @@ class AsyncAuthorizedSession: # pylint: disable=g-bad-classes from pydantic import BaseModel from pydantic import ConfigDict -try: - from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor - - _HAS_HTTPX_INSTRUMENTOR = True -except (ImportError, AttributeError): - _HAS_HTTPX_INSTRUMENTOR = False +# NOTE: opentelemetry-instrumentation-httpx only instruments `httpx` clients. +# mcp 2.x builds its HTTP layer on `httpx2`, and no httpx2 instrumentor exists +# yet (the only PyPI candidate is a 0.0.0 placeholder), so MCP HTTP client +# spans are not emitted until one lands. `_meta` trace propagation in +# mcp_tool.py is unaffected -- it does not go through OTel HTTP instrumentation. from ...features import FeatureName from ...features import is_feature_enabled @@ -102,18 +101,21 @@ class AsyncAuthorizedSession: # pylint: disable=g-bad-classes def create_mcp_http_client( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, -) -> httpx.AsyncClient: - """Creates MCP HTTP client and instruments it when OTel is available.""" - client = _create_mcp_http_client( + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, +) -> httpx2.AsyncClient: + """Creates an MCP HTTP client with the SDK's SSE-friendly defaults. + + The client is an ``httpx2.AsyncClient`` -- mcp 2.x's transports accept + nothing else. It is deliberately not OTel-instrumented: the available + ``opentelemetry-instrumentation-httpx`` only instruments ``httpx`` (see the + note at the top of this module). + """ + return _create_mcp_http_client( headers=headers, timeout=timeout, auth=auth, ) - if _HAS_HTTPX_INSTRUMENTOR: - HTTPXClientInstrumentor.instrument_client(client) - return client _http_debug_var: contextvars.ContextVar[list[dict[str, Any]] | None] = ( @@ -135,7 +137,7 @@ class _StreamableHttpClientWrapper: def __init__( self, url: str, - http_client: httpx.AsyncClient, + http_client: httpx2.AsyncClient, terminate_on_close: bool = True, ): self.url = url @@ -246,14 +248,14 @@ class CheckableMcpHttpClientFactory(Protocol): def __call__( self, headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: ... class _DebugHttpxClientFactory: - """A factory wrapper that hooks into the httpx.AsyncClient responses to capture debug info.""" + """A factory wrapper that hooks into the httpx2.AsyncClient responses to capture debug info.""" def __init__( self, @@ -266,15 +268,15 @@ def __init__( def __call__( self, headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: client = self._base_factory(headers=headers, timeout=timeout, auth=auth) if hasattr(client, 'event_hooks') and isinstance(client.event_hooks, dict): client.event_hooks.setdefault('response', []).append(self._response_hook) return client - def _extract_session_id(self, response: httpx.Response) -> str | None: + def _extract_session_id(self, response: httpx2.Response) -> str | None: query_params = urllib.parse.parse_qs( urllib.parse.urlparse(str(response.url)).query ) @@ -283,7 +285,7 @@ def _extract_session_id(self, response: httpx.Response) -> str | None: or query_params.get('session_id', [None])[0] ) - async def _response_hook(self, response: httpx.Response): + async def _response_hook(self, response: httpx2.Response): debug_list = None if self._session_manager is not None: session_id = self._extract_session_id(response) @@ -447,8 +449,8 @@ def _refresh_sync(self) -> None: self._creds.refresh(Request()) -class _GoogleAuthAsyncByteStream(httpx.AsyncByteStream): - """Adapter to bridge google-auth Response.content with httpx.AsyncByteStream.""" +class _GoogleAuthAsyncByteStream(httpx2.AsyncByteStream): + """Adapter to bridge google-auth Response.content with httpx2.AsyncByteStream.""" def __init__(self, auth_response: Any): self._auth_response = auth_response @@ -461,15 +463,15 @@ async def aclose(self) -> None: await self._auth_response.close() -class _GoogleAuthAsyncTransport(httpx.AsyncBaseTransport): - """Adapter to bridge google-auth AsyncAuthorizedSession with httpx.AsyncBaseTransport.""" +class _GoogleAuthAsyncTransport(httpx2.AsyncBaseTransport): + """Adapter to bridge google-auth AsyncAuthorizedSession with httpx2.AsyncBaseTransport.""" def __init__(self, auth_session: Any): self._auth_session = auth_session async def handle_async_request( - self, request: httpx.Request - ) -> httpx.Response: + self, request: httpx2.Request + ) -> httpx2.Response: content = await request.aread() headers_dict = dict(request.headers) @@ -495,7 +497,7 @@ async def handle_async_request( # google-auth-aio uses aiohttp internally, which automatically handles # decompression and decodes chunked transfer encoding, but leaves the - # headers intact. We must strip these headers so httpx doesn't attempt + # headers intact. We must strip these headers so httpx2 doesn't attempt # to decompress or parse chunked framing again on the raw stream. response_headers = { k: v @@ -504,7 +506,7 @@ async def handle_async_request( not in ('content-encoding', 'content-length', 'transfer-encoding') } - return httpx.Response( + return httpx2.Response( status_code=auth_response.status_code, headers=response_headers, stream=_GoogleAuthAsyncByteStream(auth_response), @@ -514,15 +516,15 @@ async def aclose(self) -> None: await self._auth_session.close() -class _SharedAsyncTransport(httpx.AsyncBaseTransport): +class _SharedAsyncTransport(httpx2.AsyncBaseTransport): """Wrapper transport that prevents the wrapped transport from being closed.""" - def __init__(self, transport: httpx.AsyncBaseTransport): + def __init__(self, transport: httpx2.AsyncBaseTransport): self._transport = transport async def handle_async_request( - self, request: httpx.Request - ) -> httpx.Response: + self, request: httpx2.Request + ) -> httpx2.Response: return await self._transport.handle_async_request(request) async def aclose(self) -> None: @@ -530,16 +532,16 @@ async def aclose(self) -> None: def _create_mtls_client_factory( - mtls_transport: httpx.AsyncBaseTransport, + mtls_transport: httpx2.AsyncBaseTransport, ) -> CheckableMcpHttpClientFactory: - """Returns a factory that creates httpx.AsyncClient using the mtls_transport.""" + """Returns a factory that creates httpx2.AsyncClient using the mtls_transport.""" def factory( headers: dict[str, Any] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: - return httpx.AsyncClient( + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: + return httpx2.AsyncClient( headers=headers, auth=auth, timeout=timeout, @@ -1027,7 +1029,7 @@ def _evict_idle_sessions(self, keep_key: str) -> None: def _create_client( self, merged_headers: dict[str, str] | None = None, - mtls_transport: httpx.AsyncBaseTransport | None = None, + mtls_transport: httpx2.AsyncBaseTransport | None = None, *, session_key: str | None = None, ) -> AbstractAsyncContextManager[Any]: @@ -1079,7 +1081,7 @@ def _create_client( ) http_client = debug_factory( headers=merged_headers, - timeout=httpx.Timeout( + timeout=httpx2.Timeout( self._connection_params.timeout, read=self._connection_params.sse_read_timeout, ), diff --git a/src/google/adk/tools/mcp_tool/mcp_tool.py b/src/google/adk/tools/mcp_tool/mcp_tool.py index edd00768397..973d596aa7f 100644 --- a/src/google/adk/tools/mcp_tool/mcp_tool.py +++ b/src/google/adk/tools/mcp_tool/mcp_tool.py @@ -508,7 +508,11 @@ async def _run_async_impl( finally: self._mcp_session_manager._end_session_use(final_headers) # pylint: disable=protected-access - result = response.model_dump(exclude_none=True, mode="json") + # Serialize with camelCase aliases so the payload keeps the MCP wire shape + # the model saw on mcp 1.x ('isError', 'structuredContent', ...). Without + # by_alias the 2.x snake_case field names would leak into the tool result + # and silently break the 'isError' lookup in _detect_error_in_response. + result = response.model_dump(exclude_none=True, mode="json", by_alias=True) # Push UI widget to the event actions if the tool supports it. if self.mcp_app_resource_uri: @@ -527,7 +531,7 @@ async def _run_async_impl( def _detect_error_in_response(self, response: Any) -> str | None: """Telemetry hook: returns an error type if the response indicates an error.""" - if isinstance(response, dict) and response.get("is_error"): + if isinstance(response, dict) and response.get("isError"): return "MCP_TOOL_ERROR" return None diff --git a/src/google/adk/tools/mcp_tool/mcp_toolset.py b/src/google/adk/tools/mcp_tool/mcp_toolset.py index c7374d76ca3..40353730f85 100644 --- a/src/google/adk/tools/mcp_tool/mcp_toolset.py +++ b/src/google/adk/tools/mcp_tool/mcp_toolset.py @@ -594,7 +594,9 @@ async def get_resource_info( ) for resource in result.resources: if resource.name == name: - return resource.model_dump(mode="json", exclude_none=True) + # by_alias keeps the camelCase MCP wire keys ('mimeType', ...) stable + # across the mcp 1.x -> 2.x model rename. + return resource.model_dump(mode="json", exclude_none=True, by_alias=True) raise ValueError(f"Resource with name '{name}' not found.") async def close(self) -> None: diff --git a/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py b/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py index f6e5f1cdce4..4fccadb7164 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py @@ -40,7 +40,6 @@ from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams -import httpx import httpx2 from mcp import StdioServerParameters import pytest @@ -277,46 +276,27 @@ def test_init_with_streamable_http_default_httpx_factory( # mcp 2.0's default create_mcp_http_client builds a vendored httpx2 client. assert isinstance(kwargs["http_client"], httpx2.AsyncClient) - @patch( - "google.adk.tools.mcp_tool.mcp_session_manager.HTTPXClientInstrumentor", - create=True, - ) @patch( "google.adk.tools.mcp_tool.mcp_session_manager._create_mcp_http_client" ) - @patch( - "google.adk.tools.mcp_tool.mcp_session_manager._HAS_HTTPX_INSTRUMENTOR", - True, - ) - def test_default_httpx_factory_instruments_client_when_available( - self, mock_base_factory, mock_instrumentor - ): - """Test default MCP HTTP factory instruments HTTPX client when available.""" - client = Mock() - mock_base_factory.return_value = client - - result = create_mcp_http_client() - - assert result is client - mock_instrumentor.instrument_client.assert_called_once_with(client) - - @patch( - "google.adk.tools.mcp_tool.mcp_session_manager._create_mcp_http_client" - ) - @patch( - "google.adk.tools.mcp_tool.mcp_session_manager._HAS_HTTPX_INSTRUMENTOR", - False, - ) - def test_default_httpx_factory_handles_missing_opentelemetry( + def test_default_httpx_factory_delegates_to_mcp_factory( self, mock_base_factory ): - """Test default MCP HTTP factory works without OTel instrumentation.""" + """The default factory forwards to the SDK's httpx2 client factory. + + OTel instrumentation was dropped with the mcp 2.x bump: + opentelemetry-instrumentation-httpx only instruments ``httpx``, and the + clients here are ``httpx2`` (no httpx2 instrumentor exists yet). + """ client = Mock() mock_base_factory.return_value = client - result = create_mcp_http_client() + result = create_mcp_http_client(headers={"a": "b"}) assert result is client + mock_base_factory.assert_called_once_with( + headers={"a": "b"}, timeout=None, auth=None + ) def test_generate_session_key_stdio(self): """Test session key generation for stdio connections.""" @@ -1423,7 +1403,7 @@ def test_create_client_with_mtls_transport_sse(self, mock_sse_client): sse_params = SseConnectionParams(url="https://example.com/mcp") manager = MCPSessionManager(sse_params) - mock_transport = Mock(spec=httpx.AsyncBaseTransport) + mock_transport = Mock(spec=httpx2.AsyncBaseTransport) manager._create_client(mtls_transport=mock_transport) @@ -1432,8 +1412,8 @@ def test_create_client_with_mtls_transport_sse(self, mock_sse_client): factory = called_kwargs["httpx_client_factory"] # Verify the factory creates client with transport - client = factory(headers={"a": "b"}, timeout=httpx.Timeout(10.0)) - assert isinstance(client, httpx.AsyncClient) + client = factory(headers={"a": "b"}, timeout=httpx2.Timeout(10.0)) + assert isinstance(client, httpx2.AsyncClient) assert isinstance(client._transport, _SharedAsyncTransport) assert client._transport._transport == mock_transport assert client.headers.get("a") == "b" @@ -1454,7 +1434,7 @@ async def test_google_auth_async_transport_handle_request(self): transport = _GoogleAuthAsyncTransport(mock_session) - request = httpx.Request( + request = httpx2.Request( "GET", "https://example.com/api", headers={"x-test": "value"} ) @@ -1944,7 +1924,7 @@ class TestDebugHttpxClientFactory: @pytest.mark.asyncio async def test_debug_factory_registers_hook(self): """Test that the debug factory registers the response hook on client creation.""" - base_client = httpx.AsyncClient() + base_client = httpx2.AsyncClient() base_factory = Mock(return_value=base_client) debug_factory = _DebugHttpxClientFactory(base_factory) @@ -1956,21 +1936,21 @@ async def test_debug_factory_registers_hook(self): @pytest.mark.asyncio async def test_response_hook_records_when_var_set(self): """Test that the response hook records HTTP info when _http_debug_var is set.""" - base_client = httpx.AsyncClient() + base_client = httpx2.AsyncClient() base_factory = Mock(return_value=base_client) debug_factory = _DebugHttpxClientFactory(base_factory) - # Mock httpx.Response - mock_request = Mock(spec=httpx.Request) + # Mock httpx2.Response + mock_request = Mock(spec=httpx2.Request) mock_request.method = "GET" mock_request.content = b"request body" - mock_request.headers = httpx.Headers({"X-Req": "val"}) + mock_request.headers = httpx2.Headers({"X-Req": "val"}) - mock_response = Mock(spec=httpx.Response) - mock_response.url = httpx.URL("https://example.com/test") + mock_response = Mock(spec=httpx2.Response) + mock_response.url = httpx2.URL("https://example.com/test") mock_response.status_code = 200 mock_response.request = mock_request - mock_response.headers = httpx.Headers({ + mock_response.headers = httpx2.Headers({ "content-type": "application/json", "X-Resp": "val", }) @@ -1999,11 +1979,11 @@ async def test_response_hook_records_when_var_set(self): @pytest.mark.asyncio async def test_response_hook_does_not_record_when_var_not_set(self): """Test that the response hook does not record when _http_debug_var is not set.""" - base_client = httpx.AsyncClient() + base_client = httpx2.AsyncClient() base_factory = Mock(return_value=base_client) debug_factory = _DebugHttpxClientFactory(base_factory) - mock_response = Mock(spec=httpx.Response) + mock_response = Mock(spec=httpx2.Response) mock_response.aread = AsyncMock() # _http_debug_var is not set (default None) @@ -2014,20 +1994,20 @@ async def test_response_hook_does_not_record_when_var_not_set(self): @pytest.mark.asyncio async def test_response_hook_skips_sse_body(self): """Test that the response hook avoids reading the body for SSE streams.""" - base_client = httpx.AsyncClient() + base_client = httpx2.AsyncClient() base_factory = Mock(return_value=base_client) debug_factory = _DebugHttpxClientFactory(base_factory) - mock_request = Mock(spec=httpx.Request) + mock_request = Mock(spec=httpx2.Request) mock_request.method = "GET" mock_request.content = None - mock_request.headers = httpx.Headers() + mock_request.headers = httpx2.Headers() - mock_response = Mock(spec=httpx.Response) - mock_response.url = httpx.URL("https://example.com/sse") + mock_response = Mock(spec=httpx2.Response) + mock_response.url = httpx2.URL("https://example.com/sse") mock_response.status_code = 200 mock_response.request = mock_request - mock_response.headers = httpx.Headers({"content-type": "text/event-stream"}) + mock_response.headers = httpx2.Headers({"content-type": "text/event-stream"}) mock_response.aread = AsyncMock() debug_list = [] @@ -2046,10 +2026,10 @@ async def test_response_hook_skips_sse_body(self): @pytest.mark.asyncio async def test_debug_factory_passes_keyword_arguments(self): """Test that the debug factory passes keyword arguments to base_factory.""" - base_client = httpx.AsyncClient() + base_client = httpx2.AsyncClient() # A factory function that only accepts keyword arguments - def keyword_only_factory(**kwargs) -> httpx.AsyncClient: + def keyword_only_factory(**kwargs) -> httpx2.AsyncClient: assert "headers" in kwargs assert "timeout" in kwargs assert "auth" in kwargs @@ -2065,7 +2045,7 @@ def keyword_only_factory(**kwargs) -> httpx.AsyncClient: @pytest.mark.asyncio async def test_response_hook_truncates_large_bodies(self): """Test that response hook truncates request and response bodies exceeding limit.""" - base_client = httpx.AsyncClient() + base_client = httpx2.AsyncClient() base_factory = Mock(return_value=base_client) debug_factory = _DebugHttpxClientFactory(base_factory) @@ -2073,16 +2053,16 @@ async def test_response_hook_truncates_large_bodies(self): large_req_body = b"a" * 1500 large_resp_body = "b" * 1500 - mock_request = Mock(spec=httpx.Request) + mock_request = Mock(spec=httpx2.Request) mock_request.method = "POST" mock_request.content = large_req_body - mock_request.headers = httpx.Headers() + mock_request.headers = httpx2.Headers() - mock_response = Mock(spec=httpx.Response) - mock_response.url = httpx.URL("https://example.com/large") + mock_response = Mock(spec=httpx2.Response) + mock_response.url = httpx2.URL("https://example.com/large") mock_response.status_code = 200 mock_response.request = mock_request - mock_response.headers = httpx.Headers({"content-type": "application/json"}) + mock_response.headers = httpx2.Headers({"content-type": "application/json"}) mock_response.text = large_resp_body mock_response.aread = AsyncMock() diff --git a/tests/unittests/tools/mcp_tool/test_mcp_tool.py b/tests/unittests/tools/mcp_tool/test_mcp_tool.py index 74e1ed28f9a..8e7f6b0223e 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_tool.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_tool.py @@ -299,7 +299,7 @@ async def test_run_async_impl_no_auth(self): ) # Verify the result matches the model_dump output - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) self.mock_session_manager.create_session.assert_called_once_with( headers=None ) @@ -399,7 +399,7 @@ async def test_run_async_impl_adds_ui_widget(self): args=args, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) assert tool_context.actions.render_ui_widgets is not None assert len(tool_context.actions.render_ui_widgets) == 1 @@ -438,7 +438,7 @@ async def test_run_async_impl_with_oauth2(self): args=args, tool_context=tool_context, credential=credential ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) # Check that headers were passed correctly self.mock_session_manager.create_session.assert_called_once() call_args = self.mock_session_manager.create_session.call_args @@ -809,7 +809,7 @@ async def test_run_async_impl_with_api_key_header_auth(self): args=args, tool_context=tool_context, credential=auth_credential ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) # Check that headers were passed correctly with custom API key header self.mock_session_manager.create_session.assert_called_once() call_args = self.mock_session_manager.create_session.call_args @@ -1109,7 +1109,7 @@ async def test_run_async_impl_with_header_provider_no_auth(self): args=args, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) header_provider.assert_called_once() self.mock_session_manager.create_session.assert_called_once_with( headers=expected_headers @@ -1145,7 +1145,7 @@ async def header_provider(_context): args=args, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) self.mock_session_manager.create_session.assert_called_once_with( headers=expected_headers ) @@ -1183,7 +1183,7 @@ async def test_run_async_impl_with_header_provider_and_oauth2(self): args=args, tool_context=tool_context, credential=credential ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) header_provider.assert_called_once() self.mock_session_manager.create_session.assert_called_once() call_args = self.mock_session_manager.create_session.call_args @@ -1241,7 +1241,7 @@ async def my_progress_callback( args=args, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) self.mock_session_manager.create_session.assert_called_once_with( headers=None ) @@ -1444,7 +1444,7 @@ async def mock_call_tool(*args, **kwargs): result = await tool.run_async(args=args, tool_context=tool_context) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) assert "http_debug_info" in metadata_dict debug_info = metadata_dict["http_debug_info"] @@ -1490,7 +1490,7 @@ async def mock_call_tool(*args, **kwargs): result = await tool.run_async(args=args, tool_context=tool_context) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) assert "http_debug_info" not in metadata_dict @pytest.mark.asyncio @@ -1741,7 +1741,7 @@ async def _run_guarded(self, coro): args={"param1": "x"}, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) assert len(stub._run_guarded_called_with) == 1 # Verify the coro passed in was actually a coroutine (not a Mock). assert asyncio.iscoroutine(stub._run_guarded_called_with[0]) @@ -1776,7 +1776,7 @@ async def test_run_async_impl_falls_back_when_get_session_context_returns_none( args={"param1": "x"}, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) @pytest.mark.asyncio async def test_run_async_impl_falls_back_when_get_session_context_returns_mock( @@ -1810,7 +1810,7 @@ async def test_run_async_impl_falls_back_when_get_session_context_returns_mock( args={"param1": "x"}, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) @pytest.mark.asyncio async def test_run_async_impl_skips_run_guarded_when_flag_off(self): @@ -1838,5 +1838,5 @@ async def test_run_async_impl_skips_run_guarded_when_flag_off(self): args={"param1": "x"}, tool_context=tool_context, credential=None ) - assert result == mcp_response.model_dump(exclude_none=True, mode="json") + assert result == mcp_response.model_dump(exclude_none=True, mode="json", by_alias=True) self.mock_session_manager._get_session_context.assert_not_called() diff --git a/tests/unittests/tools/mcp_tool/test_mcp_toolset.py b/tests/unittests/tools/mcp_tool/test_mcp_toolset.py index 3b12bf17bc4..0c41d521ccb 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_toolset.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_toolset.py @@ -771,9 +771,11 @@ async def test_get_resource_info_success(self): result = await toolset.get_resource_info("data.json") + # by_alias keeps the camelCase MCP wire key ('mimeType') stable across + # the mcp 1.x -> 2.x model rename. assert result == { "name": "data.json", - "mime_type": "application/json", + "mimeType": "application/json", "uri": "file:///data.json", } self.mock_session.list_resources.assert_called_once()