deer-flow/backend/tests/test_mcp_session_timeouts.py
lllyfff 99c926b7bb
fix(mcp): bring-up has no timeout and externalized tool outputs are counted as undelivered artifacts (#4657)
* fix: bound MCP server bring-up timeouts and exclude externalized tool outputs from delivery verification

Two related robustness fixes:

1. MCP server bring-up was unbounded. tool_call_timeout only covered
   session.call_tool(); tool discovery (subprocess spawn + initialize +
   tools/list) and persistent stdio session initialization could hang
   forever, blocking agent construction (and on the Gateway event loop,
   the whole process). Add a per-server session_init_timeout
   (default DEFAULT_MCP_SESSION_INIT_TIMEOUT = 60s, null disables) that
   bounds both discovery and pooled-session initialization. The session
   pool's existing cancellation handling tears down a session stuck
   mid-creation in its own task.

2. ToolOutputBudgetMiddleware externalizes oversized tool outputs into
   outputs/.tool-results/ (configurable tool_output.storage_subdir). The
   workspace-change scanner and run delivery verification counted those
   files as produced artifacts, so any run that externalized a tool output
   without also presenting a real artifact failed with
   "Artifact delivery incomplete". Exclude TOOL_RESULTS_DIRNAME via a
   shared constant (mirroring BROWSER_FRAMES_DIRNAME) and thread the
   configured storage_subdir through snapshot capture so both
   workspace-changes events and delivery verification stay clean.

* review: enforce single-segment tool_output.storage_subdir; document discovery-timeout cleanup

Address review feedback:

1. A custom tool_output.storage_subdir with a path separator (e.g.
   cache/tool-results) silently no-oped the workspace-scanner exclusion:
   os.walk yields one-segment dirnames, so a nested value never matched and
   its files were counted as produced artifacts again. ToolOutputConfig now
   validates storage_subdir as a single directory name (rejects separators,
   .., absolute, empty) with tests, so the exclusion is always sound.

2. The discovery-timeout path now documents why cancellation is safe, mirroring
   the session-init note: discovery runs inside the adapter's nested async
   context managers, and stdio_client's finally terminates the process tree
   (SIGTERM->SIGKILL on POSIX, process-tree on Windows), so a timed-out npx
   subprocess and its children are reaped rather than accumulating.

* review: log session-init timeouts and align API response model default with runtime config

Address second-round review feedback:

1. A session-init timeout raised TimeoutError without any log, unlike the
   discovery timeout which logs a WARNING. Wrap the bounded get_session in a
   try/except that logs the timeout (server name + seconds) and re-raises, so
   operators can diagnose tool-call failures caused by hung MCP sessions.

2. McpServerConfigResponse.session_init_timeout defaulted to None while
   McpServerConfig defaults to 60s: a server created via PUT /api/mcp/config
   without the field was persisted with null (no timeout) while the same
   server created in the config file got 60s. Align the response-model default
   to DEFAULT_MCP_SESSION_INIT_TIMEOUT so API-created and file-created servers
   behave the same; an explicit null still opts out.

* review: narrow the discovery-timeout handler to the bounded wait_for path

The except TimeoutError clause covered both the bounded wait_for branch and
the bare discovery branch. With session_init_timeout opted out (None), a
TimeoutError raised by discovery itself would hit the %.1f format with None:
logging raises TypeError internally, the WARNING is silently dropped, and a
--- Logging error --- traceback goes to stderr.

Narrow the handler to wrap only the wait_for call, where the branch condition
guarantees the timeout value is not None. A discovery-internal TimeoutError on
the opted-out path now falls through to the generic failure handler and is
reported as 'tool discovery failed' with exc_info. Covered by a regression
test that asserts the skip is reported without any broken format.
2026-08-05 08:56:18 +08:00

245 lines
9.9 KiB
Python

"""Timeout coverage for MCP server bring-up.
``tool_call_timeout`` only bounds ``session.call_tool()``. Discovery
(subprocess spawn + initialize + tools/list) and persistent-session
initialization have no bound on their own, so a hung stdio server would block
agent construction forever. These tests pin the ``session_init_timeout`` bound
on both stages and the per-server independence of the discovery timeout.
"""
from __future__ import annotations
import asyncio
import logging
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT
from deerflow.mcp.tools import _make_session_pool_tool, get_mcp_tools
class _Args(BaseModel):
query: str = Field(..., description="query")
def _tool(name: str) -> StructuredTool:
async def _call(query: str) -> str:
return query
return StructuredTool(
name=name,
description="Search",
args_schema=_Args,
coroutine=_call,
)
def test_session_init_timeout_defaults_to_shared_constant() -> None:
assert McpServerConfig().session_init_timeout == DEFAULT_MCP_SESSION_INIT_TIMEOUT
assert McpServerConfig(session_init_timeout=None).session_init_timeout is None
@pytest.mark.asyncio
async def test_discovery_timeout_skips_hung_server_without_blocking_healthy_server() -> None:
"""A server whose discovery hangs must time out and be skipped, while a
healthy server still contributes its tools."""
extensions_config = ExtensionsConfig.model_validate(
{
"mcpServers": {
"slow_server": {
"type": "stdio",
"command": "uvx",
"args": ["slow-mcp"],
"session_init_timeout": 0.05,
},
"fast_server": {
"type": "stdio",
"command": "uvx",
"args": ["fast-mcp"],
"session_init_timeout": 1.0,
},
}
}
)
servers_config = {
"slow_server": {"transport": "stdio", "command": "uvx", "args": ["slow-mcp"]},
"fast_server": {"transport": "stdio", "command": "uvx", "args": ["fast-mcp"]},
}
class FakeClient:
def __init__(
self,
connections,
*,
callbacks=None,
tool_interceptors=None,
tool_name_prefix=False,
) -> None:
self.connections = connections
self.callbacks = callbacks
self.tool_interceptors = tool_interceptors or []
self.tool_name_prefix = tool_name_prefix
async def get_tools(self, *, server_name=None):
if server_name == "slow_server":
await asyncio.sleep(60) # hung discovery
# The real adapter returns server-prefixed tool names when
# tool_name_prefix=True.
return [_tool("fast_server_fast_search")]
with (
patch("deerflow.mcp.tools.ExtensionsConfig.from_file", return_value=extensions_config),
patch("deerflow.mcp.tools.build_servers_config", return_value=servers_config),
patch("deerflow.mcp.tools.get_initial_oauth_headers", new_callable=AsyncMock, return_value={}),
patch("deerflow.mcp.tools.build_oauth_tool_interceptor", return_value=None),
patch("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient),
patch("langchain_mcp_adapters.tools.load_mcp_tools", new_callable=AsyncMock),
patch("deerflow.mcp.tools._make_session_pool_tool", side_effect=lambda tool, *_args, **_kwargs: tool),
):
# Without the discovery timeout the slow server would hang the call past
# the 5s bound and this test would fail with TimeoutError.
tools = await asyncio.wait_for(get_mcp_tools(), timeout=5)
assert [tool.name for tool in tools] == ["fast_server_fast_search"]
@pytest.mark.asyncio
async def test_session_init_timeout_raises_when_session_creation_hangs(tmp_path, caplog) -> None:
"""A server that never finishes initialize() must not block the tool call,
and the timeout must be visible in logs at the same level as discovery
timeouts so operators can diagnose hung MCP sessions."""
mock_pool = MagicMock()
async def hanging_get_session(*_args, **_kwargs) -> None:
await asyncio.sleep(60)
mock_pool.get_session = hanging_get_session
with (
patch("deerflow.mcp.tools.get_session_pool", return_value=mock_pool),
patch("deerflow.mcp.tools.get_paths", return_value=MagicMock()),
patch(
"deerflow.mcp.tools._prepare_stdio_workspace",
return_value=(tmp_path, tmp_path / "tmp", {}),
),
caplog.at_level(logging.WARNING, logger="deerflow.mcp.tools"),
):
wrapped = _make_session_pool_tool(
_tool("github_search"),
"github",
{"transport": "stdio", "command": "mcp-server", "args": []},
session_init_timeout=0.05,
tool_name_prefix=False,
)
loop = asyncio.get_running_loop()
start = loop.time()
with pytest.raises(TimeoutError):
await wrapped.coroutine(query="repositories")
# Bounds the regression: the timeout must fire promptly, not wait on the
# hung session.
assert loop.time() - start < 1.0
timeout_warnings = [record for record in caplog.records if record.levelno == logging.WARNING and "timed out" in record.getMessage()]
assert timeout_warnings, "session-init timeout must be logged like discovery timeouts"
assert "github" in timeout_warnings[0].getMessage()
@pytest.mark.asyncio
async def test_discovery_timeout_from_sdk_with_opt_out_is_reported_without_logging_error(caplog) -> None:
"""With session_init_timeout opted out (None), a TimeoutError raised by
discovery itself (e.g. an internal timeout inside the MCP SDK) must still
be reported gracefully. The skip must go through the generic failure path —
never through the "timed out (%.1fs)" format with a None value, which
would raise inside the logging module and silently drop the warning."""
extensions_config = ExtensionsConfig.model_validate(
{
"mcpServers": {
"flaky_server": {
"type": "stdio",
"command": "uvx",
"args": ["flaky-mcp"],
"session_init_timeout": None,
},
}
}
)
servers_config = {
"flaky_server": {"transport": "stdio", "command": "uvx", "args": ["flaky-mcp"]},
}
class FakeClient:
def __init__(
self,
connections,
*,
callbacks=None,
tool_interceptors=None,
tool_name_prefix=False,
) -> None:
self.callbacks = callbacks
self.tool_interceptors = tool_interceptors or []
self.tool_name_prefix = tool_name_prefix
async def get_tools(self, *, server_name=None):
raise TimeoutError("internal SDK timeout")
with (
patch("deerflow.mcp.tools.ExtensionsConfig.from_file", return_value=extensions_config),
patch("deerflow.mcp.tools.build_servers_config", return_value=servers_config),
patch("deerflow.mcp.tools.get_initial_oauth_headers", new_callable=AsyncMock, return_value={}),
patch("deerflow.mcp.tools.build_oauth_tool_interceptor", return_value=None),
patch("langchain_mcp_adapters.client.MultiServerMCPClient", FakeClient),
patch("langchain_mcp_adapters.tools.load_mcp_tools", new_callable=AsyncMock),
caplog.at_level(logging.WARNING, logger="deerflow.mcp.tools"),
):
tools = await get_mcp_tools()
assert tools == []
# getMessage() on every captured record must not raise: pre-fix, the only
# record for this server was the broken "timed out (%.1fs)" % None format.
assert any("tool discovery failed" in record.getMessage() for record in caplog.records)
assert not any("timed out" in record.getMessage() for record in caplog.records)
def test_gateway_response_model_session_init_timeout_default_matches_runtime_config() -> None:
"""A server created via PUT /api/mcp/config without session_init_timeout
must get the same bring-up timeout as one created in the config file —
the response model's default feeds model_dump() into the persisted config."""
from app.gateway.routers.mcp import McpServerConfigResponse
assert McpServerConfigResponse.model_validate({}).session_init_timeout == DEFAULT_MCP_SESSION_INIT_TIMEOUT
# An explicit null stays an explicit opt-out (no timeout).
assert McpServerConfigResponse.model_validate({"session_init_timeout": None}).session_init_timeout is None
@pytest.mark.asyncio
async def test_session_init_timeout_does_not_block_fast_session(tmp_path) -> None:
"""A promptly-initialized session still completes the tool call."""
mock_session = AsyncMock()
mock_session.call_tool = AsyncMock(return_value=MagicMock(content=[], isError=False, structuredContent=None))
mock_pool = MagicMock()
mock_pool.get_session = AsyncMock(return_value=mock_session)
with (
patch("deerflow.mcp.tools.get_session_pool", return_value=mock_pool),
patch("deerflow.mcp.tools.get_paths", return_value=MagicMock()),
patch(
"deerflow.mcp.tools._prepare_stdio_workspace",
return_value=(tmp_path, tmp_path / "tmp", {}),
),
):
wrapped = _make_session_pool_tool(
_tool("github_search"),
"github",
{"transport": "stdio", "command": "mcp-server", "args": []},
session_init_timeout=5.0,
tool_name_prefix=False,
)
await wrapped.coroutine(query="repositories")
mock_session.call_tool.assert_awaited_once_with("github_search", {"query": "repositories"})