Skip to content

Commit d259150

Browse files
Enhance MCP tool resolution and related events (#4580)
* feat: enhance MCP tool resolution * feat: emit event when MCP configuration fails * feat: emit event when MCP tool execution has failed * style: resolve linter issues * refactor: use clear and natural mcp tool name resolution * test: fix broken tests * fix: resolve MCP connection leaks, slug validation, duplicate connections, and httpx exception handling --------- Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com> Co-authored-by: Greyson LaLonde <greyson@crewai.com>
1 parent c4a328c commit d259150

File tree

17 files changed

+2112
-611
lines changed

17 files changed

+2112
-611
lines changed

lib/crewai/src/crewai/agent/core.py

Lines changed: 10 additions & 557 deletions
Large diffs are not rendered by default.

lib/crewai/src/crewai/agents/agent_builder/base_agent.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from collections.abc import Callable
55
from copy import copy as shallow_copy
66
from hashlib import md5
7-
from typing import Any, Literal
7+
import re
8+
from typing import Any, Final, Literal
89
import uuid
910

1011
from pydantic import (
@@ -36,6 +37,11 @@
3637
from crewai.utilities.string_utils import interpolate_only
3738

3839

40+
_SLUG_RE: Final[re.Pattern[str]] = re.compile(
41+
r"^(?:crewai-amp:)?[a-zA-Z0-9][a-zA-Z0-9_-]*(?:#\w+)?$"
42+
)
43+
44+
3945
PlatformApp = Literal[
4046
"asana",
4147
"box",
@@ -197,7 +203,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
197203
)
198204
mcps: list[str | MCPServerConfig] | None = Field(
199205
default=None,
200-
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.",
206+
description="List of MCP server references. Supports 'https://server.com/path' for external servers and bare slugs like 'notion' for connected MCP integrations. Use '#tool_name' suffix for specific tools.",
201207
)
202208
memory: Any = Field(
203209
default=None,
@@ -276,14 +282,16 @@ def validate_mcps(
276282
validated_mcps: list[str | MCPServerConfig] = []
277283
for mcp in mcps:
278284
if isinstance(mcp, str):
279-
if mcp.startswith(("https://", "crewai-amp:")):
285+
if mcp.startswith("https://"):
286+
validated_mcps.append(mcp)
287+
elif _SLUG_RE.match(mcp):
280288
validated_mcps.append(mcp)
281289
else:
282290
raise ValueError(
283-
f"Invalid MCP reference: {mcp}. "
284-
"String references must start with 'https://' or 'crewai-amp:'"
291+
f"Invalid MCP reference: {mcp!r}. "
292+
"String references must be an 'https://' URL or a valid "
293+
"slug (e.g. 'notion', 'notion#search', 'crewai-amp:notion')."
285294
)
286-
287295
elif isinstance(mcp, (MCPServerConfig)):
288296
validated_mcps.append(mcp)
289297
else:

lib/crewai/src/crewai/cli/plus_api.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,15 @@ def mark_trace_batch_as_failed(
190190
timeout=30,
191191
)
192192

193+
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
194+
"""Get MCP server configurations for the given slugs."""
195+
return self._make_request(
196+
"GET",
197+
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
198+
params={"slugs": ",".join(slugs)},
199+
timeout=30,
200+
)
201+
193202
def get_triggers(self) -> httpx.Response:
194203
"""Get all available triggers from integrations."""
195204
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")

lib/crewai/src/crewai/events/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
AgentLogsStartedEvent,
6464
)
6565
from crewai.events.types.mcp_events import (
66+
MCPConfigFetchFailedEvent,
6667
MCPConnectionCompletedEvent,
6768
MCPConnectionFailedEvent,
6869
MCPConnectionStartedEvent,
@@ -165,6 +166,7 @@
165166
"LiteAgentExecutionCompletedEvent",
166167
"LiteAgentExecutionErrorEvent",
167168
"LiteAgentExecutionStartedEvent",
169+
"MCPConfigFetchFailedEvent",
168170
"MCPConnectionCompletedEvent",
169171
"MCPConnectionFailedEvent",
170172
"MCPConnectionStartedEvent",

lib/crewai/src/crewai/events/event_listener.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
AgentLogsStartedEvent,
6969
)
7070
from crewai.events.types.mcp_events import (
71+
MCPConfigFetchFailedEvent,
7172
MCPConnectionCompletedEvent,
7273
MCPConnectionFailedEvent,
7374
MCPConnectionStartedEvent,
@@ -665,6 +666,16 @@ def on_mcp_connection_failed(_: Any, event: MCPConnectionFailedEvent) -> None:
665666
event.error_type,
666667
)
667668

669+
@crewai_event_bus.on(MCPConfigFetchFailedEvent)
670+
def on_mcp_config_fetch_failed(
671+
_: Any, event: MCPConfigFetchFailedEvent
672+
) -> None:
673+
self.formatter.handle_mcp_config_fetch_failed(
674+
event.slug,
675+
event.error,
676+
event.error_type,
677+
)
678+
668679
@crewai_event_bus.on(MCPToolExecutionStartedEvent)
669680
def on_mcp_tool_execution_started(
670681
_: Any, event: MCPToolExecutionStartedEvent

lib/crewai/src/crewai/events/event_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
LLMGuardrailStartedEvent,
6868
)
6969
from crewai.events.types.mcp_events import (
70+
MCPConfigFetchFailedEvent,
7071
MCPConnectionCompletedEvent,
7172
MCPConnectionFailedEvent,
7273
MCPConnectionStartedEvent,
@@ -181,4 +182,5 @@
181182
| MCPToolExecutionStartedEvent
182183
| MCPToolExecutionCompletedEvent
183184
| MCPToolExecutionFailedEvent
185+
| MCPConfigFetchFailedEvent
184186
)

lib/crewai/src/crewai/events/types/mcp_events.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,16 @@ class MCPToolExecutionFailedEvent(MCPEvent):
8383
error_type: str | None = None # "timeout", "validation", "server_error", etc.
8484
started_at: datetime | None = None
8585
failed_at: datetime | None = None
86+
87+
88+
class MCPConfigFetchFailedEvent(BaseEvent):
89+
"""Event emitted when fetching an AMP MCP server config fails.
90+
91+
This covers cases where the slug is not connected, the API call
92+
failed, or native MCP resolution failed after config was fetched.
93+
"""
94+
95+
type: str = "mcp_config_fetch_failed"
96+
slug: str
97+
error: str
98+
error_type: str | None = None # "not_connected", "api_error", "connection_failed"

lib/crewai/src/crewai/events/utils/console_formatter.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,34 @@ def handle_mcp_connection_failed(
15121512
self.print(panel)
15131513
self.print()
15141514

1515+
def handle_mcp_config_fetch_failed(
1516+
self,
1517+
slug: str,
1518+
error: str = "",
1519+
error_type: str | None = None,
1520+
) -> None:
1521+
"""Handle MCP config fetch failed event (AMP resolution failures)."""
1522+
if not self.verbose:
1523+
return
1524+
1525+
content = Text()
1526+
content.append("MCP Config Fetch Failed\n\n", style="red bold")
1527+
content.append("Server: ", style="white")
1528+
content.append(f"{slug}\n", style="red")
1529+
1530+
if error_type:
1531+
content.append("Error Type: ", style="white")
1532+
content.append(f"{error_type}\n", style="red")
1533+
1534+
if error:
1535+
content.append("\nError: ", style="white bold")
1536+
error_preview = error[:500] + "..." if len(error) > 500 else error
1537+
content.append(f"{error_preview}\n", style="red")
1538+
1539+
panel = self.create_panel(content, "❌ MCP Config Failed", "red")
1540+
self.print(panel)
1541+
self.print()
1542+
15151543
def handle_mcp_tool_execution_started(
15161544
self,
15171545
server_name: str,

lib/crewai/src/crewai/mcp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
create_dynamic_tool_filter,
1919
create_static_tool_filter,
2020
)
21+
from crewai.mcp.tool_resolver import MCPToolResolver
2122
from crewai.mcp.transports.base import BaseTransport, TransportType
2223

2324

@@ -28,6 +29,7 @@
2829
"MCPServerHTTP",
2930
"MCPServerSSE",
3031
"MCPServerStdio",
32+
"MCPToolResolver",
3133
"StaticToolFilter",
3234
"ToolFilter",
3335
"ToolFilterContext",

lib/crewai/src/crewai/mcp/client.py

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from datetime import datetime
77
import logging
88
import time
9-
from typing import Any
9+
from typing import Any, NamedTuple
1010

1111
from typing_extensions import Self
1212

@@ -34,6 +34,13 @@
3434
from crewai.utilities.string_utils import sanitize_tool_name
3535

3636

37+
class _MCPToolResult(NamedTuple):
38+
"""Internal result from an MCP tool call, carrying the ``isError`` flag."""
39+
40+
content: str
41+
is_error: bool
42+
43+
3744
# MCP Connection timeout constants (in seconds)
3845
MCP_CONNECTION_TIMEOUT = 30 # Increased for slow servers
3946
MCP_TOOL_EXECUTION_TIMEOUT = 30
@@ -420,6 +427,7 @@ async def _list_tools_impl(self) -> list[dict[str, Any]]:
420427
return [
421428
{
422429
"name": sanitize_tool_name(tool.name),
430+
"original_name": tool.name,
423431
"description": getattr(tool, "description", ""),
424432
"inputSchema": getattr(tool, "inputSchema", {}),
425433
}
@@ -461,29 +469,46 @@ async def call_tool(
461469
)
462470

463471
try:
464-
result = await self._retry_operation(
472+
tool_result: _MCPToolResult = await self._retry_operation(
465473
lambda: self._call_tool_impl(tool_name, cleaned_arguments),
466474
timeout=self.execution_timeout,
467475
)
468476

469-
completed_at = datetime.now()
470-
execution_duration_ms = (completed_at - started_at).total_seconds() * 1000
471-
crewai_event_bus.emit(
472-
self,
473-
MCPToolExecutionCompletedEvent(
474-
server_name=server_name,
475-
server_url=server_url,
476-
transport_type=transport_type,
477-
tool_name=tool_name,
478-
tool_args=cleaned_arguments,
479-
result=result,
480-
started_at=started_at,
481-
completed_at=completed_at,
482-
execution_duration_ms=execution_duration_ms,
483-
),
484-
)
477+
finished_at = datetime.now()
478+
execution_duration_ms = (finished_at - started_at).total_seconds() * 1000
479+
480+
if tool_result.is_error:
481+
crewai_event_bus.emit(
482+
self,
483+
MCPToolExecutionFailedEvent(
484+
server_name=server_name,
485+
server_url=server_url,
486+
transport_type=transport_type,
487+
tool_name=tool_name,
488+
tool_args=cleaned_arguments,
489+
error=tool_result.content,
490+
error_type="tool_error",
491+
started_at=started_at,
492+
failed_at=finished_at,
493+
),
494+
)
495+
else:
496+
crewai_event_bus.emit(
497+
self,
498+
MCPToolExecutionCompletedEvent(
499+
server_name=server_name,
500+
server_url=server_url,
501+
transport_type=transport_type,
502+
tool_name=tool_name,
503+
tool_args=cleaned_arguments,
504+
result=tool_result.content,
505+
started_at=started_at,
506+
completed_at=finished_at,
507+
execution_duration_ms=execution_duration_ms,
508+
),
509+
)
485510

486-
return result
511+
return tool_result.content
487512
except Exception as e:
488513
failed_at = datetime.now()
489514
error_type = (
@@ -564,23 +589,27 @@ def _clean_tool_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]:
564589

565590
return cleaned
566591

567-
async def _call_tool_impl(self, tool_name: str, arguments: dict[str, Any]) -> Any:
592+
async def _call_tool_impl(
593+
self, tool_name: str, arguments: dict[str, Any]
594+
) -> _MCPToolResult:
568595
"""Internal implementation of call_tool."""
569596
result = await asyncio.wait_for(
570597
self.session.call_tool(tool_name, arguments),
571598
timeout=self.execution_timeout,
572599
)
573600

601+
is_error = getattr(result, "isError", False) or False
602+
574603
# Extract result content
575604
if hasattr(result, "content") and result.content:
576605
if isinstance(result.content, list) and len(result.content) > 0:
577606
content_item = result.content[0]
578607
if hasattr(content_item, "text"):
579-
return str(content_item.text)
580-
return str(content_item)
581-
return str(result.content)
608+
return _MCPToolResult(str(content_item.text), is_error)
609+
return _MCPToolResult(str(content_item), is_error)
610+
return _MCPToolResult(str(result.content), is_error)
582611

583-
return str(result)
612+
return _MCPToolResult(str(result), is_error)
584613

585614
async def list_prompts(self) -> list[dict[str, Any]]:
586615
"""List available prompts from MCP server.

0 commit comments

Comments
 (0)