fix(mcp): MCP provider lifecycle architecture — three-tier connection scoping - #88
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the MCP provider lifecycle in AgentPool to a three-tier connection scoping architecture, introducing immutable config snapshots, global and session connection pools, and lazy toolset materialization. These changes resolve subagent tool inheritance race conditions and cross-task CancelScope errors. The reviewer identified several critical issues in the new implementation, including a severe race condition in GlobalConnectionPool.release() where dying connections could be prematurely reused, a resource leak for HTTP/SSE connections, a ref-counting bug for HTTP/SSE connection reuse, a runtime NameError in session.py due to a missing Agent import, and silent exception swallowing in base.py.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
|
||
| # Merge new session configs into the agent's MCP snapshot, deduplicating | ||
| # by client_id so that re-initialisation does not duplicate entries. | ||
| if entries and isinstance(self.agent, Agent): |
There was a problem hiding this comment.
The Agent class is used in isinstance(self.agent, Agent) checks at lines 486 and 519, but it is not imported at runtime in session.py. This will raise a NameError at runtime when initializing MCP servers.
Please import Agent from agentpool at the top of the file.
from agentpool import Agent
from agentpool.agents.acp_agent import ACPAgentThere was a problem hiding this comment.
❌ Not a bug. Agent is imported at line 28 of session.py:
from agentpool import Agent, AgentPoolThis is a runtime import, not a TYPE_CHECKING import. isinstance(self.agent, Agent) works correctly at runtime.
There was a problem hiding this comment.
Confirmed: from agentpool import Agent, AgentPool at line 28 of session.py is a runtime import, not TYPE_CHECKING. isinstance(self.agent, Agent) works correctly. No fix needed.
| async def _build_toolset(ctx: Any) -> Any: | ||
| tools = await self.get_tools() | ||
| try: | ||
| tools = await self.get_tools() | ||
| except Exception: | ||
| return None | ||
| if not tools: | ||
| return None |
There was a problem hiding this comment.
Swallowing unexpected exceptions silently inside _build_toolset makes troubleshooting extremely difficult. If an MCP server fails to return tools, the error is completely swallowed and the operator has no visibility into why the tools are missing.
Please log the exception at warning or error level before returning None.
| async def _build_toolset(ctx: Any) -> Any: | |
| tools = await self.get_tools() | |
| try: | |
| tools = await self.get_tools() | |
| except Exception: | |
| return None | |
| if not tools: | |
| return None | |
| async def _build_toolset(ctx: Any) -> Any: | |
| try: | |
| tools = await self.get_tools() | |
| except Exception as e: | |
| logger.warning("Failed to retrieve tools from provider %s: %s", self.name, e, exc_info=True) | |
| return None | |
| if not tools: | |
| return None |
There was a problem hiding this comment.
✅ Fixed in commit 157fc4799 (originally in 83d7eb592). Added logger.warning() with provider name and exc_info=True:
except Exception:
logger.warning(
"Failed to retrieve tools from provider",
provider=self.name,
exc_info=True,
)
return NoneTest: test_build_toolset_logs_warning_on_exception in test_global_pool_review_fixes.py.
Gemini Code Review — ResponseThanks for the thorough review! All valid issues have been fixed in commit #1 🔴 CRITICAL:
|
| # | Severity | Verdict | Action |
|---|---|---|---|
| 1 | CRITICAL | ✅ Valid | Fixed: unified pop in _signal_shutdown_locked |
| 2 | CRITICAL | No fix needed — HTTP/SSE entries are cleaned up by release() |
|
| 3 | HIGH | ✅ Valid | Fixed (same as #1) |
| 4 | HIGH | Ref count semantics improved by #1 fix | |
| 5 | HIGH | ❌ Incorrect | Import exists at session.py:28 |
| 6 | MEDIUM | ✅ Valid | Fixed: added logger.warning() |
Test results: 139 tests pass, ruff clean.
83d7eb5 to
9d61b94
Compare
PR Review: MCP Provider Lifecycle Architecture🧹 PR 卫生问题以下文件不应包含在此 PR 中,建议移除:
📐 架构问题 1: 不同层级的 MCP 连接和复用三层架构设计整体正确,解决了 4 个根因。逐层验证:
遗留问题 — HTTP/SSE ref_count 语义不正确 ( elif existing is not None and not existing.is_stdio:
# HTTP/SSE: 不递增 ref_count,但创建新 transport
transport = config.to_transport()
📐 架构问题 2: 生命周期时序我构建了三个层级的完整生命周期时序图,以下是需要注意的跨层级交互风险:
建议:在
但要注意: ✅ 总结架构设计正确,4 个根因均已解决。建议:
|
架构评审:当前 MCP 设计是否最优?
问题 1:连接池代码重复 — 两个池 80% 逻辑相同
区别仅在于:键不同( 任何 owner-task 逻辑的修改都要改两处,容易出现行为不一致。 改进:统一为 class MCPConnectionPool:
"""统一的 MCP 连接池,行为由策略决定。"""
def __init__(
self,
scope: Literal["global", "session"],
session_id: str | None = None,
reuse_policy: ConnectionReusePolicy = "shared", # shared | per_call | pre_created_only
) -> None:
self._scope = scope
self._session_id = session_id
self._reuse_policy = reuse_policy
self._connections: dict[ConnectionKey, _PooledConnection] = {}
self._lock = asyncio.Lock()
问题 2:
|
| 改进 | 影响 | 难度 | 优先级 |
|---|---|---|---|
| 1. 统一两个连接池类 | -300 行重复,行为一致性 | 中(接口对齐) | P0 |
2. threading.Lock → asyncio.Lock |
消除事件循环阻塞风险 | 低(机械替换) | P0 |
| 3. 移除 legacy 路径 | 降低测试矩阵,防止腐烂 | 低(删除代码) | P1 |
| 4. ConfigRegistry 替代 snapshot 传递 | 关注点分离,-100 行胶水 | 中(重构 Agent/Pool) | P1 |
5. MCPToolset 缓存 |
减少 per-turn 开销 | 高(失效逻辑复杂) | P2 |
结论
当前设计正确但不优雅。最优雅的设计应该满足三个标准:
- 单一职责:每个组件只做一件事(当前
Agent管了太多 MCP 配置) - 无重复:相同逻辑只写一次(当前两个池 80% 代码重复)
- 最小接口:使用者不需要知道内部分层(当前
get_agentlet()要感知 snapshot 是否存在)
改进后的架构将 3 个类合并为 2 个(MCPConnectionPool + ConfigRegistry),消除重复,简化接口,同时保持所有正确性保证。这不是理论上的优化 — 它能减少约 400 行代码,同时让系统更容易理解和维护。
以上建议可以作为 follow-up 重构的方向,不阻塞当前 PR 合并。
回复架构评审感谢详细的架构评审!逐条回复: PR 卫生:
|
| 建议 | 采纳 | 理由 |
|---|---|---|
| 移除 yaml | ✅ 已修 | — |
| ref_count 修正 | 加了 TODO,当前无 bug | |
| cleanup 注释 | ✅ 已修 | — |
| Lock 注释 | ✅ 已修 | — |
| 统一连接池 | ❌ 不做 | 差异结构性 |
| MCPToolset 缓存 | ❌ 不做 | 会重新引入 bug |
| 移除 legacy | 需处理 standalone | |
| ConfigRegistry | 较大重构 | |
| register_session key | 非阻塞 |
所有争议点经 Oracle 裁决均不阻塞合并。1010 tests pass 验证了功能正确性。
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements a robust three-tier MCP connection scoping architecture (immutable config snapshots, global/session connection pools, and lazy toolset materialization) to resolve race conditions, cross-task CancelScope errors, and support stateful MCP isolation. It introduces McpConfigSnapshot and McpConfigEntry to manage configurations, replaces the legacy MCPConnectionPool with GlobalConnectionPool and SessionConnectionPool, and updates the orchestrator and ACP session handlers to bypass the mutable agent.tools.providers list. Additionally, it fixes a CancelScope error in streaming_adapter.py and adds comprehensive tests. The review feedback highlights two critical issues: a missing runtime import of Agent in session.py that will cause a NameError during MCP server initialization, and the need for GlobalConnectionPool to clean up dead stdio connections from its cache in the owner task's finally block to prevent returning crashed connections on subsequent requests.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
PR #88 changed as_capability() to async def, but test mocks still used MagicMock.return_value which returns a list instead of a coroutine. Switch to AsyncMock to properly await the call. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
PR #88 removed the _warn parameter from MCPManager.__init__(), but test_graph_teams.py still passed it. Drop the argument. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
PR #88 added new code paths in get_or_create_session_agent() that iterate pool.mcp.servers and cfg.get_mcp_servers(). The mock AgentPool didn't configure these attributes, causing TypeError: 'Mock' object is not iterable. Set both to empty lists. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…t test PR #88 removed the _warn parameter from MCPManager.__init__(), but the backward compatibility test still passed it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…r.create_session PR #88 changed ACPSessionManager.create_session() to accept agent_name: str instead of agent: Agent. Update the test call accordingly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Fix formatting issues in manager.py, session_pool.py, and capability.py introduced by PR #88 changes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… scoping Complete overhaul of MCP provider lifecycle to fix subagent MCP tool inheritance, cross-task CancelScope errors, and streaming adapter hangs. Closes #70 - Frozen dataclass capturing pool/agent/session/skill MCP configs - Child sessions inherit parent's session_configs at creation time - Eliminates race condition between receive_request() and session/load - Owner-task pattern for stdio (dedicated asyncio.Task enters/exits CM) - _SharedSessionTransport wrapper: yields shared ClientSession without duplicate connect_session() calls - HTTP/SSE: fresh transport per call (no stream contention) - LRU eviction (MAX_SESSIONS=256), ref counting, threading.Lock - (client_id, skill_name) keying for skill MCP isolation - Owner-task for stdio, add_transport() for pre-created ACP transports - copy_pre_created_transports(): child inherits parent's ACP transport - Fresh MCPToolset per agentlet from snapshot + connection pools - Bypasses agent.tools.providers for MCP (uses snapshot path) - native=False to force local MCP tool fallback - Per-session stream pairs in AcpMcpConnection (register_session()) - send_to_acp() routes response to caller's stream (no JSON-RPC id tracking — synchronous send_to_client naturally correlates) - broadcast_to_sessions() for server-initiated notifications - connect_session() finally only cleans up own pair, not connection's - Moved yield outside anyio.create_task_group() - Producer as asyncio.ensure_future(), consumer in main coroutine - to_transport() on all config classes (Stdio/SSE/StreamableHTTP) - Removed all to_pydantic_ai() methods - as_capability() creates fresh MCPToolset per call (no caching) - SkillMcpServerConfig.to_mcp_server_config() bridge method - SkillCapability.get_toolset() reads from snapshot + SessionConnectionPool - on_run_ended() uses isinstance instead of getattr - 134 MCP server tests (45 unit + 42 integration + 47 existing) - 3 transport reuse tests (concurrent connect_session, one-exit, 4-way) - 3 GlobalConnectionPool sharing tests (HTTP, stdio, shared stdio) - 17 cross-task lifecycle integration tests - ruff clean, mypy clean on changed files Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… scoping Complete overhaul of MCP provider lifecycle to fix subagent MCP tool inheritance, cross-task CancelScope errors, and streaming adapter hangs. Closes #70 - Frozen dataclass capturing pool/agent/session/skill MCP configs - Child sessions inherit parent's session_configs at creation time - Eliminates race condition between receive_request() and session/load - Owner-task pattern for stdio (dedicated asyncio.Task enters/exits CM) - _SharedSessionTransport wrapper: yields shared ClientSession without duplicate connect_session() calls - HTTP/SSE: fresh transport per call (no stream contention) - LRU eviction (MAX_SESSIONS=256), ref counting, threading.Lock - (client_id, skill_name) keying for skill MCP isolation - Owner-task for stdio, add_transport() for pre-created ACP transports - copy_pre_created_transports(): child inherits parent's ACP transport - Fresh MCPToolset per agentlet from snapshot + connection pools - Bypasses agent.tools.providers for MCP (uses snapshot path) - native=False to force local MCP tool fallback - Per-session stream pairs in AcpMcpConnection (register_session()) - send_to_acp() routes response to caller's stream (no JSON-RPC id tracking — synchronous send_to_client naturally correlates) - broadcast_to_sessions() for server-initiated notifications - connect_session() finally only cleans up own pair, not connection's - Moved yield outside anyio.create_task_group() - Producer as asyncio.ensure_future(), consumer in main coroutine - to_transport() on all config classes (Stdio/SSE/StreamableHTTP) - Removed all to_pydantic_ai() methods - as_capability() creates fresh MCPToolset per call (no caching) - SkillMcpServerConfig.to_mcp_server_config() bridge method - SkillCapability.get_toolset() reads from snapshot + SessionConnectionPool - on_run_ended() uses isinstance instead of getattr - 134 MCP server tests (45 unit + 42 integration + 47 existing) - 3 transport reuse tests (concurrent connect_session, one-exit, 4-way) - 3 GlobalConnectionPool sharing tests (HTTP, stdio, shared stdio) - 17 cross-task lifecycle integration tests - ruff clean, mypy clean on changed files Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
- Fix 8 ruff errors: TC003/TC002 (move imports to TYPE_CHECKING), E501 (line length), SIM105 (use contextlib.suppress), I001 (import sort) - Remove ~20 debug logger.debug() trace calls from turn.py, run.py, core.py that were added during subagent hang diagnosis - Keep exception-handling debug logs (RunAbortedError, tool kind map, usage extraction) — these are useful for production debugging
…t exception
TDD-driven fixes for 3 issues found by Gemini Code Assist:
1. _signal_shutdown_locked race condition (CRITICAL):
- stdio connections were not popped from _connections until after
owner task exit (up to 10s window). Concurrent get_transport()
could retrieve a dying connection.
- Fix: always pop from _connections in _signal_shutdown_locked,
regardless of transport type. Remove redundant pop in release()
and _evict_if_needed().
2. HTTP/SSE ref count imbalance (HIGH):
- get_transport() reused HTTP/SSE entry without incrementing
ref_count, but release() decremented it. Multiple releases
could drive ref_count negative.
- Verified: current code pops HTTP/SSE in _signal_shutdown_locked
so negative ref_count doesn't cause issues, but semantics
are now correct with unified pop.
3. _build_toolset silent exception (MEDIUM):
- get_tools() exceptions were silently swallowed, returning None
with no logging. Operators had no visibility into missing tools.
- Fix: add logger.warning() with provider name and exc_info=True.
Verified: #5 (Agent import in session.py) is NOT a bug — import
exists at line 28.
Tests: 5 new TDD tests in test_global_pool_review_fixes.py
(139 total pass, ruff clean)
Rewrite 5 test files to use the new per-session stream API: - register_session() / unregister_session() instead of open() - send_to_acp(message, response_stream) instead of send_to_client(message) - pair.to_session_receive.receive() instead of conn.to_session.receive() - handle_client_message broadcasts to all registered sessions - Removed 6 tests for deleted properties (to_session, from_session, etc.) 46 tests pass (was 29 failed + 8 errors).
- Remove xeno-agent/config/diag-agent-ng.yaml (deployment config, not framework code — contains private model endpoints) - Add threading.Lock boundary comment in GlobalConnectionPool - Add HTTP/SSE ref_count TODO in GlobalConnectionPool - Add cleanup boundary comment in SessionConnectionPool.cleanup() (pre-created ACP transports are managed by AcpMcpConnectionManager)
…tionPool Shared stdio connections now live for the pool lifetime — no ref counting, no LRU eviction, no MAX_SESSIONS cap. Pool-level MCP servers are typically few (<10) and long-lived, making per-connection tracking unnecessary overhead. Changes: - Remove ref_count field, release() method, _evict_if_needed() - Remove MAX_SESSIONS constant and LRU eviction logic - Remove _signal_shutdown_locked() (inlined into shutdown_all()) - HTTP/SSE transports no longer cached in _connections dict - Replace OrderedDict with plain dict (no LRU ordering needed) - Remove _PooledConnection.is_stdio field (only stdio is cached) - Add warning log when cached connections exceed 50 - Remove stale TODO about HTTP/SSE ref_count imbalance Net: -145 lines from global_pool.py Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Remove tests for deleted APIs (release(), ref_count, MAX_SESSIONS, _evict_if_needed, LRU eviction). Update remaining tests to reflect that HTTP/SSE transports are no longer cached in _connections. - test_global_pool.py: Remove 8 tests for release()/LRU, update HTTP/SSE tests to assert no caching, add fresh-transport test - test_global_pool_review_fixes.py: Remove TestReleasePopsDyingConnection and TestHTTPRefCountBalance classes (-169 lines), keep TestBuildToolsetLogsWarning - test_global_pool_sharing.py: Remove unused imports Net: -441 lines across test files Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…dcoded 30s The owner-task ready wait timeout was hardcoded to 30s, ignoring the server's configured timeout. Now uses config.timeout (default 30s) so servers with longer startup times (e.g. npx wrappers) are not killed prematurely. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…not available _ build_mcp_toolsets and on_run_ended were hardcoded to default or returning early when ctx.deps was not an AgentContext instance. This broke tests that use FakeDeps(session_id=...). Fix: use getattr(ctx.deps, "session_id", "default") as fallback before defaulting to "default" / returning early. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
If a stdio subprocess crashes, the owner task exits but the dead connection was never removed from _connections. Subsequent get_transport() calls would find the dead entry, detect owner_task.exception() != None, and raise RuntimeError every time — permanently poisoning the pool. Fix: remove the connection from _connections in _run_session's finally block (under lock, identity-checked against the pool's current entry to avoid races with shutdown_all()). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
PR #88 changed as_capability() to async def, but test mocks still used MagicMock.return_value which returns a list instead of a coroutine. Switch to AsyncMock to properly await the call. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
PR #88 removed the _warn parameter from MCPManager.__init__(), but test_graph_teams.py still passed it. Drop the argument. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
PR #88 added new code paths in get_or_create_session_agent() that iterate pool.mcp.servers and cfg.get_mcp_servers(). The mock AgentPool didn't configure these attributes, causing TypeError: 'Mock' object is not iterable. Set both to empty lists. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…t test PR #88 removed the _warn parameter from MCPManager.__init__(), but the backward compatibility test still passed it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…r.create_session PR #88 changed ACPSessionManager.create_session() to accept agent_name: str instead of agent: Agent. Update the test call accordingly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Fix formatting issues in manager.py, session_pool.py, and capability.py introduced by PR #88 changes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Mock providers were missing get_tools as AsyncMock, causing 'object MagicMock can't be used in await expression' errors during get_agentlet(). The errors were caught but generated noise and could trigger 'Event loop is closed' on CI. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
5cdcc7f to
83be928
Compare
Fix all remaining ruff check and ruff format errors in files changed by PR #88 after rebasing onto develop/agentic (which includes lint fix #90). Errors fixed: G004 (f-string logging), E501 (line length), D205 (docstring format), SIM102/SIM117 (if nesting), PERF401 (async comprehension), TRY300/TRY301 (else block), TRY004 (TypeError vs RuntimeError), and pragmatic noqa for PLR0915/PLR0911/BLE001 on complex functions. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…n Linux Fix 43 mypy errors across 9 files: missing type annotations in session_stream_pair.py, incompatible types in core.py, GraphRun generic args in streaming_adapter.py, and BaseAgent attribute access patterns. Also fix test_session_manager_with_mcp which was skipped on macOS but failed on Linux CI due to AgentPool() created without main_agent_name. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ion tests test_session_manager_with_mcp and test_session_with_mcp_servers were skipped on macOS but failed on Linux CI. AgentPool() was created without any agents in the manifest, and create_session() could not find the agent config. Register a NativeAgentConfig in the runtime registry to fix. Also fix ruff format. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
runtime_registry is on SessionPool, not AgentPool. Move register() call inside 'async with agent_pool:' block where session_pool is initialized. Remove unnecessary register() from test_session_with_mcp_servers which creates ACPSession directly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
runtime_registry is on SessionController (SessionPool.sessions), not SessionPool directly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Summary
Complete overhaul of MCP provider lifecycle: migration from deprecated
MCPServer*toMCPToolset, three-tier connection scoping architecture, ACP transport concurrent session support, and streaming adapter CancelScope fix.Closes #70
Problem
Subagent sessions could not access MCP-over-ACP tools (e.g.
workspace-fs). Four root causes:receive_request()starts the turn immediately, butsession/load(which creates ACP MCP transports) arrives ~34ms later.get_agentlet()runs before transports are available.MCPToolsetinstances shared between parent (task A) and child (task B) triggeredRuntimeError: Attempted to exit cancel scope in a different task.AcpMcpTransport.connect_session()used shared memory streams — multipleClientSessioninstances competed for the same streams.get_tools()hang:MCPResourceProvider.get_tools()blocked waiting for ACP session, hanging the entire turn.Changes
1. Migration:
MCPServer*→MCPToolset(prior work, squashed)to_transport()on all config classes (StdioMCPServerConfig,SSEMCPServerConfig,StreamableHTTPMCPServerConfig)to_pydantic_ai()methods and deprecatedMCPServer*importsas_capability()creates freshMCPToolsetper call (no caching)_make_elicitation_handler()with 4-arg FastMCP signatureMCPClient._get_client()usesconfig.to_transport()2. Three-Tier MCP Connection Scoping Architecture
Tier 1:
McpConfigSnapshot(immutable config, inheritable)session_configsat creation timerun_stream()Tier 2a:
GlobalConnectionPool(pool-level singleton)asyncio.Taskenters/exits CM)_SharedSessionTransportwrapper: yields sharedClientSessionwithout duplicateconnect_session()callsthreading.LockTier 2b:
SessionConnectionPool(per-session isolation)(client_id, skill_name)keying for skill MCP isolationadd_transport()for pre-created ACP transportscopy_pre_created_transports(): child inherits parent's ACP transportTier 3: Lazy toolset materialization in
get_agentlet()MCPToolsetper agentlet from snapshot + connection poolsagent.tools.providersfor MCP (uses snapshot path)native=Falseto force local MCP tool fallback3. AcpMcpTransport Concurrent
connect_session()SupportAcpMcpConnection(register_session()/unregister_session())send_to_acp()routes response to caller's stream (no JSON-RPC id tracking — synchronoussend_to_clientnaturally correlates)broadcast_to_sessions()for server-initiated notificationsconnect_session()finallyonly cleans up own pair, not connection's4. GlobalConnectionPool Transport Sharing
get_transport()returns_SharedSessionTransportwrapper that yields the sharedClientSessionmanaged by the owner task. Underlying transport'sconnect_session()called only once.get_transport()call. HTTP connections are cheap and stateless — no need to share.5. Streaming Adapter CancelScope Fix
yieldoutsideanyio.create_task_group()asyncio.ensure_future(), consumer in main coroutinebase_agent.pyandacp_agent.pyfixes6. SkillMcpManager Integration
SkillMcpServerConfig.to_mcp_server_config()bridge methodSkillCapability.get_toolset()reads from snapshot +SessionConnectionPoolon_run_ended()usesisinstanceinstead ofgetattr7. Other Fixes
get_tools()5s timeout to prevent subagent hang on ACP MCP providerACPSessionManager.create_session()takesagent_name: strinstead ofagent: BaseAgentnative=Falsefor MCP capabilities (ack-dev doesn't support native MCPServerTool)_get_or_create_session_locked()session/loadon active session → ignore; on stored session → restore with mcpServerssession_mcp_providerslist fromACPSession(replaced by snapshot)add_provider(mcp_provider)calls for MCP fromagent.tools.providerspath8. Code Cleanup (commit 2, -310 lines)
AcpMcpConnectionshared stream path (send_to_client(),open(), properties,handle_client_message()fallback) — ~235 lines__main__test block frommanager.pylogger.info()→logger.debug()inturn.py,run.py,core.pyAcpMCPServerConfigimport, vestigial_ref_count/_lockfrom_SharedSessionTransport_warnparameter fromMCPManager.__init__()_build_mcp_toolsets_legacy()wrapperNew Files
src/agentpool/mcp_server/config_snapshot.pyMcpConfigEntry+McpConfigSnapshotfrozen dataclassessrc/agentpool/mcp_server/global_pool.pyGlobalConnectionPoolwith owner-task pattern +_SharedSessionTransportsrc/agentpool/mcp_server/session_pool.pySessionConnectionPoolper-session isolationsrc/agentpool/mcp_server/session_stream_pair.pySessionStreamPairdataclass for per-session streamsModified Files (summary)
agentpool_config/mcp_server.pyto_transport()methods, removedto_pydantic_ai()(-104 lines)agentpool/mcp_server/manager.pyas_capability()with pools,send_to_acp(),broadcast_to_sessions()agentpool/mcp_server/client.pyconfig.to_transport()agentpool_server/acp_server/acp_mcp_manager.pyregister/unregister/send_to_acpagentpool_server/acp_server/acp_mcp_transport.pyconnect_session()agentpool_server/acp_server/session.pyinitialize_mcp_servers()builds config entries, removedsession_mcp_providersagentpool_server/acp_server/session_manager.pysession/loadsemantics,create_session()takesagent_name: stragentpool_server/acp_server/handler.pyagentpool_server/acp_server/acp_agent.pyagent_namerefactoringagentpool/agents/native_agent/agent.py_mcp_snapshot,_build_pool/agent_configs(), snapshot-awareget_agentlet()agentpool/agents/native_agent/turn.pyget_tools()timeout, debug loggingagentpool/orchestrator/core.pycopy_pre_created_transports()agentpool/messaging/streaming_adapter.pyagentpool/skills/capability.pyagentpool/skills/skill_mcp_manager.pybuild_config_entries()agentpool_config/skills.pyto_mcp_server_config()bridgeagentpool/resource_providers/mcp_provider.pyagentpool/resource_providers/base.pyTesting
connect_session, one-exit-doesn't-break-other, 4-way concurrent)Files Changed
48 files, +7991/-1172 (commit 1) + 9 files, +28/-316 (commit 2) = 50 files, +8019/-1488
Ultraworked with Sisyphus