diff --git a/pyproject.toml b/pyproject.toml index 46edcbc694..eb01d82033 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,8 @@ optional-dependencies.all = [ "llama-index-embeddings-google-genai>=0.3", "llama-index-readers-file>=0.4", "lxml>=5.3", - "mcp>=1.24,<2", + "httpx2>=2.5,<3", + "mcp>=2.0.0,<3", "nltk!=3.10.1", "oci>=2.126", "openai>=2.20,<3", @@ -245,7 +246,8 @@ optional-dependencies.gcp = [ ] optional-dependencies.mcp = [ "anyio>=4.9,<5", - "mcp>=1.24,<2", + "httpx2>=2.5,<3", # mcp 2.x's HTTP layer; imported directly by mcp_session_manager. + "mcp>=2.0.0,<3", ] optional-dependencies.oci = [ "oci>=2.126", # OCI Generative AI native SDK (OCIGenAILlm) @@ -297,7 +299,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", "nltk!=3.10.1", # Transitive via rouge-score and llama-index-core; 3.10.1's import hook breaks any venv living inside the working directory (reverted upstream in nltk/nltk#3732). "openai>=2.20,<3", "openpyxl>=3.1.5,<4", diff --git a/src/google/adk/tools/load_mcp_resource_tool.py b/src/google/adk/tools/load_mcp_resource_tool.py index 86eff9182c..b57b64387f 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/_agent_to_mcp.py b/src/google/adk/tools/mcp_tool/_agent_to_mcp.py index 54a6eb90fe..13717a49ca 100644 --- a/src/google/adk/tools/mcp_tool/_agent_to_mcp.py +++ b/src/google/adk/tools/mcp_tool/_agent_to_mcp.py @@ -24,8 +24,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 mcp.server.session import ServerSession from ...agents.base_agent import BaseAgent @@ -70,13 +70,13 @@ 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 @@ -173,7 +173,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 @@ -197,7 +197,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:: @@ -206,7 +206,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 ddf5d3aa34..0e87023ede 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 628c1b527d..ccafc1e411 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 @@ -65,17 +65,15 @@ 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 -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 @@ -103,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] = ( @@ -136,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 @@ -236,12 +237,25 @@ 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: 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, @@ -254,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 ) @@ -271,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) @@ -435,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 @@ -449,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) @@ -483,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 @@ -492,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), @@ -502,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: @@ -518,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, @@ -1015,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]: @@ -1067,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 a823e4c18a..973d596aa7 100644 --- a/src/google/adk/tools/mcp_tool/mcp_tool.py +++ b/src/google/adk/tools/mcp_tool/mcp_tool.py @@ -29,8 +29,8 @@ from fastapi.openapi.models import APIKeyIn from google.genai.types import FunctionDeclaration from mcp import ClientSession -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 @@ -222,8 +222,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, @@ -396,8 +396,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( @@ -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: diff --git a/src/google/adk/tools/mcp_tool/mcp_toolset.py b/src/google/adk/tools/mcp_tool/mcp_toolset.py index f4da389bfc..40353730f8 100644 --- a/src/google/adk/tools/mcp_tool/mcp_toolset.py +++ b/src/google/adk/tools/mcp_tool/mcp_toolset.py @@ -36,7 +36,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 mcp.types import Tool as McpBaseTool @@ -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/src/google/adk/tools/mcp_tool/session_context.py b/src/google/adk/tools/mcp_tool/session_context.py index bd6ef6f1d8..e3c0ac822d 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 @@ -346,7 +345,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, @@ -360,7 +359,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 b277e9994d..66581d7365 100644 --- a/tests/unittests/tools/mcp_tool/test_agent_to_mcp.py +++ b/tests/unittests/tools/mcp_tool/test_agent_to_mcp.py @@ -15,9 +15,11 @@ 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 @@ -25,10 +27,46 @@ 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.""" @@ -135,7 +173,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 @@ -153,10 +191,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 @@ -198,7 +236,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 @@ -275,7 +313,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 35cebea9d6..121496aa50 100644 --- a/tests/unittests/tools/mcp_tool/test_conversion_utils.py +++ b/tests/unittests/tools/mcp_tool/test_conversion_utils.py @@ -41,7 +41,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.""" @@ -74,14 +74,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.""" @@ -117,7 +117,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.""" @@ -136,7 +136,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.""" @@ -168,9 +168,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.""" @@ -208,7 +208,7 @@ 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 class TestGeminiToJsonSchema: 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 dd5dddf083..4fccadb716 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,7 @@ 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 @@ -273,48 +273,30 @@ 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", - 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.""" @@ -1421,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) @@ -1430,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" @@ -1452,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"} ) @@ -1942,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) @@ -1954,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", }) @@ -1997,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) @@ -2012,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 = [] @@ -2044,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 @@ -2063,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) @@ -2071,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 0606d592c6..8e7f6b0223 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_tool.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_tool.py @@ -48,13 +48,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"}, @@ -62,7 +62,7 @@ def __init__( }, "required": ["param1"], } - self.outputSchema = outputSchema + self.output_schema = output_schema class TestMCPToolLegacy: @@ -151,7 +151,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, ) @@ -169,7 +169,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, ) @@ -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 @@ -1547,10 +1547,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, @@ -1563,7 +1562,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 @@ -1608,17 +1607,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"} @@ -1668,16 +1669,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"} @@ -1685,7 +1688,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 @@ -1738,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]) @@ -1773,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( @@ -1807,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): @@ -1835,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 61f233abb3..0c41d521cc 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_toolset.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_toolset.py @@ -63,7 +63,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"}}, } @@ -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() @@ -843,11 +845,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 76f1fe815e..3c2b571e18 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 import time from unittest.mock import AsyncMock from unittest.mock import Mock @@ -26,7 +25,7 @@ from google.adk.features._feature_registry import temporary_feature_override from google.adk.tools.mcp_tool.session_context import _format_exception from google.adk.tools.mcp_tool.session_context import SessionContext -import httpx +import httpx2 from mcp import ClientSession import pytest @@ -424,7 +423,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() @@ -473,9 +472,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() @@ -947,9 +944,9 @@ def test_format_exception_normal(self): assert _format_exception(exc) == 'normal error' def test_format_exception_http_status_error(self): - request = httpx.Request('GET', 'http://test') - response = httpx.Response(403, request=request, text='Forbidden access') - exc = httpx.HTTPStatusError( + request = httpx2.Request('GET', 'http://test') + response = httpx2.Response(403, request=request, text='Forbidden access') + exc = httpx2.HTTPStatusError( '403 Forbidden', request=request, response=response ) @@ -964,9 +961,9 @@ def __init__(self, message, exceptions): super().__init__(message) self.exceptions = exceptions - request = httpx.Request('GET', 'http://test') - response = httpx.Response(403, request=request, text='Forbidden access') - exc1 = httpx.HTTPStatusError( + request = httpx2.Request('GET', 'http://test') + response = httpx2.Response(403, request=request, text='Forbidden access') + exc1 = httpx2.HTTPStatusError( '403 Forbidden', request=request, response=response ) exc2 = ValueError('another error')