mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): avoid blocking MCP imports during graph readiness (#4302)
LangGraph dev runs the graph readiness endpoint under BlockBuster. After server graph construction was deferred until `/assistants/agent/graph`, the MCP-enabled startup path imported `langchain_mcp_adapters` on the server event loop; that first import pulls in `jsonschema_specifications`, which scans package resources with `Path.iterdir()`, so readiness failed with `BlockingError: Blocking call to ScandirIterator.__next__`. The fix warms those adapter imports in a worker thread before the MCP resolver runs, then keeps the actual MCP discovery async on the server loop. A subprocess BlockBuster regression test initially reproduced the exact shape, but Linux CI crashed inside that child process with `SIGSEGV`; the durable test now asserts the important contract in-process: the warmup runs before the MCP resolver, and separately that the warmup and CLI graph construction stay off the event-loop thread. ## Changes - Warm MCP adapter imports through `asyncio.to_thread()` before MCP tool loading, so import-time package-resource scans happen off the LangGraph server event loop. - Offload synchronous CLI graph construction into a worker thread, covering custom subagent discovery, async-subagent config loading, settings updates, and other filesystem-backed setup before `create_cli_agent()` returns the graph. - Keep `make_graph()` lock-serialized and cached while documenting why process-global settings writes are safe in that single-build path. ## Testing - Added thread-affinity assertions for MCP import warmup and CLI graph construction. - Added in-process regression coverage that the MCP import warmup runs before the MCP resolver, plus guards for the `no_mcp=True` skip path and interpreter settings ordering. - Ran `make -C libs/code test TEST_FILE="tests/unit_tests/test_server_graph.py"`. - Ran `make -C libs/code lint`.
This commit is contained in:
@@ -47,6 +47,18 @@ 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.
|
||||
|
||||
@@ -80,7 +92,8 @@ 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.
|
||||
invocation. MCP adapter imports are warmed in a worker thread first because
|
||||
first import can perform blocking package-resource scans.
|
||||
|
||||
Args:
|
||||
config: Deserialized server configuration.
|
||||
@@ -104,6 +117,8 @@ 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,
|
||||
@@ -206,37 +221,42 @@ async def _make_graph() -> Any: # noqa: ANN401
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
async_subagents = load_async_subagents() or None
|
||||
def _create_cli_agent_sync() -> Any: # noqa: ANN401
|
||||
async_subagents = load_async_subagents() or None
|
||||
|
||||
if config.interpreter_ptc is not None:
|
||||
settings.interpreter_ptc = config.interpreter_ptc
|
||||
if config.interpreter_ptc_acknowledge_unsafe:
|
||||
settings.interpreter_ptc_acknowledge_unsafe = True
|
||||
if config.enable_interpreter:
|
||||
settings.enable_interpreter = True
|
||||
# These process-global settings writes are safe here because `make_graph`
|
||||
# is lock-serialized and caches one graph for the server process lifetime.
|
||||
if config.interpreter_ptc is not None:
|
||||
settings.interpreter_ptc = config.interpreter_ptc
|
||||
if config.interpreter_ptc_acknowledge_unsafe:
|
||||
settings.interpreter_ptc_acknowledge_unsafe = True
|
||||
if config.enable_interpreter:
|
||||
settings.enable_interpreter = True
|
||||
|
||||
agent, _ = create_cli_agent(
|
||||
model=result.model,
|
||||
assistant_id=config.assistant_id,
|
||||
tools=tools,
|
||||
sandbox=sandbox_backend,
|
||||
sandbox_type=config.sandbox_type,
|
||||
system_prompt=config.system_prompt,
|
||||
interactive=config.interactive,
|
||||
auto_approve=config.auto_approve,
|
||||
interrupt_shell_only=config.interrupt_shell_only,
|
||||
shell_allow_list=config.shell_allow_list,
|
||||
enable_ask_user=config.enable_ask_user,
|
||||
enable_memory=config.enable_memory,
|
||||
enable_skills=config.enable_skills,
|
||||
enable_shell=config.enable_shell,
|
||||
enable_interpreter=config.enable_interpreter,
|
||||
mcp_server_info=mcp_server_info,
|
||||
cwd=project_context.user_cwd if project_context is not None else config.cwd,
|
||||
project_context=project_context,
|
||||
async_subagents=async_subagents,
|
||||
)
|
||||
return agent
|
||||
agent, _ = create_cli_agent(
|
||||
model=result.model,
|
||||
assistant_id=config.assistant_id,
|
||||
tools=tools,
|
||||
sandbox=sandbox_backend,
|
||||
sandbox_type=config.sandbox_type,
|
||||
system_prompt=config.system_prompt,
|
||||
interactive=config.interactive,
|
||||
auto_approve=config.auto_approve,
|
||||
interrupt_shell_only=config.interrupt_shell_only,
|
||||
shell_allow_list=config.shell_allow_list,
|
||||
enable_ask_user=config.enable_ask_user,
|
||||
enable_memory=config.enable_memory,
|
||||
enable_skills=config.enable_skills,
|
||||
enable_shell=config.enable_shell,
|
||||
enable_interpreter=config.enable_interpreter,
|
||||
mcp_server_info=mcp_server_info,
|
||||
cwd=project_context.user_cwd if project_context is not None else config.cwd,
|
||||
project_context=project_context,
|
||||
async_subagents=async_subagents,
|
||||
)
|
||||
return agent
|
||||
|
||||
return await asyncio.to_thread(_create_cli_agent_sync)
|
||||
|
||||
|
||||
def _build_graph_factory() -> Callable[[], Awaitable[Any]]:
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -74,7 +75,18 @@ class TestServerGraph:
|
||||
thread_tool = object()
|
||||
mcp_tool = object()
|
||||
mcp_server_info = [SimpleNamespace(name="docs")]
|
||||
create_cli_agent = MagicMock(return_value=(graph_obj, object()))
|
||||
loop_thread_id = threading.get_ident()
|
||||
create_cli_agent_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())
|
||||
return graph_obj, object()
|
||||
|
||||
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",
|
||||
DEFAULT_AGENT_NAME="agent",
|
||||
@@ -145,9 +157,18 @@ class TestServerGraph:
|
||||
|
||||
module = _import_fresh_server_graph()
|
||||
resolve_mcp_tools.assert_not_awaited()
|
||||
assert await module.make_graph() is graph_obj
|
||||
with patch.object(
|
||||
module,
|
||||
"_warm_mcp_adapter_imports",
|
||||
side_effect=warm_import_side_effect,
|
||||
):
|
||||
assert await module.make_graph() is graph_obj
|
||||
|
||||
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
|
||||
kwargs = resolve_mcp_tools.await_args_list[0].kwargs
|
||||
assert kwargs["explicit_config_path"] is None
|
||||
assert kwargs["no_mcp"] is False
|
||||
@@ -177,6 +198,179 @@ class TestServerGraph:
|
||||
async_subagents=None,
|
||||
)
|
||||
|
||||
async def test_build_tools_skips_mcp_when_disabled(self) -> None:
|
||||
"""`no_mcp=True` should not warm imports or call the MCP resolver."""
|
||||
fetch_tool = object()
|
||||
thread_tool = object()
|
||||
resolve_mcp_tools = AsyncMock()
|
||||
config_module = _module_with_attrs(
|
||||
"deepagents_code.config",
|
||||
settings=SimpleNamespace(has_tavily=False),
|
||||
)
|
||||
tools_module = _module_with_attrs(
|
||||
"deepagents_code.tools",
|
||||
fetch_url=fetch_tool,
|
||||
get_current_thread_id=thread_tool,
|
||||
web_search=object(),
|
||||
)
|
||||
mcp_module = _module_with_attrs(
|
||||
"deepagents_code.mcp_tools",
|
||||
resolve_and_load_mcp_tools=resolve_mcp_tools,
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"deepagents_code.config": config_module,
|
||||
"deepagents_code.tools": tools_module,
|
||||
"deepagents_code.mcp_tools": mcp_module,
|
||||
},
|
||||
):
|
||||
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,
|
||||
)
|
||||
|
||||
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:
|
||||
"""Server config settings writes should be visible to `create_cli_agent`."""
|
||||
graph_obj = object()
|
||||
model_obj = object()
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
def create_cli_agent_side_effect(**_: object) -> tuple[object, object]:
|
||||
from deepagents_code.config import settings
|
||||
|
||||
observed["interpreter_ptc"] = settings.interpreter_ptc
|
||||
observed["acknowledge"] = settings.interpreter_ptc_acknowledge_unsafe
|
||||
observed["enable_interpreter"] = settings.enable_interpreter
|
||||
return graph_obj, object()
|
||||
|
||||
settings_obj = SimpleNamespace(
|
||||
has_tavily=False,
|
||||
interpreter_ptc=None,
|
||||
interpreter_ptc_acknowledge_unsafe=False,
|
||||
enable_interpreter=False,
|
||||
)
|
||||
config_module = _module_with_attrs(
|
||||
"deepagents_code.config",
|
||||
create_model=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
model=model_obj,
|
||||
apply_to_settings=MagicMock(),
|
||||
),
|
||||
),
|
||||
settings=settings_obj,
|
||||
)
|
||||
agent_module = _module_with_attrs(
|
||||
"deepagents_code.agent",
|
||||
create_cli_agent=MagicMock(side_effect=create_cli_agent_side_effect),
|
||||
load_async_subagents=MagicMock(return_value=None),
|
||||
)
|
||||
tools_module = _module_with_attrs(
|
||||
"deepagents_code.tools",
|
||||
fetch_url=object(),
|
||||
get_current_thread_id=object(),
|
||||
web_search=object(),
|
||||
)
|
||||
config = ServerConfig(
|
||||
no_mcp=True,
|
||||
enable_interpreter=True,
|
||||
interpreter_ptc=["js_eval"],
|
||||
interpreter_ptc_acknowledge_unsafe=True,
|
||||
)
|
||||
env_overrides = {
|
||||
f"{SERVER_ENV_PREFIX}{suffix}": value
|
||||
for suffix, value in config.to_env().items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, env_overrides, clear=False),
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"deepagents_code.agent": agent_module,
|
||||
"deepagents_code.config": config_module,
|
||||
"deepagents_code.tools": tools_module,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.project_utils.get_server_project_context",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
module = _import_fresh_server_graph()
|
||||
assert await module.make_graph() is graph_obj
|
||||
|
||||
assert observed == {
|
||||
"interpreter_ptc": ["js_eval"],
|
||||
"acknowledge": True,
|
||||
"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] = []
|
||||
fetch_tool = object()
|
||||
thread_tool = object()
|
||||
|
||||
def resolve_mcp_tools(
|
||||
**_: object,
|
||||
) -> tuple[list[object], None, list[object]]:
|
||||
events.append("resolver")
|
||||
return [], None, []
|
||||
|
||||
config_module = _module_with_attrs(
|
||||
"deepagents_code.config",
|
||||
settings=SimpleNamespace(has_tavily=False),
|
||||
)
|
||||
tools_module = _module_with_attrs(
|
||||
"deepagents_code.tools",
|
||||
fetch_url=fetch_tool,
|
||||
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),
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"deepagents_code.config": config_module,
|
||||
"deepagents_code.tools": tools_module,
|
||||
"deepagents_code.mcp_tools": mcp_module,
|
||||
},
|
||||
):
|
||||
module = _import_fresh_server_graph()
|
||||
|
||||
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"]
|
||||
assert tools == [fetch_tool, thread_tool]
|
||||
assert mcp_server_info == []
|
||||
|
||||
|
||||
class TestStartupErrorMarker:
|
||||
"""`emit_startup_failure` must produce the parser marker on stderr.
|
||||
|
||||
Reference in New Issue
Block a user