mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-08 15:48:29 +00:00
Compare commits
4 Commits
devin/1762
...
devin/1762
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25bea781d | ||
|
|
6063677c01 | ||
|
|
cfaa44012f | ||
|
|
697182b0ef |
@@ -713,7 +713,7 @@ class Agent(BaseAgent):
|
||||
"""Get tools from legacy string-based MCP references.
|
||||
|
||||
This method maintains backwards compatibility with string-based
|
||||
MCP references (https://... and crewai-amp:...).
|
||||
MCP references (http://..., https://..., and crewai-amp:...).
|
||||
|
||||
Args:
|
||||
mcp_ref: String reference to MCP server.
|
||||
@@ -723,12 +723,12 @@ class Agent(BaseAgent):
|
||||
"""
|
||||
if mcp_ref.startswith("crewai-amp:"):
|
||||
return self._get_amp_mcp_tools(mcp_ref)
|
||||
if mcp_ref.startswith("https://"):
|
||||
if mcp_ref.startswith(("http://", "https://")):
|
||||
return self._get_external_mcp_tools(mcp_ref)
|
||||
return []
|
||||
|
||||
def _get_external_mcp_tools(self, mcp_ref: str) -> list[BaseTool]:
|
||||
"""Get tools from external HTTPS MCP server with graceful error handling."""
|
||||
"""Get tools from external HTTP/HTTPS MCP server with graceful error handling."""
|
||||
from crewai.tools.mcp_tool_wrapper import MCPToolWrapper
|
||||
|
||||
# Parse server URL and optional tool name
|
||||
@@ -737,6 +737,15 @@ class Agent(BaseAgent):
|
||||
else:
|
||||
server_url, specific_tool = mcp_ref, None
|
||||
|
||||
parsed_url = urlparse(server_url)
|
||||
if parsed_url.scheme == "http":
|
||||
self._logger.log(
|
||||
"warning",
|
||||
f"Using http:// for MCP server '{server_url}'. "
|
||||
"This is intended for local development only. "
|
||||
"Use https:// in production to ensure secure communication.",
|
||||
)
|
||||
|
||||
server_params = {"url": server_url}
|
||||
server_name = self._extract_server_name(server_url)
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
)
|
||||
mcps: list[str | MCPServerConfig] | None = Field(
|
||||
default=None,
|
||||
description="List of MCP server references. Supports 'https://server.com/path' for external servers and 'crewai-amp:mcp-name' for AMP marketplace. Use '#tool_name' suffix for specific tools.",
|
||||
description="List of MCP server references. Supports 'http://localhost:port/path' or 'https://server.com/path' for external servers and 'crewai-amp:mcp-name' for AMP marketplace. Use '#tool_name' suffix for specific tools. Note: http:// is intended for local development only; use https:// in production.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -268,12 +268,12 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
validated_mcps = []
|
||||
for mcp in mcps:
|
||||
if isinstance(mcp, str):
|
||||
if mcp.startswith(("https://", "crewai-amp:")):
|
||||
if mcp.startswith(("http://", "https://", "crewai-amp:")):
|
||||
validated_mcps.append(mcp)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid MCP reference: {mcp}. "
|
||||
"String references must start with 'https://' or 'crewai-amp:'"
|
||||
"String references must start with 'http://', 'https://', or 'crewai-amp:'"
|
||||
)
|
||||
|
||||
elif isinstance(mcp, (MCPServerConfig)):
|
||||
|
||||
@@ -63,6 +63,11 @@ class MCPServerHTTP(BaseModel):
|
||||
headers={"Authorization": "Bearer ..."},
|
||||
cache_tools_list=True,
|
||||
)
|
||||
|
||||
mcp_server = MCPServerHTTP(
|
||||
url="http://localhost:8000/mcp",
|
||||
cache_tools_list=True,
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ class HTTPTransport(BaseTransport):
|
||||
url="https://api.example.com/mcp",
|
||||
headers={"Authorization": "Bearer ..."}
|
||||
)
|
||||
|
||||
transport = HTTPTransport(
|
||||
url="http://localhost:8000/mcp"
|
||||
)
|
||||
|
||||
async with transport:
|
||||
# Use transport...
|
||||
```
|
||||
|
||||
@@ -51,40 +51,6 @@ class SummaryContent(TypedDict):
|
||||
console = Console()
|
||||
|
||||
_MULTIPLE_NEWLINES: Final[re.Pattern[str]] = re.compile(r"\n+")
|
||||
_REACT_FIELD_PATTERN: Final[re.Pattern[str]] = re.compile(
|
||||
r"^(Thought|Action|Action Input|Observation):\s*",
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
def sanitize_react_output(text: str) -> str:
|
||||
"""Sanitize agent output by removing internal ReAct fields.
|
||||
|
||||
This function removes lines that start with internal ReAct formatting
|
||||
markers like "Thought:", "Action:", "Action Input:", and "Observation:".
|
||||
These fields are used internally by the agent execution loop but should
|
||||
not be exposed in final user-facing outputs.
|
||||
|
||||
Args:
|
||||
text: The raw agent output text that may contain ReAct fields.
|
||||
|
||||
Returns:
|
||||
Sanitized text with internal ReAct fields removed.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
lines = text.split("\n")
|
||||
sanitized_lines = [
|
||||
line for line in lines if not _REACT_FIELD_PATTERN.match(line)
|
||||
]
|
||||
|
||||
result = "\n".join(sanitized_lines).strip()
|
||||
|
||||
if not result:
|
||||
return "Unable to complete the task."
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_tools(tools: list[BaseTool]) -> list[CrewStructuredTool]:
|
||||
@@ -207,13 +173,10 @@ def handle_max_iterations_exceeded(
|
||||
# If format_answer returned an AgentAction, convert it to AgentFinish
|
||||
if isinstance(formatted, AgentFinish):
|
||||
return formatted
|
||||
|
||||
sanitized_output = sanitize_react_output(formatted.text)
|
||||
|
||||
return AgentFinish(
|
||||
thought=formatted.thought,
|
||||
output=sanitized_output,
|
||||
text=sanitized_output,
|
||||
output=formatted.text,
|
||||
text=formatted.text,
|
||||
)
|
||||
|
||||
|
||||
@@ -246,11 +209,10 @@ def format_answer(answer: str) -> AgentAction | AgentFinish:
|
||||
try:
|
||||
return parse(answer)
|
||||
except Exception:
|
||||
sanitized_output = sanitize_react_output(answer)
|
||||
return AgentFinish(
|
||||
thought="Failed to parse LLM response",
|
||||
output=sanitized_output,
|
||||
text=sanitized_output,
|
||||
output=answer,
|
||||
text=answer,
|
||||
)
|
||||
|
||||
|
||||
|
||||
216
lib/crewai/tests/mcp/test_http_mcp_support.py
Normal file
216
lib/crewai/tests/mcp/test_http_mcp_support.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Tests for HTTP MCP server support (issue #3876)."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from crewai.agent.core import Agent
|
||||
from crewai.mcp.config import MCPServerHTTP
|
||||
|
||||
|
||||
class TestHTTPMCPValidation:
|
||||
"""Test validation of HTTP MCP URLs."""
|
||||
|
||||
def test_validator_accepts_http_urls(self):
|
||||
"""Test that validator accepts http:// URLs."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
mcps=["http://localhost:7365/mcp"],
|
||||
)
|
||||
assert agent.mcps == ["http://localhost:7365/mcp"]
|
||||
|
||||
def test_validator_accepts_https_urls(self):
|
||||
"""Test that validator still accepts https:// URLs."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
mcps=["https://api.example.com/mcp"],
|
||||
)
|
||||
assert agent.mcps == ["https://api.example.com/mcp"]
|
||||
|
||||
def test_validator_accepts_crewai_amp_urls(self):
|
||||
"""Test that validator still accepts crewai-amp: URLs."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
mcps=["crewai-amp:mcp-name"],
|
||||
)
|
||||
assert agent.mcps == ["crewai-amp:mcp-name"]
|
||||
|
||||
def test_validator_accepts_http_with_fragment(self):
|
||||
"""Test that validator accepts http:// URLs with #tool fragment."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
mcps=["http://localhost:7365/mcp#diff_general_info"],
|
||||
)
|
||||
assert agent.mcps == ["http://localhost:7365/mcp#diff_general_info"]
|
||||
|
||||
def test_validator_rejects_unsupported_schemes(self):
|
||||
"""Test that validator rejects unsupported URL schemes with updated error message."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
mcps=["ftp://example.com/mcp"],
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Invalid MCP reference: ftp://example.com/mcp" in error_message
|
||||
assert "http://" in error_message
|
||||
assert "https://" in error_message
|
||||
assert "crewai-amp:" in error_message
|
||||
|
||||
|
||||
class TestHTTPMCPRouting:
|
||||
"""Test routing of HTTP MCP URLs."""
|
||||
|
||||
def test_get_mcp_tools_from_string_routes_http_urls(self):
|
||||
"""Test that _get_mcp_tools_from_string routes http:// URLs correctly."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
)
|
||||
|
||||
sentinel_tools = [MagicMock()]
|
||||
|
||||
with patch.object(agent, '_get_external_mcp_tools', return_value=sentinel_tools) as mock_external:
|
||||
result = agent._get_mcp_tools_from_string("http://localhost:7365/mcp")
|
||||
|
||||
assert result == sentinel_tools
|
||||
mock_external.assert_called_once_with("http://localhost:7365/mcp")
|
||||
|
||||
def test_get_mcp_tools_from_string_routes_https_urls(self):
|
||||
"""Test that _get_mcp_tools_from_string still routes https:// URLs correctly."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
)
|
||||
|
||||
sentinel_tools = [MagicMock()]
|
||||
|
||||
with patch.object(agent, '_get_external_mcp_tools', return_value=sentinel_tools) as mock_external:
|
||||
result = agent._get_mcp_tools_from_string("https://api.example.com/mcp")
|
||||
|
||||
assert result == sentinel_tools
|
||||
mock_external.assert_called_once_with("https://api.example.com/mcp")
|
||||
|
||||
|
||||
class TestMCPServerHTTPConfig:
|
||||
"""Test MCPServerHTTP configuration with HTTP URLs."""
|
||||
|
||||
def test_mcp_server_http_accepts_http_url(self):
|
||||
"""Test that MCPServerHTTP accepts http:// URLs (prevent regression)."""
|
||||
config = MCPServerHTTP(url="http://localhost:8000/mcp")
|
||||
assert config.url == "http://localhost:8000/mcp"
|
||||
|
||||
def test_mcp_server_http_accepts_https_url(self):
|
||||
"""Test that MCPServerHTTP still accepts https:// URLs."""
|
||||
config = MCPServerHTTP(url="https://api.example.com/mcp")
|
||||
assert config.url == "https://api.example.com/mcp"
|
||||
|
||||
def test_agent_with_http_mcp_server_config(self):
|
||||
"""Test that Agent accepts MCPServerHTTP with http:// URL."""
|
||||
http_config = MCPServerHTTP(url="http://localhost:8000/mcp")
|
||||
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
mcps=[http_config],
|
||||
)
|
||||
|
||||
assert agent.mcps == [http_config]
|
||||
|
||||
|
||||
class TestHTTPMCPFragmentFiltering:
|
||||
"""Test fragment filtering for HTTP MCP URLs."""
|
||||
|
||||
def test_http_url_with_fragment_filters_correctly(self):
|
||||
"""Test that http:// URL with #tool fragment filters correctly."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
)
|
||||
|
||||
mock_schemas = {
|
||||
"tool1": {"description": "Tool 1"},
|
||||
"tool2": {"description": "Tool 2"},
|
||||
"specific_tool": {"description": "Specific Tool"},
|
||||
}
|
||||
|
||||
with patch.object(agent, '_get_mcp_tool_schemas', return_value=mock_schemas):
|
||||
with patch('crewai.tools.mcp_tool_wrapper.MCPToolWrapper') as mock_wrapper_class:
|
||||
mock_tool = MagicMock()
|
||||
mock_wrapper_class.return_value = mock_tool
|
||||
|
||||
result = agent._get_external_mcp_tools("http://localhost:7365/mcp#specific_tool")
|
||||
|
||||
mock_wrapper_class.assert_called_once()
|
||||
call_args = mock_wrapper_class.call_args
|
||||
assert call_args.kwargs['tool_name'] == 'specific_tool'
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == mock_tool
|
||||
|
||||
|
||||
class TestHTTPMCPWarningLog:
|
||||
"""Test warning log for HTTP MCP URLs."""
|
||||
|
||||
def test_warning_log_emitted_for_http_url(self):
|
||||
"""Test that warning log is emitted when http:// is used."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
log_calls = []
|
||||
logger_class = type(agent._logger)
|
||||
original_log = logger_class.log
|
||||
|
||||
def mock_log(self, level, message):
|
||||
log_calls.append((level, message))
|
||||
return original_log(self, level, message)
|
||||
|
||||
with patch.object(logger_class, 'log', new=mock_log):
|
||||
with patch.object(agent, '_get_mcp_tool_schemas', return_value={}):
|
||||
agent._get_external_mcp_tools("http://localhost:7365/mcp")
|
||||
|
||||
warning_messages = [msg for level, msg in log_calls if level == "warning"]
|
||||
assert any("http://" in msg for msg in warning_messages)
|
||||
assert any("local development" in msg for msg in warning_messages)
|
||||
assert any("https://" in msg for msg in warning_messages)
|
||||
|
||||
def test_no_warning_log_for_https_url(self):
|
||||
"""Test that no warning log is emitted for https:// URLs."""
|
||||
agent = Agent(
|
||||
role="Test Agent",
|
||||
goal="Test goal",
|
||||
backstory="Test backstory",
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
log_calls = []
|
||||
logger_class = type(agent._logger)
|
||||
original_log = logger_class.log
|
||||
|
||||
def mock_log(self, level, message):
|
||||
log_calls.append((level, message))
|
||||
return original_log(self, level, message)
|
||||
|
||||
with patch.object(logger_class, 'log', new=mock_log):
|
||||
with patch.object(agent, '_get_mcp_tool_schemas', return_value={}):
|
||||
agent._get_external_mcp_tools("https://api.example.com/mcp")
|
||||
|
||||
warning_messages = [msg for level, msg in log_calls if level == "warning"]
|
||||
assert not any("http://" in msg and "local development" in msg for msg in warning_messages)
|
||||
@@ -1,167 +0,0 @@
|
||||
"""Tests for agent output sanitization to prevent internal fields from leaking."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from crewai import Agent, Crew, Task
|
||||
from crewai.agents.parser import AgentAction, AgentFinish
|
||||
from crewai.process import Process
|
||||
from crewai.utilities.agent_utils import (
|
||||
format_answer,
|
||||
handle_max_iterations_exceeded,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm():
|
||||
"""Create a mock LLM that returns ReAct-style output."""
|
||||
llm = Mock()
|
||||
llm.call = Mock()
|
||||
llm.supports_stop_words = Mock(return_value=True)
|
||||
llm.get_context_window_size = Mock(return_value=4096)
|
||||
return llm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_printer():
|
||||
"""Create a mock printer."""
|
||||
printer = Mock()
|
||||
printer.print = Mock()
|
||||
return printer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_i18n():
|
||||
"""Create a mock i18n."""
|
||||
i18n = Mock()
|
||||
i18n.errors = Mock(return_value="Please provide a final answer.")
|
||||
return i18n
|
||||
|
||||
|
||||
def test_handle_max_iterations_with_agent_action_should_not_leak_internal_fields(
|
||||
mock_llm, mock_printer, mock_i18n
|
||||
):
|
||||
"""Test that when max iterations is exceeded and we have an AgentAction,
|
||||
the final output doesn't contain internal ReAct fields like 'Thought:' and 'Action:'.
|
||||
|
||||
This reproduces issue #3873 where hierarchical crews would return internal
|
||||
fields in the final answer when delegated tasks failed.
|
||||
"""
|
||||
formatted_answer = AgentAction(
|
||||
thought="I need to fetch the database tables",
|
||||
tool="PostgresTool",
|
||||
tool_input="list_tables",
|
||||
text="Thought: I need to fetch the database tables\nAction: PostgresTool\nAction Input: list_tables",
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Fetch list of tables from postgres db"},
|
||||
]
|
||||
|
||||
mock_llm.call.return_value = (
|
||||
"Thought: I should try to connect to the database\n"
|
||||
"Action: PostgresTool\n"
|
||||
"Action Input: connect"
|
||||
)
|
||||
|
||||
callbacks = []
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
formatted_answer=formatted_answer,
|
||||
printer=mock_printer,
|
||||
i18n=mock_i18n,
|
||||
messages=messages,
|
||||
llm=mock_llm,
|
||||
callbacks=callbacks,
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentFinish)
|
||||
|
||||
assert "Thought:" not in result.output, (
|
||||
f"Output should not contain 'Thought:' but got: {result.output}"
|
||||
)
|
||||
assert "Action:" not in result.output, (
|
||||
f"Output should not contain 'Action:' but got: {result.output}"
|
||||
)
|
||||
assert "Action Input:" not in result.output, (
|
||||
f"Output should not contain 'Action Input:' but got: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
def test_format_answer_with_unparseable_output_should_not_leak_internal_fields():
|
||||
"""Test that when format_answer receives unparseable output with ReAct fields,
|
||||
it sanitizes them from the final output.
|
||||
"""
|
||||
raw_answer = (
|
||||
"Thought: I tried to connect to the database but failed\n"
|
||||
"Action: PostgresTool\n"
|
||||
"Action Input: connect\n"
|
||||
"Observation: Error: Database configuration not found"
|
||||
)
|
||||
|
||||
with patch("crewai.utilities.agent_utils.parse") as mock_parse:
|
||||
mock_parse.side_effect = Exception("Failed to parse")
|
||||
|
||||
result = format_answer(raw_answer)
|
||||
|
||||
assert isinstance(result, AgentFinish)
|
||||
|
||||
assert "Thought:" not in result.output, (
|
||||
f"Output should not contain 'Thought:' but got: {result.output}"
|
||||
)
|
||||
assert "Action:" not in result.output, (
|
||||
f"Output should not contain 'Action:' but got: {result.output}"
|
||||
)
|
||||
assert "Action Input:" not in result.output, (
|
||||
f"Output should not contain 'Action Input:' but got: {result.output}"
|
||||
)
|
||||
assert "Observation:" not in result.output, (
|
||||
f"Output should not contain 'Observation:' but got: {result.output}"
|
||||
)
|
||||
|
||||
|
||||
def test_hierarchical_crew_with_failing_task_should_not_leak_internal_fields():
|
||||
"""Integration test: hierarchical crew with a failing delegated task
|
||||
should not leak internal ReAct fields in the final output.
|
||||
|
||||
This is a full integration test that reproduces issue #3873.
|
||||
|
||||
Note: This test is skipped for now as it requires VCR cassettes.
|
||||
The unit tests above cover the core functionality.
|
||||
"""
|
||||
pytest.skip("Integration test requires VCR cassettes - covered by unit tests")
|
||||
expert = Agent(
|
||||
role="Database Expert",
|
||||
goal="Fetch database information",
|
||||
backstory="You are an expert in database operations.",
|
||||
max_iter=2, # Set low max_iter to trigger the bug
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
description="Fetch list of tables from postgres database",
|
||||
expected_output="A list of database tables",
|
||||
agent=expert,
|
||||
)
|
||||
|
||||
crew = Crew(
|
||||
agents=[expert],
|
||||
tasks=[task],
|
||||
process=Process.hierarchical,
|
||||
manager_llm="gpt-4o",
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
# Execute the crew
|
||||
result = crew.kickoff()
|
||||
|
||||
assert "Thought:" not in result.raw, (
|
||||
f"Final output should not contain 'Thought:' but got: {result.raw}"
|
||||
)
|
||||
assert "Action:" not in result.raw, (
|
||||
f"Final output should not contain 'Action:' but got: {result.raw}"
|
||||
)
|
||||
assert "Action Input:" not in result.raw, (
|
||||
f"Final output should not contain 'Action Input:' but got: {result.raw}"
|
||||
)
|
||||
@@ -15,7 +15,7 @@ dev = [
|
||||
"pytest>=8.4.2",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"pytest-subprocess>=1.5.3",
|
||||
"vcrpy==7.0.0",
|
||||
"vcrpy==7.0.0", # pinned, less versions break pytest-recording
|
||||
"pytest-recording>=0.13.4",
|
||||
"pytest-randomly>=4.0.1",
|
||||
"pytest-timeout>=2.4.0",
|
||||
|
||||
Reference in New Issue
Block a user