diff --git a/crewai_tools/adapters/mcp_adapter.py b/crewai_tools/adapters/mcp_adapter.py index 8e602f37..39b5c348 100644 --- a/crewai_tools/adapters/mcp_adapter.py +++ b/crewai_tools/adapters/mcp_adapter.py @@ -1,15 +1,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Optional, Type from crewai.tools import BaseTool from crewai_tools.adapters.tool_collection import ToolCollection -""" -MCPServer for CrewAI. - -""" logger = logging.getLogger(__name__) if TYPE_CHECKING: @@ -29,45 +25,15 @@ class MCPServerAdapter: - """Manages the lifecycle of an MCP server and make its tools available to CrewAI. + """Manages the lifecycle of an MCP server and makes its tools available to CrewAI. - Note: tools can only be accessed after the server has been started with the - `start()` method. + This adapter handles starting and stopping the MCP server, converting its + capabilities into CrewAI tools. It is best used as a context manager (`with` + statement) to ensure resources are properly cleaned up. Attributes: - tools: The CrewAI tools available from the MCP server. - - Usage: - # context manager + stdio - with MCPServerAdapter(...) as tools: - # tools is now available - - # context manager + sse - with MCPServerAdapter({"url": "http://localhost:8000/sse"}) as tools: - # tools is now available - - # context manager with filtered tools - with MCPServerAdapter(..., "tool1", "tool2") as filtered_tools: - # only tool1 and tool2 are available - - # context manager with custom connect timeout (60 seconds) - with MCPServerAdapter(..., connect_timeout=60) as tools: - # tools is now available with longer timeout - - # manually stop mcp server - try: - mcp_server = MCPServerAdapter(...) - tools = mcp_server.tools # all tools - - # or with filtered tools and custom timeout - mcp_server = MCPServerAdapter(..., "tool1", "tool2", connect_timeout=45) - filtered_tools = mcp_server.tools # only tool1 and tool2 - ... - finally: - mcp_server.stop() - - # Best practice is ensure cleanup is done after use. - mcp_server.stop() # run after crew().kickoff() + tools: A ToolCollection of the available CrewAI tools. Accessing this + before the server is ready will raise a ValueError. """ def __init__( @@ -75,61 +41,82 @@ def __init__( serverparams: StdioServerParameters | dict[str, Any], *tool_names: str, connect_timeout: int = 30, - ): - """Initialize the MCP Server + ) -> None: + """Initialize and start the MCP Server. + + Example: + .. code-block:: python + + # For a server communicating over standard I/O + from mcp import StdioServerParameters + stdio_params = StdioServerParameters(command="python", args=["-c", "..."]) + with MCPServerAdapter(stdio_params) as tools: + # use tools + + # For a server communicating over SSE (Server-Sent Events) + sse_params = {"url": "http://localhost:8000/sse"} + with MCPServerAdapter(sse_params, connect_timeout=60) as tools: + # use tools Args: - serverparams: The parameters for the MCP server it supports either a - `StdioServerParameters` or a `dict` respectively for STDIO and SSE. + serverparams: The parameters for the MCP server. This supports either a + `StdioServerParameters` object for STDIO or a `dict` for SSE connections. *tool_names: Optional names of tools to filter. If provided, only tools with matching names will be available. - connect_timeout: Connection timeout in seconds to the MCP server (default is 30s). - + connect_timeout: Connection timeout in seconds to the MCP server. + Defaults to 30. """ - - super().__init__() self._adapter = None self._tools = None self._tool_names = list(tool_names) if tool_names else None if not MCP_AVAILABLE: - import click - - if click.confirm( - "You are missing the 'mcp' package. Would you like to install it?" - ): - import subprocess - - try: - subprocess.run(["uv", "add", "mcp crewai-tools[mcp]"], check=True) - - except subprocess.CalledProcessError: - raise ImportError("Failed to install mcp package") - else: - raise ImportError( - "`mcp` package not found, please run `uv add crewai-tools[mcp]`" - ) + msg = ( + "MCP is not available. The 'mcp' package, a required dependency, " + "must be installed for MCPServerAdapter to work." + ) + logger.critical(msg) + raise ImportError( + "`mcp` package not found. Please install it with:\n" + " pip install mcp crewai-tools[mcp]" + ) try: self._serverparams = serverparams self._adapter = MCPAdapt(self._serverparams, CrewAIAdapter(), connect_timeout) self.start() - except Exception as e: + logger.exception("Failed to initialize MCP Adapter during __init__.") if self._adapter is not None: try: self.stop() except Exception as stop_e: - logger.error(f"Error during stop cleanup: {stop_e}") + logger.error(f"Error during post-failure cleanup: {stop_e}") raise RuntimeError(f"Failed to initialize MCP Adapter: {e}") from e - def start(self): + def start(self) -> None: """Start the MCP server and initialize the tools.""" + if not self._adapter: + raise RuntimeError("Cannot start MCP server: Adapter is not initialized.") + if self._tools: + logger.debug("MCP server already started.") + return self._tools = self._adapter.__enter__() - def stop(self): - """Stop the MCP server""" - self._adapter.__exit__(None, None, None) + def stop(self) -> None: + """Stop the MCP server and release all associated resources. + + This method is idempotent; calling it multiple times has no effect. + """ + if not self._adapter: + logger.debug("stop() called but adapter is already stopped.") + return + + try: + self._adapter.__exit__(None, None, None) + finally: + self._tools = None + self._adapter = None @property def tools(self) -> ToolCollection[BaseTool]: @@ -139,11 +126,11 @@ def tools(self) -> ToolCollection[BaseTool]: ValueError: If the MCP server is not started. Returns: - The CrewAI tools available from the MCP server. + A ToolCollection of the available CrewAI tools. """ if self._tools is None: raise ValueError( - "MCP server not started, run `mcp_server.start()` first before accessing `tools`" + "MCP tools are not available. The server may be stopped or initialization failed." ) tools_collection = ToolCollection(self._tools) @@ -151,13 +138,31 @@ def tools(self) -> ToolCollection[BaseTool]: return tools_collection.filter_by_names(self._tool_names) return tools_collection - def __enter__(self): - """ - Enter the context manager. Note that `__init__()` already starts the MCP server. - So tools should already be available. - """ + def __enter__(self) -> ToolCollection[BaseTool]: + """Enter the context manager, returning the initialized tools.""" return self.tools - def __exit__(self, exc_type, exc_value, traceback): - """Exit the context manager.""" - return self._adapter.__exit__(exc_type, exc_value, traceback) + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[Any], + ) -> bool: + """Exit the context manager, stop the server, and do not suppress exceptions.""" + self.stop() + return False # Ensures any exceptions that occurred are re-raised. + + def __del__(self) -> None: + """ + Finalizer to attempt cleanup if the user forgets to call stop() or use a + context manager. + + Note: This is a fallback and should not be relied upon, as Python does + not guarantee __del__ will always be called on object destruction. + """ + if self._adapter: + logger.warning( + "MCPServerAdapter was not cleanly shut down. Please use a " + "context manager (`with` statement) or call .stop() explicitly." + ) + self.stop() diff --git a/tests/adapters/mcp_adapter_test.py b/tests/adapters/mcp_adapter_test.py index 81c3c529..6f619d6d 100644 --- a/tests/adapters/mcp_adapter_test.py +++ b/tests/adapters/mcp_adapter_test.py @@ -7,76 +7,74 @@ from crewai_tools import MCPServerAdapter from crewai_tools.adapters.tool_collection import ToolCollection + @pytest.fixture -def echo_server_script(): +def echo_server_script() -> str: return dedent( - ''' + """ from mcp.server.fastmcp import FastMCP mcp = FastMCP("Echo Server") @mcp.tool() def echo_tool(text: str) -> str: - """Echo the input text""" + \"\"\"Echo the input text\"\"\" return f"Echo: {text}" @mcp.tool() def calc_tool(a: int, b: int) -> int: - """Calculate a + b""" + \"\"\"Calculate a + b\"\"\" return a + b mcp.run() - ''' + """ ) @pytest.fixture -def echo_server_sse_script(): +def echo_server_sse_script() -> str: return dedent( - ''' + """ from mcp.server.fastmcp import FastMCP mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) @mcp.tool() def echo_tool(text: str) -> str: - """Echo the input text""" + \"\"\"Echo the input text\"\"\" return f"Echo: {text}" @mcp.tool() def calc_tool(a: int, b: int) -> int: - """Calculate a + b""" + \"\"\"Calculate a + b\"\"\" return a + b mcp.run("sse") - ''' + """ ) @pytest.fixture -def echo_sse_server(echo_server_sse_script): +def echo_sse_server(echo_server_sse_script: str): import subprocess import time - # Start the SSE server process with its own process group process = subprocess.Popen( ["python", "-c", echo_server_sse_script], ) - # Give the server a moment to start up time.sleep(1) try: yield {"url": "http://127.0.0.1:8000/sse"} finally: - # Clean up the process when test is done process.kill() process.wait() -def test_context_manager_syntax(echo_server_script): +def test_context_manager_syntax(echo_server_script: str) -> None: serverparams = StdioServerParameters( - command="uv", args=["run", "python", "-c", echo_server_script] + command="python", args=["-c", echo_server_script] ) with MCPServerAdapter(serverparams) as tools: assert isinstance(tools, ToolCollection) @@ -84,21 +82,24 @@ def test_context_manager_syntax(echo_server_script): assert tools[0].name == "echo_tool" assert tools[1].name == "calc_tool" assert tools[0].run(text="hello") == "Echo: hello" - assert tools[1].run(a=5, b=3) == '8' + assert tools[1].run(a=5, b=3) == "8" + -def test_context_manager_syntax_sse(echo_sse_server): +def test_context_manager_syntax_sse(echo_sse_server) -> None: sse_serverparams = echo_sse_server with MCPServerAdapter(sse_serverparams) as tools: assert len(tools) == 2 assert tools[0].name == "echo_tool" assert tools[1].name == "calc_tool" assert tools[0].run(text="hello") == "Echo: hello" - assert tools[1].run(a=5, b=3) == '8' + assert tools[1].run(a=5, b=3) == "8" -def test_try_finally_syntax(echo_server_script): + +def test_try_finally_syntax(echo_server_script: str) -> None: serverparams = StdioServerParameters( - command="uv", args=["run", "python", "-c", echo_server_script] + command="python", args=["-c", echo_server_script] ) + mcp_server_adapter = None try: mcp_server_adapter = MCPServerAdapter(serverparams) tools = mcp_server_adapter.tools @@ -106,11 +107,13 @@ def test_try_finally_syntax(echo_server_script): assert tools[0].name == "echo_tool" assert tools[1].name == "calc_tool" assert tools[0].run(text="hello") == "Echo: hello" - assert tools[1].run(a=5, b=3) == '8' + assert tools[1].run(a=5, b=3) == "8" finally: - mcp_server_adapter.stop() + if mcp_server_adapter: + mcp_server_adapter.stop() + -def test_try_finally_syntax_sse(echo_sse_server): +def test_try_finally_syntax_sse(echo_sse_server) -> None: sse_serverparams = echo_sse_server mcp_server_adapter = MCPServerAdapter(sse_serverparams) try: @@ -119,77 +122,83 @@ def test_try_finally_syntax_sse(echo_sse_server): assert tools[0].name == "echo_tool" assert tools[1].name == "calc_tool" assert tools[0].run(text="hello") == "Echo: hello" - assert tools[1].run(a=5, b=3) == '8' + assert tools[1].run(a=5, b=3) == "8" finally: mcp_server_adapter.stop() -def test_context_manager_with_filtered_tools(echo_server_script): + +def test_context_manager_with_filtered_tools(echo_server_script: str) -> None: serverparams = StdioServerParameters( - command="uv", args=["run", "python", "-c", echo_server_script] + command="python", args=["-c", echo_server_script] ) - # Only select the echo_tool with MCPServerAdapter(serverparams, "echo_tool") as tools: assert isinstance(tools, ToolCollection) assert len(tools) == 1 assert tools[0].name == "echo_tool" assert tools[0].run(text="hello") == "Echo: hello" - # Check that calc_tool is not present with pytest.raises(IndexError): _ = tools[1] with pytest.raises(KeyError): _ = tools["calc_tool"] -def test_context_manager_sse_with_filtered_tools(echo_sse_server): + +def test_context_manager_sse_with_filtered_tools(echo_sse_server) -> None: sse_serverparams = echo_sse_server - # Only select the calc_tool with MCPServerAdapter(sse_serverparams, "calc_tool") as tools: assert isinstance(tools, ToolCollection) assert len(tools) == 1 assert tools[0].name == "calc_tool" - assert tools[0].run(a=10, b=5) == '15' - # Check that echo_tool is not present + assert tools[0].run(a=10, b=5) == "15" with pytest.raises(IndexError): _ = tools[1] with pytest.raises(KeyError): _ = tools["echo_tool"] -def test_try_finally_with_filtered_tools(echo_server_script): + +def test_try_finally_with_filtered_tools(echo_server_script: str) -> None: serverparams = StdioServerParameters( - command="uv", args=["run", "python", "-c", echo_server_script] + command="python", args=["-c", echo_server_script] ) + mcp_server_adapter = None try: - # Select both tools but in reverse order mcp_server_adapter = MCPServerAdapter(serverparams, "calc_tool", "echo_tool") tools = mcp_server_adapter.tools assert len(tools) == 2 - # The order of tools is based on filter_by_names which preserves - # the original order from the collection assert tools[0].name == "calc_tool" assert tools[1].name == "echo_tool" finally: - mcp_server_adapter.stop() + if mcp_server_adapter: + mcp_server_adapter.stop() -def test_filter_with_nonexistent_tool(echo_server_script): + +def test_filter_with_nonexistent_tool(echo_server_script: str) -> None: serverparams = StdioServerParameters( - command="uv", args=["run", "python", "-c", echo_server_script] + command="python", args=["-c", echo_server_script] ) - # Include a tool that doesn't exist with MCPServerAdapter(serverparams, "echo_tool", "nonexistent_tool") as tools: - # Only echo_tool should be in the result assert len(tools) == 1 assert tools[0].name == "echo_tool" -def test_filter_with_only_nonexistent_tools(echo_server_script): + +def test_filter_with_only_nonexistent_tools(echo_server_script: str) -> None: serverparams = StdioServerParameters( - command="uv", args=["run", "python", "-c", echo_server_script] + command="python", args=["-c", echo_server_script] ) - # All requested tools don't exist with MCPServerAdapter(serverparams, "nonexistent1", "nonexistent2") as tools: - # Should return an empty tool collection assert isinstance(tools, ToolCollection) assert len(tools) == 0 -def test_connect_timeout_parameter(echo_server_script): + +def test_adapter_raises_import_error_if_mcp_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Tests that MCPServerAdapter raises ImportError if the mcp package is not available. + """ + monkeypatch.setattr("crewai_tools.adapters.mcp_adapter.MCP_AVAILABLE", False) + with pytest.raises(ImportError, match="`mcp` package not found"): + MCPServerAdapter(serverparams={}) + + +def test_connect_timeout_parameter(echo_server_script: str) -> None: serverparams = StdioServerParameters( command="uv", args=["run", "python", "-c", echo_server_script] ) @@ -200,7 +209,7 @@ def test_connect_timeout_parameter(echo_server_script): assert tools[1].name == "calc_tool" assert tools[0].run(text="hello") == "Echo: hello" -def test_connect_timeout_with_filtered_tools(echo_server_script): +def test_connect_timeout_with_filtered_tools(echo_server_script: str) -> None: serverparams = StdioServerParameters( command="uv", args=["run", "python", "-c", echo_server_script] ) @@ -210,8 +219,8 @@ def test_connect_timeout_with_filtered_tools(echo_server_script): assert tools[0].name == "echo_tool" assert tools[0].run(text="timeout test") == "Echo: timeout test" -@patch('crewai_tools.adapters.mcp_adapter.MCPAdapt') -def test_connect_timeout_passed_to_mcpadapt(mock_mcpadapt): +@patch("crewai_tools.adapters.mcp_adapter.MCPAdapt") +def test_connect_timeout_passed_to_mcpadapt(mock_mcpadapt: MagicMock) -> None: mock_adapter_instance = MagicMock() mock_mcpadapt.return_value = mock_adapter_instance