Files
crewAI/lib/crewai/tests/mcp/test_mcp_config.py
Devin AI db4cb93770 fix: inject MCP tools in standalone agent execution (fixes #4133)
The LLM was not seeing MCP tools when using standalone agent execution
(without Crew) because the prepare_tools function in agent/utils.py
did not inject MCP tools.

Changes:
- Modified prepare_tools() in agent/utils.py to inject MCP tools when
  agent.mcps is configured, with graceful error handling
- Fixed Agent.kickoff_async() to inject MCP tools like kickoff() does
- Added comprehensive tests for MCP tool injection in prepare_tools

The fix ensures MCP tools are visible to the LLM in both:
1. Standalone agent execution via execute_task/aexecute_task
2. Async agent execution via kickoff_async

Co-Authored-By: João <joao@crewai.com>
2025-12-20 13:00:47 +00:00

400 lines
13 KiB
Python

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from crewai.agent.core import Agent
from crewai.agent.utils import prepare_tools
from crewai.mcp.config import MCPServerHTTP, MCPServerSSE, MCPServerStdio
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
@pytest.fixture
def mock_tool_definitions():
"""Create mock MCP tool definitions (as returned by list_tools)."""
return [
{
"name": "test_tool_1",
"description": "Test tool 1 description",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
},
{
"name": "test_tool_2",
"description": "Test tool 2 description",
"inputSchema": {}
}
]
def test_agent_with_stdio_mcp_config(mock_tool_definitions):
"""Test agent setup with MCPServerStdio configuration."""
stdio_config = MCPServerStdio(
command="python",
args=["server.py"],
env={"API_KEY": "test_key"},
)
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
mcps=[stdio_config],
)
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False # Will trigger connect
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
tools = agent.get_mcp_tools([stdio_config])
assert len(tools) == 2
assert all(isinstance(tool, BaseTool) for tool in tools)
mock_client_class.assert_called_once()
call_args = mock_client_class.call_args
transport = call_args.kwargs["transport"]
assert transport.command == "python"
assert transport.args == ["server.py"]
assert transport.env == {"API_KEY": "test_key"}
def test_agent_with_http_mcp_config(mock_tool_definitions):
"""Test agent setup with MCPServerHTTP configuration."""
http_config = MCPServerHTTP(
url="https://api.example.com/mcp",
headers={"Authorization": "Bearer test_token"},
streamable=True,
)
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
mcps=[http_config],
)
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False # Will trigger connect
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
tools = agent.get_mcp_tools([http_config])
assert len(tools) == 2
assert all(isinstance(tool, BaseTool) for tool in tools)
mock_client_class.assert_called_once()
call_args = mock_client_class.call_args
transport = call_args.kwargs["transport"]
assert transport.url == "https://api.example.com/mcp"
assert transport.headers == {"Authorization": "Bearer test_token"}
assert transport.streamable is True
def test_agent_with_sse_mcp_config(mock_tool_definitions):
"""Test agent setup with MCPServerSSE configuration."""
sse_config = MCPServerSSE(
url="https://api.example.com/mcp/sse",
headers={"Authorization": "Bearer test_token"},
)
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
mcps=[sse_config],
)
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
tools = agent.get_mcp_tools([sse_config])
assert len(tools) == 2
assert all(isinstance(tool, BaseTool) for tool in tools)
mock_client_class.assert_called_once()
call_args = mock_client_class.call_args
transport = call_args.kwargs["transport"]
assert transport.url == "https://api.example.com/mcp/sse"
assert transport.headers == {"Authorization": "Bearer test_token"}
def test_mcp_tool_execution_in_sync_context(mock_tool_definitions):
"""Test MCPNativeTool execution in synchronous context (normal crew execution)."""
http_config = MCPServerHTTP(url="https://api.example.com/mcp")
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client.call_tool = AsyncMock(return_value="test result")
mock_client_class.return_value = mock_client
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
mcps=[http_config],
)
tools = agent.get_mcp_tools([http_config])
assert len(tools) == 2
tool = tools[0]
result = tool.run(query="test query")
assert result == "test result"
mock_client.call_tool.assert_called()
@pytest.mark.asyncio
async def test_mcp_tool_execution_in_async_context(mock_tool_definitions):
"""Test MCPNativeTool execution in async context (e.g., from a Flow)."""
http_config = MCPServerHTTP(url="https://api.example.com/mcp")
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client.call_tool = AsyncMock(return_value="test result")
mock_client_class.return_value = mock_client
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
mcps=[http_config],
)
tools = agent.get_mcp_tools([http_config])
assert len(tools) == 2
tool = tools[0]
result = tool.run(query="test query")
assert result == "test result"
mock_client.call_tool.assert_called()
def test_prepare_tools_injects_mcp_tools(mock_tool_definitions):
"""Test that prepare_tools injects MCP tools when agent has mcps configured.
This is the core fix for issue #4133 - LLM doesn't see MCP tools when
using standalone agent execution (without Crew).
"""
http_config = MCPServerHTTP(url="https://api.example.com/mcp")
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
mcps=[http_config],
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
final_tools = prepare_tools(agent, None, task)
assert len(final_tools) == 2
assert all(isinstance(tool, BaseTool) for tool in final_tools)
tool_names = [tool.name for tool in final_tools]
assert any("test_tool_1" in name for name in tool_names)
assert any("test_tool_2" in name for name in tool_names)
def test_prepare_tools_merges_mcp_tools_with_existing_tools(mock_tool_definitions):
"""Test that prepare_tools merges MCP tools with existing agent tools.
MCP tools are added alongside existing tools. Note that MCP tools have
prefixed names (based on server URL), so they won't conflict with
existing tools that have the same base name.
"""
http_config = MCPServerHTTP(url="https://api.example.com/mcp")
class ExistingTool(BaseTool):
name: str = "existing_tool"
description: str = "An existing tool"
def _run(self, **kwargs):
return "existing result"
class AnotherTool(BaseTool):
name: str = "another_tool"
description: str = "Another existing tool"
def _run(self, **kwargs):
return "another result"
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
tools=[ExistingTool(), AnotherTool()],
mcps=[http_config],
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
final_tools = prepare_tools(agent, None, task)
assert len(final_tools) == 4
tool_names = [tool.name for tool in final_tools]
assert "existing_tool" in tool_names
assert "another_tool" in tool_names
assert any("test_tool_1" in name for name in tool_names)
assert any("test_tool_2" in name for name in tool_names)
def test_prepare_tools_does_not_mutate_original_tools_list(mock_tool_definitions):
"""Test that prepare_tools does not mutate the original tools list."""
http_config = MCPServerHTTP(url="https://api.example.com/mcp")
class ExistingTool(BaseTool):
name: str = "existing_tool"
description: str = "An existing tool"
def _run(self, **kwargs):
return "existing result"
original_tools = [ExistingTool()]
original_tools_copy = list(original_tools)
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client_class.return_value = mock_client
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
tools=original_tools,
mcps=[http_config],
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
final_tools = prepare_tools(agent, original_tools, task)
assert len(original_tools) == len(original_tools_copy)
assert len(final_tools) == 3
def test_prepare_tools_handles_mcp_failure_gracefully(mock_tool_definitions):
"""Test that prepare_tools continues without MCP tools if get_mcp_tools fails."""
http_config = MCPServerHTTP(url="https://api.example.com/mcp")
class ExistingTool(BaseTool):
name: str = "existing_tool"
description: str = "An existing tool"
def _run(self, **kwargs):
return "existing result"
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client_class.side_effect = Exception("Connection failed")
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
tools=[ExistingTool()],
mcps=[http_config],
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
final_tools = prepare_tools(agent, None, task)
assert len(final_tools) == 1
assert final_tools[0].name == "existing_tool"
def test_prepare_tools_without_mcps():
"""Test that prepare_tools works normally when agent has no mcps configured."""
class ExistingTool(BaseTool):
name: str = "existing_tool"
description: str = "An existing tool"
def _run(self, **kwargs):
return "existing result"
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
tools=[ExistingTool()],
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
final_tools = prepare_tools(agent, None, task)
assert len(final_tools) == 1
assert final_tools[0].name == "existing_tool"