Files
adk-python/tests/unittests/tools/mcp_tool/test_mcp_session_manager.py
T

729 lines
24 KiB
Python
Raw Normal View History

2026-01-20 14:49:43 -08:00
# Copyright 2026 Google LLC
2025-06-18 11:43:32 -07:00
#
# 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
from datetime import timedelta
2025-06-18 11:43:32 -07:00
import hashlib
from io import StringIO
import json
import sys
from unittest.mock import ANY
2025-06-18 11:43:32 -07:00
from unittest.mock import AsyncMock
from unittest.mock import Mock
from unittest.mock import patch
from google.adk.platform import thread as platform_thread
from google.adk.tools.mcp_tool.mcp_session_manager import MCPSessionManager
from google.adk.tools.mcp_tool.mcp_session_manager import retry_on_errors
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 mcp import StdioServerParameters
2025-06-18 11:43:32 -07:00
import pytest
class MockClientSession:
"""Mock ClientSession for testing."""
def __init__(self):
self._read_stream = Mock()
self._write_stream = Mock()
self._read_stream._closed = False
self._write_stream._closed = False
self.initialize = AsyncMock()
class MockAsyncExitStack:
"""Mock AsyncExitStack for testing."""
def __init__(self):
self.aclose = AsyncMock()
self.enter_async_context = AsyncMock()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
class MockSessionContext:
"""Mock SessionContext for testing."""
def __init__(self, session=None):
"""Initialize MockSessionContext.
Args:
session: The mock session to return from __aenter__ and session property.
"""
self._session = session
self._aenter_mock = AsyncMock(return_value=session)
self._aexit_mock = AsyncMock(return_value=False)
@property
def session(self):
"""Get the mock session."""
return self._session
async def __aenter__(self):
"""Enter the async context manager."""
return await self._aenter_mock()
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exit the async context manager."""
return await self._aexit_mock(exc_type, exc_val, exc_tb)
2025-06-18 11:43:32 -07:00
class TestMCPSessionManager:
"""Test suite for MCPSessionManager class."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_stdio_params = StdioServerParameters(
command="test_command", args=[]
)
self.mock_stdio_connection_params = StdioConnectionParams(
server_params=self.mock_stdio_params, timeout=5.0
)
def test_init_with_stdio_server_parameters(self):
"""Test initialization with StdioServerParameters (deprecated)."""
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.logger"
) as mock_logger:
manager = MCPSessionManager(self.mock_stdio_params)
# Should log deprecation warning
mock_logger.warning.assert_called_once()
assert "StdioServerParameters is not recommended" in str(
mock_logger.warning.call_args
)
# Should convert to StdioConnectionParams
assert isinstance(manager._connection_params, StdioConnectionParams)
assert manager._connection_params.server_params == self.mock_stdio_params
assert manager._connection_params.timeout == 5
def test_init_with_stdio_connection_params(self):
"""Test initialization with StdioConnectionParams."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
assert manager._connection_params == self.mock_stdio_connection_params
assert manager._errlog == sys.stderr
assert manager._sessions == {}
def test_init_with_sse_connection_params(self):
"""Test initialization with SseConnectionParams."""
sse_params = SseConnectionParams(
url="https://example.com/mcp",
headers={"Authorization": "Bearer token"},
timeout=10.0,
)
manager = MCPSessionManager(sse_params)
assert manager._connection_params == sse_params
def test_init_with_streamable_http_params(self):
"""Test initialization with StreamableHTTPConnectionParams."""
http_params = StreamableHTTPConnectionParams(
url="https://example.com/mcp", timeout=15.0
)
manager = MCPSessionManager(http_params)
assert manager._connection_params == http_params
@patch("google.adk.tools.mcp_tool.mcp_session_manager.streamablehttp_client")
def test_init_with_streamable_http_custom_httpx_factory(
self, mock_streamablehttp_client
):
"""Test that streamablehttp_client is called with custom httpx_client_factory."""
custom_httpx_factory = Mock()
http_params = StreamableHTTPConnectionParams(
url="https://example.com/mcp",
timeout=15.0,
httpx_client_factory=custom_httpx_factory,
)
manager = MCPSessionManager(http_params)
manager._create_client()
mock_streamablehttp_client.assert_called_once_with(
url="https://example.com/mcp",
headers=None,
timeout=timedelta(seconds=15.0),
sse_read_timeout=timedelta(seconds=300.0),
terminate_on_close=True,
httpx_client_factory=custom_httpx_factory,
)
@patch("google.adk.tools.mcp_tool.mcp_session_manager.streamablehttp_client")
def test_init_with_streamable_http_default_httpx_factory(
self, mock_streamablehttp_client
):
"""Test that streamablehttp_client is called with default httpx_client_factory."""
http_params = StreamableHTTPConnectionParams(
url="https://example.com/mcp", timeout=15.0
)
manager = MCPSessionManager(http_params)
manager._create_client()
mock_streamablehttp_client.assert_called_once_with(
url="https://example.com/mcp",
headers=None,
timeout=timedelta(seconds=15.0),
sse_read_timeout=timedelta(seconds=300.0),
terminate_on_close=True,
httpx_client_factory=StreamableHTTPConnectionParams.model_fields[
"httpx_client_factory"
].get_default(),
)
2025-06-18 11:43:32 -07:00
def test_generate_session_key_stdio(self):
"""Test session key generation for stdio connections."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# For stdio, headers should be ignored and return constant key
key1 = manager._generate_session_key({"Authorization": "Bearer token"})
key2 = manager._generate_session_key(None)
assert key1 == "stdio_session"
assert key2 == "stdio_session"
assert key1 == key2
def test_generate_session_key_sse(self):
"""Test session key generation for SSE connections."""
sse_params = SseConnectionParams(url="https://example.com/mcp")
manager = MCPSessionManager(sse_params)
headers1 = {"Authorization": "Bearer token1"}
headers2 = {"Authorization": "Bearer token2"}
key1 = manager._generate_session_key(headers1)
key2 = manager._generate_session_key(headers2)
key3 = manager._generate_session_key(headers1)
# Different headers should generate different keys
assert key1 != key2
# Same headers should generate same key
assert key1 == key3
# Should be deterministic hash
headers_json = json.dumps(headers1, sort_keys=True)
expected_hash = hashlib.md5(headers_json.encode()).hexdigest()
assert key1 == f"session_{expected_hash}"
def test_merge_headers_stdio(self):
"""Test header merging for stdio connections."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Stdio connections don't support headers
headers = manager._merge_headers({"Authorization": "Bearer token"})
assert headers is None
def test_merge_headers_sse(self):
"""Test header merging for SSE connections."""
base_headers = {"Content-Type": "application/json"}
sse_params = SseConnectionParams(
url="https://example.com/mcp", headers=base_headers
)
manager = MCPSessionManager(sse_params)
# With additional headers
additional = {"Authorization": "Bearer token"}
merged = manager._merge_headers(additional)
expected = {
"Content-Type": "application/json",
"Authorization": "Bearer token",
}
assert merged == expected
def test_is_session_disconnected(self):
"""Test session disconnection detection."""
2025-06-18 11:43:32 -07:00
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Create mock session
session = MockClientSession()
# Not disconnected
assert not manager._is_session_disconnected(session)
# Disconnected - read stream closed
2025-06-18 11:43:32 -07:00
session._read_stream._closed = True
assert manager._is_session_disconnected(session)
2025-06-18 11:43:32 -07:00
@pytest.mark.asyncio
async def test_create_session_stdio_new(self):
"""Test creating a new stdio session."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
mock_exit_stack = MockAsyncExitStack()
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.stdio_client"
) as mock_stdio:
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.AsyncExitStack"
) as mock_exit_stack_class:
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.SessionContext"
) as mock_session_context_class:
2025-06-18 11:43:32 -07:00
# Setup mocks
mock_exit_stack_class.return_value = mock_exit_stack
mock_stdio.return_value = AsyncMock()
# Mock SessionContext using MockSessionContext
# Create a mock session that will be returned by SessionContext
mock_session = AsyncMock()
mock_session_context = MockSessionContext(session=mock_session)
mock_session_context_class.return_value = mock_session_context
mock_exit_stack.enter_async_context.return_value = mock_session
2025-06-18 11:43:32 -07:00
# Create session
session = await manager.create_session()
# Verify session creation
assert session == mock_session
assert len(manager._sessions) == 1
assert "stdio_session" in manager._sessions
session_data = manager._sessions["stdio_session"]
assert len(session_data) == 3
assert session_data[0] == mock_session
assert session_data[2] == asyncio.get_running_loop()
2025-06-18 11:43:32 -07:00
# Verify SessionContext was created
mock_session_context_class.assert_called_once()
# Verify enter_async_context was called (which internally calls __aenter__)
mock_exit_stack.enter_async_context.assert_called_once()
2025-06-18 11:43:32 -07:00
@pytest.mark.asyncio
async def test_create_session_reuse_existing(self):
"""Test reusing an existing connected session."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Create mock existing session
existing_session = MockClientSession()
existing_exit_stack = MockAsyncExitStack()
manager._sessions["stdio_session"] = (
existing_session,
existing_exit_stack,
asyncio.get_running_loop(),
)
2025-06-18 11:43:32 -07:00
# Session is connected
existing_session._read_stream._closed = False
existing_session._write_stream._closed = False
session = await manager.create_session()
# Should reuse existing session
assert session == existing_session
assert len(manager._sessions) == 1
# Should not create new session
existing_session.initialize.assert_not_called()
@pytest.mark.asyncio
@patch("google.adk.tools.mcp_tool.mcp_session_manager.stdio_client")
@patch("google.adk.tools.mcp_tool.mcp_session_manager.AsyncExitStack")
@patch("google.adk.tools.mcp_tool.mcp_session_manager.SessionContext")
async def test_create_session_timeout(
self, mock_session_context_class, mock_exit_stack_class, mock_stdio
):
"""Test session creation timeout."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
mock_exit_stack = MockAsyncExitStack()
mock_exit_stack_class.return_value = mock_exit_stack
mock_stdio.return_value = AsyncMock()
# Mock SessionContext
mock_session_context = AsyncMock()
mock_session_context.__aenter__ = AsyncMock(
return_value=MockClientSession()
)
mock_session_context.__aexit__ = AsyncMock(return_value=False)
mock_session_context_class.return_value = mock_session_context
# Mock enter_async_context to raise TimeoutError (simulating asyncio.wait_for timeout)
mock_exit_stack.enter_async_context = AsyncMock(
side_effect=asyncio.TimeoutError("Test timeout")
)
# Expect ConnectionError due to timeout
with pytest.raises(ConnectionError, match="Failed to create MCP session"):
await manager.create_session()
# Verify SessionContext was created
mock_session_context_class.assert_called_once()
# Verify session was not added to pool
assert not manager._sessions
# Verify cleanup was called
mock_exit_stack.aclose.assert_called_once()
2025-06-18 11:43:32 -07:00
@pytest.mark.asyncio
async def test_close_success(self):
"""Test successful cleanup of all sessions."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Add mock sessions
session1 = MockClientSession()
exit_stack1 = MockAsyncExitStack()
session2 = MockClientSession()
exit_stack2 = MockAsyncExitStack()
manager._sessions["session1"] = (
session1,
exit_stack1,
asyncio.get_running_loop(),
)
manager._sessions["session2"] = (
session2,
exit_stack2,
asyncio.get_running_loop(),
)
2025-06-18 11:43:32 -07:00
await manager.close()
# All sessions should be closed
exit_stack1.aclose.assert_called_once()
exit_stack2.aclose.assert_called_once()
assert len(manager._sessions) == 0
@pytest.mark.asyncio
@patch("google.adk.tools.mcp_tool.mcp_session_manager.logger")
async def test_close_with_errors(self, mock_logger):
2025-06-18 11:43:32 -07:00
"""Test cleanup when some sessions fail to close."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Add mock sessions
session1 = MockClientSession()
exit_stack1 = MockAsyncExitStack()
exit_stack1.aclose.side_effect = Exception("Close error 1")
session2 = MockClientSession()
exit_stack2 = MockAsyncExitStack()
manager._sessions["session1"] = (
session1,
exit_stack1,
asyncio.get_running_loop(),
)
manager._sessions["session2"] = (
session2,
exit_stack2,
asyncio.get_running_loop(),
)
2025-06-18 11:43:32 -07:00
# Should not raise exception
await manager.close()
# Good session should still be closed
exit_stack2.aclose.assert_called_once()
assert len(manager._sessions) == 0
# Error should be logged via logger.warning
mock_logger.warning.assert_called_once()
args, kwargs = mock_logger.warning.call_args
assert "Error during session cleanup for session1: Close error 1" in args[0]
assert kwargs.get("exc_info")
2025-06-18 11:43:32 -07:00
@pytest.mark.asyncio
@patch("google.adk.tools.mcp_tool.mcp_session_manager.stdio_client")
@patch("google.adk.tools.mcp_tool.mcp_session_manager.AsyncExitStack")
@patch("google.adk.tools.mcp_tool.mcp_session_manager.SessionContext")
async def test_create_and_close_session_in_different_tasks(
self, mock_session_context_class, mock_exit_stack_class, mock_stdio
):
"""Test creating and closing a session in different tasks."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
mock_exit_stack_class.return_value = MockAsyncExitStack()
mock_stdio.return_value = AsyncMock()
# Mock SessionContext
mock_session_context = AsyncMock()
mock_session_context.__aenter__ = AsyncMock(
return_value=MockClientSession()
)
mock_session_context.__aexit__ = AsyncMock(return_value=False)
mock_session_context_class.return_value = mock_session_context
# Create session in a new task
await asyncio.create_task(manager.create_session())
# Close session in another task
await asyncio.create_task(manager.close())
# Verify session was closed
assert not manager._sessions
@pytest.mark.asyncio
async def test_session_lock_different_loops(self):
"""Verify that _session_lock returns different locks for different loops."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Access in current loop
lock1 = manager._session_lock
assert isinstance(lock1, asyncio.Lock)
# Access in a different loop (in a separate thread)
lock_container = []
def run_in_thread():
loop2 = asyncio.new_event_loop()
asyncio.set_event_loop(loop2)
try:
async def get_lock():
return manager._session_lock
lock_container.append(loop2.run_until_complete(get_lock()))
finally:
loop2.close()
thread = platform_thread.create_thread(target=run_in_thread)
thread.start()
thread.join()
assert lock_container
lock2 = lock_container[0]
assert isinstance(lock2, asyncio.Lock)
assert lock1 is not lock2
@pytest.mark.asyncio
async def test_cleanup_session_cross_loop(self):
"""Verify that _cleanup_session uses run_coroutine_threadsafe for different loops."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
mock_exit_stack = MockAsyncExitStack()
# Create a dummy loop that is "running" in another thread
loop2 = asyncio.new_event_loop()
try:
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.asyncio.run_coroutine_threadsafe"
) as mock_run_threadsafe:
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.logger"
) as mock_logger:
# We need to mock the return value of run_coroutine_threadsafe to be a future
mock_future = Mock()
mock_run_threadsafe.return_value = mock_future
await manager._cleanup_session("test_session", mock_exit_stack, loop2)
# Verify run_coroutine_threadsafe was called
# ANY is used because a new coroutine object is created each time
mock_run_threadsafe.assert_called_once_with(ANY, loop2)
mock_logger.info.assert_any_call(
"Scheduling cleanup of session test_session on its original"
" event loop."
)
mock_future.add_done_callback.assert_called_once()
finally:
loop2.close()
@pytest.mark.asyncio
async def test_create_session_cleans_up_without_aclose_if_loop_is_different(
self,
):
"""Verify that sessions from different loops are cleaned up without calling aclose()."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# 1. Simulate a session created in a "different" loop
mock_session = MockClientSession()
mock_exit_stack = MockAsyncExitStack()
# Use a dummy object as a different loop
different_loop = Mock(spec=asyncio.AbstractEventLoop)
manager._sessions["stdio_session"] = (
mock_session,
mock_exit_stack,
different_loop,
)
# 2. Mock creation of a new session
# We need to mock create_client, wait_for, and SessionContext
with patch.object(manager, "_create_client") as mock_create_client:
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.asyncio.wait_for"
) as mock_wait_for:
with patch(
"google.adk.tools.mcp_tool.mcp_session_manager.SessionContext"
) as mock_session_context_class:
# Setup mocks for new session creation
mock_create_client.return_value = AsyncMock()
new_session = MockClientSession()
mock_wait_for.return_value = new_session
mock_session_context_class.return_value = AsyncMock()
# 3. Call create_session
session = await manager.create_session()
# 4. Verify results
assert session == new_session
assert len(manager._sessions) == 1
# Verify that old exit_stack.aclose was NOT called since loop was different
mock_exit_stack.aclose.assert_not_called()
@pytest.mark.asyncio
async def test_close_skips_aclose_for_different_loop_sessions(self):
"""Verify that close() skips aclose() for sessions from different loops."""
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Add one session from same loop and one from different loop
current_loop = asyncio.get_running_loop()
different_loop = Mock(spec=asyncio.AbstractEventLoop)
session1 = MockClientSession()
exit_stack1 = MockAsyncExitStack()
manager._sessions["session1"] = (session1, exit_stack1, current_loop)
session2 = MockClientSession()
exit_stack2 = MockAsyncExitStack()
manager._sessions["session2"] = (session2, exit_stack2, different_loop)
await manager.close()
# exit_stack1 should be closed, exit_stack2 should be skipped
exit_stack1.aclose.assert_called_once()
exit_stack2.aclose.assert_not_called()
assert len(manager._sessions) == 0
@pytest.mark.asyncio
async def test_pickle_mcp_session_manager(self):
"""Verify that MCPSessionManager can be pickled and unpickled."""
import pickle
manager = MCPSessionManager(self.mock_stdio_connection_params)
# Access the lock to ensure it's initialized
lock = manager._session_lock
assert isinstance(lock, asyncio.Lock)
# Add a mock session to verify it's cleared on pickling
manager._sessions["test"] = (Mock(), Mock(), asyncio.get_running_loop())
# Pickle and unpickle
pickled = pickle.dumps(manager)
unpickled = pickle.loads(pickled)
# Verify basics are restored
assert unpickled._connection_params == manager._connection_params
# Verify transient/unpicklable members are re-initialized or cleared
assert unpickled._sessions == {}
assert unpickled._session_lock_map == {}
assert isinstance(unpickled._lock_map_lock, type(manager._lock_map_lock))
assert unpickled._lock_map_lock is not manager._lock_map_lock
assert unpickled._errlog == sys.stderr
# Verify we can still get a lock in the new instance
new_lock = unpickled._session_lock
assert isinstance(new_lock, asyncio.Lock)
assert new_lock is not lock
2025-06-18 11:43:32 -07:00
@pytest.mark.asyncio
async def test_retry_on_errors_decorator():
"""Test the retry_on_errors decorator."""
2025-06-18 11:43:32 -07:00
call_count = 0
@retry_on_errors
2025-06-18 11:43:32 -07:00
async def mock_function(self):
nonlocal call_count
call_count += 1
if call_count == 1:
raise ConnectionError("Resource closed")
2025-06-18 11:43:32 -07:00
return "success"
mock_self = Mock()
result = await mock_function(mock_self)
assert result == "success"
assert call_count == 2 # First call fails, second succeeds
@pytest.mark.asyncio
async def test_retry_on_errors_decorator_does_not_retry_cancelled_error():
"""Test the retry_on_errors decorator does not retry cancellation."""
call_count = 0
@retry_on_errors
async def mock_function(self):
2025-06-18 11:43:32 -07:00
nonlocal call_count
call_count += 1
raise asyncio.CancelledError()
2025-06-18 11:43:32 -07:00
mock_self = Mock()
with pytest.raises(asyncio.CancelledError):
await mock_function(mock_self)
2025-06-18 11:43:32 -07:00
assert call_count == 1
2025-06-18 11:43:32 -07:00
@pytest.mark.asyncio
async def test_retry_on_errors_decorator_does_not_retry_when_task_is_cancelling():
"""Test the retry_on_errors decorator does not retry when cancelling."""
call_count = 0
@retry_on_errors
async def mock_function(self):
nonlocal call_count
call_count += 1
raise ConnectionError("Resource closed")
class _MockTask:
def cancelling(self):
return 1
mock_self = Mock()
with patch.object(asyncio, "current_task", return_value=_MockTask()):
with pytest.raises(ConnectionError):
await mock_function(mock_self)
assert call_count == 1
@pytest.mark.asyncio
async def test_retry_on_errors_decorator_does_not_retry_exception_from_cancel():
"""Test the retry_on_errors decorator does not retry exceptions on cancel."""
call_count = 0
@retry_on_errors
async def mock_function(self):
nonlocal call_count
call_count += 1
try:
raise asyncio.CancelledError()
except asyncio.CancelledError:
raise ConnectionError("Resource closed")
mock_self = Mock()
with pytest.raises(ConnectionError):
await mock_function(mock_self)
assert call_count == 1