chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
@@ -0,0 +1,13 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
@@ -0,0 +1,217 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import base64
from types import SimpleNamespace
from typing import AsyncGenerator
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
import pytest
class _EchoAgent(BaseAgent):
"""Minimal agent that emits a single final text event."""
reply: str = "hello from the agent"
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
content=types.Content(
role="model", parts=[types.Part(text=self.reply)]
),
)
def _text_event(text: str, *, partial: bool = False) -> Event:
return Event(
author="a",
partial=partial,
content=types.Content(role="model", parts=[types.Part(text=text)]),
)
def _image_event(data: bytes, mime_type: str) -> Event:
return Event(
author="a",
content=types.Content(
role="model",
parts=[
types.Part(inline_data=types.Blob(data=data, mime_type=mime_type))
],
),
)
class _FakeRunner:
"""Runner stub that yields a fixed event sequence."""
app_name = "fake"
def __init__(self, events: list[Event]):
self._events = events
self.create_session_calls = 0
self.session_ids: list[str] = []
self.session_service = SimpleNamespace(create_session=self._create_session)
async def _create_session(self, *, app_name: str, user_id: str):
self.create_session_calls += 1
return SimpleNamespace(id=f"session-{self.create_session_calls}")
async def run_async(
self, *, user_id: str, session_id: str, new_message: types.Content
) -> AsyncGenerator[Event, None]:
self.session_ids.append(session_id)
for event in self._events:
yield event
class _ConnCtx:
"""Fake MCP Context carrying a per-connection session object."""
def __init__(self, session: object):
self.session = session
async def report_progress(self, *, progress, total=None, message=None):
pass
class _Connection:
"""Stand-in for an MCP connection object (weak-referenceable)."""
@pytest.mark.asyncio
async def test_to_mcp_server_registers_agent_as_single_tool():
agent = _EchoAgent(name="my_agent", description="does useful things")
server = to_mcp_server(agent)
tools = await server.list_tools()
assert len(tools) == 1
assert tools[0].name == "my_agent"
assert tools[0].description == "does useful things"
assert "request" in tools[0].inputSchema["properties"]
@pytest.mark.asyncio
async def test_to_mcp_server_name_override():
agent = _EchoAgent(name="my_agent")
server = to_mcp_server(agent, name="custom")
tools = await server.list_tools()
assert tools[0].name == "custom"
@pytest.mark.asyncio
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:
result = await client.call_tool("assistant", {"request": "hi"})
assert not result.isError
assert "hello from the agent" in result.content[0].text
@pytest.mark.asyncio
async def test_run_agent_returns_only_final_text():
runner = _FakeRunner([_text_event("answer")])
result = await _run_agent(runner, "hi")
assert [block.type for block in result] == ["text"]
assert result[0].text == "answer"
@pytest.mark.asyncio
async def test_run_agent_reports_intermediate_events_as_progress():
reported: list[str] = []
class _Ctx:
async def report_progress(self, *, progress, total=None, message=None):
reported.append(message)
runner = _FakeRunner(
[_text_event("thinking", partial=True), _text_event("done")]
)
result = await _run_agent(runner, "hi", _Ctx())
assert result[0].text == "done"
assert reported == ["thinking"]
@pytest.mark.asyncio
async def test_run_agent_maps_image_output_to_image_content():
png = b"\x89PNG\r\n\x1a\n"
runner = _FakeRunner([_image_event(png, "image/png")])
result = await _run_agent(runner, "draw a logo")
assert len(result) == 1
assert result[0].type == "image"
assert result[0].mimeType == "image/png"
assert base64.b64decode(result[0].data) == png
@pytest.mark.asyncio
async def test_run_agent_reuses_one_session_per_connection():
runner = _FakeRunner([_text_event("ok")])
sessions: dict[object, str] = {}
ctx = _ConnCtx(_Connection())
await _run_agent(runner, "first", ctx, sessions)
await _run_agent(runner, "second", ctx, sessions)
assert runner.create_session_calls == 1
assert runner.session_ids == ["session-1", "session-1"]
@pytest.mark.asyncio
async def test_run_agent_uses_separate_sessions_across_connections():
runner = _FakeRunner([_text_event("ok")])
sessions: dict[object, str] = {}
await _run_agent(runner, "a", _ConnCtx(_Connection()), sessions)
await _run_agent(runner, "b", _ConnCtx(_Connection()), sessions)
assert runner.create_session_calls == 2
assert runner.session_ids == ["session-1", "session-2"]
@pytest.mark.asyncio
async def test_call_tool_reuses_session_across_calls_on_one_connection():
agent = _EchoAgent(name="assistant")
runner = _FakeRunner([_text_event("ok")])
server = to_mcp_server(agent, runner=runner)
async with create_connected_server_and_client_session(server) as client:
await client.call_tool("assistant", {"request": "first"})
await client.call_tool("assistant", {"request": "second"})
assert runner.create_session_calls == 1
assert runner.session_ids == ["session-1", "session-1"]
@@ -0,0 +1,209 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for MCP tool conversion utilities."""
from __future__ import annotations
from unittest import mock
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.mcp_tool.conversion_utils import adk_to_mcp_tool_type
from google.genai import types
import mcp.types as mcp_types
class TestAdkToMcpToolType:
"""Tests for adk_to_mcp_tool_type function."""
def test_tool_with_no_declaration(self):
"""Test conversion when tool has no declaration."""
mock_tool = mock.Mock(spec=BaseTool)
mock_tool.name = "test_tool"
mock_tool.description = "Test tool"
mock_tool._get_declaration.return_value = None
result = adk_to_mcp_tool_type(mock_tool)
assert isinstance(result, mcp_types.Tool)
assert result.name == "test_tool"
assert result.description == "Test tool"
assert result.inputSchema == {}
def test_tool_with_parameters_schema(self):
"""Test conversion when tool has parameters Schema object."""
mock_tool = mock.Mock(spec=BaseTool)
mock_tool.name = "get_weather"
mock_tool.description = "Gets weather information"
declaration = types.FunctionDeclaration(
name="get_weather",
description="Gets weather information",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"location": types.Schema(
type=types.Type.STRING,
description="The location to get weather for",
),
"units": types.Schema(
type=types.Type.STRING,
description="Temperature units",
),
},
required=["location"],
),
)
mock_tool._get_declaration.return_value = declaration
result = adk_to_mcp_tool_type(mock_tool)
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"]
def test_tool_with_parameters_json_schema(self):
"""Test conversion when tool has parameters_json_schema."""
mock_tool = mock.Mock(spec=BaseTool)
mock_tool.name = "search_database"
mock_tool.description = "Searches a database"
json_schema = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query",
},
"limit": {
"type": "integer",
"description": "Maximum number of results",
},
},
"required": ["query"],
}
declaration = types.FunctionDeclaration(
name="search_database",
description="Searches a database",
parameters_json_schema=json_schema,
)
mock_tool._get_declaration.return_value = declaration
result = adk_to_mcp_tool_type(mock_tool)
assert isinstance(result, mcp_types.Tool)
assert result.name == "search_database"
assert result.description == "Searches a database"
# Should use the JSON schema directly
assert result.inputSchema == json_schema
def test_tool_with_no_parameters(self):
"""Test conversion when tool has declaration but no parameters."""
mock_tool = mock.Mock(spec=BaseTool)
mock_tool.name = "get_current_time"
mock_tool.description = "Gets the current time"
declaration = types.FunctionDeclaration(
name="get_current_time",
description="Gets the current time",
)
mock_tool._get_declaration.return_value = declaration
result = adk_to_mcp_tool_type(mock_tool)
assert isinstance(result, mcp_types.Tool)
assert result.name == "get_current_time"
assert result.description == "Gets the current time"
assert not result.inputSchema
def test_tool_prefers_json_schema_over_parameters(self):
"""Test that parameters_json_schema is preferred over parameters."""
mock_tool = mock.Mock(spec=BaseTool)
mock_tool.name = "test_tool"
mock_tool.description = "Test tool"
json_schema = {
"type": "object",
"properties": {
"json_param": {"type": "string"},
},
}
# Create a declaration with BOTH parameters and parameters_json_schema
declaration = types.FunctionDeclaration(
name="test_tool",
description="Test tool",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"schema_param": types.Schema(type=types.Type.STRING),
},
),
parameters_json_schema=json_schema,
)
mock_tool._get_declaration.return_value = declaration
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"]
def test_tool_with_complex_nested_schema(self):
"""Test conversion with complex nested parameters_json_schema."""
mock_tool = mock.Mock(spec=BaseTool)
mock_tool.name = "create_user"
mock_tool.description = "Creates a new user"
json_schema = {
"type": "object",
"properties": {
"username": {"type": "string"},
"profile": {
"type": "object",
"properties": {
"email": {"type": "string"},
"age": {"type": "integer"},
"tags": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["email"],
},
},
"required": ["username", "profile"],
}
declaration = types.FunctionDeclaration(
name="create_user",
description="Creates a new user",
parameters_json_schema=json_schema,
)
mock_tool._get_declaration.return_value = declaration
result = adk_to_mcp_tool_type(mock_tool)
assert isinstance(result, mcp_types.Tool)
assert result.inputSchema == json_schema
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,800 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import base64
from io import StringIO
import pickle
import sys
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import Mock
from fastapi.openapi.models import OAuth2
from fastapi.openapi.models import OAuthFlowAuthorizationCode
from fastapi.openapi.models import OAuthFlows
from google.adk.agents.readonly_context import ReadonlyContext
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.auth.auth_credential import HttpAuth
from google.adk.auth.auth_credential import HttpCredentials
from google.adk.auth.auth_credential import OAuth2Auth
from google.adk.auth.auth_tool import AuthConfig
from google.adk.tools.load_mcp_resource_tool import LoadMcpResourceTool
from google.adk.tools.mcp_tool.mcp_session_manager import MCPSessionManager
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
from google.adk.tools.mcp_tool.mcp_tool import MCPTool
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
from google.adk.tools.tool_configs import ToolArgsConfig
from mcp import StdioServerParameters
from mcp.types import BlobResourceContents
from mcp.types import ListResourcesResult
from mcp.types import ReadResourceResult
from mcp.types import Resource
from mcp.types import TextResourceContents
import pytest
class MockMCPTool:
"""Mock MCP Tool for testing."""
def __init__(self, name, description="Test tool description"):
self.name = name
self.description = description
self.inputSchema = {
"type": "object",
"properties": {"param": {"type": "string"}},
}
class MockListToolsResult:
"""Mock ListToolsResult for testing."""
def __init__(self, tools):
self.tools = tools
class TestMcpToolset:
"""Test suite for McpToolset class."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_stdio_params = StdioServerParameters(
command="test_command", args=[]
)
self.mock_session_manager = Mock(spec=MCPSessionManager)
self.mock_session = AsyncMock()
self.mock_session_manager.create_session = AsyncMock(
return_value=self.mock_session
)
def test_init_basic(self):
"""Test basic initialization with StdioServerParameters."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
# Note: StdioServerParameters gets converted to StdioConnectionParams internally
assert toolset._errlog == sys.stderr
assert toolset._auth_scheme is None
assert toolset._auth_credential is None
assert toolset._use_mcp_resources is False
def test_init_with_use_mcp_resources(self):
"""Test initialization with use_mcp_resources."""
toolset = McpToolset(
connection_params=self.mock_stdio_params, use_mcp_resources=True
)
assert toolset._use_mcp_resources is True
def test_connection_params(self):
"""Test getting connection params."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
assert toolset.connection_params == self.mock_stdio_params
def test_auth_scheme(self):
"""Test getting auth scheme."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
assert toolset.auth_scheme is None
def test_auth_credential(self):
"""Test getting auth credential."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
assert toolset.auth_credential is None
def test_error_log(self):
"""Test getting error log."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
assert toolset.errlog == sys.stderr
def test_auth_scheme_with_value(self):
"""Test getting auth scheme when provided at initialization."""
auth_scheme = OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://example.com/auth",
tokenUrl="https://example.com/token",
scopes={"read": "Read access"},
)
)
)
toolset = McpToolset(
connection_params=self.mock_stdio_params,
auth_scheme=auth_scheme,
)
assert toolset.auth_scheme == auth_scheme
def test_require_confirmation(self):
"""Test getting require_confirmation flag."""
toolset = McpToolset(
connection_params=self.mock_stdio_params,
require_confirmation=True,
)
assert toolset.require_confirmation is True
def test_header_provider(self):
"""Test getting header_provider."""
mock_header_provider = Mock()
toolset = McpToolset(
connection_params=self.mock_stdio_params,
header_provider=mock_header_provider,
)
assert toolset.header_provider == mock_header_provider
def test_auth_credential_with_value(self):
"""Test getting auth credential when provided at initialization."""
mock_credential = Mock(spec=AuthCredential)
toolset = McpToolset(
connection_params=self.mock_stdio_params,
auth_credential=mock_credential,
)
assert toolset.auth_credential == mock_credential
def test_init_with_stdio_connection_params(self):
"""Test initialization with StdioConnectionParams."""
stdio_params = StdioConnectionParams(
server_params=self.mock_stdio_params, timeout=10.0
)
toolset = McpToolset(connection_params=stdio_params)
assert toolset._connection_params == stdio_params
def test_init_with_sse_connection_params(self):
"""Test initialization with SseConnectionParams."""
sse_params = SseConnectionParams(
url="https://example.com/mcp", headers={"Authorization": "Bearer token"}
)
toolset = McpToolset(connection_params=sse_params)
assert toolset._connection_params == sse_params
def test_init_with_streamable_http_params(self):
"""Test initialization with StreamableHTTPConnectionParams."""
http_params = StreamableHTTPConnectionParams(
url="https://example.com/mcp",
headers={"Content-Type": "application/json"},
)
toolset = McpToolset(connection_params=http_params)
assert toolset._connection_params == http_params
def test_init_with_tool_filter_list(self):
"""Test initialization with tool filter as list."""
tool_filter = ["tool1", "tool2"]
toolset = McpToolset(
connection_params=self.mock_stdio_params, tool_filter=tool_filter
)
# The tool filter is stored in the parent BaseToolset class
# We can verify it by checking the filtering behavior in get_tools
assert toolset._is_tool_selected is not None
def test_init_with_auth(self):
"""Test initialization with authentication."""
# Create real auth scheme instances
auth_scheme = OAuth2(flows={})
auth_credential = AuthCredential(
auth_type="oauth2",
oauth2=OAuth2Auth(client_id="test_id", client_secret="test_secret"),
)
toolset = McpToolset(
connection_params=self.mock_stdio_params,
auth_scheme=auth_scheme,
auth_credential=auth_credential,
)
assert toolset._auth_scheme == auth_scheme
assert toolset._auth_credential == auth_credential
def test_init_with_auth_and_credential_key(self):
"""Test initialization with authentication and a custom credential_key."""
auth_scheme = OAuth2(flows={})
auth_credential = AuthCredential(
auth_type="oauth2",
oauth2=OAuth2Auth(client_id="test_id", client_secret="test_secret"),
)
toolset = McpToolset(
connection_params=self.mock_stdio_params,
auth_scheme=auth_scheme,
auth_credential=auth_credential,
credential_key="my_custom_key",
)
assert toolset._auth_scheme == auth_scheme
assert toolset._auth_credential == auth_credential
assert toolset._auth_config.credential_key == "my_custom_key"
def test_from_config_with_credential_key(self):
"""Test that from_config correctly parses credential_key."""
auth_scheme = OAuth2(flows={})
config = ToolArgsConfig(
stdio_server_params=self.mock_stdio_params,
auth_scheme=auth_scheme,
credential_key="my_custom_key",
)
toolset = McpToolset.from_config(config, "")
assert isinstance(toolset._auth_scheme, OAuth2)
assert toolset._auth_config.credential_key == "my_custom_key"
def test_init_missing_connection_params(self):
"""Test initialization with missing connection params raises error."""
with pytest.raises(ValueError, match="Missing connection params"):
McpToolset(connection_params=None)
@pytest.mark.asyncio
async def test_get_tools_basic(self):
"""Test getting tools without filtering."""
# Mock tools from MCP server
mock_tools = [
MockMCPTool("tool1"),
MockMCPTool("tool2"),
MockMCPTool("tool3"),
]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
toolset = McpToolset(
connection_params=self.mock_stdio_params, use_mcp_resources=True
)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools()
assert len(tools) == 4
for tool in tools[:3]:
assert isinstance(tool, MCPTool)
assert isinstance(tools[3], LoadMcpResourceTool)
assert tools[0].name == "tool1"
assert tools[1].name == "tool2"
assert tools[2].name == "tool3"
assert tools[3].name == "load_mcp_resource"
@pytest.mark.asyncio
async def test_get_tools_with_list_filter(self):
"""Test getting tools with list-based filtering."""
# Mock tools from MCP server
mock_tools = [
MockMCPTool("tool1"),
MockMCPTool("tool2"),
MockMCPTool("tool3"),
]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
tool_filter = ["tool1", "tool3"]
toolset = McpToolset(
connection_params=self.mock_stdio_params, tool_filter=tool_filter
)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools()
assert len(tools) == 2
assert tools[0].name == "tool1"
assert tools[1].name == "tool3"
@pytest.mark.asyncio
async def test_get_tools_with_function_filter(self):
"""Test getting tools with function-based filtering."""
# Mock tools from MCP server
mock_tools = [
MockMCPTool("read_file"),
MockMCPTool("write_file"),
MockMCPTool("list_directory"),
]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
def file_tools_filter(tool, context):
"""Filter for file-related tools only."""
return "file" in tool.name
toolset = McpToolset(
connection_params=self.mock_stdio_params, tool_filter=file_tools_filter
)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools()
assert len(tools) == 2
assert tools[0].name == "read_file"
assert tools[1].name == "write_file"
@pytest.mark.asyncio
async def test_get_tools_with_header_provider(self):
"""Test get_tools with a header_provider."""
mock_tools = [MockMCPTool("tool1"), MockMCPTool("tool2")]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
mock_readonly_context = Mock(spec=ReadonlyContext)
expected_headers = {"X-Tenant-ID": "test-tenant"}
header_provider = Mock(return_value=expected_headers)
toolset = McpToolset(
connection_params=self.mock_stdio_params,
header_provider=header_provider,
)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools(readonly_context=mock_readonly_context)
assert len(tools) == 2
header_provider.assert_called_once_with(mock_readonly_context)
self.mock_session_manager.create_session.assert_called_once_with(
headers=expected_headers
)
@pytest.mark.asyncio
async def test_get_tools_with_async_header_provider(self):
"""Test get_tools with an async header_provider."""
mock_tools = [MockMCPTool("tool1"), MockMCPTool("tool2")]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
mock_readonly_context = Mock(spec=ReadonlyContext)
expected_headers = {"X-Tenant-ID": "test-tenant"}
async def header_provider(_context):
return expected_headers
toolset = McpToolset(
connection_params=self.mock_stdio_params,
header_provider=header_provider,
)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools(readonly_context=mock_readonly_context)
assert len(tools) == 2
self.mock_session_manager.create_session.assert_called_once_with(
headers=expected_headers
)
@pytest.mark.asyncio
async def test_close_success(self):
"""Test successful cleanup."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
await toolset.close()
self.mock_session_manager.close.assert_called_once()
@pytest.mark.asyncio
async def test_close_with_exception(self):
"""Test cleanup when session manager raises exception."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
# Mock close to raise an exception
self.mock_session_manager.close = AsyncMock(
side_effect=Exception("Cleanup error")
)
# Should not raise exception, should log the warning
await toolset.close()
@pytest.mark.asyncio
async def test_get_tools_with_timeout(self):
"""Test get_tools with timeout."""
stdio_params = StdioConnectionParams(
server_params=self.mock_stdio_params, timeout=0.01
)
toolset = McpToolset(connection_params=stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
async def long_running_list_tools():
await asyncio.sleep(0.1)
return MockListToolsResult([])
self.mock_session.list_tools = long_running_list_tools
with pytest.raises(
ConnectionError, match="Failed to get tools from MCP server."
):
await toolset.get_tools()
@pytest.mark.asyncio
async def test_get_tools_retry_decorator(self):
"""Test that get_tools has retry decorator applied."""
toolset = McpToolset(connection_params=self.mock_stdio_params)
# Check that the method has the retry decorator
assert hasattr(toolset.get_tools, "__wrapped__")
@pytest.mark.asyncio
async def test_mcp_toolset_with_prefix(self):
"""Test that McpToolset correctly applies the tool_name_prefix."""
# Mock the connection parameters
mock_connection_params = MagicMock()
mock_connection_params.timeout = None
# Mock the MCPSessionManager and its create_session method
mock_session_manager = MagicMock()
mock_session = MagicMock()
# Mock the list_tools response from the MCP server
mock_tool1 = MagicMock()
mock_tool1.name = "tool1"
mock_tool1.description = "tool 1 desc"
mock_tool2 = MagicMock()
mock_tool2.name = "tool2"
mock_tool2.description = "tool 2 desc"
list_tools_result = MagicMock()
list_tools_result.tools = [mock_tool1, mock_tool2]
mock_session.list_tools = AsyncMock(return_value=list_tools_result)
mock_session_manager.create_session = AsyncMock(return_value=mock_session)
# Create an instance of McpToolset with a prefix
toolset = McpToolset(
connection_params=mock_connection_params,
tool_name_prefix="my_prefix",
use_mcp_resources=True,
)
# Replace the internal session manager with our mock
toolset._mcp_session_manager = mock_session_manager
# Get the tools from the toolset
tools = await toolset.get_tools()
# The get_tools method in McpToolset returns MCPTool objects, which are
# instances of BaseTool. The prefixing is handled by the BaseToolset,
# so we need to call get_tools_with_prefix to get the prefixed tools.
prefixed_tools = await toolset.get_tools_with_prefix()
# Assert that the tools are prefixed correctly
assert len(prefixed_tools) == 3
assert prefixed_tools[0].name == "my_prefix_tool1"
assert prefixed_tools[1].name == "my_prefix_tool2"
assert prefixed_tools[2].name == "my_prefix_load_mcp_resource"
# Assert that the original tools are not modified
assert tools[0].name == "tool1"
assert tools[1].name == "tool2"
assert tools[2].name == "load_mcp_resource"
def test_init_with_progress_callback(self):
"""Test initialization with progress_callback."""
async def my_progress_callback(
progress: float, total: float | None, message: str | None
) -> None:
pass
toolset = McpToolset(
connection_params=self.mock_stdio_params,
progress_callback=my_progress_callback,
)
assert toolset._progress_callback == my_progress_callback
@pytest.mark.asyncio
async def test_get_tools_passes_progress_callback_to_mcp_tools(self):
"""Test that get_tools passes progress_callback to created MCPTool instances."""
progress_updates = []
async def my_progress_callback(
progress: float, total: float | None, message: str | None
) -> None:
progress_updates.append((progress, total, message))
mock_tools = [MockMCPTool("tool1"), MockMCPTool("tool2")]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
toolset = McpToolset(
connection_params=self.mock_stdio_params,
progress_callback=my_progress_callback,
)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools()
assert len(tools) == 2
# Verify each tool has the progress_callback set
for tool in tools:
assert tool._progress_callback == my_progress_callback
def test_init_with_progress_callback_factory(self):
"""Test initialization with a ProgressCallbackFactory."""
def my_callback_factory(tool_name: str, *, readonly_context=None, **kwargs):
async def callback(
progress: float, total: float | None, message: str | None
) -> None:
pass
return callback
toolset = McpToolset(
connection_params=self.mock_stdio_params,
progress_callback=my_callback_factory,
)
assert toolset._progress_callback == my_callback_factory
@pytest.mark.asyncio
async def test_get_tools_passes_factory_to_mcp_tools(self):
"""Test that get_tools passes factory directly to MCPTool instances.
The factory is resolved at runtime in McpTool._run_async_impl, not at
tool creation time. This allows the factory to receive ReadonlyContext.
"""
def my_callback_factory(tool_name: str, *, readonly_context=None, **kwargs):
async def callback(
progress: float, total: float | None, message: str | None
) -> None:
pass
return callback
mock_tools = [MockMCPTool("tool1"), MockMCPTool("tool2")]
self.mock_session.list_tools = AsyncMock(
return_value=MockListToolsResult(mock_tools)
)
toolset = McpToolset(
connection_params=self.mock_stdio_params,
progress_callback=my_callback_factory,
)
toolset._mcp_session_manager = self.mock_session_manager
tools = await toolset.get_tools()
assert len(tools) == 2
# Factory is passed directly to each tool (resolved at runtime)
for tool in tools:
assert tool._progress_callback == my_callback_factory
@pytest.mark.asyncio
async def test_list_resources(self):
"""Test listing resources."""
resources = [
Resource(
name="file1.txt", mime_type="text/plain", uri="file:///file1.txt"
),
Resource(
name="data.json",
mime_type="application/json",
uri="file:///data.json",
),
]
list_resources_result = ListResourcesResult(resources=resources)
self.mock_session.list_resources = AsyncMock(
return_value=list_resources_result
)
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
result = await toolset.list_resources()
assert result == ["file1.txt", "data.json"]
self.mock_session.list_resources.assert_called_once()
@pytest.mark.asyncio
async def test_get_resource_info_success(self):
"""Test getting resource info for an existing resource."""
resources = [
Resource(
name="file1.txt", mime_type="text/plain", uri="file:///file1.txt"
),
Resource(
name="data.json",
mime_type="application/json",
uri="file:///data.json",
),
]
list_resources_result = ListResourcesResult(resources=resources)
self.mock_session.list_resources = AsyncMock(
return_value=list_resources_result
)
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
result = await toolset.get_resource_info("data.json")
assert result == {
"name": "data.json",
"mime_type": "application/json",
"uri": "file:///data.json",
}
self.mock_session.list_resources.assert_called_once()
@pytest.mark.asyncio
async def test_get_resource_info_not_found(self):
"""Test getting resource info for a non-existent resource."""
resources = [
Resource(
name="file1.txt", mime_type="text/plain", uri="file:///file1.txt"
),
]
list_resources_result = ListResourcesResult(resources=resources)
self.mock_session.list_resources = AsyncMock(
return_value=list_resources_result
)
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
with pytest.raises(
ValueError, match="Resource with name 'other.json' not found."
):
await toolset.get_resource_info("other.json")
@pytest.mark.parametrize(
"name,mime_type,content,encoding",
[
("file1.txt", "text/plain", "hello world", None),
(
"data.json",
"application/json",
'{"key": "value"}',
None,
),
(
"file1_b64.txt",
"text/plain",
base64.b64encode(b"hello world").decode("ascii"),
"base64",
),
(
"data_b64.json",
"application/json",
base64.b64encode(b'{"key": "value"}').decode("ascii"),
"base64",
),
(
"data.bin",
"application/octet-stream",
base64.b64encode(b"\x01\x02\x03").decode("ascii"),
"base64",
),
],
)
@pytest.mark.asyncio
async def test_read_resource(self, name, mime_type, content, encoding):
"""Test reading various resource types."""
uri = f"file:///{name}"
# Mock list_resources for get_resource_info
resources = [Resource(name=name, mime_type=mime_type, uri=uri)]
list_resources_result = ListResourcesResult(resources=resources)
self.mock_session.list_resources = AsyncMock(
return_value=list_resources_result
)
# Mock read_resource
if encoding == "base64":
contents = [
BlobResourceContents(uri=uri, mimeType=mime_type, blob=content)
]
else:
contents = [
TextResourceContents(uri=uri, mimeType=mime_type, text=content)
]
read_resource_result = ReadResourceResult(contents=contents)
self.mock_session.read_resource = AsyncMock(
return_value=read_resource_result
)
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._mcp_session_manager = self.mock_session_manager
result = await toolset.read_resource(name)
assert result == contents
self.mock_session.list_resources.assert_called_once()
self.mock_session.read_resource.assert_called_once_with(uri=uri)
@pytest.mark.asyncio
async def test_sampling_callback_invoked(self):
called = {"value": False}
async def mock_sampling_handler(messages, params=None, context=None):
called["value"] = True
assert isinstance(messages, list)
assert messages[0]["role"] == "user"
return {
"model": "test-model",
"role": "assistant",
"content": {"type": "text", "text": "sampling response"},
"stopReason": "endTurn",
}
toolset = McpToolset(
connection_params=StreamableHTTPConnectionParams(
url="http://localhost:9999",
timeout=10,
),
sampling_callback=mock_sampling_handler,
)
messages = [{"role": "user", "content": {"type": "text", "text": "hello"}}]
result = await toolset._sampling_callback(messages)
assert called["value"] is True
assert result["role"] == "assistant"
assert result["content"]["text"] == "sampling response"
@pytest.mark.asyncio
async def test_get_auth_headers_includes_additional_headers(self):
credential = AuthCredential(
auth_type=AuthCredentialTypes.HTTP,
http=HttpAuth(
scheme="bearer",
credentials=HttpCredentials(token="token"),
additional_headers={"X-API-Key": "secret"},
),
)
auth_config = AuthConfig(
auth_scheme=OAuth2(flows={}),
raw_auth_credential=credential,
)
auth_config.exchanged_auth_credential = credential
toolset = McpToolset(connection_params=self.mock_stdio_params)
toolset._auth_config = auth_config
headers = toolset._get_auth_headers()
assert headers["Authorization"] == "Bearer token"
assert headers["X-API-Key"] == "secret"
def test_pickle_mcp_toolset(self):
toolset = McpToolset(connection_params=self.mock_stdio_params)
pickled = pickle.dumps(toolset)
unpickled = pickle.loads(pickled)
assert unpickled._connection_params == self.mock_stdio_params
assert unpickled._errlog == sys.stderr
@@ -0,0 +1,298 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for MCPToolset authentication functionality."""
import base64
from unittest.mock import Mock
from fastapi.openapi.models import APIKey as APIKeyScheme
from fastapi.openapi.models import APIKeyIn
from fastapi.openapi.models import OAuth2
from fastapi.openapi.models import OAuthFlowAuthorizationCode
from fastapi.openapi.models import OAuthFlows
from google.adk.auth.auth_credential import AuthCredential
from google.adk.auth.auth_credential import AuthCredentialTypes
from google.adk.auth.auth_credential import HttpAuth
from google.adk.auth.auth_credential import HttpCredentials
from google.adk.auth.auth_credential import OAuth2Auth
from google.adk.auth.auth_tool import AuthConfig
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
from mcp import StdioServerParameters
import pytest
class TestMcpToolsetGetAuthConfig:
"""Tests for McpToolset.get_auth_config method."""
def test_get_auth_config_returns_none_without_auth_scheme(self):
"""Test that get_auth_config returns None when no auth configured."""
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"])
)
assert toolset.get_auth_config() is None
def test_get_auth_config_returns_config_with_auth_scheme(self):
"""Test that get_auth_config returns AuthConfig when auth configured."""
auth_scheme = OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://example.com/auth",
tokenUrl="https://example.com/token",
scopes={"read": "Read access"},
)
)
)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id="test_client_id",
client_secret="test_client_secret",
),
)
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"]),
auth_scheme=auth_scheme,
auth_credential=auth_credential,
)
auth_config = toolset.get_auth_config()
assert auth_config is not None
assert auth_config.auth_scheme == auth_scheme
assert auth_config.raw_auth_credential == auth_credential
def test_get_auth_config_returns_same_instance(self):
"""Test that get_auth_config returns the same instance each time."""
auth_scheme = OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://example.com/auth",
tokenUrl="https://example.com/token",
scopes={},
)
)
)
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"]),
auth_scheme=auth_scheme,
)
# Should return the same instance
config1 = toolset.get_auth_config()
config2 = toolset.get_auth_config()
assert config1 is config2
class TestMcpToolsetGetAuthHeaders:
"""Tests for McpToolset._get_auth_headers method."""
@pytest.fixture
def toolset_with_oauth2(self):
"""Create a toolset with OAuth2 auth configured."""
auth_scheme = OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://example.com/auth",
tokenUrl="https://example.com/token",
scopes={"read": "Read access"},
)
)
)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id="test_client_id",
client_secret="test_client_secret",
),
)
return McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"]),
auth_scheme=auth_scheme,
auth_credential=auth_credential,
)
def test_get_auth_headers_returns_none_without_auth_config(self):
"""Test that _get_auth_headers returns None without auth config."""
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"])
)
assert toolset._get_auth_headers() is None
def test_get_auth_headers_returns_none_without_exchanged_credential(
self, toolset_with_oauth2
):
"""Test that _get_auth_headers returns None without exchanged credential."""
# No exchanged credential set yet
assert toolset_with_oauth2._get_auth_headers() is None
def test_get_auth_headers_oauth2_bearer_token(self, toolset_with_oauth2):
"""Test that _get_auth_headers returns Bearer token for OAuth2."""
# Set exchanged credential with access token
toolset_with_oauth2._auth_config.exchanged_auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(access_token="test-access-token"),
)
headers = toolset_with_oauth2._get_auth_headers()
assert headers is not None
assert headers["Authorization"] == "Bearer test-access-token"
def test_get_auth_headers_http_bearer_token(self):
"""Test that _get_auth_headers returns Bearer token for HTTP bearer."""
auth_scheme = OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://example.com/auth",
tokenUrl="https://example.com/token",
scopes={},
)
)
)
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"]),
auth_scheme=auth_scheme,
)
# Set exchanged credential with HTTP bearer token
toolset._auth_config.exchanged_auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.HTTP,
http=HttpAuth(
scheme="bearer",
credentials=HttpCredentials(token="test-bearer-token"),
),
)
headers = toolset._get_auth_headers()
assert headers is not None
assert headers["Authorization"] == "Bearer test-bearer-token"
def test_get_auth_headers_http_basic_auth(self):
"""Test that _get_auth_headers returns Basic auth for HTTP basic."""
auth_scheme = OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://example.com/auth",
tokenUrl="https://example.com/token",
scopes={},
)
)
)
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"]),
auth_scheme=auth_scheme,
)
# Set exchanged credential with HTTP basic auth
toolset._auth_config.exchanged_auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.HTTP,
http=HttpAuth(
scheme="basic",
credentials=HttpCredentials(
username="testuser",
password="testpass",
),
),
)
headers = toolset._get_auth_headers()
assert headers is not None
expected_credentials = base64.b64encode(b"testuser:testpass").decode()
assert headers["Authorization"] == f"Basic {expected_credentials}"
def test_get_auth_headers_api_key_header(self):
"""Test that _get_auth_headers returns API key in header."""
# Note: fastapi's APIKey model uses 'in' not 'in_', but accepts both
auth_scheme = APIKeyScheme(**{
"in": APIKeyIn.header,
"name": "X-API-Key",
})
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"]),
auth_scheme=auth_scheme,
)
# Set exchanged credential with API key
toolset._auth_config.exchanged_auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.API_KEY,
api_key="test-api-key-12345",
)
headers = toolset._get_auth_headers()
assert headers is not None
assert headers["X-API-Key"] == "test-api-key-12345"
def test_get_auth_headers_api_key_non_header_logs_warning(self, caplog):
"""Test that non-header API key logs a warning."""
# Note: fastapi's APIKey model uses 'in' not 'in_'
auth_scheme = APIKeyScheme(**{
"in": APIKeyIn.query, # Query param, not header
"name": "api_key",
})
toolset = McpToolset(
connection_params=StdioServerParameters(command="echo", args=["test"]),
auth_scheme=auth_scheme,
)
# Set exchanged credential with API key
toolset._auth_config.exchanged_auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.API_KEY,
api_key="test-api-key",
)
headers = toolset._get_auth_headers()
# Should return None for non-header API key
assert headers is None
def test_get_auth_headers_reads_from_readonly_context(
self, toolset_with_oauth2
):
"""Test that _get_auth_headers reads from ReadonlyContext first."""
from google.adk.agents.readonly_context import ReadonlyContext
mock_readonly_context = Mock(spec=ReadonlyContext)
mock_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(access_token="token-from-context"),
)
mock_readonly_context.get_credential.return_value = mock_credential
# Even if exchanged_auth_credential has a different value
toolset_with_oauth2._auth_config.exchanged_auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(access_token="token-from-config"),
)
headers = toolset_with_oauth2._get_auth_headers(
readonly_context=mock_readonly_context
)
assert headers is not None
assert headers["Authorization"] == "Bearer token-from-context"
mock_readonly_context.get_credential.assert_called_once_with(
toolset_with_oauth2._auth_config.credential_key
)
@@ -0,0 +1,948 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
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
from google.adk.features import FeatureName
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
from mcp import ClientSession
import pytest
class MockClientSession:
"""Mock ClientSession for testing."""
def __init__(self, *args, **kwargs):
self._initialized = False
self._args = args
self._kwargs = kwargs
async def initialize(self):
"""Mock initialize method."""
self._initialized = True
return self
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
return False
class MockClient:
"""Mock MCP client."""
def __init__(
self,
transports=None,
raise_on_enter=None,
delay_on_enter=0,
):
self._transports = transports or ('read_stream', 'write_stream')
self._raise_on_enter = raise_on_enter
self._delay_on_enter = delay_on_enter
self._entered = False
self._exited = False
async def __aenter__(self):
if self._delay_on_enter > 0:
await asyncio.sleep(self._delay_on_enter)
if self._raise_on_enter:
raise self._raise_on_enter
self._entered = True
return self._transports
async def __aexit__(self, exc_type, exc_val, exc_tb):
self._exited = True
return False
class TestSessionContext:
"""Test suite for SessionContext class."""
@pytest.mark.asyncio
async def test_start_success_ready_event_set_and_session_returned(self):
"""Test that start() sets _ready_event and returns session."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Mock ClientSession
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
session = await session_context.start()
# Verify ready_event was set
assert session_context._ready_event.is_set()
# Verify session was returned
assert session == mock_session
assert session_context.session == mock_session
# Verify initialize was called
assert mock_session._initialized
# Verify task was created and is still running (waiting for close)
assert session_context._task is not None
assert not session_context._task.done()
# Clean up
await session_context.close()
@pytest.mark.asyncio
async def test_start_raises_connection_error_on_exception(self):
"""Test that start() raises ConnectionError when exception occurs."""
test_exception = ValueError('Connection failed')
mock_client = MockClient(raise_on_enter=test_exception)
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with pytest.raises(ConnectionError) as exc_info:
await session_context.start()
# Verify ConnectionError message contains original exception
assert 'Failed to create MCP session' in str(exc_info.value)
assert 'Connection failed' in str(exc_info.value)
# Verify ready_event was set (in finally block)
assert session_context._ready_event.is_set()
@pytest.mark.asyncio
async def test_start_raises_connection_error_on_cancelled_error(self):
"""Test that start() raises ConnectionError when CancelledError occurs."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Mock session that will cause cancellation
mock_session = MockClientSession()
# Make initialize raise CancelledError
async def cancelled_initialize():
raise asyncio.CancelledError('Task cancelled')
mock_session.initialize = cancelled_initialize
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
# Should raise ConnectionError (not CancelledError directly)
with pytest.raises(ConnectionError) as exc_info:
await session_context.start()
# Verify it's a ConnectionError about cancellation
assert 'Failed to create MCP session' in str(exc_info.value)
assert 'task cancelled' in str(exc_info.value)
# Verify ready_event was set
assert session_context._ready_event.is_set()
@pytest.mark.asyncio
async def test_close_cleans_up_task(self):
"""Test that close() properly cleans up the task."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Mock ClientSession
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
# Start the session context
await session_context.start()
# Verify task is running
assert session_context._task is not None
assert not session_context._task.done()
# Close the session context
await session_context.close()
# Wait a bit for cleanup
await asyncio.sleep(0.1)
# Verify close_event was set
assert session_context._close_event.is_set()
# Verify task completed (may take a moment)
# The task should finish after close_event is set
assert session_context._task.done()
@pytest.mark.asyncio
async def test_session_exception_does_not_break_event_loop(self):
"""Test that session exceptions don't break the event loop."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Mock ClientSession that raises exception during use
mock_session = MockClientSession()
async def failing_operation():
raise RuntimeError('Session operation failed')
mock_session.failing_operation = failing_operation
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
# Start the session context
session = await session_context.start()
# Use session and trigger exception
with pytest.raises(RuntimeError, match='Session operation failed'):
await session.failing_operation()
# Close the session context - should not break event loop
await session_context.close()
# Verify event loop is still healthy by running another task
result = await asyncio.sleep(0.01)
assert result is None
@pytest.mark.asyncio
async def test_async_context_manager(self):
"""Test using SessionContext as async context manager."""
mock_client = MockClient()
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
async with SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
) as session:
assert session == mock_session
# Verify initialize was called by checking _initialized flag
assert session._initialized
@pytest.mark.asyncio
async def test_timeout_during_connection(self):
"""Test timeout during client connection."""
# Client that takes longer than timeout
mock_client = MockClient(delay_on_enter=10.0)
session_context = SessionContext(
mock_client, timeout=0.1, sse_read_timeout=None
)
with pytest.raises(ConnectionError) as exc_info:
await session_context.start()
assert 'Failed to create MCP session' in str(exc_info.value)
@pytest.mark.asyncio
async def test_timeout_during_initialization(self):
"""Test timeout during session initialization."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=0.1, sse_read_timeout=None
)
# Mock ClientSession with slow initialize
mock_session = MockClientSession()
async def slow_initialize():
await asyncio.sleep(1.0)
return mock_session
mock_session.initialize = slow_initialize
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
with pytest.raises(ConnectionError) as exc_info:
await session_context.start()
assert 'Failed to create MCP session' in str(exc_info.value)
@pytest.mark.asyncio
async def test_timeout_during_initialization_with_flag_on(self):
"""Test timeout during session initialization with flag ON.
Verifies that session initialization uses `anyio.fail_after` under the
graceful error handling feature flag.
"""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=0.1, sse_read_timeout=None
)
# Mock ClientSession with slow initialize
mock_session = MockClientSession()
async def slow_initialize():
await asyncio.sleep(1.0)
return mock_session
mock_session.initialize = slow_initialize
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
with temporary_feature_override(
FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
):
with pytest.raises(ConnectionError) as exc_info:
await session_context.start()
assert 'Failed to create MCP session' in str(exc_info.value)
@pytest.mark.asyncio
async def test_timeout_during_initialization_with_flag_off(self):
"""Test timeout during session initialization with flag OFF.
Verifies that session initialization falls back to `asyncio.wait_for`
when graceful error handling is disabled.
"""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=0.1, sse_read_timeout=None
)
# Mock ClientSession with slow initialize
mock_session = MockClientSession()
async def slow_initialize():
await asyncio.sleep(1.0)
return mock_session
mock_session.initialize = slow_initialize
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
with temporary_feature_override(
FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
):
with pytest.raises(ConnectionError) as exc_info:
await session_context.start()
assert 'Failed to create MCP session' in str(exc_info.value)
@pytest.mark.asyncio
async def test_uses_anyio_fail_after_when_flag_on(self):
"""Test that session initialization structurally uses anyio.fail_after.
Asserts that the session runner enters `anyio.fail_after` context
with the timeout limit when graceful error handling is enabled.
"""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=2.5, sse_read_timeout=None
)
mock_session = MockClientSession()
with (
patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class,
patch('anyio.fail_after') as mock_fail_after,
):
mock_session_class.return_value = mock_session
# Configure mock_fail_after synchronous context manager to do nothing
mock_fail_after.return_value.__enter__ = Mock()
mock_fail_after.return_value.__exit__ = Mock(return_value=False)
with temporary_feature_override(
FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
):
await session_context.start()
# Verify anyio.fail_after was called with the correct timeout
mock_fail_after.assert_called_once_with(2.5)
await session_context.close()
@pytest.mark.asyncio
async def test_stdio_client_with_read_timeout(self):
"""Test stdio client includes read_timeout_seconds parameter."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None, is_stdio=True
)
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
await session_context.start()
# 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)
await session_context.close()
@pytest.mark.asyncio
async def test_non_stdio_client_without_read_timeout(self):
"""Test non-stdio client does not include read_timeout_seconds."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None, is_stdio=False
)
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
await session_context.start()
# Verify ClientSession was called with read_timeout_seconds=None for non-stdio
# when sse_read_timeout is None
call_args = mock_session_class.call_args
assert 'read_timeout_seconds' in call_args.kwargs
assert call_args.kwargs['read_timeout_seconds'] is None
await session_context.close()
@pytest.mark.asyncio
async def test_sse_read_timeout_passed_to_client_session(self):
"""Test that sse_read_timeout is passed to ClientSession for non-stdio."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=300.0, is_stdio=False
)
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
await session_context.start()
# 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
)
await session_context.close()
@pytest.mark.asyncio
async def test_close_multiple_times(self):
"""Test that close() can be called multiple times safely."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
await session_context.start()
# Close multiple times
await session_context.close()
await session_context.close()
await session_context.close()
# Should not raise exception
assert session_context._close_event.is_set()
@pytest.mark.asyncio
async def test_close_before_start(self):
"""Test that close() works even if start() was never called."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Close before starting should not raise
await session_context.close()
assert session_context._close_event.is_set()
@pytest.mark.asyncio
async def test_close_before_start_ends(self):
"""Test that close() before start() ends the task."""
# Client has enough time to delay the start task
mock_client = MockClient(delay_on_enter=10.0)
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
start_task = asyncio.create_task(session_context.start())
await asyncio.sleep(0.1)
assert not start_task.done()
# Call close before start() ends the task
await session_context.close()
await asyncio.sleep(0.1)
assert start_task.done()
assert isinstance(
start_task.exception(), ConnectionError
) and 'task cancelled' in str(start_task.exception())
@pytest.mark.asyncio
async def test_close_before_start_called(self):
"""Test that close() before start() called sets the close event."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Call close() before start() called
await session_context.close()
await asyncio.sleep(0.1)
assert session_context._task is None
assert session_context._close_event.is_set()
with pytest.raises(ConnectionError) as exc_info:
await session_context.start()
assert 'session already closed' in str(exc_info.value)
assert session_context._task is None
@pytest.mark.asyncio
async def test_session_property(self):
"""Test that session property returns the managed session."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Initially None
assert session_context.session is None
mock_session = MockClientSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = mock_session
await session_context.start()
# Should return the session
assert session_context.session == mock_session
await session_context.close()
@pytest.mark.asyncio
async def test_client_cleanup_on_exception(self):
"""Test that client is properly cleaned up even when exception occurs."""
test_exception = RuntimeError('Test error')
mock_client = MockClient(raise_on_enter=test_exception)
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with pytest.raises(ConnectionError):
await session_context.start()
# Wait a bit for cleanup
await asyncio.sleep(0.1)
# Verify task completed
assert session_context._task.done()
@pytest.mark.asyncio
async def test_close_handles_cancelled_error(self):
"""Test that close() handles CancelledError gracefully."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
mock_session = MockClientSession()
with (
patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class,
patch(
'google.adk.tools.mcp_tool.session_context.logger'
) as mock_logger,
):
mock_session_class.return_value = mock_session
await session_context.start()
# Cancel the task
if session_context._task:
session_context._task.cancel()
# Close should handle CancelledError gracefully
await session_context.close()
# Should not raise exception
assert session_context._close_event.is_set()
# Verify no warning logs were generated
mock_logger.warning.assert_not_called()
@pytest.mark.asyncio
async def test_close_handles_exception_during_cleanup(self):
"""Test that close() handles exceptions during cleanup gracefully."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
# Create a mock session that raises during exit
class FailingMockSession(MockClientSession):
async def __aexit__(self, exc_type, exc_val, exc_tb):
raise RuntimeError('Cleanup failed')
failing_session = FailingMockSession()
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = failing_session
await session_context.start()
# Close should handle the exception gracefully
await session_context.close()
# Should not raise exception
assert session_context._close_event.is_set()
class TestSessionContextIsTaskAlive:
"""Tests for the SessionContext._is_task_alive property."""
def test_is_task_alive_false_before_start(self):
"""Before start(), there is no task and the property returns False."""
session_context = SessionContext(
MockClient(), timeout=5.0, sse_read_timeout=None
)
assert session_context._is_task_alive is False
@pytest.mark.asyncio
async def test_is_task_alive_true_while_session_running(self):
"""After start(), the background task is alive until close()."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = MockClientSession()
await session_context.start()
try:
assert session_context._is_task_alive is True
finally:
await session_context.close()
assert session_context._is_task_alive is False
class TestSessionContextRunGuarded:
"""Tests for SessionContext._run_guarded.
This is the heart of the 5-minute-hang fix: the method races a
coroutine against the background session task and surfaces transport
crashes immediately.
"""
@pytest.mark.asyncio
async def test_run_guarded_raises_when_task_not_started(self):
"""If start() was never called, _run_guarded refuses to run the coro."""
session_context = SessionContext(
MockClient(), timeout=5.0, sse_read_timeout=None
)
async def coro():
return 'should never run'
with pytest.raises(ConnectionError, match='task has not been started'):
await session_context._run_guarded(coro())
@pytest.mark.asyncio
async def test_run_guarded_returns_result_on_success(self):
"""When the coroutine completes first, its result is returned."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = MockClientSession()
await session_context.start()
try:
async def coro():
return 'expected_result'
result = await session_context._run_guarded(coro())
assert result == 'expected_result'
finally:
await session_context.close()
@pytest.mark.asyncio
async def test_run_guarded_propagates_coro_exception(self):
"""A coroutine-level exception propagates as-is (not wrapped).
This is intentional: callers (McpTool) need to distinguish a
tool-level failure (McpError) from a transport-level failure
(ConnectionError).
"""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = MockClientSession()
await session_context.start()
try:
async def coro():
raise ValueError('tool error')
with pytest.raises(ValueError, match='tool error'):
await session_context._run_guarded(coro())
finally:
await session_context.close()
@pytest.mark.asyncio
async def test_run_guarded_raises_when_task_died_before_call(self):
"""If the background task already died, surface ConnectionError immediately."""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = MockClientSession()
await session_context.start()
# Simulate a transport crash by closing the session.
await session_context.close()
async def coro():
return 'should not run'
with pytest.raises(ConnectionError, match='already terminated'):
await session_context._run_guarded(coro())
@pytest.mark.asyncio
async def test_run_guarded_cancels_coro_when_task_dies_first(self):
"""If the background task dies mid-flight, cancel the coro and raise.
This is the regression test for the 5-minute hang: when the MCP
transport crashes (e.g. AGW returns 403), the background task ends
quickly, and the in-flight call must be cancelled rather than
waiting for sse_read_timeout.
"""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = MockClientSession()
await session_context.start()
coro_started = asyncio.Event()
coro_was_cancelled = False
async def slow_coro():
nonlocal coro_was_cancelled
coro_started.set()
try:
# Pretend we're awaiting a 5-minute SSE read.
await asyncio.sleep(300)
return 'should never reach here'
except asyncio.CancelledError:
coro_was_cancelled = True
raise
async def kill_background_task():
await coro_started.wait()
# Simulate a transport crash by closing the session, which ends
# the background task quickly.
await session_context.close()
killer = asyncio.create_task(kill_background_task())
try:
with pytest.raises(ConnectionError, match='connection lost'):
await session_context._run_guarded(slow_coro())
assert coro_was_cancelled is True
finally:
await killer
class TestSessionContextFlagOffPreservesPreFixBehavior:
"""Pin down that flag=OFF reproduces pre-fix behavior exactly.
These tests guard against accidental changes leaking into the flag=OFF
path, which is the default. An earlier unconditional version of this
fix caused existing callers to hit a 3-minute hang because behavior
changes were applied to the default path. We must keep flag=OFF
byte-for-byte equivalent to pre-fix.
"""
@pytest.mark.asyncio
async def test_inner_wait_for_is_used_when_flag_off(self):
"""The inner asyncio.wait_for around enter_async_context must run.
Pre-fix code wrapped client entry in `asyncio.wait_for(..., timeout)`.
Callers that depend on that inner timeout firing for hanging mocks
rely on this behavior. With the flag OFF we must restore it.
"""
delayed_client = MockClient(delay_on_enter=10.0)
session_context = SessionContext(
delayed_client, timeout=0.2, sse_read_timeout=None
)
with temporary_feature_override(
FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
):
with pytest.raises(ConnectionError):
# The inner wait_for should fire at ~timeout=0.2s, surfacing as
# ConnectionError from start(). If the inner wait_for is missing
# (the AnyIO fix being applied unconditionally), this test would
# block until the OUTER timeout cancels - which doesn't exist
# here because we're calling start() directly.
await asyncio.wait_for(session_context.start(), timeout=2.0)
# And confirm: this would NOT raise quickly with the flag ON
# because the inner wait_for is removed. We don't actually run the
# flag-on case here because there's no outer timeout in this direct
# call - that's tested at the McpTool integration level.
@pytest.mark.asyncio
async def test_no_extra_none_check_when_flag_off(self):
"""The 'session is None' raise must NOT happen when flag is off.
Pre-fix code returned `self._session` directly, even if it was
somehow None. Our new None check is gated to preserve that.
"""
mock_client = MockClient()
session_context = SessionContext(
mock_client, timeout=5.0, sse_read_timeout=None
)
with patch(
'google.adk.tools.mcp_tool.session_context.ClientSession'
) as mock_session_class:
mock_session_class.return_value = MockClientSession()
with temporary_feature_override(
FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
):
# In normal flow, _session is set; the gated None check is moot.
# This test exists primarily to document and guard the flag-OFF
# code path.
result = await session_context.start()
try:
assert result is not None
finally:
await session_context.close()
class TestFormatException:
"""Test suite for _format_exception helper."""
def test_format_exception_normal(self):
exc = ValueError('normal error')
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(
'403 Forbidden', request=request, response=response
)
formatted = _format_exception(exc)
assert '403 Forbidden' in formatted
assert 'Forbidden access' in formatted
def test_format_exception_group(self):
class MockExceptionGroup(Exception):
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(
'403 Forbidden', request=request, response=response
)
exc2 = ValueError('another error')
eg = MockExceptionGroup('Group', [exc1, exc2])
formatted = _format_exception(eg)
assert '403 Forbidden' in formatted
assert 'Forbidden access' in formatted
assert 'another error' in formatted