From 7dfaace26f1197d176c81e6f53e9c360699c0a97 Mon Sep 17 00:00:00 2001 From: MervinPraison Date: Mon, 31 Mar 2025 13:23:49 +0100 Subject: [PATCH] Add MCP Integration and Update Documentation - Added `mcp` dependency to `pyproject.toml` for enhanced functionality. - Introduced new example scripts for various MCP tools, including `fetch-mcp.py`, `git-mcp.py`, `ollama.py`, `sentry-mcp.py`, and `time-mcp.py`, to demonstrate their usage. - Updated `airbnb.mdx` to reflect the addition of the Airbnb tool to the AI agent. - Enhanced the `Agent` class to support MCP tools and ensure proper integration with custom LLMs. - Improved logging for better debugging and tool management. - Incremented version number to 0.0.68 in `pyproject.toml` for release clarity. --- docs/mcp/airbnb.mdx | 2 +- examples/mcp/fetch-mcp.py | 13 +++ examples/mcp/git-mcp.py | 23 ++++ examples/mcp/ollama.py | 9 ++ examples/mcp/sentry-mcp.py | 16 +++ examples/mcp/time-mcp.py | 13 +++ pyproject.toml | 2 + src/praisonai-agents/mcp-ollama.py | 9 ++ .../praisonaiagents/agent/agent.py | 108 +++++++++++++++--- .../praisonaiagents/llm/llm.py | 14 ++- .../praisonaiagents/mcp/mcp.py | 39 +++++++ src/praisonai-agents/pyproject.toml | 5 +- src/praisonai-agents/uv.lock | 58 +++++++++- 13 files changed, 284 insertions(+), 27 deletions(-) create mode 100644 examples/mcp/fetch-mcp.py create mode 100644 examples/mcp/git-mcp.py create mode 100644 examples/mcp/ollama.py create mode 100644 examples/mcp/sentry-mcp.py create mode 100644 examples/mcp/time-mcp.py create mode 100644 src/praisonai-agents/mcp-ollama.py diff --git a/docs/mcp/airbnb.mdx b/docs/mcp/airbnb.mdx index fd3072602..ed901202f 100644 --- a/docs/mcp/airbnb.mdx +++ b/docs/mcp/airbnb.mdx @@ -5,7 +5,7 @@ description: "Guide for integrating Airbnb booking capabilities with PraisonAI a icon: "airbnb" --- -# Airbnb MCP Integration +## Add Airbnb Tool to AI Agent ```mermaid flowchart LR diff --git a/examples/mcp/fetch-mcp.py b/examples/mcp/fetch-mcp.py new file mode 100644 index 000000000..3180b5ca7 --- /dev/null +++ b/examples/mcp/fetch-mcp.py @@ -0,0 +1,13 @@ +from praisonaiagents import Agent, MCP +import os + +# pip install mcp-server-fetch +# Use a single string command with Fetch configuration +fetch_agent = Agent( + instructions="""You are a helpful assistant that can fetch and process web content. + Use the available tools when relevant to retrieve and convert web pages to markdown.""", + llm="gpt-4o-mini", + tools=MCP("python -m mcp_server_fetch") +) + +fetch_agent.start("Fetch and convert the content from https://example.com to markdown") \ No newline at end of file diff --git a/examples/mcp/git-mcp.py b/examples/mcp/git-mcp.py new file mode 100644 index 000000000..7c037aa88 --- /dev/null +++ b/examples/mcp/git-mcp.py @@ -0,0 +1,23 @@ +from praisonaiagents import Agent, MCP +import os + +# pip install mcp-server-git +# Get Git credentials from environment +git_username = os.getenv("GIT_USERNAME") +git_email = os.getenv("GIT_EMAIL") +git_token = os.getenv("GIT_TOKEN") # For private repos + +# Use a single string command with Git configuration +git_agent = Agent( + instructions="""You are a helpful assistant that can perform Git operations. + Use the available tools when relevant to manage repositories, commits, and branches.""", + llm="gpt-4o-mini", + tools=MCP("python -m mcp_server_git", + env={ + "GIT_USERNAME": git_username, + "GIT_EMAIL": git_email, + "GIT_TOKEN": git_token + }) +) + +git_agent.start("Clone and analyze the repository at https://github.com/modelcontextprotocol/servers") \ No newline at end of file diff --git a/examples/mcp/ollama.py b/examples/mcp/ollama.py new file mode 100644 index 000000000..49921db97 --- /dev/null +++ b/examples/mcp/ollama.py @@ -0,0 +1,9 @@ +from praisonaiagents import Agent, MCP + +search_agent = Agent( + instructions="""You help book apartments on Airbnb.""", + llm="ollama/llama3.2", + tools=MCP("npx -y @openbnb/mcp-server-airbnb --ignore-robots-txt") +) + +search_agent.start("MUST USE airbnb_search Tool to Search. Search for Apartments in Paris for 2 nights. 04/28 - 04/30 for 2 adults. All Your Preference") \ No newline at end of file diff --git a/examples/mcp/sentry-mcp.py b/examples/mcp/sentry-mcp.py new file mode 100644 index 000000000..28f045b15 --- /dev/null +++ b/examples/mcp/sentry-mcp.py @@ -0,0 +1,16 @@ +from praisonaiagents import Agent, MCP +import os + +# pip install mcp-server-sentry +# Get Sentry auth token from environment +sentry_token = os.getenv("SENTRY_AUTH_TOKEN") + +# Use a single string command with Sentry configuration +sentry_agent = Agent( + instructions="""You are a helpful assistant that can analyze Sentry error reports. + Use the available tools when relevant to inspect and debug application issues.""", + llm="gpt-4o-mini", + tools=MCP("python -m mcp_server_sentry --auth-token", args=[sentry_token]) +) + +sentry_agent.start("Analyze the most recent critical error in Sentry") \ No newline at end of file diff --git a/examples/mcp/time-mcp.py b/examples/mcp/time-mcp.py new file mode 100644 index 000000000..5cc2ffaeb --- /dev/null +++ b/examples/mcp/time-mcp.py @@ -0,0 +1,13 @@ +from praisonaiagents import Agent, MCP +import os + +# pip install mcp-server-time +# Use a single string command with Time Server configuration +time_agent = Agent( + instructions="""You are a helpful assistant that can handle time-related operations. + Use the available tools when relevant to manage timezone conversions and time information.""", + llm="gpt-4o-mini", + tools=MCP("python -m mcp_server_time --local-timezone=America/New_York") +) + +time_agent.start("Get the current time in New York and convert it to UTC") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bf63c3079..2f13ec800 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "python-dotenv>=0.19.0", "instructor>=1.3.3", "PyYAML>=6.0", + "mcp==1.6.0", ] [project.optional-dependencies] @@ -106,6 +107,7 @@ praisonaiagents = ">=0.0.67" python-dotenv = ">=0.19.0" instructor = ">=1.3.3" PyYAML = ">=6.0" +mcp = "==1.6.0" pyautogen = {version = ">=0.2.19", optional = true} crewai = {version = ">=0.32.0", optional = true} praisonai-tools = {version = ">=0.0.7", optional = true} diff --git a/src/praisonai-agents/mcp-ollama.py b/src/praisonai-agents/mcp-ollama.py new file mode 100644 index 000000000..f63eac4f4 --- /dev/null +++ b/src/praisonai-agents/mcp-ollama.py @@ -0,0 +1,9 @@ +from praisonaiagents import Agent, MCP + +search_agent = Agent( + instructions="""You help book apartments on Airbnb.""", + llm="ollama/llama3.2", + tools=MCP("npx -y @openbnb/mcp-server-airbnb --ignore-robots-txt") +) + +search_agent.start("Search for Apartments in Paris for 2 nights. 04/28 - 04/30 for 2 adults. All Your Preference. After searching Give me summary") \ No newline at end of file diff --git a/src/praisonai-agents/praisonaiagents/agent/agent.py b/src/praisonai-agents/praisonaiagents/agent/agent.py index 261fc61f0..3046488e3 100644 --- a/src/praisonai-agents/praisonaiagents/agent/agent.py +++ b/src/praisonai-agents/praisonaiagents/agent/agent.py @@ -421,6 +421,12 @@ def __init__( # Pass the entire string so LiteLLM can parse provider/model self.llm_instance = LLM(model=llm) self._using_custom_llm = True + + # Ensure tools are properly accessible when using custom LLM + if tools: + logging.debug(f"Tools passed to Agent with custom LLM: {tools}") + # Store the tools for later use + self.tools = tools except ImportError as e: raise ImportError( "LLM features requested but dependencies not installed. " @@ -519,9 +525,20 @@ def execute_tool(self, function_name, arguments): """ logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}") + # Special handling for MCP tools + # Check if tools is an MCP instance with the requested function name + from ..mcp.mcp import MCP + if isinstance(self.tools, MCP): + logging.debug(f"Looking for MCP tool {function_name}") + # Check if any of the MCP tools match the function name + for mcp_tool in self.tools.runner.tools: + if hasattr(mcp_tool, 'name') and mcp_tool.name == function_name: + logging.debug(f"Found matching MCP tool: {function_name}") + return self.tools.runner.call_tool(function_name, arguments) + # Try to find the function in the agent's tools list first func = None - for tool in self.tools: + for tool in self.tools if isinstance(self.tools, (list, tuple)) else []: if (callable(tool) and getattr(tool, '__name__', '') == function_name) or \ (inspect.isclass(tool) and tool.__name__ == function_name): func = tool @@ -643,24 +660,64 @@ def _chat_completion(self, messages, temperature=0.2, tools=None, stream=True, r logging.warning(f"Tool {tool} not recognized") try: - if stream: - # Process as streaming response with formatted tools - final_response = self._process_stream_response( - messages, - temperature, - start_time, - formatted_tools=formatted_tools if formatted_tools else None, - reasoning_steps=reasoning_steps - ) + # Use the custom LLM instance if available + if self._using_custom_llm and hasattr(self, 'llm_instance'): + if stream: + # Debug logs for tool info + if formatted_tools: + logging.debug(f"Passing {len(formatted_tools)} formatted tools to LLM instance: {formatted_tools}") + + # Use the LLM instance for streaming responses + final_response = self.llm_instance.get_response( + prompt=messages[1:], # Skip system message as LLM handles it separately + system_prompt=messages[0]['content'] if messages and messages[0]['role'] == 'system' else None, + temperature=temperature, + tools=formatted_tools if formatted_tools else None, + verbose=self.verbose, + markdown=self.markdown, + stream=True, + console=self.console, + execute_tool_fn=self.execute_tool, + agent_name=self.name, + agent_role=self.role, + reasoning_steps=reasoning_steps + ) + else: + # Non-streaming with custom LLM + final_response = self.llm_instance.get_response( + prompt=messages[1:], + system_prompt=messages[0]['content'] if messages and messages[0]['role'] == 'system' else None, + temperature=temperature, + tools=formatted_tools if formatted_tools else None, + verbose=self.verbose, + markdown=self.markdown, + stream=False, + console=self.console, + execute_tool_fn=self.execute_tool, + agent_name=self.name, + agent_role=self.role, + reasoning_steps=reasoning_steps + ) else: - # Process as regular non-streaming response - final_response = client.chat.completions.create( - model=self.llm, - messages=messages, - temperature=temperature, - tools=formatted_tools if formatted_tools else None, - stream=False - ) + # Use the standard OpenAI client approach + if stream: + # Process as streaming response with formatted tools + final_response = self._process_stream_response( + messages, + temperature, + start_time, + formatted_tools=formatted_tools if formatted_tools else None, + reasoning_steps=reasoning_steps + ) + else: + # Process as regular non-streaming response + final_response = client.chat.completions.create( + model=self.llm, + messages=messages, + temperature=temperature, + tools=formatted_tools if formatted_tools else None, + stream=False + ) tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None) @@ -748,13 +805,26 @@ def chat(self, prompt, temperature=0.2, tools=None, output_json=None, output_pyd if self._using_custom_llm: try: + # Special handling for MCP tools when using provider/model format + tool_param = self.tools if tools is None else tools + + # Convert MCP tool objects to OpenAI format if needed + if tool_param is not None: + from ..mcp.mcp import MCP + if isinstance(tool_param, MCP) and hasattr(tool_param, 'to_openai_tool'): + logging.debug("Converting MCP tool to OpenAI format") + openai_tool = tool_param.to_openai_tool() + if openai_tool: + tool_param = [openai_tool] + logging.debug(f"Converted MCP tool: {tool_param}") + # Pass everything to LLM class response_text = self.llm_instance.get_response( prompt=prompt, system_prompt=f"{self.backstory}\n\nYour Role: {self.role}\n\nYour Goal: {self.goal}" if self.use_system_prompt else None, chat_history=self.chat_history, temperature=temperature, - tools=self.tools if tools is None else tools, + tools=tool_param, output_json=output_json, output_pydantic=output_pydantic, verbose=self.verbose, diff --git a/src/praisonai-agents/praisonaiagents/llm/llm.py b/src/praisonai-agents/praisonaiagents/llm/llm.py index 8017ddf9f..23f75f7bf 100644 --- a/src/praisonai-agents/praisonaiagents/llm/llm.py +++ b/src/praisonai-agents/praisonaiagents/llm/llm.py @@ -289,15 +289,21 @@ def get_response( if tools: formatted_tools = [] for tool in tools: - if callable(tool): + # Check if the tool is already in OpenAI format (e.g. from MCP.to_openai_tool()) + if isinstance(tool, dict) and 'type' in tool and tool['type'] == 'function': + logging.debug(f"Using pre-formatted OpenAI tool: {tool['function']['name']}") + formatted_tools.append(tool) + elif callable(tool): tool_def = self._generate_tool_definition(tool.__name__) + if tool_def: + formatted_tools.append(tool_def) elif isinstance(tool, str): tool_def = self._generate_tool_definition(tool) + if tool_def: + formatted_tools.append(tool_def) else: - continue + logging.debug(f"Skipping tool of unsupported type: {type(tool)}") - if tool_def: - formatted_tools.append(tool_def) if not formatted_tools: formatted_tools = None diff --git a/src/praisonai-agents/praisonaiagents/mcp/mcp.py b/src/praisonai-agents/praisonaiagents/mcp/mcp.py index 26f4a40cb..6ca53f858 100644 --- a/src/praisonai-agents/praisonaiagents/mcp/mcp.py +++ b/src/praisonai-agents/praisonaiagents/mcp/mcp.py @@ -313,6 +313,45 @@ def __iter__(self) -> Iterable[Callable]: """ return iter(self._tools) + def to_openai_tool(self): + """Convert the MCP tool to an OpenAI-compatible tool definition. + + This method is specifically invoked by the Agent class when using + provider/model format (e.g., "openai/gpt-4o-mini"). + + Returns: + dict: OpenAI-compatible tool definition + """ + # For simplicity, we'll convert the first tool only if multiple exist + # More complex implementations could handle multiple tools + if not self.runner.tools: + logging.warning("No MCP tools available to convert to OpenAI format") + return None + + # Get the first tool's schema + tool = self.runner.tools[0] + + # Create OpenAI tool definition + parameters = {} + if hasattr(tool, 'inputSchema') and tool.inputSchema: + parameters = tool.inputSchema + else: + # Create a minimal schema if none exists + parameters = { + "type": "object", + "properties": {}, + "required": [] + } + + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description if hasattr(tool, 'description') else f"Call the {tool.name} tool", + "parameters": parameters + } + } + def __del__(self): """Clean up resources when the object is garbage collected.""" if hasattr(self, 'runner'): diff --git a/src/praisonai-agents/pyproject.toml b/src/praisonai-agents/pyproject.toml index 01b6c48e1..7f49b84bb 100644 --- a/src/praisonai-agents/pyproject.toml +++ b/src/praisonai-agents/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonaiagents" -version = "0.0.67" +version = "0.0.68" description = "Praison AI agents for completing complex tasks with Self Reflection Agents" authors = [ { name="Mervin Praison" } @@ -12,7 +12,8 @@ authors = [ dependencies = [ "pydantic", "rich", - "openai" + "openai", + "mcp==1.6.0" ] [project.optional-dependencies] diff --git a/src/praisonai-agents/uv.lock b/src/praisonai-agents/uv.lock index 9aaa99d64..7368dc9ae 100644 --- a/src/praisonai-agents/uv.lock +++ b/src/praisonai-agents/uv.lock @@ -884,6 +884,15 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + [[package]] name = "huggingface-hub" version = "0.27.1" @@ -1254,6 +1263,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, ] +[[package]] +name = "mcp" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1868,9 +1896,10 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "0.0.67" +version = "0.0.68" source = { editable = "." } dependencies = [ + { name = "mcp" }, { name = "openai" }, { name = "pydantic" }, { name = "rich" }, @@ -1906,6 +1935,7 @@ requires-dist = [ { name = "chromadb", marker = "extra == 'memory'", specifier = ">=0.5.23" }, { name = "litellm", marker = "extra == 'llm'", specifier = ">=1.50.0" }, { name = "markitdown", marker = "extra == 'knowledge'" }, + { name = "mcp", specifier = "==1.6.0" }, { name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" }, { name = "openai" }, { name = "praisonaiagents", extras = ["knowledge"], marker = "extra == 'all'" }, @@ -2093,6 +2123,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, ] +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + [[package]] name = "pydub" version = "0.25.1" @@ -2525,6 +2568,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/36/59cc97c365f2f79ac9f3f51446cae56dfd82c4f2dd98497e6be6de20fb91/SQLAlchemy-2.0.37-py3-none-any.whl", hash = "sha256:a8998bf9f8658bd3839cbc44ddbe982955641863da0c1efe5b00c1ab4f5c16b1", size = 1894113 }, ] +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + [[package]] name = "standard-aifc" version = "3.13.0"