Merge branch 'main' into alert-autofix-26

This commit is contained in:
Greyson LaLonde
2025-11-24 12:11:15 -05:00
committed by GitHub
10 changed files with 1122 additions and 19 deletions

View File

@@ -90,6 +90,9 @@ from crewai_tools.tools.json_search_tool.json_search_tool import JSONSearchTool
from crewai_tools.tools.linkup.linkup_search_tool import LinkupSearchTool
from crewai_tools.tools.llamaindex_tool.llamaindex_tool import LlamaIndexTool
from crewai_tools.tools.mdx_search_tool.mdx_search_tool import MDXSearchTool
from crewai_tools.tools.merge_agent_handler_tool.merge_agent_handler_tool import (
MergeAgentHandlerTool,
)
from crewai_tools.tools.mongodb_vector_search_tool.vector_search import (
MongoDBVectorSearchConfig,
MongoDBVectorSearchTool,
@@ -235,6 +238,7 @@ __all__ = [
"LlamaIndexTool",
"MCPServerAdapter",
"MDXSearchTool",
"MergeAgentHandlerTool",
"MongoDBVectorSearchConfig",
"MongoDBVectorSearchTool",
"MultiOnTool",

View File

@@ -79,6 +79,9 @@ from crewai_tools.tools.json_search_tool.json_search_tool import JSONSearchTool
from crewai_tools.tools.linkup.linkup_search_tool import LinkupSearchTool
from crewai_tools.tools.llamaindex_tool.llamaindex_tool import LlamaIndexTool
from crewai_tools.tools.mdx_search_tool.mdx_search_tool import MDXSearchTool
from crewai_tools.tools.merge_agent_handler_tool.merge_agent_handler_tool import (
MergeAgentHandlerTool,
)
from crewai_tools.tools.mongodb_vector_search_tool import (
MongoDBToolSchema,
MongoDBVectorSearchConfig,
@@ -218,6 +221,7 @@ __all__ = [
"LinkupSearchTool",
"LlamaIndexTool",
"MDXSearchTool",
"MergeAgentHandlerTool",
"MongoDBToolSchema",
"MongoDBVectorSearchConfig",
"MongoDBVectorSearchTool",

View File

@@ -0,0 +1,231 @@
# MergeAgentHandlerTool Documentation
## Description
This tool is a wrapper around the Merge Agent Handler platform and gives your agent access to third-party tools and integrations via the Model Context Protocol (MCP). Merge Agent Handler securely manages authentication, permissions, and monitoring of all tool interactions across platforms like Linear, Jira, Slack, GitHub, and many more.
## Installation
### Step 1: Set up a virtual environment (recommended)
It's recommended to use a virtual environment to avoid conflicts with other packages:
```shell
# Create a virtual environment
python3 -m venv venv
# Activate the virtual environment
# On macOS/Linux:
source venv/bin/activate
# On Windows:
# venv\Scripts\activate
```
### Step 2: Install CrewAI Tools
To incorporate this tool into your project, install CrewAI with tools support:
```shell
pip install 'crewai[tools]'
```
### Step 3: Set up your Agent Handler credentials
You'll need to set up your Agent Handler API key. You can get your API key from the [Agent Handler dashboard](https://ah.merge.dev).
```shell
# Set the API key in your current terminal session
export AGENT_HANDLER_API_KEY='your-api-key-here'
# Or add it to your shell profile for persistence (e.g., ~/.bashrc, ~/.zshrc)
echo "export AGENT_HANDLER_API_KEY='your-api-key-here'" >> ~/.zshrc
source ~/.zshrc
```
**Alternative: Use a `.env` file**
You can also use a `.env` file in your project directory:
```shell
# Create a .env file
echo "AGENT_HANDLER_API_KEY=your-api-key-here" > .env
# Load it in your Python script
from dotenv import load_dotenv
load_dotenv()
```
**Note**: Make sure to add `.env` to your `.gitignore` to avoid committing secrets!
## Prerequisites
Before using this tool, you need to:
1. **Create a Tool Pack** in Agent Handler with the connectors and tools you want to use
2. **Register a User** who will be executing the tools
3. **Authenticate connectors** for the registered user (using Agent Handler Link)
You can do this via the [Agent Handler dashboard](https://ah.merge.dev) or the [Agent Handler API](https://docs.ah.merge.dev).
## Example Usage
### Example 1: Using a specific tool
The following example demonstrates how to initialize a specific tool and use it with a CrewAI agent:
```python
from crewai_tools import MergeAgentHandlerTool
from crewai import Agent, Task
# Initialize a specific tool
create_issue_tool = MergeAgentHandlerTool.from_tool_name(
tool_name="linear__create_issue",
tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa"
)
# Define agent with the tool
project_manager = Agent(
role="Project Manager",
goal="Create and manage project tasks efficiently",
backstory=(
"You are an experienced project manager who tracks tasks "
"and issues across various project management tools."
),
verbose=True,
tools=[create_issue_tool],
)
# Execute task
task = Task(
description="Create a new issue in Linear titled 'Implement user authentication' with high priority",
agent=project_manager,
expected_output="Confirmation that the issue was created with its ID",
)
task.execute()
```
### Example 2: Loading all tools from a Tool Pack
You can load all tools from a Tool Pack at once:
```python
from crewai_tools import MergeAgentHandlerTool
from crewai import Agent, Task
# Load all tools from a Tool Pack
tools = MergeAgentHandlerTool.from_tool_pack(
tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa"
)
# Define agent with all tools
support_agent = Agent(
role="Support Engineer",
goal="Handle customer support requests across multiple platforms",
backstory=(
"You are a skilled support engineer who can access customer "
"data and create tickets across various support tools."
),
verbose=True,
tools=tools,
)
```
### Example 3: Loading specific tools from a Tool Pack
You can also load only specific tools from a Tool Pack:
```python
from crewai_tools import MergeAgentHandlerTool
# Load only specific tools
tools = MergeAgentHandlerTool.from_tool_pack(
tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa",
tool_names=["linear__create_issue", "linear__get_issues", "slack__send_message"]
)
```
### Example 4: Using with local/staging environment
For development, you can point to a different Agent Handler environment:
```python
from crewai_tools import MergeAgentHandlerTool
# Use with local or staging environment
tool = MergeAgentHandlerTool.from_tool_name(
tool_name="linear__create_issue",
tool_pack_id="your-tool-pack-id",
registered_user_id="your-user-id",
base_url="http://localhost:8000" # or your staging URL
)
```
## API Reference
### Class Methods
#### `from_tool_name()`
Create a single tool instance for a specific tool.
**Parameters:**
- `tool_name` (str): Name of the tool (e.g., "linear__create_issue")
- `tool_pack_id` (str): UUID of the Tool Pack
- `registered_user_id` (str): UUID or origin_id of the registered user
- `base_url` (str, optional): Base URL for Agent Handler API (defaults to "https://api.ah.merge.dev")
**Returns:** `MergeAgentHandlerTool` instance
#### `from_tool_pack()`
Create multiple tool instances from a Tool Pack.
**Parameters:**
- `tool_pack_id` (str): UUID of the Tool Pack
- `registered_user_id` (str): UUID or origin_id of the registered user
- `tool_names` (List[str], optional): List of specific tool names to load. If None, loads all tools.
- `base_url` (str, optional): Base URL for Agent Handler API (defaults to "https://api.ah.merge.dev")
**Returns:** `List[MergeAgentHandlerTool]` instances
## Available Connectors
Merge Agent Handler supports 100+ integrations including:
**Project Management:** Linear, Jira, Asana, Monday, ClickUp, Height, Shortcut
**Communication:** Slack, Microsoft Teams, Discord
**CRM:** Salesforce, HubSpot, Pipedrive
**Development:** GitHub, GitLab, Bitbucket
**Documentation:** Notion, Confluence, Google Docs
**And many more...**
For a complete list of available connectors and tools, visit the [Agent Handler documentation](https://docs.ah.merge.dev).
## Authentication
Agent Handler handles all authentication for you. Users authenticate to third-party services via Agent Handler Link, and the platform securely manages tokens and credentials. Your agents can then execute tools without worrying about authentication details.
## Security
All tool executions are:
- **Logged and monitored** for audit trails
- **Scanned for PII** to prevent sensitive data leaks
- **Rate limited** based on your plan
- **Permission-controlled** at the user and organization level
## Support
For questions or issues:
- 📚 [Documentation](https://docs.ah.merge.dev)
- 💬 [Discord Community](https://merge.dev/discord)
- 📧 [Support Email](mailto:support@merge.dev)

View File

@@ -0,0 +1,8 @@
"""Merge Agent Handler tool for CrewAI."""
from crewai_tools.tools.merge_agent_handler_tool.merge_agent_handler_tool import (
MergeAgentHandlerTool,
)
__all__ = ["MergeAgentHandlerTool"]

View File

@@ -0,0 +1,362 @@
"""Merge Agent Handler tools wrapper for CrewAI."""
import json
import logging
from typing import Any
from uuid import uuid4
from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, Field, create_model
import requests
import typing_extensions as te
logger = logging.getLogger(__name__)
class MergeAgentHandlerToolError(Exception):
"""Base exception for Merge Agent Handler tool errors."""
class MergeAgentHandlerTool(BaseTool):
"""
Wrapper for Merge Agent Handler tools.
This tool allows CrewAI agents to execute tools from Merge Agent Handler,
which provides secure access to third-party integrations via the Model Context Protocol (MCP).
Agent Handler manages authentication, permissions, and monitoring of all tool interactions.
"""
tool_pack_id: str = Field(
..., description="UUID of the Agent Handler Tool Pack to use"
)
registered_user_id: str = Field(
..., description="UUID or origin_id of the registered user"
)
tool_name: str = Field(..., description="Name of the specific tool to execute")
base_url: str = Field(
default="https://ah-api.merge.dev",
description="Base URL for Agent Handler API",
)
session_id: str | None = Field(
default=None, description="MCP session ID (generated if not provided)"
)
env_vars: list[EnvVar] = Field(
default_factory=lambda: [
EnvVar(
name="AGENT_HANDLER_API_KEY",
description="Production API key for Agent Handler services",
required=True,
),
]
)
def model_post_init(self, __context: Any) -> None:
"""Initialize session ID if not provided."""
super().model_post_init(__context)
if self.session_id is None:
self.session_id = str(uuid4())
def _get_api_key(self) -> str:
"""Get the API key from environment variables."""
import os
api_key = os.environ.get("AGENT_HANDLER_API_KEY")
if not api_key:
raise MergeAgentHandlerToolError(
"AGENT_HANDLER_API_KEY environment variable is required. "
"Set it with: export AGENT_HANDLER_API_KEY='your-key-here'"
)
return api_key
def _make_mcp_request(
self, method: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Make a JSON-RPC 2.0 MCP request to Agent Handler."""
url = f"{self.base_url}/api/v1/tool-packs/{self.tool_pack_id}/registered-users/{self.registered_user_id}/mcp"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._get_api_key()}",
"Mcp-Session-Id": self.session_id or str(uuid4()),
}
payload: dict[str, Any] = {
"jsonrpc": "2.0",
"method": method,
"id": str(uuid4()),
}
if params:
payload["params"] = params
# Log the full payload for debugging
logger.debug(f"MCP Request to {url}: {json.dumps(payload, indent=2)}")
try:
response = requests.post(url, json=payload, headers=headers, timeout=60)
response.raise_for_status()
result = response.json()
# Handle JSON-RPC error responses
if "error" in result:
error_msg = result["error"].get("message", "Unknown error")
error_code = result["error"].get("code", -1)
logger.error(
f"Agent Handler API error (code {error_code}): {error_msg}"
)
raise MergeAgentHandlerToolError(f"API Error: {error_msg}")
return result
except requests.exceptions.RequestException as e:
logger.error(f"Failed to call Agent Handler API: {e!s}")
raise MergeAgentHandlerToolError(
f"Failed to communicate with Agent Handler API: {e!s}"
) from e
def _run(self, **kwargs: Any) -> Any:
"""Execute the Agent Handler tool with the given arguments."""
try:
# Log what we're about to send
logger.info(f"Executing {self.tool_name} with arguments: {kwargs}")
# Make the tool call via MCP
result = self._make_mcp_request(
method="tools/call",
params={"name": self.tool_name, "arguments": kwargs},
)
# Extract the actual result from the MCP response
if "result" in result and "content" in result["result"]:
content = result["result"]["content"]
if content and len(content) > 0:
# Parse the text content (it's JSON-encoded)
text_content = content[0].get("text", "")
try:
return json.loads(text_content)
except json.JSONDecodeError:
return text_content
return result
except MergeAgentHandlerToolError:
raise
except Exception as e:
logger.error(f"Unexpected error executing tool {self.tool_name}: {e!s}")
raise MergeAgentHandlerToolError(f"Tool execution failed: {e!s}") from e
@classmethod
def from_tool_name(
cls,
tool_name: str,
tool_pack_id: str,
registered_user_id: str,
base_url: str = "https://ah-api.merge.dev",
**kwargs: Any,
) -> te.Self:
"""
Create a MergeAgentHandlerTool from a tool name.
Args:
tool_name: Name of the tool (e.g., "linear__create_issue")
tool_pack_id: UUID of the Tool Pack
registered_user_id: UUID of the registered user
base_url: Base URL for Agent Handler API (defaults to production)
**kwargs: Additional arguments to pass to the tool
Returns:
MergeAgentHandlerTool instance ready to use
Example:
>>> tool = MergeAgentHandlerTool.from_tool_name(
... tool_name="linear__create_issue",
... tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
... registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa"
... )
"""
# Create an empty args schema model (proper BaseModel subclass)
empty_args_schema = create_model(f"{tool_name.replace('__', '_').title()}Args")
# Initialize session and get tool schema
instance = cls(
name=tool_name,
description=f"Execute {tool_name} via Agent Handler",
tool_pack_id=tool_pack_id,
registered_user_id=registered_user_id,
tool_name=tool_name,
base_url=base_url,
args_schema=empty_args_schema, # Empty schema that properly inherits from BaseModel
**kwargs,
)
# Try to fetch the actual tool schema from Agent Handler
try:
result = instance._make_mcp_request(method="tools/list")
if "result" in result and "tools" in result["result"]:
tools = result["result"]["tools"]
tool_schema = next(
(t for t in tools if t.get("name") == tool_name), None
)
if tool_schema:
instance.description = tool_schema.get(
"description", instance.description
)
# Convert parameters schema to Pydantic model
if "parameters" in tool_schema:
try:
params = tool_schema["parameters"]
if params.get("type") == "object" and "properties" in params:
# Build field definitions for Pydantic
fields = {}
properties = params["properties"]
required = params.get("required", [])
for field_name, field_schema in properties.items():
field_type = Any # Default type
field_default = ... # Required by default
# Map JSON schema types to Python types
json_type = field_schema.get("type", "string")
if json_type == "string":
field_type = str
elif json_type == "integer":
field_type = int
elif json_type == "number":
field_type = float
elif json_type == "boolean":
field_type = bool
elif json_type == "array":
field_type = list[Any]
elif json_type == "object":
field_type = dict[str, Any]
# Make field optional if not required
if field_name not in required:
field_type = field_type | None
field_default = None
field_description = field_schema.get("description")
if field_description:
fields[field_name] = (
field_type,
Field(
default=field_default,
description=field_description,
),
)
else:
fields[field_name] = (field_type, field_default)
# Create the Pydantic model
if fields:
args_schema = create_model(
f"{tool_name.replace('__', '_').title()}Args",
**fields,
)
instance.args_schema = args_schema
except Exception as e:
logger.warning(
f"Failed to create args schema for {tool_name}: {e!s}"
)
except Exception as e:
logger.warning(
f"Failed to fetch tool schema for {tool_name}, using defaults: {e!s}"
)
return instance
@classmethod
def from_tool_pack(
cls,
tool_pack_id: str,
registered_user_id: str,
tool_names: list[str] | None = None,
base_url: str = "https://ah-api.merge.dev",
**kwargs: Any,
) -> list[te.Self]:
"""
Create multiple MergeAgentHandlerTool instances from a Tool Pack.
Args:
tool_pack_id: UUID of the Tool Pack
registered_user_id: UUID or origin_id of the registered user
tool_names: Optional list of specific tool names to load. If None, loads all tools.
base_url: Base URL for Agent Handler API (defaults to production)
**kwargs: Additional arguments to pass to each tool
Returns:
List of MergeAgentHandlerTool instances
Example:
>>> tools = MergeAgentHandlerTool.from_tool_pack(
... tool_pack_id="134e0111-0f67-44f6-98f0-597000290bb3",
... registered_user_id="91b2b905-e866-40c8-8be2-efe53827a0aa",
... tool_names=["linear__create_issue", "linear__get_issues"]
... )
"""
# Create a temporary instance to fetch the tool list
temp_instance = cls(
name="temp",
description="temp",
tool_pack_id=tool_pack_id,
registered_user_id=registered_user_id,
tool_name="temp",
base_url=base_url,
args_schema=BaseModel,
)
try:
# Fetch available tools
result = temp_instance._make_mcp_request(method="tools/list")
if "result" not in result or "tools" not in result["result"]:
raise MergeAgentHandlerToolError(
"Failed to fetch tools from Agent Handler Tool Pack"
)
available_tools = result["result"]["tools"]
# Filter tools if specific names were requested
if tool_names:
available_tools = [
t for t in available_tools if t.get("name") in tool_names
]
# Check if all requested tools were found
found_names = {t.get("name") for t in available_tools}
missing_names = set(tool_names) - found_names
if missing_names:
logger.warning(
f"The following tools were not found in the Tool Pack: {missing_names}"
)
# Create tool instances
tools = []
for tool_schema in available_tools:
tool_name = tool_schema.get("name")
if not tool_name:
continue
tool = cls.from_tool_name(
tool_name=tool_name,
tool_pack_id=tool_pack_id,
registered_user_id=registered_user_id,
base_url=base_url,
**kwargs,
)
tools.append(tool)
return tools
except MergeAgentHandlerToolError:
raise
except Exception as e:
logger.error(f"Failed to create tools from Tool Pack: {e!s}")
raise MergeAgentHandlerToolError(f"Failed to load Tool Pack: {e!s}") from e

View File

@@ -0,0 +1,490 @@
"""Tests for MergeAgentHandlerTool."""
import os
from unittest.mock import Mock, patch
import pytest
from crewai_tools import MergeAgentHandlerTool
@pytest.fixture(autouse=True)
def mock_agent_handler_api_key():
"""Mock the Agent Handler API key environment variable."""
with patch.dict(os.environ, {"AGENT_HANDLER_API_KEY": "test_key"}):
yield
@pytest.fixture
def mock_tool_pack_response():
"""Mock response for tools/list MCP request."""
return {
"jsonrpc": "2.0",
"id": "test-id",
"result": {
"tools": [
{
"name": "linear__create_issue",
"description": "Creates a new issue in Linear",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The issue title",
},
"description": {
"type": "string",
"description": "The issue description",
},
"priority": {
"type": "integer",
"description": "Priority level (1-4)",
},
},
"required": ["title"],
},
},
{
"name": "linear__get_issues",
"description": "Get issues from Linear",
"parameters": {
"type": "object",
"properties": {
"filter": {
"type": "object",
"description": "Filter criteria",
}
},
},
},
]
},
}
@pytest.fixture
def mock_tool_execute_response():
"""Mock response for tools/call MCP request."""
return {
"jsonrpc": "2.0",
"id": "test-id",
"result": {
"content": [
{
"type": "text",
"text": '{"success": true, "id": "ISS-123", "title": "Test Issue"}',
}
]
},
}
def test_tool_initialization():
"""Test basic tool initialization."""
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
assert tool.name == "test_tool"
assert "Test tool" in tool.description # Description gets formatted by BaseTool
assert tool.tool_pack_id == "test-pack-id"
assert tool.registered_user_id == "test-user-id"
assert tool.tool_name == "linear__create_issue"
assert tool.base_url == "https://ah-api.merge.dev"
assert tool.session_id is not None
def test_tool_initialization_with_custom_base_url():
"""Test tool initialization with custom base URL."""
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
base_url="http://localhost:8000",
)
assert tool.base_url == "http://localhost:8000"
def test_missing_api_key():
"""Test that missing API key raises appropriate error."""
with patch.dict(os.environ, {}, clear=True):
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
with pytest.raises(Exception) as exc_info:
tool._get_api_key()
assert "AGENT_HANDLER_API_KEY" in str(exc_info.value)
@patch("requests.post")
def test_mcp_request_success(mock_post, mock_tool_pack_response):
"""Test successful MCP request."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
result = tool._make_mcp_request(method="tools/list")
assert "result" in result
assert "tools" in result["result"]
assert len(result["result"]["tools"]) == 2
@patch("requests.post")
def test_mcp_request_error(mock_post):
"""Test MCP request with error response."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"jsonrpc": "2.0",
"id": "test-id",
"error": {"code": -32601, "message": "Method not found"},
}
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
with pytest.raises(Exception) as exc_info:
tool._make_mcp_request(method="invalid/method")
assert "Method not found" in str(exc_info.value)
@patch("requests.post")
def test_mcp_request_http_error(mock_post):
"""Test MCP request with HTTP error."""
mock_post.side_effect = Exception("Connection error")
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
with pytest.raises(Exception) as exc_info:
tool._make_mcp_request(method="tools/list")
assert "Connection error" in str(exc_info.value)
@patch("requests.post")
def test_tool_execution(mock_post, mock_tool_execute_response):
"""Test tool execution via _run method."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_execute_response
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
result = tool._run(title="Test Issue", description="Test description")
assert result["success"] is True
assert result["id"] == "ISS-123"
assert result["title"] == "Test Issue"
@patch("requests.post")
def test_from_tool_name(mock_post, mock_tool_pack_response):
"""Test creating tool from tool name."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool.from_tool_name(
tool_name="linear__create_issue",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
)
assert tool.name == "linear__create_issue"
assert tool.description == "Creates a new issue in Linear"
assert tool.tool_name == "linear__create_issue"
@patch("requests.post")
def test_from_tool_name_with_custom_base_url(mock_post, mock_tool_pack_response):
"""Test creating tool from tool name with custom base URL."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool.from_tool_name(
tool_name="linear__create_issue",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
base_url="http://localhost:8000",
)
assert tool.base_url == "http://localhost:8000"
@patch("requests.post")
def test_from_tool_pack_all_tools(mock_post, mock_tool_pack_response):
"""Test creating all tools from a Tool Pack."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tools = MergeAgentHandlerTool.from_tool_pack(
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
)
assert len(tools) == 2
assert tools[0].name == "linear__create_issue"
assert tools[1].name == "linear__get_issues"
@patch("requests.post")
def test_from_tool_pack_specific_tools(mock_post, mock_tool_pack_response):
"""Test creating specific tools from a Tool Pack."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tools = MergeAgentHandlerTool.from_tool_pack(
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_names=["linear__create_issue"],
)
assert len(tools) == 1
assert tools[0].name == "linear__create_issue"
@patch("requests.post")
def test_from_tool_pack_with_custom_base_url(mock_post, mock_tool_pack_response):
"""Test creating tools from Tool Pack with custom base URL."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tools = MergeAgentHandlerTool.from_tool_pack(
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
base_url="http://localhost:8000",
)
assert len(tools) == 2
assert all(tool.base_url == "http://localhost:8000" for tool in tools)
@patch("requests.post")
def test_tool_execution_with_text_response(mock_post):
"""Test tool execution with plain text response."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"jsonrpc": "2.0",
"id": "test-id",
"result": {"content": [{"type": "text", "text": "Plain text result"}]},
}
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
result = tool._run(title="Test")
assert result == "Plain text result"
@patch("requests.post")
def test_mcp_request_builds_correct_url(mock_post, mock_tool_pack_response):
"""Test that MCP request builds correct URL."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-123",
registered_user_id="user-456",
tool_name="linear__create_issue",
base_url="https://ah-api.merge.dev",
)
tool._make_mcp_request(method="tools/list")
expected_url = (
"https://ah-api.merge.dev/api/v1/tool-packs/"
"test-pack-123/registered-users/user-456/mcp"
)
mock_post.assert_called_once()
assert mock_post.call_args[0][0] == expected_url
@patch("requests.post")
def test_mcp_request_includes_correct_headers(mock_post, mock_tool_pack_response):
"""Test that MCP request includes correct headers."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_tool_pack_response
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__create_issue",
)
tool._make_mcp_request(method="tools/list")
mock_post.assert_called_once()
headers = mock_post.call_args.kwargs["headers"]
assert headers["Content-Type"] == "application/json"
assert headers["Authorization"] == "Bearer test_key"
assert "Mcp-Session-Id" in headers
@patch("requests.post")
def test_tool_parameters_are_passed_in_request(mock_post):
"""Test that tool parameters are correctly included in the MCP request."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"jsonrpc": "2.0",
"id": "test-id",
"result": {"content": [{"type": "text", "text": '{"success": true}'}]},
}
mock_post.return_value = mock_response
tool = MergeAgentHandlerTool(
name="test_tool",
description="Test tool",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
tool_name="linear__update_issue",
)
# Execute tool with specific parameters
tool._run(id="issue-123", title="New Title", priority=1)
# Verify the request was made
mock_post.assert_called_once()
# Get the JSON payload that was sent
payload = mock_post.call_args.kwargs["json"]
# Verify MCP structure
assert payload["jsonrpc"] == "2.0"
assert payload["method"] == "tools/call"
assert "id" in payload
# Verify parameters are in the request
assert "params" in payload
assert payload["params"]["name"] == "linear__update_issue"
assert "arguments" in payload["params"]
# Verify the actual arguments were passed
arguments = payload["params"]["arguments"]
assert arguments["id"] == "issue-123"
assert arguments["title"] == "New Title"
assert arguments["priority"] == 1
@patch("requests.post")
def test_tool_run_method_passes_parameters(mock_post, mock_tool_pack_response):
"""Test that parameters are passed when using the .run() method (how CrewAI calls it)."""
# Mock the tools/list response
mock_response = Mock()
mock_response.status_code = 200
# First call: tools/list
# Second call: tools/call
mock_response.json.side_effect = [
mock_tool_pack_response, # tools/list response
{
"jsonrpc": "2.0",
"id": "test-id",
"result": {"content": [{"type": "text", "text": '{"success": true, "id": "issue-123"}'}]},
}, # tools/call response
]
mock_post.return_value = mock_response
# Create tool using from_tool_name (which fetches schema)
tool = MergeAgentHandlerTool.from_tool_name(
tool_name="linear__create_issue",
tool_pack_id="test-pack-id",
registered_user_id="test-user-id",
)
# Call using .run() method (this is how CrewAI invokes tools)
result = tool.run(title="Test Issue", description="Test description", priority=2)
# Verify two calls were made: tools/list and tools/call
assert mock_post.call_count == 2
# Get the second call (tools/call)
second_call = mock_post.call_args_list[1]
payload = second_call.kwargs["json"]
# Verify it's a tools/call request
assert payload["method"] == "tools/call"
assert payload["params"]["name"] == "linear__create_issue"
# Verify parameters were passed
arguments = payload["params"]["arguments"]
assert arguments["title"] == "Test Issue"
assert arguments["description"] == "Test description"
assert arguments["priority"] == 2
# Verify result was returned
assert result["success"] is True
assert result["id"] == "issue-123"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -13,7 +13,7 @@ load_result = load_dotenv(override=True)
@pytest.fixture(autouse=True)
def setup_test_environment():
"""Set up test environment with a temporary directory for SQLite storage."""
with tempfile.TemporaryDirectory() as temp_dir:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
# Create the directory with proper permissions
storage_dir = Path(temp_dir) / "crewai_test_storage"
storage_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -144,9 +144,8 @@ class TestAgentEvaluator:
mock_crew.tasks.append(task)
events = {}
started_event = threading.Event()
completed_event = threading.Event()
task_completed_event = threading.Event()
results_condition = threading.Condition()
results_ready = False
agent_evaluator = AgentEvaluator(
agents=[agent], evaluators=[GoalAlignmentEvaluator()]
@@ -156,13 +155,11 @@ class TestAgentEvaluator:
async def capture_started(source, event):
if event.agent_id == str(agent.id):
events["started"] = event
started_event.set()
@crewai_event_bus.on(AgentEvaluationCompletedEvent)
async def capture_completed(source, event):
if event.agent_id == str(agent.id):
events["completed"] = event
completed_event.set()
@crewai_event_bus.on(AgentEvaluationFailedEvent)
def capture_failed(source, event):
@@ -170,17 +167,20 @@ class TestAgentEvaluator:
@crewai_event_bus.on(TaskCompletedEvent)
async def on_task_completed(source, event):
# TaskCompletedEvent fires AFTER evaluation results are stored
nonlocal results_ready
if event.task and event.task.id == task.id:
task_completed_event.set()
while not agent_evaluator.get_evaluation_results().get(agent.role):
pass
with results_condition:
results_ready = True
results_condition.notify()
mock_crew.kickoff()
assert started_event.wait(timeout=5), "Timeout waiting for started event"
assert completed_event.wait(timeout=5), "Timeout waiting for completed event"
assert task_completed_event.wait(timeout=5), (
"Timeout waiting for task completion"
)
with results_condition:
assert results_condition.wait_for(
lambda: results_ready, timeout=5
), "Timeout waiting for evaluation results"
assert events.keys() == {"started", "completed"}
assert events["started"].agent_id == str(agent.id)

View File

@@ -647,6 +647,7 @@ def test_handle_streaming_tool_calls_no_tools(mock_emit):
@pytest.mark.vcr(filter_headers=["authorization"])
@pytest.mark.skip(reason="Highly flaky on ci")
def test_llm_call_when_stop_is_unsupported(caplog):
llm = LLM(model="o1-mini", stop=["stop"], is_litellm=True)
with caplog.at_level(logging.INFO):
@@ -657,6 +658,7 @@ def test_llm_call_when_stop_is_unsupported(caplog):
@pytest.mark.vcr(filter_headers=["authorization"])
@pytest.mark.skip(reason="Highly flaky on ci")
def test_llm_call_when_stop_is_unsupported_when_additional_drop_params_is_provided(
caplog,
):
@@ -664,7 +666,6 @@ def test_llm_call_when_stop_is_unsupported_when_additional_drop_params_is_provid
model="o1-mini",
stop=["stop"],
additional_drop_params=["another_param"],
is_litellm=True,
)
with caplog.at_level(logging.INFO):
result = llm.call("What is the capital of France?")

View File

@@ -273,12 +273,15 @@ def another_simple_tool():
def test_internal_crew_with_mcp():
from crewai_tools import MCPServerAdapter
from crewai_tools.adapters.mcp_adapter import ToolCollection
from crewai_tools.adapters.tool_collection import ToolCollection
mock = Mock(spec=MCPServerAdapter)
mock.tools = ToolCollection([simple_tool, another_simple_tool])
with patch("crewai_tools.MCPServerAdapter", return_value=mock) as adapter_mock:
mock_adapter = Mock()
mock_adapter.tools = ToolCollection([simple_tool, another_simple_tool])
with (
patch("crewai_tools.MCPServerAdapter", return_value=mock_adapter) as adapter_mock,
patch("crewai.llm.LLM.__new__", return_value=Mock()),
):
crew = InternalCrewWithMCP()
assert crew.reporting_analyst().tools == [simple_tool, another_simple_tool]
assert crew.researcher().tools == [simple_tool]