From 0439ea74e53f89b1987a05b7b37bb193ef3f3fa8 Mon Sep 17 00:00:00 2001 From: Ming Du Date: Wed, 23 Jul 2025 20:02:53 -0500 Subject: [PATCH 1/4] FEAT: MCP server wrapper --- docs/mcp_server_guide.md | 300 ++++++++++++++++++++++++++++ examples/mcp_calculator_server.py | 132 ++++++++++++ src/eaa/mcp/__init__.py | 14 ++ src/eaa/mcp/server.py | 271 +++++++++++++++++++++++++ src/eaa/tools/example_calculator.py | 182 +++++++++++++++++ tests/test_mcp_server.py | 201 +++++++++++++++++++ 6 files changed, 1100 insertions(+) create mode 100644 docs/mcp_server_guide.md create mode 100644 examples/mcp_calculator_server.py create mode 100644 src/eaa/mcp/__init__.py create mode 100644 src/eaa/mcp/server.py create mode 100644 src/eaa/tools/example_calculator.py create mode 100644 tests/test_mcp_server.py diff --git a/docs/mcp_server_guide.md b/docs/mcp_server_guide.md new file mode 100644 index 0000000..b2ddd5c --- /dev/null +++ b/docs/mcp_server_guide.md @@ -0,0 +1,300 @@ +# MCP Server for BaseTool Integration + +This guide explains how to use the MCP (Model Context Protocol) server component to expose any `BaseTool` subclass as MCP tools for use by AI applications like Cursor. + +## Overview + +The MCP server component automatically converts `BaseTool` subclasses into MCP-compatible tools, allowing AI applications to call the tool methods directly through the standardized MCP protocol. + +## Key Components + +### MCPToolServer + +The main class that creates and manages an MCP server: + +```python +from eaa.mcp import MCPToolServer +from eaa.tools.example_calculator import CalculatorTool + +# Create a calculator tool +calculator = CalculatorTool() + +# Create and configure the MCP server +server = MCPToolServer(name="My Calculator Server") +server.register_tools(calculator) + +# Run the server +server.run() +``` + +### Convenience Functions + +For quick setup, use the convenience functions: + +```python +from eaa.mcp import run_mcp_server_from_tools +from eaa.tools.example_calculator import CalculatorTool + +# Create and run server in one step +calculator = CalculatorTool() +run_mcp_server_from_tools( + tools=calculator, + server_name="Calculator MCP Server" +) +``` + +## Creating Compatible BaseTool Subclasses + +To create a `BaseTool` that works with the MCP server: + +1. **Inherit from BaseTool** +2. **Define exposed_tools** - List the methods you want to expose +3. **Use proper type annotations** - Required for schema generation +4. **Add docstrings** - Used as tool descriptions + +### Example BaseTool Implementation + +```python +from typing import Dict, List, Any +from eaa.tools.base import BaseTool, ToolReturnType, check + +class MyTool(BaseTool): + name: str = "my_tool" + + @check + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.exposed_tools: List[Dict[str, Any]] = [ + { + "name": "calculate_something", + "function": self.calculate_something, + "return_type": ToolReturnType.NUMBER + }, + { + "name": "get_status", + "function": self.get_status, + "return_type": ToolReturnType.TEXT + } + ] + + def calculate_something(self, x: float, y: float) -> float: + """ + Calculate something with two numbers. + + Parameters + ---------- + x : float + First number + y : float + Second number + + Returns + ------- + float + The result of the calculation + """ + return x * y + 42 + + def get_status(self) -> str: + """ + Get the current status of the tool. + + Returns + ------- + str + Status message + """ + return "Tool is ready" +``` + +## Return Types + +The MCP server handles different return types automatically: + +- `ToolReturnType.TEXT` - Returns as string +- `ToolReturnType.NUMBER` - Returns as float +- `ToolReturnType.BOOL` - Returns as boolean +- `ToolReturnType.LIST` - Returns as list +- `ToolReturnType.DICT` - Returns as dictionary +- `ToolReturnType.IMAGE_PATH` - Returns path as string +- `ToolReturnType.EXCEPTION` - Handled as error + +## Running the MCP Server + +### Basic Usage + +```python +from eaa.tools.example_calculator import CalculatorTool +from eaa.mcp import run_mcp_server_from_tools + +# Create tool instance +calculator = CalculatorTool() + +# Run server +run_mcp_server_from_tools( + tools=calculator, + server_name="Calculator Server" +) +``` + +### Multiple Tools + +```python +from eaa.mcp import MCPToolServer + +# Create multiple tools +tool1 = MyTool1() +tool2 = MyTool2() + +# Create server with multiple tools +server = MCPToolServer("Multi-Tool Server") +server.register_tools([tool1, tool2]) +server.run() +``` + +### Advanced Configuration + +```python +from eaa.mcp import create_mcp_server_from_tools + +# Create server without running +server = create_mcp_server_from_tools( + tools=[tool1, tool2], + server_name="Advanced Server" +) + +# Inspect tool schemas +schemas = server.get_tool_schemas() +print(f"Registered {len(schemas)} tools:") +for schema in schemas: + print(f" - {schema['function']['name']}") + +# List tool names +tools = server.list_tools() +print(f"Available tools: {', '.join(tools)}") + +# Run with custom options +server.run(debug=True) +``` + +## Connecting to MCP Clients + +### Cursor IDE + +1. Create your MCP server script +2. Add MCP configuration to Cursor settings +3. Reference your server script in the configuration + +Example Cursor MCP configuration: + +```json +{ + "mcpServers": { + "calculator": { + "command": "python", + "args": ["path/to/your/mcp_server.py"] + } + } +} +``` + +### Other MCP Clients + +The server follows the standard MCP protocol and should work with any compatible client. + +## Example: Calculator MCP Server + +See `examples/mcp_calculator_server.py` for a complete working example: + +```bash +# Run the calculator server +python examples/mcp_calculator_server.py +``` + +This exposes the following tools: +- `add(a, b)` - Add two numbers +- `subtract(a, b)` - Subtract two numbers +- `multiply(a, b)` - Multiply two numbers +- `divide(a, b)` - Divide two numbers +- `get_history()` - Get calculation history +- `clear_history()` - Clear calculation history + +## Error Handling + +The MCP server automatically handles errors: + +- **Missing parameters** - Returns error message +- **Type conversion errors** - Attempts conversion, falls back to string +- **Tool execution errors** - Catches exceptions and returns error message +- **Duplicate tool names** - Raises ValueError during registration + +## Schema Generation + +Tool schemas are automatically generated from: + +- **Function signatures** - Parameter names and types +- **Type annotations** - Converted to JSON schema types +- **Docstrings** - Used as tool descriptions +- **Default values** - Handled for optional parameters + +## Best Practices + +1. **Use descriptive docstrings** - They become tool descriptions +2. **Add proper type annotations** - Required for schema generation +3. **Handle errors gracefully** - Use try/catch in tool methods +4. **Avoid naming conflicts** - Ensure unique tool names across all tools +5. **Keep tools focused** - One responsibility per tool method +6. **Test tools independently** - Before exposing via MCP + +## Troubleshooting + +### Common Issues + +**ImportError: mcp package not found** +```bash +pip install mcp +``` + +**Tool not appearing in client** +- Check that `exposed_tools` is properly defined +- Verify type annotations are present +- Ensure docstrings are added + +**Naming conflicts** +- Make sure tool names are unique across all registered tools +- Consider namespacing for similar tools + +**Parameter errors** +- Verify type annotations match expected types +- Check that required parameters are properly marked + +### Debugging + +Enable debug logging: + +```python +import logging +logging.basicConfig(level=logging.DEBUG) + +# Your MCP server code here +``` + +Inspect generated schemas: + +```python +server = create_mcp_server_from_tools(tools) +schemas = server.get_tool_schemas() +import json +print(json.dumps(schemas, indent=2)) +``` + +## Dependencies + +Required packages: +- `mcp` - MCP protocol implementation +- Standard Python packages (inspect, json, logging, typing) + +## License + +This component follows the same license as the EAA project. \ No newline at end of file diff --git a/examples/mcp_calculator_server.py b/examples/mcp_calculator_server.py new file mode 100644 index 0000000..b1d994f --- /dev/null +++ b/examples/mcp_calculator_server.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +Example MCP Server using CalculatorTool. + +This script demonstrates how to create and run an MCP server that exposes +BaseTool methods as MCP tools for use by AI applications like Cursor. + +Usage: + python examples/mcp_calculator_server.py + +The server will expose the calculator tool methods (add, subtract, multiply, +divide, get_history, clear_history) as MCP tools. +""" + +import sys +import logging +from pathlib import Path + +# Add the src directory to the Python path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from eaa.tools.example_calculator import CalculatorTool +from eaa.mcp import run_mcp_server_from_tools + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + + +def main(): + """Main function to run the MCP calculator server.""" + try: + # Create the calculator tool + calculator = CalculatorTool() + + logger.info("Created calculator tool with the following methods:") + for tool_dict in calculator.exposed_tools: + logger.info(f" - {tool_dict['name']}: {tool_dict['function'].__doc__.split('.')[0] if tool_dict['function'].__doc__ else 'No description'}") + + # Create and run the MCP server + logger.info("Starting MCP server...") + run_mcp_server_from_tools( + tools=calculator, + server_name="Calculator MCP Server" + ) + + except KeyboardInterrupt: + logger.info("Server stopped by user") + except Exception as e: + logger.error(f"Error running MCP server: {e}") + raise + + +def create_server_only(): + """ + Example function showing how to create the server without running it. + + This can be useful for testing or when you want more control over + the server lifecycle. + """ + from eaa.mcp import create_mcp_server_from_tools + + # Create the calculator tool + calculator = CalculatorTool() + + # Create the MCP server (but don't run it yet) + server = create_mcp_server_from_tools( + tools=calculator, + server_name="Calculator MCP Server" + ) + + # Get tool schemas (useful for debugging) + schemas = server.get_tool_schemas() + logger.info(f"Created server with {len(schemas)} tool schemas:") + for schema in schemas: + logger.info(f" - {schema['function']['name']}: {schema['function']['description']}") + + # List available tools + tools = server.list_tools() + logger.info(f"Available tools: {', '.join(tools)}") + + return server + + +def demonstrate_multiple_tools(): + """ + Example showing how to create an MCP server with multiple BaseTool instances. + """ + from eaa.mcp import MCPToolServer + + # Create multiple tool instances + calculator1 = CalculatorTool() + calculator2 = CalculatorTool() # Different instance with separate history + + # Create server and register tools + server = MCPToolServer(name="Multi-Calculator MCP Server") + + # Note: This would cause naming conflicts since both tools have the same method names + # In practice, you'd want tools with different method names or use namespacing + try: + server.register_tools([calculator1, calculator2]) + except ValueError as e: + logger.warning(f"Expected naming conflict: {e}") + logger.info("In practice, use tools with different method names or implement namespacing") + + return server + + +if __name__ == "__main__": + print(""" + Calculator MCP Server Example + ============================ + + This server exposes calculator operations as MCP tools: + - add(a, b): Add two numbers + - subtract(a, b): Subtract two numbers + - multiply(a, b): Multiply two numbers + - divide(a, b): Divide two numbers + - get_history(): Get calculation history + - clear_history(): Clear calculation history + + Connect this server to your MCP client (like Cursor) to use these tools + in AI conversations. + + Press Ctrl+C to stop the server. + """) + + main() \ No newline at end of file diff --git a/src/eaa/mcp/__init__.py b/src/eaa/mcp/__init__.py new file mode 100644 index 0000000..4aab2b5 --- /dev/null +++ b/src/eaa/mcp/__init__.py @@ -0,0 +1,14 @@ +""" +MCP (Model Context Protocol) components for EAA. + +This package provides functionality to expose BaseTool subclasses as MCP servers, +allowing them to be used by MCP clients like Cursor or other AI applications. +""" + +from .server import MCPToolServer, create_mcp_server_from_tools, run_mcp_server_from_tools + +__all__ = [ + "MCPToolServer", + "create_mcp_server_from_tools", + "run_mcp_server_from_tools" +] \ No newline at end of file diff --git a/src/eaa/mcp/server.py b/src/eaa/mcp/server.py new file mode 100644 index 0000000..5cd780a --- /dev/null +++ b/src/eaa/mcp/server.py @@ -0,0 +1,271 @@ +""" +MCP Server component for exposing BaseTool subclasses as MCP tools. + +This module provides functionality to create and run MCP servers that expose +methods from BaseTool subclasses as standardized MCP tools. +""" + +import inspect +import json +import logging +from typing import Any, Dict, List, Optional, Union, Callable +import asyncio + +try: + from mcp.server.fastmcp import FastMCP +except ImportError: + raise ImportError( + "The 'mcp' package is required to use the MCP server. " + "Install it with: pip install mcp" + ) + +from eaa.tools.base import BaseTool, ToolReturnType +from eaa.agents.base import generate_openai_tool_schema + +logger = logging.getLogger(__name__) + + +class MCPToolServer: + """ + An MCP server that exposes BaseTool methods as MCP tools. + + This class creates an MCP server that automatically converts BaseTool + subclasses into MCP-compatible tools, allowing them to be used by + MCP clients like Cursor or other AI applications. + """ + + def __init__( + self, + name: str = "BaseTool MCP Server", + tools: Optional[List[BaseTool]] = None + ): + """ + Initialize the MCP Tool Server. + + Parameters + ---------- + name : str, optional + The name of the MCP server. + tools : List[BaseTool], optional + List of BaseTool instances to expose via MCP. + """ + self.name = name + self.tools: List[BaseTool] = tools or [] + self.mcp_server = FastMCP(name) + self._tool_instances: Dict[str, BaseTool] = {} + self._registered_tools: Dict[str, Dict[str, Any]] = {} + + # Register tools if provided + if self.tools: + self.register_tools(self.tools) + + def register_tools(self, tools: Union[BaseTool, List[BaseTool]]) -> None: + """ + Register BaseTool instances with the MCP server. + + Parameters + ---------- + tools : Union[BaseTool, List[BaseTool]] + BaseTool instance(s) to register. + """ + if not isinstance(tools, (list, tuple)): + tools = [tools] + + for tool in tools: + if not isinstance(tool, BaseTool): + raise ValueError(f"Tool must be a BaseTool instance, got {type(tool)}") + + if not hasattr(tool, "exposed_tools") or not tool.exposed_tools: + raise ValueError( + f"BaseTool {tool.__class__.__name__} must have non-empty " + "`exposed_tools` attribute" + ) + + self._register_tool_instance(tool) + + def _register_tool_instance(self, tool: BaseTool) -> None: + """ + Register a single BaseTool instance. + + Parameters + ---------- + tool : BaseTool + The BaseTool instance to register. + """ + for tool_dict in tool.exposed_tools: + tool_name = tool_dict["name"] + tool_function = tool_dict["function"] + return_type = tool_dict["return_type"] + + if tool_name in self._registered_tools: + raise ValueError(f"Tool '{tool_name}' is already registered") + + # Store the tool instance for later method calls + self._tool_instances[tool_name] = tool + self._registered_tools[tool_name] = tool_dict + + # Create the MCP tool wrapper + self._create_mcp_tool(tool_name, tool_function, return_type) + + def _create_mcp_tool( + self, + tool_name: str, + tool_function: Callable, + return_type: ToolReturnType + ) -> None: + """ + Create an MCP tool from a BaseTool method. + + Parameters + ---------- + tool_name : str + Name of the tool. + tool_function : Callable + The callable method from the BaseTool. + return_type : ToolReturnType + Expected return type of the tool. + """ + # Get function signature for parameter validation + sig = inspect.signature(tool_function) + + # Create wrapper function that handles tool execution + def mcp_tool_wrapper(**kwargs): + """Wrapper function for MCP tool execution.""" + try: + # Filter kwargs to match function signature + filtered_kwargs = {} + for param_name, param in sig.parameters.items(): + if param_name in kwargs: + filtered_kwargs[param_name] = kwargs[param_name] + elif param.default == inspect.Parameter.empty: + raise ValueError(f"Required parameter '{param_name}' is missing") + + # Execute the tool function + result = tool_function(**filtered_kwargs) + + # Handle different return types + if return_type == ToolReturnType.TEXT: + return str(result) + elif return_type == ToolReturnType.IMAGE_PATH: + return str(result) + elif return_type == ToolReturnType.NUMBER: + return float(result) if result is not None else None + elif return_type == ToolReturnType.BOOL: + return bool(result) + elif return_type in [ToolReturnType.LIST, ToolReturnType.DICT]: + return result + else: + return str(result) + + except Exception as e: + logger.error(f"Error executing tool '{tool_name}': {str(e)}") + return f"Error: {str(e)}" + + # Set the docstring from the original function + mcp_tool_wrapper.__doc__ = tool_function.__doc__ or f"Execute {tool_name}" + + # Set the function name to match the tool name for proper registration + mcp_tool_wrapper.__name__ = tool_name + + # Register the tool with the MCP server using the decorator + decorated_wrapper = self.mcp_server.tool()(mcp_tool_wrapper) + + logger.info(f"Registered MCP tool: {tool_name}") + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + """ + Get OpenAI-compatible tool schemas for all registered tools. + + Returns + ------- + List[Dict[str, Any]] + List of tool schemas. + """ + schemas = [] + for tool_name, tool_dict in self._registered_tools.items(): + schema = generate_openai_tool_schema(tool_name, tool_dict["function"]) + schemas.append(schema) + return schemas + + def list_tools(self) -> List[str]: + """ + List all registered tool names. + + Returns + ------- + List[str] + List of tool names. + """ + return list(self._registered_tools.keys()) + + def get_server(self) -> FastMCP: + """ + Get the underlying FastMCP server instance. + + Returns + ------- + FastMCP + The FastMCP server instance. + """ + return self.mcp_server + + def run(self, **kwargs) -> None: + """ + Run the MCP server. + + Parameters + ---------- + **kwargs + Additional arguments passed to the FastMCP server. + """ + logger.info(f"Starting MCP server '{self.name}' with {len(self._registered_tools)} tools") + for tool_name in self.list_tools(): + logger.info(f" - {tool_name}") + + # Run the server + self.mcp_server.run(**kwargs) + + +def create_mcp_server_from_tools( + tools: Union[BaseTool, List[BaseTool]], + server_name: str = "BaseTool MCP Server" +) -> MCPToolServer: + """ + Convenience function to create an MCP server from BaseTool instances. + + Parameters + ---------- + tools : Union[BaseTool, List[BaseTool]] + BaseTool instance(s) to expose via MCP. + server_name : str, optional + Name of the MCP server. + + Returns + ------- + MCPToolServer + Configured MCP server ready to run. + """ + server = MCPToolServer(name=server_name) + server.register_tools(tools) + return server + + +def run_mcp_server_from_tools( + tools: Union[BaseTool, List[BaseTool]], + server_name: str = "BaseTool MCP Server", + **server_kwargs +) -> None: + """ + Create and run an MCP server from BaseTool instances. + + Parameters + ---------- + tools : Union[BaseTool, List[BaseTool]] + BaseTool instance(s) to expose via MCP. + server_name : str, optional + Name of the MCP server. + **server_kwargs + Additional arguments passed to the server run method. + """ + server = create_mcp_server_from_tools(tools, server_name) + server.run(**server_kwargs) diff --git a/src/eaa/tools/example_calculator.py b/src/eaa/tools/example_calculator.py new file mode 100644 index 0000000..28c8e35 --- /dev/null +++ b/src/eaa/tools/example_calculator.py @@ -0,0 +1,182 @@ +""" +Example Calculator Tool for demonstrating MCP server functionality. + +This module provides a simple calculator tool that can be exposed via MCP. +""" + +from typing import Dict, List, Any +import logging + +from eaa.tools.base import BaseTool, ToolReturnType, check + +logger = logging.getLogger(__name__) + + +class CalculatorTool(BaseTool): + """ + A simple calculator tool for basic arithmetic operations. + + This tool demonstrates how to create a BaseTool that can be exposed + via MCP server for use by AI applications. + """ + + name: str = "calculator" + + @check + def __init__(self, *args, **kwargs): + """Initialize the calculator tool.""" + super().__init__(*args, **kwargs) + + self.calculation_history: List[str] = [] + + self.exposed_tools: List[Dict[str, Any]] = [ + { + "name": "add", + "function": self.add, + "return_type": ToolReturnType.NUMBER + }, + { + "name": "subtract", + "function": self.subtract, + "return_type": ToolReturnType.NUMBER + }, + { + "name": "multiply", + "function": self.multiply, + "return_type": ToolReturnType.NUMBER + }, + { + "name": "divide", + "function": self.divide, + "return_type": ToolReturnType.NUMBER + }, + { + "name": "get_history", + "function": self.get_history, + "return_type": ToolReturnType.LIST + }, + { + "name": "clear_history", + "function": self.clear_history, + "return_type": ToolReturnType.TEXT + } + ] + + def add(self, a: float, b: float) -> float: + """ + Add two numbers together. + + Parameters + ---------- + a : float + The first number. + b : float + The second number. + + Returns + ------- + float + The sum of a and b. + """ + result = a + b + self.calculation_history.append(f"{a} + {b} = {result}") + logger.info(f"Added {a} + {b} = {result}") + return result + + def subtract(self, a: float, b: float) -> float: + """ + Subtract the second number from the first. + + Parameters + ---------- + a : float + The first number (minuend). + b : float + The second number (subtrahend). + + Returns + ------- + float + The difference of a and b. + """ + result = a - b + self.calculation_history.append(f"{a} - {b} = {result}") + logger.info(f"Subtracted {a} - {b} = {result}") + return result + + def multiply(self, a: float, b: float) -> float: + """ + Multiply two numbers together. + + Parameters + ---------- + a : float + The first number. + b : float + The second number. + + Returns + ------- + float + The product of a and b. + """ + result = a * b + self.calculation_history.append(f"{a} * {b} = {result}") + logger.info(f"Multiplied {a} * {b} = {result}") + return result + + def divide(self, a: float, b: float) -> float: + """ + Divide the first number by the second. + + Parameters + ---------- + a : float + The dividend. + b : float + The divisor. + + Returns + ------- + float + The quotient of a and b. + + Raises + ------ + ValueError + If b is zero (division by zero). + """ + if b == 0: + raise ValueError("Cannot divide by zero") + + result = a / b + self.calculation_history.append(f"{a} / {b} = {result}") + logger.info(f"Divided {a} / {b} = {result}") + return result + + def get_history(self) -> List[str]: + """ + Get the calculation history. + + Returns + ------- + List[str] + List of all calculations performed. + """ + logger.info(f"Retrieved calculation history with {len(self.calculation_history)} entries") + return self.calculation_history.copy() + + def clear_history(self) -> str: + """ + Clear the calculation history. + + Returns + ------- + str + Confirmation message. + """ + count = len(self.calculation_history) + self.calculation_history.clear() + message = f"Cleared {count} calculations from history" + logger.info(message) + return message \ No newline at end of file diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..16e2776 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Test script for MCP server functionality. + +This script tests the MCPToolServer component with the example calculator tool +to ensure proper tool registration and schema generation. +""" + +import sys +import logging +from pathlib import Path + +# Add the src directory to the Python path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from eaa.tools.example_calculator import CalculatorTool +from eaa.mcp import MCPToolServer, create_mcp_server_from_tools + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + + +def test_tool_creation(): + """Test that the calculator tool is created correctly.""" + logger.info("Testing tool creation...") + + calculator = CalculatorTool() + + # Check that exposed_tools is properly set + assert hasattr(calculator, "exposed_tools"), "Calculator should have exposed_tools attribute" + assert len(calculator.exposed_tools) > 0, "Calculator should have at least one exposed tool" + + # Check expected tools + tool_names = [tool["name"] for tool in calculator.exposed_tools] + expected_tools = ["add", "subtract", "multiply", "divide", "get_history", "clear_history"] + + for expected_tool in expected_tools: + assert expected_tool in tool_names, f"Expected tool '{expected_tool}' not found" + + logger.info(f"✓ Calculator tool created successfully with {len(tool_names)} tools") + return calculator + + +def test_server_creation(): + """Test that the MCP server can be created.""" + logger.info("Testing server creation...") + + calculator = CalculatorTool() + server = MCPToolServer(name="Test Calculator Server") + + # Register tools + server.register_tools(calculator) + + # Check that tools are registered + registered_tools = server.list_tools() + assert len(registered_tools) == len(calculator.exposed_tools), "All tools should be registered" + + logger.info(f"✓ Server created successfully with {len(registered_tools)} registered tools") + return server + + +def test_tool_schemas(): + """Test that tool schemas are generated correctly.""" + logger.info("Testing tool schema generation...") + + calculator = CalculatorTool() + server = create_mcp_server_from_tools(calculator, "Schema Test Server") + + # Get schemas + schemas = server.get_tool_schemas() + + # Check schema structure + assert len(schemas) > 0, "Should have at least one schema" + + for schema in schemas: + # Check basic schema structure + assert "type" in schema, "Schema should have 'type' field" + assert "function" in schema, "Schema should have 'function' field" + assert schema["type"] == "function", "Schema type should be 'function'" + + func_def = schema["function"] + assert "name" in func_def, "Function should have 'name' field" + assert "description" in func_def, "Function should have 'description' field" + assert "parameters" in func_def, "Function should have 'parameters' field" + + params = func_def["parameters"] + assert "type" in params, "Parameters should have 'type' field" + assert "properties" in params, "Parameters should have 'properties' field" + assert params["type"] == "object", "Parameters type should be 'object'" + + logger.info(f"✓ Generated {len(schemas)} valid tool schemas") + + # Print schema details for debugging + for schema in schemas: + func_name = schema["function"]["name"] + func_desc = schema["function"]["description"] + logger.info(f" - {func_name}: {func_desc[:50]}...") + + return schemas + + +def test_tool_execution(): + """Test that tools can be executed correctly.""" + logger.info("Testing tool execution...") + + calculator = CalculatorTool() + + # Test basic arithmetic + result = calculator.add(5, 3) + assert result == 8, f"Expected 8, got {result}" + + result = calculator.subtract(10, 4) + assert result == 6, f"Expected 6, got {result}" + + result = calculator.multiply(3, 4) + assert result == 12, f"Expected 12, got {result}" + + result = calculator.divide(15, 3) + assert result == 5, f"Expected 5, got {result}" + + # Test history + history = calculator.get_history() + assert len(history) == 4, f"Expected 4 history entries, got {len(history)}" + + # Test clear history + clear_msg = calculator.clear_history() + assert "Cleared" in clear_msg, "Clear message should contain 'Cleared'" + + history = calculator.get_history() + assert len(history) == 0, f"Expected empty history, got {len(history)}" + + # Test error handling + try: + calculator.divide(5, 0) + assert False, "Division by zero should raise an error" + except ValueError as e: + assert "Cannot divide by zero" in str(e), "Expected division by zero error" + + logger.info("✓ All tool executions completed successfully") + + +def test_multiple_tools(): + """Test server with multiple tool instances.""" + logger.info("Testing multiple tool instances...") + + calc1 = CalculatorTool() + calc2 = CalculatorTool() # Different instance + + # This should fail due to naming conflicts + server = MCPToolServer(name="Multi-Tool Test") + try: + server.register_tools([calc1, calc2]) + assert False, "Should have failed due to naming conflicts" + except ValueError as e: + assert "already registered" in str(e), "Expected naming conflict error" + logger.info("✓ Correctly detected naming conflicts") + + +def run_all_tests(): + """Run all tests.""" + logger.info("Starting MCP server tests...") + + try: + # Test individual components + calculator = test_tool_creation() + server = test_server_creation() + schemas = test_tool_schemas() + test_tool_execution() + test_multiple_tools() + + logger.info("=" * 50) + logger.info("✓ All tests passed successfully!") + logger.info("=" * 50) + + # Print summary + logger.info("Test Summary:") + logger.info(f" - Tool creation: ✓") + logger.info(f" - Server creation: ✓") + logger.info(f" - Schema generation: ✓ ({len(schemas)} schemas)") + logger.info(f" - Tool execution: ✓") + logger.info(f" - Error handling: ✓") + + return True + + except Exception as e: + logger.error(f"✗ Test failed: {e}") + raise + + +if __name__ == "__main__": + success = run_all_tests() + if success: + print("\n🎉 All tests passed! The MCP server component is working correctly.") + else: + print("\n❌ Some tests failed. Check the logs above.") + sys.exit(1) \ No newline at end of file From f91811fa270c07e0b262ef722f3b382ac17d6d50 Mon Sep 17 00:00:00 2001 From: Ming Du Date: Tue, 5 Aug 2025 11:43:53 -0500 Subject: [PATCH 2/4] CHORE: simplify MCP server wrapper --- README.md | 28 +++ docs/mcp_server_guide.md | 300 ------------------------------ examples/mcp_calculator_server.py | 57 +----- src/eaa/mcp/__init__.py | 7 - src/eaa/mcp/server.py | 96 ++-------- tests/test_mcp_server.py | 10 +- 6 files changed, 51 insertions(+), 447 deletions(-) delete mode 100644 docs/mcp_server_guide.md diff --git a/README.md b/README.md index 0104656..4c3a6fd 100644 --- a/README.md +++ b/README.md @@ -132,3 +132,31 @@ Launch the webUI using ``` chainlit run start_webui.py ``` + +## MCP tool wrapper + +EAA's MCP tool wrapper allows you to convert any tools that are subclasses of +`BaseTool` into an MCP tool and launch an MCP server offering these tools. +This allows you to use the tools in EAA with other MCP clients such as +Claude Code and Gemini CLI. + +We will illustrate how an MCP server can be set up using a simple example. A +calculator tool, subclassing `BaseTool`, is created in +`src/eaa/tools/example_calculator.py`. To turn it into an MCP server, we +use `eaa.mcp.un_mcp_server_from_tools`. See `examples/mcp_calculator_server.py` +for an example. + +After the server script is created, add it to the config JSON of your MCP client. +Refer to the documentations of the client on where this config file is located. +```json +{ + "mcpServers": { + "calculator": { + "command": "python", + "args": ["path/to/mcp_calculator_server.py"] + } + } +} +``` +Now the MCP client should be able to run and connect to the MCP server and use the +tool. \ No newline at end of file diff --git a/docs/mcp_server_guide.md b/docs/mcp_server_guide.md deleted file mode 100644 index b2ddd5c..0000000 --- a/docs/mcp_server_guide.md +++ /dev/null @@ -1,300 +0,0 @@ -# MCP Server for BaseTool Integration - -This guide explains how to use the MCP (Model Context Protocol) server component to expose any `BaseTool` subclass as MCP tools for use by AI applications like Cursor. - -## Overview - -The MCP server component automatically converts `BaseTool` subclasses into MCP-compatible tools, allowing AI applications to call the tool methods directly through the standardized MCP protocol. - -## Key Components - -### MCPToolServer - -The main class that creates and manages an MCP server: - -```python -from eaa.mcp import MCPToolServer -from eaa.tools.example_calculator import CalculatorTool - -# Create a calculator tool -calculator = CalculatorTool() - -# Create and configure the MCP server -server = MCPToolServer(name="My Calculator Server") -server.register_tools(calculator) - -# Run the server -server.run() -``` - -### Convenience Functions - -For quick setup, use the convenience functions: - -```python -from eaa.mcp import run_mcp_server_from_tools -from eaa.tools.example_calculator import CalculatorTool - -# Create and run server in one step -calculator = CalculatorTool() -run_mcp_server_from_tools( - tools=calculator, - server_name="Calculator MCP Server" -) -``` - -## Creating Compatible BaseTool Subclasses - -To create a `BaseTool` that works with the MCP server: - -1. **Inherit from BaseTool** -2. **Define exposed_tools** - List the methods you want to expose -3. **Use proper type annotations** - Required for schema generation -4. **Add docstrings** - Used as tool descriptions - -### Example BaseTool Implementation - -```python -from typing import Dict, List, Any -from eaa.tools.base import BaseTool, ToolReturnType, check - -class MyTool(BaseTool): - name: str = "my_tool" - - @check - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.exposed_tools: List[Dict[str, Any]] = [ - { - "name": "calculate_something", - "function": self.calculate_something, - "return_type": ToolReturnType.NUMBER - }, - { - "name": "get_status", - "function": self.get_status, - "return_type": ToolReturnType.TEXT - } - ] - - def calculate_something(self, x: float, y: float) -> float: - """ - Calculate something with two numbers. - - Parameters - ---------- - x : float - First number - y : float - Second number - - Returns - ------- - float - The result of the calculation - """ - return x * y + 42 - - def get_status(self) -> str: - """ - Get the current status of the tool. - - Returns - ------- - str - Status message - """ - return "Tool is ready" -``` - -## Return Types - -The MCP server handles different return types automatically: - -- `ToolReturnType.TEXT` - Returns as string -- `ToolReturnType.NUMBER` - Returns as float -- `ToolReturnType.BOOL` - Returns as boolean -- `ToolReturnType.LIST` - Returns as list -- `ToolReturnType.DICT` - Returns as dictionary -- `ToolReturnType.IMAGE_PATH` - Returns path as string -- `ToolReturnType.EXCEPTION` - Handled as error - -## Running the MCP Server - -### Basic Usage - -```python -from eaa.tools.example_calculator import CalculatorTool -from eaa.mcp import run_mcp_server_from_tools - -# Create tool instance -calculator = CalculatorTool() - -# Run server -run_mcp_server_from_tools( - tools=calculator, - server_name="Calculator Server" -) -``` - -### Multiple Tools - -```python -from eaa.mcp import MCPToolServer - -# Create multiple tools -tool1 = MyTool1() -tool2 = MyTool2() - -# Create server with multiple tools -server = MCPToolServer("Multi-Tool Server") -server.register_tools([tool1, tool2]) -server.run() -``` - -### Advanced Configuration - -```python -from eaa.mcp import create_mcp_server_from_tools - -# Create server without running -server = create_mcp_server_from_tools( - tools=[tool1, tool2], - server_name="Advanced Server" -) - -# Inspect tool schemas -schemas = server.get_tool_schemas() -print(f"Registered {len(schemas)} tools:") -for schema in schemas: - print(f" - {schema['function']['name']}") - -# List tool names -tools = server.list_tools() -print(f"Available tools: {', '.join(tools)}") - -# Run with custom options -server.run(debug=True) -``` - -## Connecting to MCP Clients - -### Cursor IDE - -1. Create your MCP server script -2. Add MCP configuration to Cursor settings -3. Reference your server script in the configuration - -Example Cursor MCP configuration: - -```json -{ - "mcpServers": { - "calculator": { - "command": "python", - "args": ["path/to/your/mcp_server.py"] - } - } -} -``` - -### Other MCP Clients - -The server follows the standard MCP protocol and should work with any compatible client. - -## Example: Calculator MCP Server - -See `examples/mcp_calculator_server.py` for a complete working example: - -```bash -# Run the calculator server -python examples/mcp_calculator_server.py -``` - -This exposes the following tools: -- `add(a, b)` - Add two numbers -- `subtract(a, b)` - Subtract two numbers -- `multiply(a, b)` - Multiply two numbers -- `divide(a, b)` - Divide two numbers -- `get_history()` - Get calculation history -- `clear_history()` - Clear calculation history - -## Error Handling - -The MCP server automatically handles errors: - -- **Missing parameters** - Returns error message -- **Type conversion errors** - Attempts conversion, falls back to string -- **Tool execution errors** - Catches exceptions and returns error message -- **Duplicate tool names** - Raises ValueError during registration - -## Schema Generation - -Tool schemas are automatically generated from: - -- **Function signatures** - Parameter names and types -- **Type annotations** - Converted to JSON schema types -- **Docstrings** - Used as tool descriptions -- **Default values** - Handled for optional parameters - -## Best Practices - -1. **Use descriptive docstrings** - They become tool descriptions -2. **Add proper type annotations** - Required for schema generation -3. **Handle errors gracefully** - Use try/catch in tool methods -4. **Avoid naming conflicts** - Ensure unique tool names across all tools -5. **Keep tools focused** - One responsibility per tool method -6. **Test tools independently** - Before exposing via MCP - -## Troubleshooting - -### Common Issues - -**ImportError: mcp package not found** -```bash -pip install mcp -``` - -**Tool not appearing in client** -- Check that `exposed_tools` is properly defined -- Verify type annotations are present -- Ensure docstrings are added - -**Naming conflicts** -- Make sure tool names are unique across all registered tools -- Consider namespacing for similar tools - -**Parameter errors** -- Verify type annotations match expected types -- Check that required parameters are properly marked - -### Debugging - -Enable debug logging: - -```python -import logging -logging.basicConfig(level=logging.DEBUG) - -# Your MCP server code here -``` - -Inspect generated schemas: - -```python -server = create_mcp_server_from_tools(tools) -schemas = server.get_tool_schemas() -import json -print(json.dumps(schemas, indent=2)) -``` - -## Dependencies - -Required packages: -- `mcp` - MCP protocol implementation -- Standard Python packages (inspect, json, logging, typing) - -## License - -This component follows the same license as the EAA project. \ No newline at end of file diff --git a/examples/mcp_calculator_server.py b/examples/mcp_calculator_server.py index b1d994f..60e7f77 100644 --- a/examples/mcp_calculator_server.py +++ b/examples/mcp_calculator_server.py @@ -45,7 +45,7 @@ def main(): logger.info("Starting MCP server...") run_mcp_server_from_tools( tools=calculator, - server_name="Calculator MCP Server" + server_name="Calculator MCP Server", ) except KeyboardInterrupt: @@ -55,61 +55,6 @@ def main(): raise -def create_server_only(): - """ - Example function showing how to create the server without running it. - - This can be useful for testing or when you want more control over - the server lifecycle. - """ - from eaa.mcp import create_mcp_server_from_tools - - # Create the calculator tool - calculator = CalculatorTool() - - # Create the MCP server (but don't run it yet) - server = create_mcp_server_from_tools( - tools=calculator, - server_name="Calculator MCP Server" - ) - - # Get tool schemas (useful for debugging) - schemas = server.get_tool_schemas() - logger.info(f"Created server with {len(schemas)} tool schemas:") - for schema in schemas: - logger.info(f" - {schema['function']['name']}: {schema['function']['description']}") - - # List available tools - tools = server.list_tools() - logger.info(f"Available tools: {', '.join(tools)}") - - return server - - -def demonstrate_multiple_tools(): - """ - Example showing how to create an MCP server with multiple BaseTool instances. - """ - from eaa.mcp import MCPToolServer - - # Create multiple tool instances - calculator1 = CalculatorTool() - calculator2 = CalculatorTool() # Different instance with separate history - - # Create server and register tools - server = MCPToolServer(name="Multi-Calculator MCP Server") - - # Note: This would cause naming conflicts since both tools have the same method names - # In practice, you'd want tools with different method names or use namespacing - try: - server.register_tools([calculator1, calculator2]) - except ValueError as e: - logger.warning(f"Expected naming conflict: {e}") - logger.info("In practice, use tools with different method names or implement namespacing") - - return server - - if __name__ == "__main__": print(""" Calculator MCP Server Example diff --git a/src/eaa/mcp/__init__.py b/src/eaa/mcp/__init__.py index 4aab2b5..a0b3ac0 100644 --- a/src/eaa/mcp/__init__.py +++ b/src/eaa/mcp/__init__.py @@ -1,10 +1,3 @@ -""" -MCP (Model Context Protocol) components for EAA. - -This package provides functionality to expose BaseTool subclasses as MCP servers, -allowing them to be used by MCP clients like Cursor or other AI applications. -""" - from .server import MCPToolServer, create_mcp_server_from_tools, run_mcp_server_from_tools __all__ = [ diff --git a/src/eaa/mcp/server.py b/src/eaa/mcp/server.py index 5cd780a..17d681b 100644 --- a/src/eaa/mcp/server.py +++ b/src/eaa/mcp/server.py @@ -5,11 +5,8 @@ methods from BaseTool subclasses as standardized MCP tools. """ -import inspect -import json import logging from typing import Any, Dict, List, Optional, Union, Callable -import asyncio try: from mcp.server.fastmcp import FastMCP @@ -19,7 +16,7 @@ "Install it with: pip install mcp" ) -from eaa.tools.base import BaseTool, ToolReturnType +from eaa.tools.base import BaseTool from eaa.agents.base import generate_openai_tool_schema logger = logging.getLogger(__name__) @@ -50,14 +47,13 @@ def __init__( List of BaseTool instances to expose via MCP. """ self.name = name - self.tools: List[BaseTool] = tools or [] self.mcp_server = FastMCP(name) self._tool_instances: Dict[str, BaseTool] = {} self._registered_tools: Dict[str, Dict[str, Any]] = {} # Register tools if provided - if self.tools: - self.register_tools(self.tools) + if tools: + self.register_tools(tools) def register_tools(self, tools: Union[BaseTool, List[BaseTool]]) -> None: """ @@ -85,7 +81,7 @@ def register_tools(self, tools: Union[BaseTool, List[BaseTool]]) -> None: def _register_tool_instance(self, tool: BaseTool) -> None: """ - Register a single BaseTool instance. + Register all exposed tool methods of a BaseTool instance. Parameters ---------- @@ -95,7 +91,6 @@ def _register_tool_instance(self, tool: BaseTool) -> None: for tool_dict in tool.exposed_tools: tool_name = tool_dict["name"] tool_function = tool_dict["function"] - return_type = tool_dict["return_type"] if tool_name in self._registered_tools: raise ValueError(f"Tool '{tool_name}' is already registered") @@ -104,78 +99,19 @@ def _register_tool_instance(self, tool: BaseTool) -> None: self._tool_instances[tool_name] = tool self._registered_tools[tool_name] = tool_dict - # Create the MCP tool wrapper - self._create_mcp_tool(tool_name, tool_function, return_type) - - def _create_mcp_tool( - self, - tool_name: str, - tool_function: Callable, - return_type: ToolReturnType - ) -> None: - """ - Create an MCP tool from a BaseTool method. - - Parameters - ---------- - tool_name : str - Name of the tool. - tool_function : Callable - The callable method from the BaseTool. - return_type : ToolReturnType - Expected return type of the tool. - """ - # Get function signature for parameter validation - sig = inspect.signature(tool_function) - - # Create wrapper function that handles tool execution - def mcp_tool_wrapper(**kwargs): - """Wrapper function for MCP tool execution.""" - try: - # Filter kwargs to match function signature - filtered_kwargs = {} - for param_name, param in sig.parameters.items(): - if param_name in kwargs: - filtered_kwargs[param_name] = kwargs[param_name] - elif param.default == inspect.Parameter.empty: - raise ValueError(f"Required parameter '{param_name}' is missing") - - # Execute the tool function - result = tool_function(**filtered_kwargs) - - # Handle different return types - if return_type == ToolReturnType.TEXT: - return str(result) - elif return_type == ToolReturnType.IMAGE_PATH: - return str(result) - elif return_type == ToolReturnType.NUMBER: - return float(result) if result is not None else None - elif return_type == ToolReturnType.BOOL: - return bool(result) - elif return_type in [ToolReturnType.LIST, ToolReturnType.DICT]: - return result - else: - return str(result) - - except Exception as e: - logger.error(f"Error executing tool '{tool_name}': {str(e)}") - return f"Error: {str(e)}" - - # Set the docstring from the original function - mcp_tool_wrapper.__doc__ = tool_function.__doc__ or f"Execute {tool_name}" - - # Set the function name to match the tool name for proper registration - mcp_tool_wrapper.__name__ = tool_name - - # Register the tool with the MCP server using the decorator - decorated_wrapper = self.mcp_server.tool()(mcp_tool_wrapper) - - logger.info(f"Registered MCP tool: {tool_name}") + # Create the MCP tool + # This is equivalent to adding @self.mcp_server.tool() + # to the definition of the tool function. + self.mcp_server.tool()(tool_function) def get_tool_schemas(self) -> List[Dict[str, Any]]: """ Get OpenAI-compatible tool schemas for all registered tools. + Note that the schemas returned are NOT what's used by the MCP server. + The MCP SDK creates the schemas itself using annotations and docstrings + in the tool functions. Only use this function for reference. + Returns ------- List[Dict[str, Any]] @@ -209,12 +145,14 @@ def get_server(self) -> FastMCP: """ return self.mcp_server - def run(self, **kwargs) -> None: + def run(self) -> None: """ Run the MCP server. Parameters ---------- + port : int, optional + The port to listen on. **kwargs Additional arguments passed to the FastMCP server. """ @@ -223,7 +161,7 @@ def run(self, **kwargs) -> None: logger.info(f" - {tool_name}") # Run the server - self.mcp_server.run(**kwargs) + self.mcp_server.run() def create_mcp_server_from_tools( @@ -265,7 +203,7 @@ def run_mcp_server_from_tools( server_name : str, optional Name of the MCP server. **server_kwargs - Additional arguments passed to the server run method. + Additional arguments passed to the FastMCP.run method. """ server = create_mcp_server_from_tools(tools, server_name) server.run(**server_kwargs) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 16e2776..18e212f 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -179,11 +179,11 @@ def run_all_tests(): # Print summary logger.info("Test Summary:") - logger.info(f" - Tool creation: ✓") - logger.info(f" - Server creation: ✓") - logger.info(f" - Schema generation: ✓ ({len(schemas)} schemas)") - logger.info(f" - Tool execution: ✓") - logger.info(f" - Error handling: ✓") + logger.info(" - Tool creation: ✓") + logger.info(" - Server creation: ✓") + logger.info(" - Schema generation: ✓ ({len(schemas)} schemas)") + logger.info(" - Tool execution: ✓") + logger.info(" - Error handling: ✓") return True From 7a483540d3d03831faf5a0c7e464d47300d98bee Mon Sep 17 00:00:00 2001 From: Ming Du Date: Tue, 5 Aug 2025 12:03:54 -0500 Subject: [PATCH 3/4] DOCS: add MCP config with env activation --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 4c3a6fd..2df6f6a 100644 --- a/README.md +++ b/README.md @@ -158,5 +158,20 @@ Refer to the documentations of the client on where this config file is located. } } ``` +If EAA is installed in a virtual environment, you will need to ask the MCP client +to activate the environment before launching the tool. Below is an example: +```json +{ + "mcpServers": { + "calculator": { + "command": "bash", + "args": [ + "-c", + "source /path/to/.venv/bin/activate && python path/to/mcp_calculator_server.py" + ] + } + } +} +``` Now the MCP client should be able to run and connect to the MCP server and use the tool. \ No newline at end of file From 885d557c6ad9e89df45d738922e04f001310c083 Mon Sep 17 00:00:00 2001 From: Ming Du Date: Tue, 5 Aug 2025 13:01:34 -0500 Subject: [PATCH 4/4] FIX: fix linting --- src/eaa/mcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eaa/mcp/server.py b/src/eaa/mcp/server.py index 17d681b..2bf4bc6 100644 --- a/src/eaa/mcp/server.py +++ b/src/eaa/mcp/server.py @@ -6,7 +6,7 @@ """ import logging -from typing import Any, Dict, List, Optional, Union, Callable +from typing import Any, Dict, List, Optional, Union try: from mcp.server.fastmcp import FastMCP