fix(code): run stdio MCP server pre-flight check off the event loop (#4434)

Fixes #4433

---

The stdio MCP server pre-flight check calls `shutil.which`, which makes
blocking `os.access` calls. When run on the event loop, this triggers a
`BlockingError` from blockbuster, preventing the agent from starting.

This wraps the `_check_stdio_server` call in `asyncio.to_thread` to
execute it off the event loop, resolving the blocking call issue.

---------

Co-authored-by: Dhruv <heyparth@Dhruvs-MacBook-Air.local>
Co-authored-by: Mason Daugherty <mason@langchain.dev>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
heyparth
2026-07-09 02:20:19 +05:30
committed by GitHub
parent b83b02bc0b
commit c9636e2272
2 changed files with 36 additions and 1 deletions
+3 -1
View File
@@ -1521,7 +1521,9 @@ async def _load_tools_from_config(
if server_type in _SUPPORTED_REMOTE_TYPES:
await _check_remote_server(server_name, server_config)
elif server_type == "stdio":
_check_stdio_server(server_name, server_config)
# `shutil.which` makes blocking `os.access` calls; run it
# off the event loop so blockbuster doesn't reject it.
await asyncio.to_thread(_check_stdio_server, server_name, server_config)
except RuntimeError as exc:
logger.warning(
"MCP server '%s' skipped: pre-flight failed: %s",
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -2075,6 +2076,38 @@ class TestHealthChecks:
):
_check_stdio_server("srv", {"command": "missing"})
async def test_check_stdio_server_runs_off_event_loop(
self,
write_config: Callable[..., str],
) -> None:
"""The stdio pre-flight's `shutil.which` runs off the event loop."""
path = write_config({"mcpServers": {"srv": {"command": "missing"}}})
event_loop_thread = threading.current_thread()
which_threads: list[threading.Thread] = []
def _record_missing_command(_command: str) -> str | None:
which_threads.append(threading.current_thread())
return None
with patch(
"deepagents_code.mcp_tools.shutil.which",
side_effect=_record_missing_command,
):
tools, manager, server_infos = await get_mcp_tools(path)
try:
assert which_threads
assert which_threads[0] is not event_loop_thread
assert tools == []
assert server_infos[0].name == "srv"
assert server_infos[0].status == "error"
error = server_infos[0].error or ""
assert "command 'missing' not found on PATH" in error
assert manager is not None
finally:
if manager is not None:
await manager.cleanup()
async def test_check_remote_server_transport_error(self) -> None:
"""Transport errors are wrapped as `RuntimeError`."""
import httpx