From feb2e715a315c52053002410368366ef6692556d Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Thu, 30 Apr 2026 02:41:01 +0800 Subject: [PATCH] fix(mcp): warn and return empty when native MCP server returns no tools --- lib/crewai/src/crewai/mcp/tool_resolver.py | 8 ++ .../tests/mcp/test_tool_resolver_native.py | 99 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 lib/crewai/tests/mcp/test_tool_resolver_native.py diff --git a/lib/crewai/src/crewai/mcp/tool_resolver.py b/lib/crewai/src/crewai/mcp/tool_resolver.py index a394741fd..e4cad9a7f 100644 --- a/lib/crewai/src/crewai/mcp/tool_resolver.py +++ b/lib/crewai/src/crewai/mcp/tool_resolver.py @@ -374,6 +374,7 @@ class MCPToolResolver: "MCP connection failed due to event loop cleanup issues. " "This may be due to authentication errors or server unavailability." ) from e + raise except asyncio.CancelledError as e: raise ConnectionError( "MCP connection was cancelled. This may indicate an authentication " @@ -401,6 +402,13 @@ class MCPToolResolver: filtered_tools.append(tool) tools_list = filtered_tools + if not tools_list: + self._logger.log( + "warning", + f"No tools discovered from MCP server: {server_name}", + ) + return cast(list[BaseTool], []), [] + def _client_factory() -> MCPClient: transport, _ = self._create_transport(mcp_config) return MCPClient( diff --git a/lib/crewai/tests/mcp/test_tool_resolver_native.py b/lib/crewai/tests/mcp/test_tool_resolver_native.py new file mode 100644 index 000000000..7fe2ed5be --- /dev/null +++ b/lib/crewai/tests/mcp/test_tool_resolver_native.py @@ -0,0 +1,99 @@ +"""Tests for MCPToolResolver native (non-AMP) resolution paths.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from crewai.agent.core import Agent +from crewai.mcp.config import MCPServerHTTP +from crewai.mcp.tool_resolver import MCPToolResolver + + +@pytest.fixture +def agent(): + return Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + ) + + +@pytest.fixture +def resolver(agent): + return MCPToolResolver(agent=agent, logger=agent._logger) + + +@pytest.fixture +def http_config(): + return MCPServerHTTP(url="https://mcp.example.com/api") + + +class TestResolveNativeEmptyTools: + @patch("crewai.mcp.tool_resolver.MCPClient") + def test_logs_warning_and_returns_empty_when_server_has_no_tools( + self, mock_client_class, resolver, http_config + ): + mock_client = AsyncMock() + mock_client.list_tools = AsyncMock(return_value=[]) + mock_client.connected = False + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client_class.return_value = mock_client + + mock_log = MagicMock() + resolver._logger = MagicMock(log=mock_log) + + tools, clients = resolver._resolve_native(http_config) + + assert tools == [] + assert clients == [] + warning_calls = [ + call for call in mock_log.call_args_list if call.args[0] == "warning" + ] + assert any( + "No tools discovered from MCP server" in call.args[1] + for call in warning_calls + ) + + @patch("crewai.mcp.tool_resolver.MCPClient") + def test_logs_warning_when_tool_filter_removes_all_tools( + self, mock_client_class, resolver + ): + mock_client = AsyncMock() + mock_client.list_tools = AsyncMock( + return_value=[{"name": "search", "description": "Search"}] + ) + mock_client.connected = False + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client_class.return_value = mock_client + + config = MCPServerHTTP( + url="https://mcp.example.com/api", + tool_filter=lambda _tool: False, + ) + + mock_log = MagicMock() + resolver._logger = MagicMock(log=mock_log) + + tools, clients = resolver._resolve_native(config) + + assert tools == [] + assert clients == [] + warning_calls = [ + call for call in mock_log.call_args_list if call.args[0] == "warning" + ] + assert any( + "No tools discovered from MCP server" in call.args[1] + for call in warning_calls + ) + + +class TestResolveNativeRuntimeError: + @patch("crewai.mcp.tool_resolver.asyncio.run") + def test_unmatched_runtime_error_is_wrapped_not_swallowed( + self, mock_asyncio_run, resolver, http_config + ): + mock_asyncio_run.side_effect = RuntimeError("some other failure") + + with pytest.raises(RuntimeError, match="Failed to get native MCP tools"): + resolver._resolve_native(http_config) \ No newline at end of file