diff --git a/README.md b/README.md index d68d5ff73..dd2e304e5 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,123 @@ def my_custom_function(input): --- +## CrewAI Tools and MCP + +CrewAI Tools supports the Model Context Protocol (MCP). It gives you access to thousands of tools from the hundreds of MCP servers out there built by the community. + +Before you start using MCP with CrewAI tools, you need to install the `mcp` extra dependencies: + +```bash +pip install crewai-tools[mcp] +# or +uv add crewai-tools --extra mcp +``` + +To quickly get started with MCP in CrewAI you have 2 options: + +### Option 1: Fully managed connection + +In this scenario we use a contextmanager (`with` statement) to start and stop the the connection with the MCP server. +This is done in the background and you only get to interact with the CrewAI tools corresponding to the MCP server's tools. + +For an STDIO based MCP server: + +```python +from mcp import StdioServerParameters +from crewai_tools import MCPServerAdapter + +serverparams = StdioServerParameters( + command="uvx", + args=["--quiet", "pubmedmcp@0.1.3"], + env={"UV_PYTHON": "3.12", **os.environ}, +) + +with MCPServerAdapter(serverparams) as tools: + # tools is now a list of CrewAI Tools matching 1:1 with the MCP server's tools + agent = Agent(..., tools=tools) + task = Task(...) + crew = Crew(..., agents=[agent], tasks=[task]) + crew.kickoff(...) +``` +For an SSE based MCP server: + +```python +serverparams = {"url": "http://localhost:8000/sse"} +with MCPServerAdapter(serverparams) as tools: + # tools is now a list of CrewAI Tools matching 1:1 with the MCP server's tools + agent = Agent(..., tools=tools) + task = Task(...) + crew = Crew(..., agents=[agent], tasks=[task]) + crew.kickoff(...) +``` + +### Option 2: More control over the MCP connection + +If you need more control over the MCP connection, you can instanciate the MCPServerAdapter into an `mcp_server_adapter` object which can be used to manage the connection with the MCP server and access the available tools. + +**important**: in this case you need to call `mcp_server_adapter.stop()` to make sure the connection is correctly stopped. We recommend that you use a `try ... finally` block run to make sure the `.stop()` is called even in case of errors. + +Here is the same example for an STDIO MCP Server: + +```python +from mcp import StdioServerParameters +from crewai_tools import MCPServerAdapter + +serverparams = StdioServerParameters( + command="uvx", + args=["--quiet", "pubmedmcp@0.1.3"], + env={"UV_PYTHON": "3.12", **os.environ}, +) + +try: + mcp_server_adapter = MCPServerAdapter(serverparams) + tools = mcp_server_adapter.tools + # tools is now a list of CrewAI Tools matching 1:1 with the MCP server's tools + agent = Agent(..., tools=tools) + task = Task(...) + crew = Crew(..., agents=[agent], tasks=[task]) + crew.kickoff(...) + +# ** important ** don't forget to stop the connection +finally: + mcp_server_adapter.stop() +``` + +And finally the same thing but for an SSE MCP Server: + +```python +from mcp import StdioServerParameters +from crewai_tools import MCPServerAdapter + +serverparams = {"url": "http://localhost:8000/sse"} + +try: + mcp_server_adapter = MCPServerAdapter(serverparams) + tools = mcp_server_adapter.tools + # tools is now a list of CrewAI Tools matching 1:1 with the MCP server's tools + agent = Agent(..., tools=tools) + task = Task(...) + crew = Crew(..., agents=[agent], tasks=[task]) + crew.kickoff(...) + +# ** important ** don't forget to stop the connection +finally: + mcp_server_adapter.stop() +``` + +### Considerations & Limitations + +#### Staying Safe with MCP + +Always make sure that you trust the MCP Server before using it. Using an STDIO server will execute code on your machine. Using SSE is still not a silver bullet with many injection possible into your application from a malicious MCP server. + +#### Limitations + +* At this time we only support tools from MCP Server not other type of primitives like prompts, resources... +* We only return the first text output returned by the MCP Server tool using `.content[0].text` + +--- + ## Why Use CrewAI Tools? - **Simplicity & Flexibility**: Easy-to-use yet powerful enough for complex workflows. diff --git a/src/crewai_tools/__init__.py b/src/crewai_tools/__init__.py index c13ac5007..3e3cdc019 100644 --- a/src/crewai_tools/__init__.py +++ b/src/crewai_tools/__init__.py @@ -66,3 +66,7 @@ from .aws import ( BedrockKBRetrieverTool, BedrockInvokeAgentTool, ) + +from .adapters.mcp_adapter import ( + MCPServerAdapter, +) diff --git a/src/crewai_tools/adapters/mcp_adapter.py b/src/crewai_tools/adapters/mcp_adapter.py new file mode 100644 index 000000000..2f5cc71f8 --- /dev/null +++ b/src/crewai_tools/adapters/mcp_adapter.py @@ -0,0 +1,142 @@ +from __future__ import annotations +import logging +from typing import Any, TYPE_CHECKING +from crewai.tools import BaseTool + +""" +MCPServer for CrewAI. + + +""" +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from mcp import StdioServerParameters + from mcpadapt.core import MCPAdapt + from mcpadapt.crewai_adapter import CrewAIAdapter + + +try: + from mcp import StdioServerParameters + from mcpadapt.core import MCPAdapt + from mcpadapt.crewai_adapter import CrewAIAdapter + + MCP_AVAILABLE = True +except ImportError: + MCP_AVAILABLE = False + + +class MCPServerAdapter: + """Manages the lifecycle of an MCP server and make its tools available to CrewAI. + + Note: tools can only be accessed after the server has been started with the + `start()` method. + + 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 + + # manually stop mcp server + try: + mcp_server = MCPServerAdapter(...) + tools = mcp_server.tools + ... + finally: + mcp_server.stop() + + # Best practice is ensure cleanup is done after use. + mcp_server.stop() # run after crew().kickoff() + """ + + def __init__( + self, + serverparams: StdioServerParameters | dict[str, Any], + ): + """Initialize the MCP Server + + Args: + serverparams: The parameters for the MCP server it supports either a + `StdioServerParameters` or a `dict` respectively for STDIO and SSE. + + """ + + super().__init__() + self._adapter = None + self._tools = 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]`" + ) + + try: + self._serverparams = serverparams + self._adapter = MCPAdapt(self._serverparams, CrewAIAdapter()) + self.start() + + except Exception as e: + if self._adapter is not None: + try: + self.stop() + except Exception as stop_e: + logger.error(f"Error during stop cleanup: {stop_e}") + raise RuntimeError(f"Failed to initialize MCP Adapter: {e}") from e + + def start(self): + """Start the MCP server and initialize the tools.""" + self._tools = self._adapter.__enter__() + + def stop(self): + """Stop the MCP server""" + self._adapter.__exit__(None, None, None) + + @property + def tools(self) -> list[BaseTool]: + """The CrewAI tools available from the MCP server. + + Raises: + ValueError: If the MCP server is not started. + + Returns: + The CrewAI tools available from the MCP server. + """ + if self._tools is None: + raise ValueError( + "MCP server not started, run `mcp_server.start()` first before accessing `tools`" + ) + return self._tools + + def __enter__(self): + """ + Enter the context manager. Note that `__init__()` already starts the MCP server. + So tools should already be available. + """ + return self.tools + + def __exit__(self, exc_type, exc_value, traceback): + """Exit the context manager.""" + return self._adapter.__exit__(exc_type, exc_value, traceback) diff --git a/tests/adapters/mcp_adapter.py b/tests/adapters/mcp_adapter.py new file mode 100644 index 000000000..569a10ae6 --- /dev/null +++ b/tests/adapters/mcp_adapter.py @@ -0,0 +1,104 @@ +from textwrap import dedent + +import pytest +from mcp import StdioServerParameters + +from crewai_tools import MCPServerAdapter + + +@pytest.fixture +def echo_server_script(): + return dedent( + ''' + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("Echo Server") + + @mcp.tool() + def echo_tool(text: str) -> str: + """Echo the input text""" + return f"Echo: {text}" + + mcp.run() + ''' + ) + + +@pytest.fixture +def echo_server_sse_script(): + 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""" + return f"Echo: {text}" + + mcp.run("sse") + ''' + ) + + +@pytest.fixture +def echo_sse_server(echo_server_sse_script): + 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): + serverparams = StdioServerParameters( + command="uv", args=["run", "python", "-c", echo_server_script] + ) + with MCPServerAdapter(serverparams) as tools: + assert len(tools) == 1 + assert tools[0].name == "echo_tool" + assert tools[0].run(text="hello") == "Echo: hello" + +def test_context_manager_syntax_sse(echo_sse_server): + sse_serverparams = echo_sse_server + with MCPServerAdapter(sse_serverparams) as tools: + assert len(tools) == 1 + assert tools[0].name == "echo_tool" + assert tools[0].run(text="hello") == "Echo: hello" + +def test_try_finally_syntax(echo_server_script): + serverparams = StdioServerParameters( + command="uv", args=["run", "python", "-c", echo_server_script] + ) + try: + mcp_server_adapter = MCPServerAdapter(serverparams) + tools = mcp_server_adapter.tools + assert len(tools) == 1 + assert tools[0].name == "echo_tool" + assert tools[0].run(text="hello") == "Echo: hello" + finally: + mcp_server_adapter.stop() + +def test_try_finally_syntax_sse(echo_sse_server): + sse_serverparams = echo_sse_server + mcp_server_adapter = MCPServerAdapter(sse_serverparams) + try: + tools = mcp_server_adapter.tools + assert len(tools) == 1 + assert tools[0].name == "echo_tool" + assert tools[0].run(text="hello") == "Echo: hello" + finally: + mcp_server_adapter.stop()