perf(code): load MCP servers concurrently during graph build (#4659)

Deep Agents Code now discovers MCP servers concurrently, cutting initial
graph-load time when multiple MCP servers are configured.

---

Initial Deep Agents Code graph load scaled linearly with the number of
configured MCP servers because `_load_tools_from_config` ran per-server
preflight and tool discovery sequentially — the dominant contributor to
startup time. This runs independent per-server
preflight/connection-build and discovery concurrently under a bounded
semaphore (`_MCP_LOAD_CONCURRENCY = 8`) while keeping deterministic
server/tool ordering, per-server error isolation, auth, cleanup, and
session-manager semantics unchanged. The adapter-import warmup was moved
into `_load_tools_from_config`, so MCP adapters are imported only when
at least one active MCP server exists.

Measured (simulated, 0.4s preflight + 0.6s discovery per server): 6
servers 6.02s → 1.01s (~6x); 10 servers 10.03s → 2.01s (limit=8). Tool
ordering and `server_infos` ordering verified identical to the
sequential loader. The slow-load warning is untouched and no public APIs
changed.

Made by [Open
SWE](https://openswe.vercel.app/agents/8dfe87f3-154b-050b-93a0-4a2a9af39fee)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-11 19:39:05 -04:00
committed by GitHub
parent 1c08d2705f
commit c5345cc04c
4 changed files with 862 additions and 175 deletions
+290 -116
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import copy
import fnmatch
import functools
import json
import logging
import re
@@ -18,10 +19,10 @@ import shutil
from contextlib import AsyncExitStack
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from langchain_core.tools import BaseTool
from langchain_mcp_adapters.client import Connection
@@ -32,6 +33,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_T = TypeVar("_T")
# Maintainer note: `deepagents-talon` imports `MCPConfigError`,
# `MCPServerInfo`, and `get_mcp_tools` from this module, and its tests construct
# `MCPToolInfo`. Keep those symbols' names, signatures, and return/dataclass
@@ -1474,6 +1477,84 @@ def _apply_tool_filter(
return [t for t in tools if not _any_entry_matches(t.name, entries)]
_MCP_LOAD_CONCURRENCY = 8
"""Upper bound on MCP servers preflighted/discovered concurrently.
Independent servers are probed in parallel so graph load no longer scales
linearly with server count, but the fan-out is capped so a large config cannot
spawn an unbounded number of simultaneous socket/subprocess handshakes (or
`asyncio.to_thread` `shutil.which` workers).
"""
def _warm_mcp_adapter_imports() -> None:
"""Eagerly import MCP adapter modules whose first import may block.
Run via `asyncio.to_thread` before adapter symbols are used, so the initial
(potentially blocking) package-resource scan stays off the server event
loop. Because this runs inside `_load_tools_from_config`, it happens only
when at least one active MCP server exists — a config with no MCP servers
never imports the adapters.
"""
from langchain_mcp_adapters import (
sessions as _sessions, # noqa: F401
tools as _tools, # noqa: F401
)
async def _gather_bounded(
factories: Sequence[Callable[[], Awaitable[_T]]],
*,
limit: int,
) -> list[_T]:
"""Await coroutine factories with bounded concurrency, preserving order.
Results are returned in submission order (not completion order), so callers
can zip them back against their inputs to keep deterministic ordering. If a
factory raises (including a cancellation/shutdown signal), the remaining
tasks are cancelled and awaited before the exception propagates, so no
background work is left running.
`asyncio.gather` propagates only the *first* task to finish with an
exception; when several tasks fail concurrently the rest are cancelled
during teardown and their exceptions would otherwise be discarded silently.
To keep concurrent failures debuggable, each dropped (non-cancellation)
sibling exception is logged at debug level before the first one propagates.
Args:
factories: Zero-arg callables each returning an awaitable to run.
limit: Maximum number of awaitables in flight at once. Values below 1
are clamped to 1.
Returns:
The awaited results in the same order as `factories`.
"""
semaphore = asyncio.Semaphore(max(1, limit))
async def _run(factory: Callable[[], Awaitable[_T]]) -> _T:
async with semaphore:
return await factory()
tasks = [asyncio.create_task(_run(factory)) for factory in factories]
try:
return await asyncio.gather(*tasks)
except BaseException:
for task in tasks:
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, BaseException) and not isinstance(
result, asyncio.CancelledError
):
logger.debug(
"MCP concurrent load: a sibling task failed while another "
"failure was already propagating; logging the dropped "
"exception for debugging",
exc_info=result,
)
raise
async def _load_tools_from_config(
config: dict[str, Any],
*,
@@ -1504,7 +1585,11 @@ async def _load_tools_from_config(
Raises:
RuntimeError: If `session_manager` is reconfigured incompatibly with
sessions already active on it.
""" # noqa: DOC501, DOC502 - `RuntimeError` surfaces via `MCPSessionManager.configure`; `KeyboardInterrupt` / `SystemExit` / `CancelledError` are re-raised pass-throughs
""" # noqa: DOC502 - `RuntimeError` surfaces via `MCPSessionManager.configure`
# Warm the adapter imports off the event loop *here* (rather than in the
# caller) so a config with no active MCP servers — which returns before
# ever reaching this function — never pays the adapter-import cost.
await asyncio.to_thread(_warm_mcp_adapter_imports)
from langchain_mcp_adapters.sessions import (
SSEConnection,
StdioConnection,
@@ -1513,10 +1598,26 @@ async def _load_tools_from_config(
)
from langchain_mcp_adapters.tools import convert_mcp_tool_to_langchain_tool
skipped: dict[str, tuple[MCPServerStatus, str]] = {}
server_items = list(config["mcpServers"].items())
# Resolve each server's transport once, up front. `_resolve_server_type` is
# pure, so this is a readability/DRY win over recomputing it in preflight,
# discovery, and the final fold-in loop below.
transports = {name: _resolve_server_type(cfg) for name, cfg in server_items}
for server_name, server_config in config["mcpServers"].items():
server_type = _resolve_server_type(server_config)
async def _preflight_and_connect(
server_name: str,
server_config: dict[str, Any],
) -> tuple[MCPServerStatus, str] | Connection:
"""Preflight one server and build its connection config.
Per-server preflight/config failures are captured here so one bad
server never aborts loading the others.
Returns:
A `(status, error)` tuple when the server must be skipped, or a
ready `Connection` otherwise.
"""
server_type = transports[server_name]
try:
if server_type in _SUPPORTED_REMOTE_TYPES:
await _check_remote_server(server_name, server_config)
@@ -1530,13 +1631,8 @@ async def _load_tools_from_config(
server_name,
exc,
)
skipped[server_name] = ("error", str(exc))
return ("error", str(exc))
connections: dict[str, Connection] = {}
for server_name, server_config in config["mcpServers"].items():
if server_name in skipped:
continue
server_type = _resolve_server_type(server_config)
try:
if server_type in _SUPPORTED_REMOTE_TYPES:
if server_type == "http":
@@ -1582,8 +1678,7 @@ async def _load_tools_from_config(
"MCP server '%s' skipped: not authenticated.",
server_name,
)
skipped[server_name] = ("unauthenticated", auth_msg)
continue
return ("unauthenticated", auth_msg)
if explicit_oauth or (
stored_tokens is not None and not has_authorization_header
@@ -1599,21 +1694,42 @@ async def _load_tools_from_config(
interactive=False,
)
connections[server_name] = conn
else:
connections[server_name] = StdioConnection(
command=server_config["command"],
args=server_config.get("args", []),
env=server_config.get("env") or None,
transport="stdio",
)
return conn
return StdioConnection(
command=server_config["command"],
args=server_config.get("args", []),
env=server_config.get("env") or None,
transport="stdio",
)
except (OSError, RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"MCP server '%s' skipped: config/setup failed: %s",
server_name,
exc,
)
skipped[server_name] = ("error", str(exc))
return ("error", str(exc))
# Preflight + connection build runs concurrently across servers (bounded).
# Results come back in submission order, so `skipped`/`connections` are
# assembled in config order and stay deterministic regardless of which
# server's probe finished first.
preflight_results = await _gather_bounded(
[
functools.partial(_preflight_and_connect, name, cfg)
for name, cfg in server_items
],
limit=_MCP_LOAD_CONCURRENCY,
)
skipped: dict[str, tuple[MCPServerStatus, str]] = {}
connections: dict[str, Connection] = {}
for (server_name, _server_config), result in zip(
server_items, preflight_results, strict=True
):
if isinstance(result, tuple):
skipped[server_name] = result
else:
connections[server_name] = result
runtime_manager: MCPSessionManager | None = session_manager
if runtime_manager is not None:
@@ -1621,23 +1737,22 @@ async def _load_tools_from_config(
elif not stateless:
runtime_manager = MCPSessionManager(connections=connections)
all_tools: list[BaseTool] = []
server_infos: list[MCPServerInfo] = []
async def _discover_server(
server_name: str,
server_config: dict[str, Any],
transport: str,
) -> tuple[list[BaseTool], MCPServerInfo]:
"""Discover one server's tools and build its `MCPServerInfo`.
for server_name, server_config in config["mcpServers"].items():
transport = _resolve_server_type(server_config)
if server_name in skipped:
status, error = skipped[server_name]
server_infos.append(
MCPServerInfo(
name=server_name,
transport=transport,
status=status,
error=error,
),
)
continue
Both discovery failures (classified as auth vs. generic error) and
post-discovery tool-construction failures are captured as a non-`ok`
`MCPServerInfo` with no tools, so a single failing server never aborts
the load for the others. Cancellation/shutdown signals are re-raised so
the bounded runner can tear the whole load down.
Returns:
The server's LangChain tools plus its `MCPServerInfo` entry.
""" # noqa: DOC501 - CancelledError/KeyboardInterrupt/SystemExit are re-raised pass-throughs
try:
async with create_session(connections[server_name]) as discover_session:
await discover_session.initialize()
@@ -1721,93 +1836,152 @@ async def _load_tools_from_config(
server_name,
exc_info=True,
)
return [], MCPServerInfo(
name=server_name,
transport=transport,
status=status,
error=error,
)
# Tool construction and filtering run after the discovery session has
# closed and can still fail (schema conversion, custom tool filters).
# Isolate them too so a construction error degrades this one server to
# an error entry instead of aborting the whole concurrent load — the
# same guarantee the discovery `try` above provides. Cancellation and
# shutdown signals still propagate so the bounded runner can tear down.
try:
if runtime_manager is None:
server_tools: list[BaseTool] = [
convert_mcp_tool_to_langchain_tool(
None,
mcp_tool,
connection=connections[server_name],
server_name=server_name,
tool_name_prefix=True,
)
for mcp_tool in mcp_tools
]
else:
server_tools = [
_build_cached_mcp_tool(
mcp_tool=mcp_tool,
server_name=server_name,
session_manager=runtime_manager,
tool_name_prefix=True,
)
for mcp_tool in mcp_tools
]
server_tools = _apply_tool_filter(server_tools, server_name, server_config)
# Pair each tool's input_schema by its LangChain (server-prefixed)
# name — the same form `server_tools` carries — so the lookup needs
# no string surgery and stays correct if `tool_name_prefix` ever
# changes. Deep-copy the raw dict because `MCPToolInfo` is `frozen`
# but Python's `frozen=True` does not freeze nested mutables; a
# shared reference would let one holder mutate every other's view.
schemas: dict[str, dict[str, Any] | None] = {}
for mcp_tool in mcp_tools:
tool_name = getattr(mcp_tool, "name", "")
try:
raw_schema = getattr(mcp_tool, "inputSchema", None)
schema_copy = (
copy.deepcopy(raw_schema) if raw_schema is not None else None
)
except (AttributeError, TypeError, RecursionError) as exc:
logger.warning(
"MCP tool %r on server %r: inputSchema access raised "
"%s: %s; rendering with no parameters",
tool_name,
server_name,
exc.__class__.__name__,
exc,
)
schema_copy = None
lc_name = f"{server_name}_{tool_name}"
schemas[lc_name] = schema_copy
tool_infos: list[MCPToolInfo] = []
for tool in server_tools:
schema = schemas.get(tool.name)
if schema is None and schemas:
logger.debug(
"MCP tool %r on server %r: no schema matched in lookup "
"(available keys: %s); rendering with no parameters",
tool.name,
server_name,
list(schemas.keys())[:5],
)
tool_infos.append(
MCPToolInfo(
name=tool.name,
description=tool.description or "",
input_schema=schema,
),
)
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
raise
except Exception as exc:
logger.warning(
"MCP server '%s' skipped: tool construction failed",
server_name,
exc_info=True,
)
return [], MCPServerInfo(
name=server_name,
transport=transport,
status="error",
error=str(exc),
)
return server_tools, MCPServerInfo(
name=server_name,
transport=transport,
tools=tuple(tool_infos),
)
# Discovery also runs concurrently (bounded) across the servers that
# survived preflight. Because `_gather_bounded` returns results in
# submission order and skipped servers are folded back in below by
# iterating `server_items` in config order, `server_infos` stays in config
# order and the returned tools stay sorted by tool name — regardless of
# which server's probe finished first.
discover_items = [
(server_name, server_config, transports[server_name])
for server_name, server_config in server_items
if server_name not in skipped
]
discovery_results = await _gather_bounded(
[
functools.partial(_discover_server, name, cfg, transport)
for name, cfg, transport in discover_items
],
limit=_MCP_LOAD_CONCURRENCY,
)
discovered: dict[str, tuple[list[BaseTool], MCPServerInfo]] = {
server_name: result
for (server_name, _cfg, _transport), result in zip(
discover_items, discovery_results, strict=True
)
}
all_tools: list[BaseTool] = []
server_infos: list[MCPServerInfo] = []
for server_name, _server_config in server_items:
if server_name in skipped:
status, error = skipped[server_name]
server_infos.append(
MCPServerInfo(
name=server_name,
transport=transport,
transport=transports[server_name],
status=status,
error=error,
),
)
continue
if runtime_manager is None:
server_tools = [
convert_mcp_tool_to_langchain_tool(
None,
mcp_tool,
connection=connections[server_name],
server_name=server_name,
tool_name_prefix=True,
)
for mcp_tool in mcp_tools
]
else:
server_tools = [
_build_cached_mcp_tool(
mcp_tool=mcp_tool,
server_name=server_name,
session_manager=runtime_manager,
tool_name_prefix=True,
)
for mcp_tool in mcp_tools
]
server_tools = _apply_tool_filter(server_tools, server_name, server_config)
server_tools, server_info = discovered[server_name]
all_tools.extend(server_tools)
# Pair each tool's input_schema by its LangChain (server-prefixed)
# name — the same form `server_tools` carries — so the lookup needs
# no string surgery and stays correct if `tool_name_prefix` ever
# changes. Deep-copy the raw dict because `MCPToolInfo` is `frozen`
# but Python's `frozen=True` does not freeze nested mutables; a
# shared reference would let one holder mutate every other's view.
schemas: dict[str, dict[str, Any] | None] = {}
for mcp_tool in mcp_tools:
tool_name = getattr(mcp_tool, "name", "")
try:
raw_schema = getattr(mcp_tool, "inputSchema", None)
schema_copy = (
copy.deepcopy(raw_schema) if raw_schema is not None else None
)
except (AttributeError, TypeError, RecursionError) as exc:
logger.warning(
"MCP tool %r on server %r: inputSchema access raised %s: %s; "
"rendering with no parameters",
tool_name,
server_name,
exc.__class__.__name__,
exc,
)
schema_copy = None
lc_name = f"{server_name}_{tool_name}"
schemas[lc_name] = schema_copy
tool_infos: list[MCPToolInfo] = []
for tool in server_tools:
schema = schemas.get(tool.name)
if schema is None and schemas:
logger.debug(
"MCP tool %r on server %r: no schema matched in lookup "
"(available keys: %s); rendering with no parameters",
tool.name,
server_name,
list(schemas.keys())[:5],
)
tool_infos.append(
MCPToolInfo(
name=tool.name,
description=tool.description or "",
input_schema=schema,
),
)
server_infos.append(
MCPServerInfo(
name=server_name,
transport=transport,
tools=tuple(tool_infos),
),
)
server_infos.append(server_info)
all_tools.sort(key=lambda tool: tool.name)
return all_tools, None if stateless else runtime_manager, server_infos
+3 -16
View File
@@ -47,18 +47,6 @@ def _print_startup_error(message: str) -> None:
)
def _warm_mcp_adapter_imports() -> None:
"""Eagerly import MCP adapter modules whose first import may block.
Called via `asyncio.to_thread` before MCP loading; kept as a small helper so
tests can patch it and assert the warmup stays off the server event loop.
"""
from langchain_mcp_adapters import (
sessions as _sessions, # noqa: F401
tools as _tools, # noqa: F401
)
def _get_mcp_session_manager() -> Any: # noqa: ANN401
"""Return the process-wide MCP session manager singleton.
@@ -92,8 +80,9 @@ async def _build_tools(
`asyncio.run` (which raises inside a running loop). `stateless=True` ensures
discovery only uses throwaway sessions, while the shared runtime session
manager binds real sessions lazily inside the server loop on first tool
invocation. MCP adapter imports are warmed in a worker thread first because
first import can perform blocking package-resource scans.
invocation. MCP adapter imports are warmed in a worker thread inside
`_load_tools_from_config` (only when active servers exist) because first
import can perform blocking package-resource scans.
Args:
config: Deserialized server configuration.
@@ -117,8 +106,6 @@ async def _build_tools(
if not config.no_mcp:
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
await asyncio.to_thread(_warm_mcp_adapter_imports)
try:
mcp_tools, _, mcp_server_info = await resolve_and_load_mcp_tools(
explicit_config_path=config.mcp_config_path,
@@ -6,6 +6,7 @@ import asyncio
import json
import logging
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -27,6 +28,7 @@ from deepagents_code.mcp_tools import (
_apply_tool_filter,
_check_remote_server,
_check_stdio_server,
_gather_bounded,
_json_error_snippet,
_load_tools_from_config,
_normalize_mcp_arguments,
@@ -1628,6 +1630,28 @@ class TestResolveAndLoadMcpTools:
assert manager is None
assert infos == []
@patch("deepagents_code.mcp_tools._warm_mcp_adapter_imports")
@patch("deepagents_code.mcp_tools.discover_mcp_configs")
async def test_no_adapter_warmup_when_no_active_servers(
self,
mock_discover: MagicMock,
mock_warm: MagicMock,
) -> None:
"""With no configured servers, MCP adapters are never imported.
`_warm_mcp_adapter_imports` (and the adapter imports that follow it)
live inside `_load_tools_from_config`, which the resolver never reaches
when discovery yields no servers — so the warmup must not run.
"""
mock_discover.return_value = []
tools, manager, infos = await resolve_and_load_mcp_tools(no_mcp=False)
assert tools == []
assert manager is None
assert infos == []
mock_warm.assert_not_called()
@patch("deepagents_code.mcp_tools._load_tools_from_config")
@patch("deepagents_code.mcp_tools.discover_mcp_configs")
async def test_explicit_path_merges_with_discovery(
@@ -2177,6 +2201,528 @@ class TestToolOrdering:
await manager.cleanup()
class TestLoadToolsConcurrency:
"""`_load_tools_from_config` probes independent servers concurrently.
These tests pin the new behavior: bounded-concurrency preflight and
discovery, with per-server error isolation and cancellation semantics
matching the previous sequential loader. The load-bearing ordering
guarantee is that `server_infos` follows config order regardless of
completion order; the returned tool list is always sorted by tool name
(via the terminal sort in the loader), so tool-name assertions here are
content checks rather than ordering proofs.
"""
@pytest.fixture(autouse=True)
def _bypass_stdio_health_check(self) -> Generator[None]:
"""Bypass stdio pre-flight so tests focus on discovery concurrency."""
with patch("deepagents_code.mcp_tools._check_stdio_server"):
yield
@staticmethod
def _config(*names: str) -> dict[str, Any]:
return {
"mcpServers": {
name: {"command": "node", "args": [f"{name}.js"]} for name in names
}
}
def _tracking_session_factory(
self,
*,
tool_by_server: dict[str, str],
hold: asyncio.Event | None = None,
sleep_s: float = 0.05,
) -> tuple[Any, dict[str, int]]:
"""Build a `create_session` fake that records concurrency.
Returns the async context-manager fake plus a mutable stats dict with
`max_inflight` (peak simultaneous open discovery sessions).
"""
stats = {"inflight": 0, "max_inflight": 0}
@asynccontextmanager
async def _fake(
connection: dict[str, Any],
*,
_mcp_callbacks: object | None = None,
) -> AsyncIterator[AsyncMock]:
stats["inflight"] += 1
stats["max_inflight"] = max(stats["max_inflight"], stats["inflight"])
try:
# Derive the server name from the recorded command arg so each
# session yields that server's tool.
args = connection.get("args") or []
server = args[0].removesuffix(".js") if args else "srv"
session = AsyncMock()
session.initialize = AsyncMock()
session.list_tools = AsyncMock(
return_value=_make_tool_page(
[_make_mcp_tool(tool_by_server[server])]
)
)
if hold is not None:
await hold.wait()
else:
await asyncio.sleep(sleep_s)
yield session
finally:
stats["inflight"] -= 1
return _fake, stats
async def test_discovery_runs_concurrently(self) -> None:
"""All servers' discovery sessions are open at the same time."""
names = ["a", "b", "c", "d"]
tool_by_server = {n: f"tool_{n}" for n in names}
hold = asyncio.Event()
fake, stats = self._tracking_session_factory(
tool_by_server=tool_by_server, hold=hold
)
async def _release_when_all_open() -> None:
for _ in range(200):
if stats["inflight"] >= len(names):
break
await asyncio.sleep(0.005)
hold.set()
with patch("langchain_mcp_adapters.sessions.create_session", fake):
releaser = asyncio.create_task(_release_when_all_open())
tools, manager, infos = await _load_tools_from_config(self._config(*names))
await releaser
assert stats["max_inflight"] == len(names)
assert [t.name for t in tools] == [
"a_tool_a",
"b_tool_b",
"c_tool_c",
"d_tool_d",
]
assert [i.name for i in infos] == names
assert manager is not None
await manager.cleanup()
async def test_discovery_concurrency_is_bounded(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No more than `_MCP_LOAD_CONCURRENCY` sessions are open at once."""
monkeypatch.setattr(
"deepagents_code.mcp_tools._MCP_LOAD_CONCURRENCY", 2, raising=True
)
names = ["s1", "s2", "s3", "s4", "s5"]
tool_by_server = {n: f"t_{n}" for n in names}
fake, stats = self._tracking_session_factory(
tool_by_server=tool_by_server, sleep_s=0.03
)
with patch("langchain_mcp_adapters.sessions.create_session", fake):
tools, manager, infos = await _load_tools_from_config(self._config(*names))
assert stats["max_inflight"] == 2
assert [i.name for i in infos] == names
assert len(tools) == len(names)
assert manager is not None
await manager.cleanup()
async def test_order_preserved_when_later_servers_finish_first(self) -> None:
"""`server_infos` follows config order regardless of completion order."""
names = ["first", "second", "third"]
# Later servers sleep less, so they finish discovery before earlier ones.
delays = {"first": 0.09, "second": 0.05, "third": 0.01}
finish_order: list[str] = []
@asynccontextmanager
async def _fake(
connection: dict[str, Any],
*,
_mcp_callbacks: object | None = None,
) -> AsyncIterator[AsyncMock]:
server = (connection.get("args") or ["x"])[0].removesuffix(".js")
session = AsyncMock()
session.initialize = AsyncMock()
session.list_tools = AsyncMock(
return_value=_make_tool_page([_make_mcp_tool(f"tool_{server}")])
)
await asyncio.sleep(delays[server])
finish_order.append(server)
yield session
with patch("langchain_mcp_adapters.sessions.create_session", _fake):
tools, manager, infos = await _load_tools_from_config(self._config(*names))
assert finish_order == ["third", "second", "first"]
assert [i.name for i in infos] == names
assert [t.name for t in tools] == [
"first_tool_first",
"second_tool_second",
"third_tool_third",
]
assert manager is not None
await manager.cleanup()
async def test_one_server_failure_isolated_from_others(self) -> None:
"""A single discovery failure does not abort the other servers."""
names = ["ok1", "boom", "ok2"]
@asynccontextmanager
async def _fake(
connection: dict[str, Any],
*,
_mcp_callbacks: object | None = None,
) -> AsyncIterator[AsyncMock]:
server = (connection.get("args") or ["x"])[0].removesuffix(".js")
await asyncio.sleep(0.01)
if server == "boom":
msg = "discovery exploded"
raise RuntimeError(msg)
session = AsyncMock()
session.initialize = AsyncMock()
session.list_tools = AsyncMock(
return_value=_make_tool_page([_make_mcp_tool(f"tool_{server}")])
)
yield session
with patch("langchain_mcp_adapters.sessions.create_session", _fake):
tools, manager, infos = await _load_tools_from_config(self._config(*names))
by_name = {i.name: i for i in infos}
assert [i.name for i in infos] == names
assert by_name["ok1"].status == "ok"
assert by_name["ok2"].status == "ok"
assert by_name["boom"].status == "error"
assert "discovery exploded" in (by_name["boom"].error or "")
assert [t.name for t in tools] == ["ok1_tool_ok1", "ok2_tool_ok2"]
assert manager is not None
await manager.cleanup()
async def test_preflight_failure_isolated_and_order_preserved(self) -> None:
"""A mid-config preflight failure is skipped; survivors keep order.
Exercises the preflight-error path and the fold-in loop that
interleaves a skipped server *between* discovered ones in config order,
with discovery finishing out of order so the ordering cannot be an
accident of completion timing.
"""
names = ["ok_a", "pf_fail", "ok_b"]
# `ok_b` discovers faster than `ok_a`, so completion order is reversed.
delays = {"ok_a": 0.06, "ok_b": 0.01}
def _check(name: str, _cfg: dict[str, Any]) -> None:
if name == "pf_fail":
msg = "preflight boom"
raise RuntimeError(msg)
@asynccontextmanager
async def _fake(
connection: dict[str, Any],
*,
_mcp_callbacks: object | None = None,
) -> AsyncIterator[AsyncMock]:
server = (connection.get("args") or ["x"])[0].removesuffix(".js")
session = AsyncMock()
session.initialize = AsyncMock()
session.list_tools = AsyncMock(
return_value=_make_tool_page([_make_mcp_tool(f"tool_{server}")])
)
await asyncio.sleep(delays[server])
yield session
with (
patch("deepagents_code.mcp_tools._check_stdio_server", _check),
patch("langchain_mcp_adapters.sessions.create_session", _fake),
):
tools, manager, infos = await _load_tools_from_config(self._config(*names))
by_name = {i.name: i for i in infos}
# server_infos follows config order, with the skipped server in place.
assert [i.name for i in infos] == names
assert by_name["ok_a"].status == "ok"
assert by_name["ok_b"].status == "ok"
assert by_name["pf_fail"].status == "error"
assert "preflight boom" in (by_name["pf_fail"].error or "")
assert [t.name for t in tools] == ["ok_a_tool_ok_a", "ok_b_tool_ok_b"]
assert manager is not None
await manager.cleanup()
async def test_all_servers_fail_preflight_yields_empty(self) -> None:
"""Every server failing preflight yields no tools, infos in order.
Drives the empty-discovery `_gather_bounded([], ...)` path and asserts a
non-`None` (empty) session manager is still returned.
"""
names = ["x1", "x2", "x3"]
def _check(_name: str, _cfg: dict[str, Any]) -> None:
msg = "nope"
raise RuntimeError(msg)
with patch("deepagents_code.mcp_tools._check_stdio_server", _check):
tools, manager, infos = await _load_tools_from_config(self._config(*names))
assert tools == []
assert [i.name for i in infos] == names
assert all(i.status == "error" for i in infos)
assert manager is not None
await manager.cleanup()
async def test_tool_construction_failure_isolated(self) -> None:
"""A post-discovery construction failure degrades one server only.
Discovery succeeds for every server, but tool filtering raises for one.
That server must become an `error` info while its siblings load
normally — proving isolation now covers the post-discovery construction
block, not just the discovery session. Without that guard the failure
would abort the entire concurrent load.
"""
names = ["good", "bad_build"]
fake, _ = self._tracking_session_factory(
tool_by_server={n: f"t_{n}" for n in names}, sleep_s=0.0
)
def _filter(
server_tools: list[Any], server_name: str, _server_config: dict[str, Any]
) -> list[Any]:
if server_name == "bad_build":
msg = "build boom"
raise RuntimeError(msg)
return server_tools
with (
patch("langchain_mcp_adapters.sessions.create_session", fake),
patch("deepagents_code.mcp_tools._apply_tool_filter", _filter),
):
tools, manager, infos = await _load_tools_from_config(self._config(*names))
by_name = {i.name: i for i in infos}
assert [i.name for i in infos] == names
assert by_name["good"].status == "ok"
assert by_name["bad_build"].status == "error"
assert "build boom" in (by_name["bad_build"].error or "")
assert [t.name for t in tools] == ["good_t_good"]
assert manager is not None
await manager.cleanup()
async def test_cancellation_propagates_and_cancels_siblings(self) -> None:
"""A cancelled worker propagates and tears down its siblings."""
names = ["cancel", "sibling"]
sibling_cancelled = asyncio.Event()
@asynccontextmanager
async def _fake(
connection: dict[str, Any],
*,
_mcp_callbacks: object | None = None,
) -> AsyncIterator[AsyncMock]:
server = (connection.get("args") or ["x"])[0].removesuffix(".js")
if server == "cancel":
await asyncio.sleep(0.01)
raise asyncio.CancelledError
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
sibling_cancelled.set()
raise
yield AsyncMock() # pragma: no cover - never reached
with (
patch("langchain_mcp_adapters.sessions.create_session", _fake),
pytest.raises(asyncio.CancelledError),
):
await _load_tools_from_config(self._config(*names))
assert sibling_cancelled.is_set()
async def test_preflight_runs_concurrently(self) -> None:
"""Stdio pre-flight checks run concurrently across servers."""
names = ["p1", "p2", "p3"]
tool_by_server = {n: f"pt_{n}" for n in names}
stats = {"inflight": 0, "max_inflight": 0}
# `_slow_check` runs in `asyncio.to_thread` worker threads, so the shared
# counters must be guarded with a real lock (`+=` is not atomic across
# threads) and the barrier must be a thread-safe primitive.
stats_lock = threading.Lock()
barrier = threading.Event()
def _slow_check(_name: str, _cfg: dict[str, Any]) -> None:
# `_check_stdio_server` is sync and invoked via asyncio.to_thread,
# so bump the counter and block until every worker is in-flight.
with stats_lock:
stats["inflight"] += 1
stats["max_inflight"] = max(stats["max_inflight"], stats["inflight"])
barrier.wait()
with stats_lock:
stats["inflight"] -= 1
async def _release() -> None:
for _ in range(200):
with stats_lock:
peak = stats["max_inflight"]
if peak >= len(names):
break
await asyncio.sleep(0.005)
barrier.set()
fake, _ = self._tracking_session_factory(
tool_by_server=tool_by_server, sleep_s=0.0
)
with (
patch("deepagents_code.mcp_tools._check_stdio_server", _slow_check),
patch("langchain_mcp_adapters.sessions.create_session", fake),
):
releaser = asyncio.create_task(_release())
_tools, manager, infos = await _load_tools_from_config(self._config(*names))
await releaser
assert stats["max_inflight"] == len(names)
assert [i.name for i in infos] == names
assert manager is not None
await manager.cleanup()
async def test_warmup_runs_off_loop_before_discovery(self) -> None:
"""Adapter warmup runs, off the event loop, before any discovery."""
loop_thread_id = threading.get_ident()
events: list[tuple[str, int]] = []
def _warm() -> None:
events.append(("warm", threading.get_ident()))
@asynccontextmanager
async def _fake(
connection: dict[str, Any],
*,
_mcp_callbacks: object | None = None,
) -> AsyncIterator[AsyncMock]:
events.append(("discover", threading.get_ident()))
session = AsyncMock()
session.initialize = AsyncMock()
session.list_tools = AsyncMock(return_value=_make_tool_page([]))
yield session
with (
patch("deepagents_code.mcp_tools._warm_mcp_adapter_imports", _warm),
patch("langchain_mcp_adapters.sessions.create_session", _fake),
):
_tools, manager, _infos = await _load_tools_from_config(
self._config("only")
)
assert events[0][0] == "warm"
assert events[0][1] != loop_thread_id
assert any(kind == "discover" for kind, _ in events)
assert manager is not None
await manager.cleanup()
class TestGatherBounded:
"""Direct tests for the `_gather_bounded` concurrency helper.
These pin the helper's contract independently of MCP loading: submission
(not completion) ordering, the empty and clamped-limit edge cases, and the
failure path that cancels + awaits siblings and never silently drops a
concurrent failure.
"""
async def test_results_follow_submission_order(self) -> None:
"""Results zip back to submission order even when completion differs."""
completed: list[int] = []
def _factory(idx: int, delay: float) -> Callable[[], Any]:
async def _run() -> int:
await asyncio.sleep(delay)
completed.append(idx)
return idx
return _run
# Index 0 finishes last, index 2 finishes first.
factories = [_factory(0, 0.03), _factory(1, 0.02), _factory(2, 0.001)]
results = await _gather_bounded(factories, limit=8)
assert results == [0, 1, 2]
assert completed == [2, 1, 0]
async def test_empty_returns_empty(self) -> None:
"""Zero factories return an empty list without touching the loop."""
assert await _gather_bounded([], limit=8) == []
async def test_limit_below_one_is_clamped_to_serial(self) -> None:
"""A limit < 1 is clamped to 1, so factories run strictly serially."""
active = {"n": 0, "max": 0}
def _factory() -> Callable[[], Any]:
async def _run() -> None:
active["n"] += 1
active["max"] = max(active["max"], active["n"])
await asyncio.sleep(0.01)
active["n"] -= 1
return _run
await _gather_bounded([_factory(), _factory(), _factory()], limit=0)
assert active["max"] == 1
async def test_failure_cancels_and_awaits_siblings(self) -> None:
"""A raising factory cancels the rest and awaits them before raising."""
sibling = {"cancelled": False, "completed": False}
started = asyncio.Event()
def _failing() -> Callable[[], Any]:
async def _run() -> None:
await started.wait()
msg = "boom"
raise RuntimeError(msg)
return _run
def _sibling() -> Callable[[], Any]:
async def _run() -> None:
started.set()
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
sibling["cancelled"] = True
raise
sibling["completed"] = True # pragma: no cover - never reached
return _run
with pytest.raises(RuntimeError, match="boom"):
await _gather_bounded([_failing(), _sibling()], limit=8)
assert sibling["cancelled"] is True
assert sibling["completed"] is False
async def test_concurrent_failures_are_logged_not_lost(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""When several factories fail, no failure vanishes silently.
`asyncio.gather` propagates only the first exception; the others are
logged at debug so a concurrent failure is never dropped without trace.
"""
def _failing(message: str) -> Callable[[], Any]:
async def _run() -> None:
raise RuntimeError(message)
return _run
with (
caplog.at_level(logging.DEBUG, logger="deepagents_code.mcp_tools"),
pytest.raises(RuntimeError),
):
await _gather_bounded(
[_failing("first_failure"), _failing("second_failure")], limit=8
)
assert "sibling task failed" in caplog.text
# Both failures are represented in the captured logs (the propagated one
# plus the logged sibling), so neither is lost.
assert "first_failure" in caplog.text
assert "second_failure" in caplog.text
class TestCachedSessionProxy:
"""Runtime tool wrappers use lazy cached sessions with retry semantics."""
+23 -43
View File
@@ -78,7 +78,6 @@ class TestServerGraph:
loop_thread_id = threading.get_ident()
create_cli_agent_thread_ids: list[int] = []
create_model_thread_ids: list[int] = []
warm_import_thread_ids: list[int] = []
def create_cli_agent_side_effect(**_: object) -> tuple[object, object]:
create_cli_agent_thread_ids.append(threading.get_ident())
@@ -88,9 +87,6 @@ class TestServerGraph:
create_model_thread_ids.append(threading.get_ident())
return model_result
def warm_import_side_effect() -> None:
warm_import_thread_ids.append(threading.get_ident())
create_cli_agent = MagicMock(side_effect=create_cli_agent_side_effect)
agent_module = _module_with_attrs(
"deepagents_code.agent",
@@ -164,17 +160,10 @@ class TestServerGraph:
module = _import_fresh_server_graph()
resolve_mcp_tools.assert_not_awaited()
with patch.object(
module,
"_warm_mcp_adapter_imports",
side_effect=warm_import_side_effect,
):
assert await module.make_graph() is graph_obj
assert await module.make_graph() is graph_obj
configure_redaction.assert_called_once_with()
resolve_mcp_tools.assert_awaited_once()
assert warm_import_thread_ids
assert warm_import_thread_ids[0] != loop_thread_id
assert create_cli_agent_thread_ids
assert create_cli_agent_thread_ids[0] != loop_thread_id
# `create_model` must run off the loop thread: it does blocking disk IO
@@ -214,7 +203,7 @@ class TestServerGraph:
)
async def test_build_tools_skips_mcp_when_disabled(self) -> None:
"""`no_mcp=True` should not warm imports or call the MCP resolver."""
"""`no_mcp=True` should not call the MCP resolver at all."""
fetch_tool = object()
thread_tool = object()
resolve_mcp_tools = AsyncMock()
@@ -242,16 +231,13 @@ class TestServerGraph:
},
):
module = _import_fresh_server_graph()
warm_imports = MagicMock(side_effect=AssertionError("MCP warmup ran"))
with patch.object(module, "_warm_mcp_adapter_imports", warm_imports):
tools, mcp_server_info = await module._build_tools(
ServerConfig(no_mcp=True),
None,
)
tools, mcp_server_info = await module._build_tools(
ServerConfig(no_mcp=True),
None,
)
assert tools == [fetch_tool, thread_tool]
assert mcp_server_info is None
warm_imports.assert_not_called()
resolve_mcp_tools.assert_not_awaited()
async def test_interpreter_settings_apply_before_agent_construction(self) -> None:
@@ -332,18 +318,20 @@ class TestServerGraph:
"enable_interpreter": True,
}
async def test_mcp_adapter_warmup_runs_before_mcp_resolver(self) -> None:
"""MCP imports must be warmed before resolver imports adapter modules."""
events: list[str] = []
async def test_build_tools_delegates_mcp_loading_to_resolver(self) -> None:
"""`_build_tools` should defer all MCP work to the resolver.
Adapter warmup now lives inside `_load_tools_from_config` (gated on
active servers existing), so `_build_tools` no longer warms imports
itself — it just calls the resolver and appends the returned tools.
"""
fetch_tool = object()
thread_tool = object()
def resolve_mcp_tools(
**_: object,
) -> tuple[list[object], None, list[object]]:
events.append("resolver")
return [], None, []
class FakeSessionManager:
pass
resolve_mcp_tools = AsyncMock(return_value=([], None, []))
config_module = _module_with_attrs(
"deepagents_code.config",
settings=SimpleNamespace(has_tavily=False),
@@ -354,14 +342,10 @@ class TestServerGraph:
get_current_thread_id=thread_tool,
web_search=object(),
)
class FakeSessionManager:
pass
mcp_module = _module_with_attrs(
"deepagents_code.mcp_tools",
MCPSessionManager=FakeSessionManager,
resolve_and_load_mcp_tools=AsyncMock(side_effect=resolve_mcp_tools),
resolve_and_load_mcp_tools=resolve_mcp_tools,
)
with patch.dict(
@@ -373,17 +357,13 @@ class TestServerGraph:
},
):
module = _import_fresh_server_graph()
assert not hasattr(module, "_warm_mcp_adapter_imports")
tools, mcp_server_info = await module._build_tools(
ServerConfig(no_mcp=False),
None,
)
def warm_imports() -> None:
events.append("warmup")
with patch.object(module, "_warm_mcp_adapter_imports", warm_imports):
tools, mcp_server_info = await module._build_tools(
ServerConfig(no_mcp=False),
None,
)
assert events == ["warmup", "resolver"]
resolve_mcp_tools.assert_awaited_once()
assert tools == [fetch_tool, thread_tool]
assert mcp_server_info == []