Compare commits

..

4 Commits

Author SHA1 Message Date
Devin AI
d25bea781d fix: Resolve test failures in HTTP MCP support tests
1. Fragment filtering test: Added proper assertion for MCPToolWrapper call
   and verified the mock tool is returned in the result list.

2. Logger patching tests: Changed from patching instance method to patching
   class method to avoid Pydantic's __delattr__ restriction. This prevents
   AttributeError when the patch context manager tries to restore the
   original attribute.

Co-Authored-By: João <joao@crewai.com>
2025-11-10 15:22:50 +00:00
Devin AI
6063677c01 fix: Correct MCPToolWrapper patch path in fragment filtering test
The test was patching 'crewai.agent.core.MCPToolWrapper' but the import
happens inside _get_external_mcp_tools as 'from crewai.tools.mcp_tool_wrapper
import MCPToolWrapper'. Updated to patch the correct import path to ensure
the test works when it actually runs.

Co-Authored-By: João <joao@crewai.com>
2025-11-10 15:17:30 +00:00
Devin AI
cfaa44012f fix: Update warning log tests to use custom Logger monkeypatch
The project uses a custom Logger that emits via _logger.log() rather than
Python's standard logging module, so caplog won't capture the warnings.
Updated tests to monkeypatch agent._logger.log instead of using caplog.

Co-Authored-By: João <joao@crewai.com>
2025-11-10 15:10:59 +00:00
Devin AI
697182b0ef feat: Add HTTP support for local MCP servers (#3876)
- Update validate_mcps() to accept http://, https://, and crewai-amp: URLs
- Update _get_mcp_tools_from_string() to route http:// URLs to _get_external_mcp_tools()
- Add warning log when http:// scheme is detected (security awareness)
- Update mcps field description to mention http:// support for local development
- Update HTTPTransport and MCPServerHTTP docstrings with http:// examples
- Add comprehensive tests for HTTP MCP support

This enables developers to use local MCP servers running on http://localhost
or other local HTTP endpoints during development, while maintaining security
awareness by logging warnings when http:// is used.

Fixes #3876

Co-Authored-By: João <joao@crewai.com>
2025-11-10 15:05:30 +00:00
9 changed files with 4242 additions and 4280 deletions

View File

@@ -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)

View File

@@ -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)):

View File

@@ -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,
)
```
"""

View File

@@ -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...
```

View File

@@ -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,
)

View 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)

View File

@@ -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}"
)

View File

@@ -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",

8060
uv.lock generated

File diff suppressed because it is too large Load Diff