Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.24.1"
".": "1.25.1"
}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [1.25.1](https://github.com/google/adk-python/compare/v1.25.0...v1.25.1) (2026-02-18)

### Bug Fixes

* Fix pickling lock errors in McpSessionManager ([4e2d615](https://github.com/google/adk-python/commit/4e2d6159ae3552954aaae295fef3e09118502898))

## [1.25.0](https://github.com/google/adk-python/compare/v1.24.1...v1.25.0) (2026-02-11)

### Features
Expand Down
24 changes: 24 additions & 0 deletions src/google/adk/tools/mcp_tool/mcp_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,30 @@ async def create_session(
)
raise ConnectionError(f'Failed to create MCP session: {e}') from e

def __getstate__(self):
"""Custom pickling to exclude non-picklable runtime objects."""
state = self.__dict__.copy()
# Remove unpicklable entries or those that shouldn't persist across pickle
state['_sessions'] = {}
state['_session_lock_map'] = {}

# Locks and file-like objects cannot be pickled
state.pop('_lock_map_lock', None)
state.pop('_errlog', None)

return state

def __setstate__(self, state):
"""Custom unpickling to restore state."""
self.__dict__.update(state)
# Re-initialize members that were not pickled
self._sessions = {}
self._session_lock_map = {}
Comment on lines +520 to +521
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _sessions and _session_lock_map attributes are already set to empty dictionaries in __getstate__. This state is restored via self.__dict__.update(state) on line 518. Re-initializing them here is redundant and can be removed for better code clarity.

self._lock_map_lock = threading.Lock()
# If _errlog was removed during pickling, default to sys.stderr
if not hasattr(self, '_errlog') or self._errlog is None:
self._errlog = sys.stderr

async def close(self):
"""Closes all sessions and cleans up resources."""
async with self._session_lock:
Expand Down
2 changes: 1 addition & 1 deletion src/google/adk/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.

# version: major.minor.patch
__version__ = "1.25.0"
__version__ = "1.25.1"
33 changes: 33 additions & 0 deletions tests/unittests/tools/mcp_tool/test_mcp_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,39 @@ async def test_close_skips_aclose_for_different_loop_sessions(self):
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Per PEP 8 (E402), imports should be at the top of the file. Please move import pickle to the module-level imports at the top of the file for consistency and to adhere to standard Python style guidelines.

References
  1. PEP 8 E402: Module level import not at top of file. Imports should usually be on separate lines at the top of the module. (link)


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


@pytest.mark.asyncio
async def test_retry_on_errors_decorator():
Expand Down
Loading