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>
This commit is contained in:
Devin AI
2025-12-20 13:00:47 +00:00
parent be70a04153
commit db4cb93770
3 changed files with 232 additions and 1 deletions

View File

@@ -1576,7 +1576,17 @@ class Agent(BaseAgent):
Returns:
LiteAgentOutput: The result of the agent execution.
"""
if self.apps:
platform_tools = self.get_platform_tools(self.apps)
if platform_tools and self.tools is not None:
self.tools.extend(platform_tools)
if self.mcps:
mcps = self.get_mcp_tools(self.mcps)
if mcps and self.tools is not None:
self.tools.extend(mcps)
lite_agent = LiteAgent(
id=self.id,
role=self.role,
goal=self.goal,
backstory=self.backstory,

View File

@@ -251,6 +251,10 @@ def prepare_tools(
) -> list[BaseTool]:
"""Prepare tools for task execution and create agent executor.
This function prepares tools for task execution, including injecting MCP tools
if the agent has MCP server configurations. MCP tools are merged with existing
tools, with MCP tools replacing any existing tools with the same name.
Args:
agent: The agent instance.
tools: Optional list of tools.
@@ -259,7 +263,25 @@ def prepare_tools(
Returns:
The list of tools to use.
"""
final_tools = tools or agent.tools or []
# Create a copy to avoid mutating the original list
final_tools = list(tools or agent.tools or [])
# Inject MCP tools if agent has mcps configured
if hasattr(agent, "mcps") and agent.mcps:
try:
mcp_tools = agent.get_mcp_tools(agent.mcps)
if mcp_tools:
# Merge tools: MCP tools replace existing tools with the same name
mcp_tool_names = {tool.name for tool in mcp_tools}
final_tools = [
tool for tool in final_tools if tool.name not in mcp_tool_names
]
final_tools.extend(mcp_tools)
except Exception as e:
agent._logger.log(
"warning", f"Failed to get MCP tools, continuing without them: {e}"
)
agent.create_agent_executor(tools=final_tools, task=task)
return final_tools

View File

@@ -3,7 +3,9 @@ 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
@@ -198,3 +200,200 @@ async def test_mcp_tool_execution_in_async_context(mock_tool_definitions):
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"