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:
Mason Daugherty
2026-06-26 00:50:27 -04:00
committed by GitHub
parent 11e5359851
commit 7533ca89f3
2 changed files with 246 additions and 32 deletions
+50 -30
View File
@@ -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]]:
+196 -2
View File
@@ -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.