From 3518b96ea8c23e6ba554877064b19c3767b19448 Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Fri, 21 Feb 2025 17:23:44 +0100 Subject: [PATCH 01/21] feat: Implement support for MCP Servers (Claude Tools) - Part 1 Stdio Servers feat: (draft) support MCP Servers feat: install npx for local MCP Servers execution feat: add nest-asyncio as direct dependency feat: add pdf2image to requirements.txt feat: add local nginx for playwright file access feat: MCP Server Support (Part 1: local stdio servers) --- agent.py | 96 +++- docker/run/fs/etc/nginx/nginx.conf | 31 ++ docker/run/fs/ins/install_A0.sh | 2 +- docker/run/fs/ins/pre_install.sh | 4 + initialize.py | 18 + prompts/default/agent.system.mcp_tools.md | 1 + python/api/message.py | 2 +- python/api/settings_set.py | 4 +- .../system_prompt/_10_system_prompt.py | 15 +- python/helpers/mcp.py | 455 ++++++++++++++++++ python/helpers/settings.py | 51 +- requirements.txt | 3 + 12 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 docker/run/fs/etc/nginx/nginx.conf create mode 100644 prompts/default/agent.system.mcp_tools.md create mode 100644 python/helpers/mcp.py diff --git a/agent.py b/agent.py index fad78a423..6b9525adf 100644 --- a/agent.py +++ b/agent.py @@ -1,4 +1,7 @@ import asyncio +import nest_asyncio +nest_asyncio.apply() + from collections import OrderedDict from dataclasses import dataclass, field from datetime import datetime @@ -183,6 +186,25 @@ class AgentConfig: prompts_subdir: str = "" memory_subdir: str = "" knowledge_subdirs: list[str] = field(default_factory=lambda: ["default", "custom"]) + mcp_servers: str = """[ + { + "name": "MCP Server 1", + "url": "https://mcp.server.com", + "headers": { + "Authorization": "Bearer 1234567890" + }, + "disabled": true, + }, + { + "name": "MCP Server 2", + "command": "python3", + "args": ["mcp.py"], + "env": { + "PYTHONPATH": "." + }, + "disabled": true, + } +]""" code_exec_docker_enabled: bool = False code_exec_docker_name: str = "A0-dev" code_exec_docker_image: str = "frdel/agent-zero-run:development" @@ -655,30 +677,68 @@ class Agent: tool_request = extract_tools.json_parse_dirty(msg) if tool_request is not None: - tool_name = tool_request.get("tool_name", "") - tool_method = None + raw_tool_name = tool_request.get("tool_name", "") # Get the raw tool name tool_args = tool_request.get("tool_args", {}) + + tool_name = raw_tool_name # Initialize tool_name with raw_tool_name + tool_method = None # Initialize tool_method - if ":" in tool_name: - tool_name, tool_method = tool_name.split(":", 1) + # Split raw_tool_name into tool_name and tool_method if applicable + if ":" in raw_tool_name: + tool_name, tool_method = raw_tool_name.split(":", 1) + + tool = None # Initialize tool to None - tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg) + # Try getting tool from MCP first + try: + import python.helpers.mcp as mcp_helper + mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool(self, tool_name) + if mcp_tool_candidate: + tool = mcp_tool_candidate + except ImportError: + # Get context safely + current_context = AgentContext.first() + if current_context: + current_context.log.log(type="warning", content="MCP helper module not found. Skipping MCP tool lookup.", temp=True) + PrintStyle(background_color="black", font_color="yellow", padding=True).print( + "MCP helper module not found. Skipping MCP tool lookup." + ) + except Exception as e: + # Get context safely + current_context = AgentContext.first() + if current_context: + current_context.log.log(type="warning", content=f"Failed to get MCP tool '{tool_name}': {e}", temp=True) + PrintStyle(background_color="black", font_color="red", padding=True).print( + f"Failed to get MCP tool '{tool_name}': {e}" + ) - await self.handle_intervention() # wait if paused and handle intervention message if needed - await tool.before_execution(**tool_args) - await self.handle_intervention() # wait if paused and handle intervention message if needed - response = await tool.execute(**tool_args) - await self.handle_intervention() # wait if paused and handle intervention message if needed - await tool.after_execution(response) - await self.handle_intervention() # wait if paused and handle intervention message if needed - if response.break_loop: - return response.message + # Fallback to local get_tool if MCP tool was not found or MCP lookup failed + if not tool: + tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg) + + if tool: + await self.handle_intervention() + await tool.before_execution(**tool_args) + await self.handle_intervention() + response = await tool.execute(**tool_args) + await self.handle_intervention() + await tool.after_execution(response) + await self.handle_intervention() + if response.break_loop: + return response.message + else: + error_detail = f"Tool '{raw_tool_name}' not found or could not be initialized." + self.hist_add_warning(error_detail) + PrintStyle(font_color="red", padding=True).print(error_detail) + self.context.log.log( + type="error", content=f"{self.agent_name}: {error_detail}" + ) else: - msg = self.read_prompt("fw.msg_misformat.md") - self.hist_add_warning(msg) - PrintStyle(font_color="red", padding=True).print(msg) + warning_msg_misformat = self.read_prompt("fw.msg_misformat.md") + self.hist_add_warning(warning_msg_misformat) + PrintStyle(font_color="red", padding=True).print(warning_msg_misformat) self.context.log.log( - type="error", content=f"{self.agent_name}: Message misformat" + type="error", content=f"{self.agent_name}: Message misformat, no valid tool request found." ) def log_from_stream(self, stream: str, logItem: Log.LogItem): diff --git a/docker/run/fs/etc/nginx/nginx.conf b/docker/run/fs/etc/nginx/nginx.conf new file mode 100644 index 000000000..54dffe65d --- /dev/null +++ b/docker/run/fs/etc/nginx/nginx.conf @@ -0,0 +1,31 @@ +daemon off; +worker_processes 2; +user www-data; + +events { + use epoll; + worker_connections 128; +} + +error_log /var/log/nginx/error.log info; + +http { + server_tokens off; + include mime.types; + charset utf-8; + + access_log /var/log/nginx/access.log combined; + + server { + server_name 127.0.0.1:31735; + listen 127.0.0.1:31735; + + error_page 500 502 503 504 /50x.html; + + location / { + root /; + } + + } + +} diff --git a/docker/run/fs/ins/install_A0.sh b/docker/run/fs/ins/install_A0.sh index 2685dc66e..c89c2cecf 100644 --- a/docker/run/fs/ins/install_A0.sh +++ b/docker/run/fs/ins/install_A0.sh @@ -26,4 +26,4 @@ pip install -r /git/agent-zero/requirements.txt bash /ins/install_playwright.sh "$@" # Preload A0 -python /git/agent-zero/preload.py --dockerized=true \ No newline at end of file +python /git/agent-zero/preload.py --dockerized=true diff --git a/docker/run/fs/ins/pre_install.sh b/docker/run/fs/ins/pre_install.sh index a073183cb..9cabd11d0 100644 --- a/docker/run/fs/ins/pre_install.sh +++ b/docker/run/fs/ins/pre_install.sh @@ -18,6 +18,7 @@ apt-get update && apt-get upgrade -y && apt-get install -y \ wget \ git \ ffmpeg \ + nginx\ supervisor \ cron @@ -43,5 +44,8 @@ echo "=====AFTER UPDATE=====" # python3 -m pip install --upgrade pip # fi +# Install npx for use by local MCP Servers +npm i -g npx shx + # Prepare SSH daemon bash /ins/setup_ssh.sh "$@" diff --git a/initialize.py b/initialize.py index 0003b0223..92961689c 100644 --- a/initialize.py +++ b/initialize.py @@ -53,6 +53,7 @@ def initialize(): prompts_subdir=current_settings["agent_prompts_subdir"], memory_subdir=current_settings["agent_memory_subdir"], knowledge_subdirs=["default", current_settings["agent_knowledge_subdir"]], + mcp_servers=current_settings["mcp_servers"], code_exec_docker_enabled=False, # code_exec_docker_name = "A0-dev", # code_exec_docker_image = "frdel/agent-zero-run:development", @@ -75,6 +76,23 @@ def initialize(): # update config with runtime args args_override(config) + import python.helpers.mcp as mcp_helper + import agent as agent_helper + import python.helpers.print_style as print_style_helper + if not mcp_helper.MCPConfig.get_instance().is_initialized(): + try: + mcp_helper.MCPConfig.update(config.mcp_servers) + except Exception as e: + if agent_helper.AgentContext.first(): + ( + agent_helper.AgentContext.first().log + .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False) + ) + ( + print_style_helper.PrintStyle(background_color="black", font_color="red", padding=True) + .print(f"Failed to update MCP settings: {e}") + ) + # return config object return config diff --git a/prompts/default/agent.system.mcp_tools.md b/prompts/default/agent.system.mcp_tools.md new file mode 100644 index 000000000..67776e5b4 --- /dev/null +++ b/prompts/default/agent.system.mcp_tools.md @@ -0,0 +1 @@ +{{tools}} diff --git a/python/api/message.py b/python/api/message.py index fc3091fba..de397355a 100644 --- a/python/api/message.py +++ b/python/api/message.py @@ -85,4 +85,4 @@ class Message(ApiHandler): id=message_id, ) - return context.communicate(UserMessage(message, attachment_paths)), context \ No newline at end of file + return context.communicate(UserMessage(message, attachment_paths)), context diff --git a/python/api/settings_set.py b/python/api/settings_set.py index 93440876f..6700aace5 100644 --- a/python/api/settings_set.py +++ b/python/api/settings_set.py @@ -3,9 +3,11 @@ from flask import Request, Response from python.helpers import settings +from typing import Any + class SetSettings(ApiHandler): - async def process(self, input: dict, request: Request) -> dict | Response: + async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: set = settings.convert_in(input) set = settings.set_settings(set) return {"settings": set} diff --git a/python/extensions/system_prompt/_10_system_prompt.py b/python/extensions/system_prompt/_10_system_prompt.py index f9fdcb14a..1ff461551 100644 --- a/python/extensions/system_prompt/_10_system_prompt.py +++ b/python/extensions/system_prompt/_10_system_prompt.py @@ -1,17 +1,22 @@ -from datetime import datetime, timezone +from datetime import datetime +from typing import Any, Optional from python.helpers.extension import Extension +from python.helpers.mcp import MCPConfig from agent import Agent, LoopData from python.helpers.localization import Localization class SystemPrompt(Extension): - async def execute(self, system_prompt: list[str]=[], loop_data: LoopData = LoopData(), **kwargs): + async def execute(self, system_prompt: list[str] = [], loop_data: LoopData = LoopData(), **kwargs: Any): # append main system prompt and tools main = get_main_prompt(self.agent) tools = get_tools_prompt(self.agent) + mcp_tools = get_mcp_tools_prompt(self.agent) + system_prompt.append(main) system_prompt.append(tools) + system_prompt.append(mcp_tools) def get_main_prompt(agent: Agent): @@ -22,4 +27,8 @@ def get_tools_prompt(agent: Agent): prompt = agent.read_prompt("agent.system.tools.md") if agent.config.chat_model.vision: prompt += '\n' + agent.read_prompt("agent.system.tools_vision.md") - return prompt \ No newline at end of file + return prompt + + +def get_mcp_tools_prompt(agent: Agent): + return MCPConfig.get_instance().get_tools_prompt() diff --git a/python/helpers/mcp.py b/python/helpers/mcp.py new file mode 100644 index 000000000..5940fae87 --- /dev/null +++ b/python/helpers/mcp.py @@ -0,0 +1,455 @@ +from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr +from typing import List, Dict, Optional, Any, Union, Literal, Annotated +from typing import ( + List, Dict, Optional, Any, + Union, Literal, Annotated, ClassVar, +) +import threading +import asyncio +from contextlib import AsyncExitStack +from shutil import which +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import CallToolResult, ListToolsResult, JSONRPCMessage +from anyio.streams.memory import ( + MemoryObjectReceiveStream, + MemoryObjectSendStream, +) +from python.helpers.dirty_json import DirtyJson +from python.helpers.print_style import PrintStyle +import dirtyjson + +from python.helpers.tool import Tool, Response +from datetime import timedelta + +from abc import ABC, abstractmethod + + +class MCPTool(Tool): + """MCP Tool wrapper""" + async def execute(self, **kwargs: Any): + error = "" + try: + response: CallToolResult = await MCPConfig.get_instance().call_tool(self.name, kwargs) + message = "\n\n".join([item.text for item in response.content if item.type == "text"]) + if response.isError: + error = message + except Exception as e: + error = f"MCP Tool Exception: {str(e)}" + message = f"ERROR: {str(e)}" + + if error: + PrintStyle( + background_color="#CC34C3", font_color="white", bold=True, padding=True + ).print(f"MCPTool::Failed to call mcp tool {self.name}:") + PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(error) + + self.agent.context.log.log( + type="warning", + content=f"{self.name}: {error}", + ) + + return Response(message=message, break_loop=False) + + async def before_execution(self, **kwargs: Any): + ( + PrintStyle(font_color="#1B4F72", padding=True, background_color="white", bold=True) + .print(f"{self.agent.agent_name}: Using tool '{self.name}'") + ) + self.log = self.get_log_object() + + for key, value in self.args.items(): + PrintStyle(font_color="#85C1E9", bold=True).stream(self.nice_key(key)+": ") + PrintStyle(font_color="#85C1E9", padding=isinstance(value, str) and "\n" in value).stream(value) + PrintStyle().print() + + async def after_execution(self, response: Response, **kwargs: Any): + # Check if response or message is None + if not response.message.strip(): + text = "" + PrintStyle(font_color="red").print(f"Warning: Tool '{self.name}' returned None response or message") + else: + text = response.message.strip() + + await self.agent.hist_add_tool_result(self.name, text) + ( + PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True) + .print(f"{self.agent.agent_name}: Response from tool '{self.name}'") + ) + PrintStyle(font_color="#85C1E9").print(text) + self.log.update(content=text) + + +class MCPServerRemote(BaseModel): + name: str = Field(default_factory=str) + description: Optional[str] = Field(default="Remote SSE Server") + url: str = Field(default_factory=str) + headers: dict[str, Any] | None = Field(default_factory=dict[str, Any]) + timeout: float = 5.0 + sse_read_timeout: float = 60.0 * 5.0 + disabled: bool = False + + __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) + + def __init__(self, config: dict[str, Any]): + super().__init__() + self.update(config) + + def get_tools(self) -> List[dict[str, Any]]: + """Get all tools from the server""" + return [] + + def has_tool(self, tool_name: str) -> bool: + """Check if a tool is available""" + return False + + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: + """Call a tool with the given input data""" + raise NotImplementedError("MCPServerRemote does not support calling tools") + + def update(self, config: dict[str, Any]) -> "MCPServerRemote": + with self.__lock: + for key, value in config.items(): + if key in ["name", "description", "url", "headers", "timeout", "sse_read_timeout", "disabled"]: + setattr(self, key, value) + # We already run in an event loop, dont believe Pylance + return asyncio.run(self.__on_update()) + + async def __on_update(self) -> "MCPServerRemote": + return self + + +class MCPServerLocal(BaseModel): + name: str = Field(default_factory=str) + description: Optional[str] = Field(default="Local StdIO Server") + command: str = Field(default_factory=str) + args: list[str] = Field(default_factory=list) + env: dict[str, str] | None = Field(default_factory=dict[str, str]) + encoding: str = "utf-8" + encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict" + disabled: bool = False + + __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) + __client: Optional["MCPClientLocal"] = PrivateAttr(default=None) + + def __init__(self, config: dict[str, Any]): + super().__init__() + self.__client = MCPClientLocal(self) + self.update(config) + + def get_tools(self) -> List[dict[str, Any]]: + """Get all tools from the server""" + with self.__lock: + return self.__client.tools + + def has_tool(self, tool_name: str) -> bool: + """Check if a tool is available""" + with self.__lock: + return self.__client.has_tool(tool_name) + + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: + """Call a tool with the given input data""" + with self.__lock: + # We already run in an event loop, dont believe Pylance + return await self.__client.call_tool(tool_name, input_data) + + def update(self, config: dict[str, Any]) -> "MCPServerLocal": + with self.__lock: + for key, value in config.items(): + if key in ["name", "description", "command", "args", "env", "encoding", "encoding_error_handler", "disabled"]: + if key == "name": + value = value.strip().lower().replace(" ", "_").replace("-", "_").replace(".", "_") + setattr(self, key, value) + # We already run in an event loop, dont believe Pylance + return asyncio.run(self.__on_update()) + + async def __on_update(self) -> "MCPServerLocal": + await self.__client.update_tools() + return self + + +MCPServer = Annotated[ + Union[ + Annotated[MCPServerRemote, Tag('MCPServerRemote')], + Annotated[MCPServerLocal, Tag('MCPServerLocal')] + ], + Discriminator(lambda v: "MCPServerRemote" if "url" in v else "MCPServerLocal") +] + + +class MCPConfig(BaseModel): + servers: List[MCPServer] = Field(default_factory=list[MCPServer]) + + __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) + + # Singleton instance + __instance: ClassVar[Any] = PrivateAttr(default=None) + __initialized: ClassVar[bool] = PrivateAttr(default=False) + + @classmethod + def get_instance(cls) -> "MCPConfig": + if cls.__instance is None: + cls.__instance = cls(servers_list=[]) + return cls.__instance + + @classmethod + def update(cls, config_str: str) -> Any: + """Parse the MCP config string into a MCPConfig object.""" + with cls.__lock: + try: + servers = dirtyjson.loads(config_str) + except Exception: + try: + servers = DirtyJson.parse_string(config_str) + except Exception as e: + raise ValueError(f"Failed to parse MCP config: {e}") + cls.get_instance().__init__(servers_list=servers) + cls.__initialized = True + return cls.get_instance() + + def __init__(self, servers_list: List[Dict[str, Any]]): + from collections.abc import Mapping, Iterable + + # This empties the servers list + super().__init__() + + if not isinstance(servers_list, Iterable): + ( + PrintStyle(background_color="grey", font_color="red", padding=True) + .print("MCPConfig::__init__::servers_list must be a list") + ) + return + + for server_item in servers_list: + if not isinstance(server_item, Mapping): + ( + PrintStyle(background_color="grey", font_color="red", padding=True) + .print("MCPConfig::__init__::server_item must be a mapping") + ) + continue + + if server_item.get("disabled", False): + continue + + server_name = server_item.get("name", "__not__found__") + if server_name == "__not__found__": + ( + PrintStyle(background_color="grey", font_color="red", padding=True) + .print("MCPConfig::__init__::server_name is required") + ) + continue + + try: + # not generic MCPServer because: "Annotated can not be instatioated" + if server_item.get("url", None): + self.servers.append(MCPServerRemote(server_item)) + else: + self.servers.append(MCPServerLocal(server_item)) + except Exception as e: + ( + PrintStyle(background_color="grey", font_color="red", padding=True) + .print(f"MCPConfig::__init__: Failedto create MCPServer '{server_name}': {e}") + ) + continue + + def is_initialized(self) -> bool: + """Check if the client is initialized""" + with self.__lock: + return self.__initialized + + def get_tools(self) -> List[dict[str, str | dict[str, Any] | None]]: + """Get all tools from all servers""" + with self.__lock: + tools = [] + for server in self.servers: + for tool in server.get_tools(): + tool_copy = tool.copy() + tool_copy["server"] = server.name + tools.append({f"{server.name}.{tool['name']}": tool_copy}) + return tools + + def get_tools_prompt(self, server_name: str = "") -> str: + """Get a prompt for all tools""" + prompt = '## "Remote (MCP Server) Agent Tools" available:\n\n' + server_names = [] + for server in self.servers: + if not server_name or server.name == server_name: + server_names.append(server.name) + + if server_name and server_name not in server_names: + raise ValueError(f"Server {server_name} not found") + + for server in self.servers: + if server.name in server_names: + server_name = server.name + for tool in server.get_tools(): + prompt += ( + f"### {server_name}.{tool['name']}:\n" + f"{tool['description']}\n\n" + f"#### Categories:\n" + f"* kind: MCP Server Tool\n" + f'* server: "{server_name}" ({server.description})\n\n' + f"#### Arguments:\n" + ) + + tool_args = "" + properties: dict[str, Any] = tool["input_schema"]["properties"] + for key, value in properties.items(): + tool_args += f" \"{key}\": \"...\",\n" + if "examples" in value: + prompt += ( + f" * {key} ({value['type']}): {value['description']} (examples: {value['examples']})\n" + ) + else: + prompt += ( + f" * {key} ({value['type']}): {value['description']}\n" + ) + prompt += "\n" + + prompt += ( + f"#### Usage:\n" + f"~~~json\n" + f"{{\n" + f" \"observations\": [\"...\"],\n" + f" \"thoughts\": [\"...\"],\n" + f" \"reflection\": [\"...\"],\n" + f" \"tool_name\": \"{server_name}.{tool['name']}\",\n" + f" \"tool_args\": {{\n" + f"{tool_args}" + f" }}\n" + f"}}\n" + f"~~~\n" + ) + + return prompt + + def has_tool(self, tool_name: str) -> bool: + """Check if a tool is available""" + if "." not in tool_name: + return False + server_name_part, tool_name_part = tool_name.split(".") + with self.__lock: + for server in self.servers: + if server.name == server_name_part: + return server.has_tool(tool_name_part) + return False + + def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None: + if not self.has_tool(tool_name): + return None + return MCPTool(agent, tool_name, {}, "", **{}) + + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: + """Call a tool with the given input data""" + if "." not in tool_name: + raise ValueError(f"Tool {tool_name} not found") + server_name_part, tool_name_part = tool_name.split(".") + with self.__lock: + for server in self.servers: + if server.name == server_name_part and server.has_tool(tool_name_part): + return await server.call_tool(tool_name_part, input_data) + raise ValueError(f"Tool {tool_name} not found") + + +class MCPClientLocal: + session: Optional[ClientSession] = None + exit_stack: AsyncExitStack = AsyncExitStack() + stdio: Optional[MemoryObjectReceiveStream[JSONRPCMessage | Exception]] = None + write: Optional[MemoryObjectSendStream[JSONRPCMessage]] = None + + tools: List[dict[str, Any]] = [] + server: Optional[MCPServerLocal] = None + + __lock: ClassVar[threading.Lock] = threading.Lock() + + def __init__(self, server: MCPServerLocal): + self.server = server + + async def __connect_to_server(self) -> Any: + """Connect to an MCP server""" + + if not which(self.server.command): + raise ValueError(f"Command {self.server.command} not found") + + which_args = 0 + for arg in self.server.args: + if which(arg): + which_args = which_args + 1 + if which_args == 0: + raise ValueError(f"None of the arguments {self.server.args} is a file") + + with self.__lock: + server_params = StdioServerParameters( + command=self.server.command, + args=self.server.args, + env=self.server.env, + encoding=self.server.encoding, + encoding_error_handler=self.server.encoding_error_handler + ) + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) + self.stdio, self.write = stdio_transport + + self.session = ( + await self.exit_stack.enter_async_context( + ClientSession( + self.stdio, + self.write, + read_timeout_seconds=timedelta(seconds=15) + ) + ) + ) + + # Initialize session + await self.session.initialize() + return self + + async def update_tools(self) -> Any: + """List available tools from the server""" + try: + await self.__connect_to_server() + + with self.__lock: + response: ListToolsResult = await self.session.list_tools() + available_tools = [{ + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } for tool in response.tools] + + self.tools = available_tools + await self.exit_stack.aclose() + return self + except Exception as e: + PrintStyle( + background_color="#CC34C3", font_color="white", bold=True, padding=True + ).print("MCPClientLocal::Failed to update tools:") + PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(str(e)) + + def has_tool(self, tool_name: str) -> bool: + """Check if a tool is available""" + with self.__lock: + for tool in self.tools: + if tool["name"] == tool_name: + return True + return False + + def get_tools(self) -> List[dict[str, Any]]: + """Get all tools from the server""" + with self.__lock: + return self.tools + + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: + """Call a tool with the given input data""" + if not self.has_tool(tool_name): + await self.update_tools() + + await self.__connect_to_server() + + with self.__lock: + for tool in self.tools: + if tool["name"] == tool_name: + response: CallToolResult = await self.session.call_tool(tool_name, input_data) + # after connect have to close the stack within this function + await self.exit_stack.aclose() + return response + raise ValueError(f"Tool {tool_name} not found") diff --git a/python/helpers/settings.py b/python/helpers/settings.py index 33c457b32..e8c6e5cd4 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -7,6 +7,8 @@ from typing import Any, Literal, TypedDict import models from python.helpers import runtime, whisper, defer from . import files, dotenv +from python.helpers.print_style import PrintStyle + class Settings(TypedDict): @@ -43,6 +45,7 @@ class Settings(TypedDict): agent_prompts_subdir: str agent_memory_subdir: str agent_knowledge_subdir: str + mcp_servers: str api_keys: dict[str, str] @@ -531,6 +534,16 @@ def convert_out(settings: Settings) -> SettingsOutput: } ) + agent_fields.append( + { + "id": "mcp_servers", + "title": "MCP Servers", + "description": "(JSON list of) >> RemoteServer <<: [name, url, headers, timeout (opt), sse_read_timeout (opt), disabled (opt)] / >> Local Server <<: [name, command, args, env, encoding (opt), encoding_error_handler (opt), disabled (opt)]", + "type": "textarea", + "value": settings["mcp_servers"], + } + ) + agent_section: SettingsSection = { "id": "agent", "title": "Agent Config", @@ -829,6 +842,7 @@ def get_default_settings() -> Settings: agent_prompts_subdir="default", agent_memory_subdir="default", agent_knowledge_subdir="custom", + mcp_servers="", rfc_auto_docker=True, rfc_url="localhost", rfc_password="", @@ -848,8 +862,9 @@ def _apply_settings(previous: Settings | None): from agent import AgentContext from initialize import initialize + config = initialize() for ctx in AgentContext._contexts.values(): - ctx.config = initialize() # reinitialize context config with new settings + ctx.config = config # reinitialize context config with new settings # apply config to agents agent = ctx.agent0 while agent: @@ -870,6 +885,40 @@ def _apply_settings(previous: Settings | None): from python.helpers.memory import reload as memory_reload memory_reload() + # update mcp settings if necessary + from python.helpers.mcp import MCPConfig + + async def update_mcp_settings(mcp_servers: str): + PrintStyle(background_color="black", font_color="white", padding=True).print("Updating MCP config...") + AgentContext.first().log.log(type="info", content="Updating MCP settings...", temp=True) + + mcp_config = MCPConfig.get_instance() + try: + MCPConfig.update(mcp_servers) + except Exception as e: + AgentContext.first().log.log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False) + ( + PrintStyle(background_color="red", font_color="black", padding=True) + .print("Failed to update MCP settings") + ) + ( + PrintStyle(background_color="black", font_color="red", padding=True) + .print(f"{e}") + ) + + PrintStyle( + background_color="#6734C3", font_color="white", padding=True + ).print("Parsed MCP config:") + ( + PrintStyle(background_color="#334455", font_color="white", padding=False) + .print(mcp_config.model_dump_json()) + ) + AgentContext.first().log.log(type="info", content="Finished updating MCP settings :)", temp=True) + + task2 = defer.DeferredTask().start_task( + update_mcp_settings, config.mcp_servers + ) # TODO overkill, replace with background task + def _env_to_dict(data: str): env_dict = {} diff --git a/requirements.txt b/requirements.txt index a2cb4d1f1..3d9be0540 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,4 +31,7 @@ tiktoken==0.8.0 unstructured==0.15.13 unstructured-client==0.25.9 webcolors==24.6.0 +mcp==1.3.0 +nest-asyncio==1.6.0 +pdf2image==1.17.0 crontab==1.0.1 From d49b09083d455aa658655a88f7b03a6e86b7a646 Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Thu, 13 Mar 2025 11:25:10 +0100 Subject: [PATCH 02/21] feat: MCP initial support for sse servers (Part 2) --- docker/run/fs/ins/pre_install.sh | 16 +++- python/helpers/mcp.py | 132 +++++++++++++++++++------------ requirements.txt | 3 + 3 files changed, 98 insertions(+), 53 deletions(-) diff --git a/docker/run/fs/ins/pre_install.sh b/docker/run/fs/ins/pre_install.sh index 9cabd11d0..35cb14b9d 100644 --- a/docker/run/fs/ins/pre_install.sh +++ b/docker/run/fs/ins/pre_install.sh @@ -20,7 +20,21 @@ apt-get update && apt-get upgrade -y && apt-get install -y \ ffmpeg \ nginx\ supervisor \ - cron + cron \ + libmagic-dev \ + poppler-utils \ + tesseract-ocr \ + qpdf \ + libreoffice \ + pandoc \ + libgtk-3-0 \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libasound2 \ + libasound2-data \ + cargo echo "=====MID UPDATE=====" diff --git a/python/helpers/mcp.py b/python/helpers/mcp.py index 5940fae87..5af5f7aa9 100644 --- a/python/helpers/mcp.py +++ b/python/helpers/mcp.py @@ -1,28 +1,25 @@ -from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr -from typing import List, Dict, Optional, Any, Union, Literal, Annotated -from typing import ( - List, Dict, Optional, Any, - Union, Literal, Annotated, ClassVar, -) +from abc import ABC, abstractmethod +from typing import List, Dict, Optional, Any, Union, Literal, Annotated, ClassVar, cast import threading import asyncio from contextlib import AsyncExitStack from shutil import which +from datetime import timedelta +import dirtyjson +import json from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client +from mcp.client.sse import sse_client from mcp.types import CallToolResult, ListToolsResult, JSONRPCMessage from anyio.streams.memory import ( MemoryObjectReceiveStream, MemoryObjectSendStream, ) + +from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr from python.helpers.dirty_json import DirtyJson from python.helpers.print_style import PrintStyle -import dirtyjson - from python.helpers.tool import Tool, Response -from datetime import timedelta - -from abc import ABC, abstractmethod class MCPTool(Tool): @@ -59,7 +56,7 @@ class MCPTool(Tool): self.log = self.get_log_object() for key, value in self.args.items(): - PrintStyle(font_color="#85C1E9", bold=True).stream(self.nice_key(key)+": ") + PrintStyle(font_color="#85C1E9", bold=True).stream(self.nice_key(key) + ": ") PrintStyle(font_color="#85C1E9", padding=isinstance(value, str) and "\n" in value).stream(value) PrintStyle().print() @@ -85,37 +82,46 @@ class MCPServerRemote(BaseModel): description: Optional[str] = Field(default="Remote SSE Server") url: str = Field(default_factory=str) headers: dict[str, Any] | None = Field(default_factory=dict[str, Any]) - timeout: float = 5.0 - sse_read_timeout: float = 60.0 * 5.0 - disabled: bool = False + timeout: float = Field(default=5.0) + sse_read_timeout: float = Field(default=60.0 * 5.0) + disabled: bool = Field(default=False) __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) + __client: Optional["MCPClientRemote"] = PrivateAttr(default=None) def __init__(self, config: dict[str, Any]): super().__init__() + self.__client = MCPClientRemote(self) self.update(config) def get_tools(self) -> List[dict[str, Any]]: """Get all tools from the server""" - return [] + with self.__lock: + return self.__client.tools # type: ignore def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" - return False + with self.__lock: + return self.__client.has_tool(tool_name) # type: ignore async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: """Call a tool with the given input data""" - raise NotImplementedError("MCPServerRemote does not support calling tools") + with self.__lock: + # We already run in an event loop, dont believe Pylance + return await self.__client.call_tool(tool_name, input_data) # type: ignore def update(self, config: dict[str, Any]) -> "MCPServerRemote": with self.__lock: for key, value in config.items(): if key in ["name", "description", "url", "headers", "timeout", "sse_read_timeout", "disabled"]: + if key == "name": + value = value.strip().lower().replace(" ", "_").replace("-", "_").replace(".", "_") setattr(self, key, value) - # We already run in an event loop, dont believe Pylance - return asyncio.run(self.__on_update()) + # We already run in an event loop, dont believe Pylance + return asyncio.run(self.__on_update()) async def __on_update(self) -> "MCPServerRemote": + await self.__client.update_tools() # type: ignore return self @@ -125,9 +131,9 @@ class MCPServerLocal(BaseModel): command: str = Field(default_factory=str) args: list[str] = Field(default_factory=list) env: dict[str, str] | None = Field(default_factory=dict[str, str]) - encoding: str = "utf-8" - encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict" - disabled: bool = False + encoding: str = Field(default="utf-8") + encoding_error_handler: Literal["strict", "ignore", "replace"] = Field(default="strict") + disabled: bool = Field(default=False) __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) __client: Optional["MCPClientLocal"] = PrivateAttr(default=None) @@ -140,18 +146,18 @@ class MCPServerLocal(BaseModel): def get_tools(self) -> List[dict[str, Any]]: """Get all tools from the server""" with self.__lock: - return self.__client.tools + return self.__client.tools # type: ignore def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" with self.__lock: - return self.__client.has_tool(tool_name) + return self.__client.has_tool(tool_name) # type: ignore async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: """Call a tool with the given input data""" with self.__lock: # We already run in an event loop, dont believe Pylance - return await self.__client.call_tool(tool_name, input_data) + return await self.__client.call_tool(tool_name, input_data) # type: ignore def update(self, config: dict[str, Any]) -> "MCPServerLocal": with self.__lock: @@ -164,7 +170,7 @@ class MCPServerLocal(BaseModel): return asyncio.run(self.__on_update()) async def __on_update(self) -> "MCPServerLocal": - await self.__client.update_tools() + await self.__client.update_tools() # type: ignore return self @@ -202,7 +208,7 @@ class MCPConfig(BaseModel): try: servers = DirtyJson.parse_string(config_str) except Exception as e: - raise ValueError(f"Failed to parse MCP config: {e}") + raise ValueError(f"Failed to parse MCP config: {e}") from e cls.get_instance().__init__(servers_list=servers) cls.__initialized = True return cls.get_instance() @@ -351,43 +357,30 @@ class MCPConfig(BaseModel): raise ValueError(f"Tool {tool_name} not found") -class MCPClientLocal: +class MCPClientBase(ABC): session: Optional[ClientSession] = None exit_stack: AsyncExitStack = AsyncExitStack() stdio: Optional[MemoryObjectReceiveStream[JSONRPCMessage | Exception]] = None write: Optional[MemoryObjectSendStream[JSONRPCMessage]] = None tools: List[dict[str, Any]] = [] - server: Optional[MCPServerLocal] = None + server: Optional[Union[MCPServerLocal, MCPServerRemote]] = None __lock: ClassVar[threading.Lock] = threading.Lock() - def __init__(self, server: MCPServerLocal): + def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]): self.server = server + # Protected method + @abstractmethod + async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: + """Connect to an MCP server, init client and save stdio/write streams""" + ... + async def __connect_to_server(self) -> Any: """Connect to an MCP server""" - - if not which(self.server.command): - raise ValueError(f"Command {self.server.command} not found") - - which_args = 0 - for arg in self.server.args: - if which(arg): - which_args = which_args + 1 - if which_args == 0: - raise ValueError(f"None of the arguments {self.server.args} is a file") - with self.__lock: - server_params = StdioServerParameters( - command=self.server.command, - args=self.server.args, - env=self.server.env, - encoding=self.server.encoding, - encoding_error_handler=self.server.encoding_error_handler - ) - stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) - self.stdio, self.write = stdio_transport + self.stdio, self.write = await self._connect_client() self.session = ( await self.exit_stack.enter_async_context( @@ -407,7 +400,6 @@ class MCPClientLocal: """List available tools from the server""" try: await self.__connect_to_server() - with self.__lock: response: ListToolsResult = await self.session.list_tools() available_tools = [{ @@ -453,3 +445,39 @@ class MCPClientLocal: await self.exit_stack.aclose() return response raise ValueError(f"Tool {tool_name} not found") + + +class MCPClientLocal(MCPClientBase): + async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: + """Connect to an MCP server, init client and save stdio/write streams""" + server: MCPServerLocal = cast(MCPServerLocal, self.server) + + if not which(server.command): + raise ValueError(f"Command {server.command} not found") + + which_args = 0 + for arg in server.args: + if which(arg): + which_args = which_args + 1 + if which_args == 0: + raise ValueError(f"None of the arguments {server.args} is a file") + + server_params = StdioServerParameters( + command=server.command, + args=server.args, + env=server.env, + encoding=server.encoding, + encoding_error_handler=server.encoding_error_handler + ) + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) + return stdio_transport + + +class MCPClientRemote(MCPClientBase): + async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: + """Connect to an MCP server, init client and save stdio/write streams""" + server: MCPServerRemote = cast(MCPServerRemote, self.server) + stdio_transport = await self.exit_stack.enter_async_context( + sse_client(url=server.url, headers=server.headers, timeout=server.timeout, sse_read_timeout=server.sse_read_timeout) + ) + return stdio_transport diff --git a/requirements.txt b/requirements.txt index 3d9be0540..102986c83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,3 +35,6 @@ mcp==1.3.0 nest-asyncio==1.6.0 pdf2image==1.17.0 crontab==1.0.1 +uv==0.6.6 +uvenv==3.6.5 +uvx==2.5.1 From 0c1fdd49a8ef88ca6666a0039345d0f907482b40 Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Thu, 1 May 2025 19:45:01 +0200 Subject: [PATCH 03/21] fix: startup error missing module --- python/helpers/mcp.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/python/helpers/mcp.py b/python/helpers/mcp.py index 5af5f7aa9..b73e0948d 100644 --- a/python/helpers/mcp.py +++ b/python/helpers/mcp.py @@ -5,8 +5,6 @@ import asyncio from contextlib import AsyncExitStack from shutil import which from datetime import timedelta -import dirtyjson -import json from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from mcp.client.sse import sse_client @@ -203,12 +201,9 @@ class MCPConfig(BaseModel): """Parse the MCP config string into a MCPConfig object.""" with cls.__lock: try: - servers = dirtyjson.loads(config_str) - except Exception: - try: - servers = DirtyJson.parse_string(config_str) - except Exception as e: - raise ValueError(f"Failed to parse MCP config: {e}") from e + servers = DirtyJson.parse_string(config_str) + except Exception as e: + raise ValueError(f"Failed to parse MCP config: {e}") from e cls.get_instance().__init__(servers_list=servers) cls.__initialized = True return cls.get_instance() From 6f0ee9130cb75126c93fe8951b18c7554250fdae Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Thu, 1 May 2025 19:53:26 +0200 Subject: [PATCH 04/21] fix: mcp tool call error because of erroneous await() --- python/helpers/mcp.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/helpers/mcp.py b/python/helpers/mcp.py index b73e0948d..81454a355 100644 --- a/python/helpers/mcp.py +++ b/python/helpers/mcp.py @@ -66,7 +66,7 @@ class MCPTool(Tool): else: text = response.message.strip() - await self.agent.hist_add_tool_result(self.name, text) + self.agent.hist_add_tool_result(self.name, text) ( PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True) .print(f"{self.agent.agent_name}: Response from tool '{self.name}'") @@ -258,7 +258,7 @@ class MCPConfig(BaseModel): with self.__lock: return self.__initialized - def get_tools(self) -> List[dict[str, str | dict[str, Any] | None]]: + def get_tools(self) -> List[dict[str, dict[str, Any]]]: """Get all tools from all servers""" with self.__lock: tools = [] @@ -398,10 +398,10 @@ class MCPClientBase(ABC): with self.__lock: response: ListToolsResult = await self.session.list_tools() available_tools = [{ - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } for tool in response.tools] + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } for tool in response.tools] self.tools = available_tools await self.exit_stack.aclose() From 8c4f6c20b055727f4c5416de2afb4bbd582fe23e Mon Sep 17 00:00:00 2001 From: Rafael Uzarowski Date: Fri, 2 May 2025 11:17:30 +0200 Subject: [PATCH 05/21] fix: mcp server tool discovery --- python/helpers/mcp.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/python/helpers/mcp.py b/python/helpers/mcp.py index 81454a355..cc2fb6f3f 100644 --- a/python/helpers/mcp.py +++ b/python/helpers/mcp.py @@ -297,14 +297,15 @@ class MCPConfig(BaseModel): properties: dict[str, Any] = tool["input_schema"]["properties"] for key, value in properties.items(): tool_args += f" \"{key}\": \"...\",\n" + examples = "" + description = "" if "examples" in value: - prompt += ( - f" * {key} ({value['type']}): {value['description']} (examples: {value['examples']})\n" - ) - else: - prompt += ( - f" * {key} ({value['type']}): {value['description']}\n" - ) + examples = f"(examples: {value['examples']})" + if "description" in value: + description = f": {value['description']}" + prompt += ( + f" * {key} ({value['type']}){description} {examples}\n" + ) prompt += "\n" prompt += ( @@ -450,12 +451,12 @@ class MCPClientLocal(MCPClientBase): if not which(server.command): raise ValueError(f"Command {server.command} not found") - which_args = 0 - for arg in server.args: - if which(arg): - which_args = which_args + 1 - if which_args == 0: - raise ValueError(f"None of the arguments {server.args} is a file") + # which_args = 0 + # for arg in server.args: + # if which(arg): + # which_args = which_args + 1 + # if which_args == 0: + # raise ValueError(f"None of the arguments {server.args} is a file") server_params = StdioServerParameters( command=server.command, From 030478adecf5b7e01850533fb86a29fa235ed202 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 13 May 2025 14:19:29 -0500 Subject: [PATCH 06/21] Added: mcp setup documentation --- docs/mcp_setup.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/mcp_setup.md diff --git a/docs/mcp_setup.md b/docs/mcp_setup.md new file mode 100644 index 000000000..aef7c9dd2 --- /dev/null +++ b/docs/mcp_setup.md @@ -0,0 +1,112 @@ +# Agent Zero: MCP Server Integration Guide + +This guide explains how to configure and utilize external tool providers through the Model Context Protocol (MCP) with Agent Zero. This allows Agent Zero to leverage tools hosted by separate local or remote MCP-compliant servers. + +## What are MCP Servers? + +MCP servers are external processes or services that expose a set of tools that Agent Zero can use. Agent Zero acts as an MCP *client*, consuming tools made available by these servers. The integration supports two main types of MCP servers: + +1. **Local Stdio Servers**: These are typically local executables that Agent Zero communicates with via standard input/output (stdio). +2. **Remote SSE Servers**: These are servers, often accessible over a network, that Agent Zero communicates with using Server-Sent Events (SSE), usually over HTTP/S. + +## How Agent Zero Consumes MCP Tools + +Agent Zero discovers and integrates MCP tools dynamically: + +1. **Configuration**: You define the MCP servers Agent Zero should connect to in its configuration. +2. **Tool Discovery**: Upon initialization (or when settings are updated), Agent Zero connects to each configured and enabled MCP server and queries it for the list of available tools, their descriptions, and expected parameters. +3. **Dynamic Prompting**: The information about these discovered tools is then dynamically injected into the agent's system prompt. A placeholder like `{{tools}}` in a system prompt template (e.g., `prompts/default/agent.system.mcp_tools.md`) is replaced with a formatted list of all available MCP tools. This allows the agent's underlying Language Model (LLM) to know which external tools it can request. +4. **Tool Invocation**: When the LLM decides to use an MCP tool, Agent Zero's `process_tools` method identifies it as an MCP tool and routes the request to the appropriate `MCPConfig` helper, which then communicates with the designated MCP server to execute the tool. + +## Configuration + +### Configuration File + +The primary location for user-specific Agent Zero settings, including MCP server configurations, is: + +* `tmp/settings.json` + +This file is created or updated when you save settings, typically through Agent Zero's settings UI (if available). + +### The `mcp_servers` Setting + +Within `tmp/settings.json`, the MCP servers are defined under the `"mcp_servers"` key. + +* **Value Type**: The value for `"mcp_servers"` must be a **JSON formatted string**. This string itself contains an **array** of server configuration objects. +* **Default Value**: If `tmp/settings.json` does not exist, or if it exists but does not contain the `"mcp_servers"` key, Agent Zero will use a default value of `""` (an empty string). This means no MCP servers are configured by default. +* **Updating**: The recommended way to set or update this value is through Agent Zero's settings UI. When you save settings via the UI, the current configuration (including your MCP server definitions) is written to `tmp/settings.json`. +* **For Existing `settings.json` Files (After an Upgrade)**: If you have an existing `tmp/settings.json` from a version of Agent Zero prior to MCP server support, the `"mcp_servers"` key will likely be missing. To add this key to your file (initially with an empty string value if you don't configure any servers immediately): + 1. Ensure you are running the version of Agent Zero that includes MCP server support. If you are using Docker, this might involve rebuilding or pulling the latest image. + 2. Run Agent Zero. + 3. Access the settings UI. + 4. Save the settings. Even if you don't make any changes in the UI during this session, saving will write the complete current settings structure (including `"mcp_servers": ""`) to your `tmp/settings.json` file. You can then populate it with your server configurations as needed. + +### MCP Server Configuration Structure + +Here are templates for configuring individual servers within the `mcp_servers` JSON array string: + +**1. Local Stdio Server** + +```json +{ + "name": "My Local Tool Server", + "description": "Optional: A brief description of this server.", + "command": "python", // The executable to run (e.g., python, /path/to/my_tool_server) + "args": ["path/to/your/mcp_stdio_script.py", "--some-arg"], // List of arguments for the command + "env": { // Optional: Environment variables for the command's process + "PYTHONPATH": "/path/to/custom/libs:.", + "ANOTHER_VAR": "value" + }, + "encoding": "utf-8", // Optional: Encoding for stdio communication (default: "utf-8") + "encoding_error_handler": "strict", // Optional: How to handle encoding errors. Can be "strict", "ignore", or "replace" (default: "strict"). + "disabled": false // Set to true to temporarily disable this server without removing its configuration. +} +``` + +**2. Remote SSE Server** + +```json +{ + "name": "My Remote API Tools", + "description": "Optional: Description of the remote SSE server.", + "url": "https://api.example.com/mcp-sse-endpoint", // The full URL for the SSE endpoint of the MCP server. + "headers": { // Optional: Any HTTP headers required for the connection. + "Authorization": "Bearer YOUR_API_KEY_OR_TOKEN", + "X-Custom-Header": "some_value" + }, + "timeout": 5.0, // Optional: Connection timeout in seconds (default: 5.0). + "sse_read_timeout": 300.0, // Optional: Read timeout for the SSE stream in seconds (default: 300.0, i.e., 5 minutes). + "disabled": false +} +``` + +**Example `mcp_servers` value in `tmp/settings.json`:** + +Remember, the entire array must be a string within the main JSON structure. Quotes within this string must be escaped. + +```json +{ + // ... other settings ... + "mcp_servers": "[{\\\"name\\\": \\\"MyPythonTools\\\", \\\"command\\\": \\\"python3\\\", \\\"args\\\": [\\\"mcp_scripts/my_server.py\\\"], \\\"disabled\\\": false}, {\\\"name\\\": \\\"ExternalAPI\\\", \\\"url\\\": \\\"https://data.example.com/mcp\\\", \\\"headers\\\": {\\\"X-Auth-Token\\\": \\\"supersecret\\\"}, \\\"disabled\\\": false}]", + // ... other settings ... +} +``` + +**Key Configuration Fields:** + +* `"name"`: A unique name for the server. This name will be used to prefix the tools provided by this server (e.g., `my_server_name.tool_name`). The name is normalized internally (converted to lowercase, spaces and hyphens replaced with underscores). +* `"disabled"`: A boolean (`true` or `false`). If `true`, Agent Zero will ignore this server configuration. +* `"url"`: **Required for Remote SSE Servers.** The endpoint URL. +* `"command"`: **Required for Local Stdio Servers.** The executable command. +* `"args"`: Optional list of arguments for local Stdio servers. +* Other fields are specific to the server type and mostly optional with defaults. + +## Using MCP Tools + +Once configured and discovered: + +* **Tool Naming**: MCP tools will appear to the agent with a name prefixed by the server name you defined, e.g., if your server is named `"MyPythonTools"` and it offers a tool named `"calculate_sum"`, the agent will know it as `mypythontools.calculate_sum`. +* **Agent Interaction**: The agent's LLM can then request to use these tools like any other built-in tool, by outputting a JSON structure specifying the `tool_name` and `tool_args`. +* **Execution Flow**: Agent Zero's `process_tools` method prioritizes looking up the tool name in the `MCPConfig`. If found, the execution is delegated to the corresponding MCP server. If not found as an MCP tool, it then attempts to find a local/built-in tool with that name. + +This setup provides a flexible way to extend Agent Zero's capabilities by integrating with various external tool providers without modifying its core codebase. \ No newline at end of file From 02bf7c10724e920081b7d666fc12675871eeef94 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 13 May 2025 18:54:41 -0500 Subject: [PATCH 07/21] Edit: Changed mcp.py to mcp_handler.py to prevent potential import confusion between 'mcp' package and 'mcp.py'. Edited dockerfile to 'latest' so it doesnt spin up old builds after image creation. Also edited preinstall to ensure the mcp related dependencies can load non-interactive. --- agent.py | 2 +- docker/run/docker-compose.yml | 2 +- docker/run/fs/ins/install_A0.sh | 21 ++++++++++++++++--- docker/run/fs/ins/pre_install.sh | 17 ++++++++++++--- initialize.py | 7 ++++--- .../system_prompt/_10_system_prompt.py | 2 +- python/helpers/{mcp.py => mcp_handler.py} | 4 ++++ python/helpers/settings.py | 2 +- 8 files changed, 44 insertions(+), 13 deletions(-) rename python/helpers/{mcp.py => mcp_handler.py} (99%) diff --git a/agent.py b/agent.py index 6b9525adf..288e1127d 100644 --- a/agent.py +++ b/agent.py @@ -691,7 +691,7 @@ class Agent: # Try getting tool from MCP first try: - import python.helpers.mcp as mcp_helper + import python.helpers.mcp_handler as mcp_helper mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool(self, tool_name) if mcp_tool_candidate: tool = mcp_tool_candidate diff --git a/docker/run/docker-compose.yml b/docker/run/docker-compose.yml index 725be771a..d2a828b31 100644 --- a/docker/run/docker-compose.yml +++ b/docker/run/docker-compose.yml @@ -1,7 +1,7 @@ services: agent-zero: container_name: agent-zero - image: frdel/agent-zero-run:testing + image: frdel/agent-zero:latest volumes: - ./agent-zero:/a0 ports: diff --git a/docker/run/fs/ins/install_A0.sh b/docker/run/fs/ins/install_A0.sh index c89c2cecf..8fb6eeb84 100644 --- a/docker/run/fs/ins/install_A0.sh +++ b/docker/run/fs/ins/install_A0.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Exit immediately if a command exits with a non-zero status. +# set -e + # branch from parameter if [ -z "$1" ]; then echo "Error: Branch parameter is empty. Please provide a valid branch name." @@ -7,10 +10,11 @@ if [ -z "$1" ]; then fi BRANCH="$1" -# clone project repo branch -git clone -b "$BRANCH" "https://github.com/frdel/agent-zero" "/git/agent-zero" +git clone -b "$BRANCH" "https://github.com/frdel/agent-zero" "/git/agent-zero" || { + echo "CRITICAL ERROR: Failed to clone repository. Branch: $BRANCH" + exit 1 +} -# setup python environment . "/ins/setup_venv.sh" "$@" # Ensure the virtual environment and pip setup @@ -19,9 +23,20 @@ pip install --upgrade pip ipython requests # Install some packages in specific variants pip install torch --index-url https://download.pytorch.org/whl/cpu +pip install -v mcp==1.3.0 || { + echo "ERROR: Failed during separate attempt to install mcp==1.3.0. Will proceed to full requirements.txt install anyway." +} +python -c "import mcp; from mcp import ClientSession; print(f'DEBUG: mcp and mcp.ClientSession imported successfully after separate install. mcp path: {mcp.__file__}')" || { + echo "ERROR: mcp package or mcp.ClientSession NOT importable after separate mcp==1.3.0 installation attempt. Full requirements.txt will run next." +} + # Install remaining A0 python packages pip install -r /git/agent-zero/requirements.txt +python -c "import mcp; from mcp import ClientSession; print(f'DEBUG: mcp and mcp.ClientSession imported successfully after requirements.txt. mcp path: {mcp.__file__}')" || { + echo "CRITICAL ERROR: mcp package or mcp.ClientSession not found or failed to import after requirements.txt processing." +} + # install playwright bash /ins/install_playwright.sh "$@" diff --git a/docker/run/fs/ins/pre_install.sh b/docker/run/fs/ins/pre_install.sh index 35cb14b9d..d901d1200 100644 --- a/docker/run/fs/ins/pre_install.sh +++ b/docker/run/fs/ins/pre_install.sh @@ -5,20 +5,24 @@ chmod 0644 /etc/cron.d/* echo "=====BEFORE UPDATE=====" +# Set DEBIAN_FRONTEND to noninteractive to prevent prompts +export DEBIAN_FRONTEND=noninteractive + # Update and install necessary packages apt clean -apt-get update && apt-get upgrade -y && apt-get install -y \ +apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install -y \ python3 \ python3-venv \ python3-pip \ nodejs \ + npm \ openssh-server \ sudo \ curl \ wget \ git \ ffmpeg \ - nginx\ + nginx \ supervisor \ cron \ libmagic-dev \ @@ -40,6 +44,7 @@ echo "=====MID UPDATE=====" # for some reason npm crashes builds on amd64 in this version and has to be installed separately # A0 can install it when needed +# The line below is now redundant as npm is included in the main install list above # apt-get install -y \ # npm @@ -59,7 +64,13 @@ echo "=====AFTER UPDATE=====" # fi # Install npx for use by local MCP Servers -npm i -g npx shx +echo "DEBUG: Installing npx and shx globally using npm..." +npm i -g npx shx || { + echo "CRITICAL ERROR: Failed to install npx and shx using npm." + # exit 1 # Optionally exit if this is critical enough +} +echo "DEBUG: npx and shx installation attempt finished." + # Prepare SSH daemon bash /ins/setup_ssh.sh "$@" diff --git a/initialize.py b/initialize.py index 92961689c..9e92a2270 100644 --- a/initialize.py +++ b/initialize.py @@ -76,16 +76,17 @@ def initialize(): # update config with runtime args args_override(config) - import python.helpers.mcp as mcp_helper + import python.helpers.mcp_handler as mcp_helper import agent as agent_helper import python.helpers.print_style as print_style_helper if not mcp_helper.MCPConfig.get_instance().is_initialized(): try: mcp_helper.MCPConfig.update(config.mcp_servers) except Exception as e: - if agent_helper.AgentContext.first(): + first_context = agent_helper.AgentContext.first() + if first_context: ( - agent_helper.AgentContext.first().log + first_context.log .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False) ) ( diff --git a/python/extensions/system_prompt/_10_system_prompt.py b/python/extensions/system_prompt/_10_system_prompt.py index 1ff461551..dda842fd3 100644 --- a/python/extensions/system_prompt/_10_system_prompt.py +++ b/python/extensions/system_prompt/_10_system_prompt.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any, Optional from python.helpers.extension import Extension -from python.helpers.mcp import MCPConfig +from python.helpers.mcp_handler import MCPConfig from agent import Agent, LoopData from python.helpers.localization import Localization diff --git a/python/helpers/mcp.py b/python/helpers/mcp_handler.py similarity index 99% rename from python/helpers/mcp.py rename to python/helpers/mcp_handler.py index cc2fb6f3f..fdd935ab8 100644 --- a/python/helpers/mcp.py +++ b/python/helpers/mcp_handler.py @@ -5,6 +5,10 @@ import asyncio from contextlib import AsyncExitStack from shutil import which from datetime import timedelta + +import os +print(f"DEBUG: Listing /opt/venv/lib/python3.11/site-packages/ before mcp import: {os.listdir('/opt/venv/lib/python3.11/site-packages/')}") + from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from mcp.client.sse import sse_client diff --git a/python/helpers/settings.py b/python/helpers/settings.py index e8c6e5cd4..9e45f2b87 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -886,7 +886,7 @@ def _apply_settings(previous: Settings | None): memory_reload() # update mcp settings if necessary - from python.helpers.mcp import MCPConfig + from python.helpers.mcp_handler import MCPConfig async def update_mcp_settings(mcp_servers: str): PrintStyle(background_color="black", font_color="white", padding=True).print("Updating MCP config...") From cafcef00e1daeeea270931c4d389168427c49799 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 13 May 2025 22:30:46 -0500 Subject: [PATCH 08/21] Update: Improved MCP setup and config, async handling for sessions, auto mcp install if present in settings config (on compose) --- docker/run/Dockerfile.cuda | 174 ++++++++++++++++++++++ docker/run/fs/ins/pre_install.sh | 20 ++- initialize.py | 53 +++++++ python/helpers/mcp_handler.py | 246 +++++++++++++++++++++---------- python/helpers/settings.py | 10 +- 5 files changed, 418 insertions(+), 85 deletions(-) create mode 100644 docker/run/Dockerfile.cuda diff --git a/docker/run/Dockerfile.cuda b/docker/run/Dockerfile.cuda new file mode 100644 index 000000000..392090838 --- /dev/null +++ b/docker/run/Dockerfile.cuda @@ -0,0 +1,174 @@ +# Use the NVIDIA CUDA base image with Ubuntu +FROM nvidia/cuda:12.8.1-base-ubuntu22.04 + +# Set non-interactive installation and timezone +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +# Check if the argument is provided, else throw an error +ARG BRANCH +RUN if [ -z "$BRANCH" ]; then echo "ERROR: BRANCH is not set!" >&2; exit 1; fi +ENV BRANCH=$BRANCH + +# Set locale to en_US.UTF-8 and timezone to UTC (matching main Dockerfile) +RUN apt-get update && apt-get install -y locales tzdata +RUN sed -i -e 's/# \(en_US\.UTF-8 .*\)/\1/' /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales && \ + update-locale LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 +RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime +RUN echo "UTC" > /etc/timezone +RUN dpkg-reconfigure -f noninteractive tzdata +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 +ENV TZ=UTC + +# Copy contents of the project to root directory +COPY ./fs/ / + +# Fix permissions for cron files from pre_install.sh +RUN chmod 0644 /etc/cron.d/* + +# Install essential packages (from pre_install.sh but avoiding supervisor from apt) +# Node.js and npm will be installed separately using NodeSource for a specific version +RUN apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install -y \ + python3 \ + python3-venv \ + python3-pip \ + openssh-server \ + sudo \ + curl \ + wget \ + git \ + ffmpeg \ + nginx \ + cron \ + libmagic-dev \ + poppler-utils \ + tesseract-ocr \ + qpdf \ + libreoffice \ + pandoc \ + libgtk-3-0 \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libasound2 \ + libasound2-data \ + cargo + +# Install Node.js 20.x (LTS) and a compatible npm +RUN apt-get update && apt-get install -y ca-certificates curl gnupg +RUN mkdir -p /etc/apt/keyrings +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list +RUN apt-get update && apt-get install -y nodejs || { echo "CRITICAL ERROR: Failed to install Node.js from NodeSource." ; exit 1; } + +# Prepare SSH daemon (from pre_install.sh) +RUN bash /ins/setup_ssh.sh $BRANCH + +# Configure Python 3.12 (specific to Ubuntu-based CUDA image) +RUN apt-get update && apt-get install -y \ + software-properties-common && \ + add-apt-repository -y ppa:deadsnakes/ppa && \ + apt-get update && \ + apt-get install -y python3.12 python3.12-venv python3.12-dev && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \ + update-alternatives --set python3 /usr/bin/python3.12 + +# Bootstrap pip for Python 3.12 and install supervisor +RUN python3 -m ensurepip --upgrade && \ + python3 -m pip install --upgrade pip setuptools wheel && \ + python3 -m pip install supervisor + +# Create supervisor directories +RUN mkdir -p /var/log/supervisor /etc/supervisor/conf.d + +# Create root dotfiles with appropriate permissions +RUN touch /root/.bashrc /root/.profile && \ + chmod 644 /root/.bashrc /root/.profile + +# Create a symlink for supervisord to the expected location +RUN ln -sf $(which supervisord) /usr/bin/supervisord + +# Install additional software +RUN bash /ins/install_additional.sh $BRANCH + +# Install core CUDA dependencies (minimized to essentials) +RUN apt-get update && apt-get install -y --no-install-recommends \ + cuda-cudart-dev-12-8 \ + libcublas-dev-12-8 \ + libcudnn8 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Set CUDA environment variables +ENV PATH=/usr/local/cuda/bin:${PATH} +ENV LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH} +ENV CUDA_HOME=/usr/local/cuda +ENV CUDA_VERSION=12.8.1 + +# Install A0 +RUN bash /ins/install_A0.sh $BRANCH + +# Create and set up the shared instruments virtual environment +ENV INSTRUMENTS_VENV_PATH=/opt/instruments_venv +RUN python3 -m venv $INSTRUMENTS_VENV_PATH + +# Switch to bash for the next RUN step (required for set -o pipefail) +SHELL ["/bin/bash", "-c"] + +# Install all heavy dependencies into the instruments venv, with explicit checks +RUN set -euxo pipefail; \ + . $INSTRUMENTS_VENV_PATH/bin/activate; \ + echo "=== Upgrading pip, setuptools, wheel ==="; \ + pip install --upgrade pip setuptools wheel; \ + echo "=== Installing PyTorch with CUDA ==="; \ + pip install --no-cache-dir torch==2.6.0+cu124 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124; \ + echo "=== Installing other heavy dependencies ==="; \ + pip install --no-cache-dir \ + huggingface-hub==0.20.3 \ + safetensors==0.4.1 \ + accelerate==0.21.0 \ + diffusers==0.25.0 \ + transformers==4.38.2 \ + scipy==1.15.2 \ + xformers==0.0.29.post3; \ + echo "=== Checking all critical imports ==="; \ + python -c "import torch; print(f'PyTorch {torch.__version__} imported successfully. CUDA: {getattr(torch, 'cuda', None) and torch.cuda.is_available()}')" || (echo 'PyTorch import failed!' && exit 1); \ + python -c "import torchvision; print(f'Torchvision {torchvision.__version__} imported successfully.')" || (echo 'Torchvision import failed!' && exit 1); \ + python -c "import torchaudio; print(f'Torchaudio {torchaudio.__version__} imported successfully.')" || (echo 'Torchaudio import failed!' && exit 1); \ + python -c "import diffusers; print(f'Diffusers {diffusers.__version__} imported successfully.')" || (echo 'Diffusers import failed!' && exit 1); \ + python -c "import transformers; print(f'Transformers {transformers.__version__} imported successfully.')" || (echo 'Transformers import failed!' && exit 1); \ + python -c "import xformers; print(f'Xformers {xformers.__version__} imported successfully.')" || (echo 'Xformers import failed!' && exit 1); \ + python -c "import accelerate; print(f'Accelerate {accelerate.__version__} imported successfully.')" || (echo 'Accelerate import failed!' && exit 1); \ + python -c "import safetensors; print(f'Safetensors {safetensors.__version__} imported successfully.')" || (echo 'Safetensors import failed!' && exit 1); \ + python -c "import scipy; print(f'Scipy {scipy.__version__} imported successfully.')" || (echo 'Scipy import failed!' && exit 1); \ + python -c "import huggingface_hub; print(f'Huggingface_hub {huggingface_hub.__version__} imported successfully.')" || (echo 'Huggingface_hub import failed!' && exit 1); \ + echo "=== All critical imports succeeded ===" + +# Optionally revert to sh for subsequent steps if needed +SHELL ["/bin/sh", "-c"] + +# The existing global PyTorch install for /opt/venv (A0's main venv) is below. +# It might be used by other core A0 components, so we keep it. +# Our instruments will use the dedicated $INSTRUMENTS_VENV_PATH. +RUN . /opt/venv/bin/activate && \ + pip uninstall -y torch torchvision && \ + pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cu124 + +# Cleanup repo and install A0 without caching, this speeds up builds +ARG CACHE_DATE=none +RUN echo "cache buster $CACHE_DATE" && bash /ins/install_A02.sh $BRANCH + +# Post installation steps +RUN bash /ins/post_install.sh $BRANCH + +# Expose ports +EXPOSE 22 80 + +RUN chmod +x /exe/initialize.sh /exe/run_A0.sh /exe/run_searxng.sh + +# Initialize runtime +CMD ["/exe/initialize.sh", "$BRANCH"] diff --git a/docker/run/fs/ins/pre_install.sh b/docker/run/fs/ins/pre_install.sh index d901d1200..ff54962d6 100644 --- a/docker/run/fs/ins/pre_install.sh +++ b/docker/run/fs/ins/pre_install.sh @@ -14,8 +14,6 @@ apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force-conf python3 \ python3-venv \ python3-pip \ - nodejs \ - npm \ openssh-server \ sudo \ curl \ @@ -38,15 +36,21 @@ apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force-conf libcups2 \ libasound2 \ libasound2-data \ - cargo + cargo \ + ca-certificates \ + gnupg echo "=====MID UPDATE=====" -# for some reason npm crashes builds on amd64 in this version and has to be installed separately -# A0 can install it when needed -# The line below is now redundant as npm is included in the main install list above -# apt-get install -y \ -# npm +# Install Node.js 20.x (LTS) and a compatible npm using NodeSource +echo "Setting up NodeSource repository for Node.js 20.x..." +mkdir -p /etc/apt/keyrings +curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list + +echo "Installing Node.js..." +apt-get update # Update package list again after adding new source +apt-get install -y nodejs || { echo "CRITICAL ERROR: Failed to install Node.js from NodeSource." ; exit 1; } echo "=====AFTER UPDATE=====" diff --git a/initialize.py b/initialize.py index 9e92a2270..0e5a467c3 100644 --- a/initialize.py +++ b/initialize.py @@ -2,10 +2,63 @@ import asyncio import models from agent import AgentConfig, ModelConfig from python.helpers import dotenv, files, rfc_exchange, runtime, settings, docker, log +import subprocess +import shutil +from python.helpers.print_style import PrintStyle def initialize(): + PrintStyle(background_color="blue", font_color="white", padding=True).print( + "Attempting to ensure MCP server 'mcp-server-sequential-thinking' is available..." + ) + # Check if npm is available first, as it's needed for the install. + if shutil.which("npm"): + if not shutil.which("mcp-server-sequential-thinking"): + PrintStyle(font_color="yellow", padding=True).print( + "'mcp-server-sequential-thinking' not found in PATH. Attempting global npm install..." + ) + try: + # Attempt to install @modelcontextprotocol/server-sequential-thinking globally + npm_command = ["npm", "i", "-g", "@modelcontextprotocol/server-sequential-thinking", "--no-fund", "--no-audit"] + process = subprocess.run(npm_command, capture_output=True, text=True, check=False) + if process.returncode == 0: + PrintStyle(font_color="green", padding=True).print( + "Successfully installed @modelcontextprotocol/server-sequential-thinking globally via npm." + ) + # Re-check if it's available in PATH now. This depends on npm's global bin location being in PATH. + if shutil.which("mcp-server-sequential-thinking"): + PrintStyle(font_color="green", padding=True).print( + "'mcp-server-sequential-thinking' is now available in PATH after install." + ) + else: + PrintStyle(font_color="orange", padding=True).print( + "WARNING: npm install reported success, but 'mcp-server-sequential-thinking' still not found in PATH by shutil.which(). " + + "The 'npx' command in settings.json might still be necessary and hopefully works." + ) + else: + PrintStyle(font_color="red", padding=True).print( + f"Failed to install @modelcontextprotocol/server-sequential-thinking globally via npm. Return code: {process.returncode}" + ) + PrintStyle(font_color="red", padding=False).print(f"npm stdout: {process.stdout.strip()}") + PrintStyle(font_color="red", padding=False).print(f"npm stderr: {process.stderr.strip()}") + except FileNotFoundError: + PrintStyle(font_color="red", padding=True).print( + "ERROR: 'npm' command not found. Cannot attempt to install @modelcontextprotocol/server-sequential-thinking." + ) + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"Exception during npm install of @modelcontextprotocol/server-sequential-thinking: {e}" + ) + else: + PrintStyle(font_color="green", padding=True).print( + "'mcp-server-sequential-thinking' already found in PATH." + ) + else: + PrintStyle(font_color="red", padding=True).print( + "ERROR: 'npm' command not found. Cannot check for or install 'mcp-server-sequential-thinking'." + ) + current_settings = settings.get_settings() # chat model from user settings diff --git a/python/helpers/mcp_handler.py b/python/helpers/mcp_handler.py index fdd935ab8..666a85362 100644 --- a/python/helpers/mcp_handler.py +++ b/python/helpers/mcp_handler.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Dict, Optional, Any, Union, Literal, Annotated, ClassVar, cast +from typing import List, Dict, Optional, Any, Union, Literal, Annotated, ClassVar, cast, Callable, Awaitable, TypeVar import threading import asyncio from contextlib import AsyncExitStack @@ -7,7 +7,7 @@ from shutil import which from datetime import timedelta import os -print(f"DEBUG: Listing /opt/venv/lib/python3.11/site-packages/ before mcp import: {os.listdir('/opt/venv/lib/python3.11/site-packages/')}") +# print(f"DEBUG: Listing /opt/venv/lib/python3.11/site-packages/ before mcp import: {os.listdir('/opt/venv/lib/python3.11/site-packages/')}") # This line caused FileNotFoundError, **FOR CUDA CHANGE TO '3.12'** from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -202,21 +202,98 @@ class MCPConfig(BaseModel): @classmethod def update(cls, config_str: str) -> Any: - """Parse the MCP config string into a MCPConfig object.""" with cls.__lock: - try: - servers = DirtyJson.parse_string(config_str) - except Exception as e: - raise ValueError(f"Failed to parse MCP config: {e}") from e - cls.get_instance().__init__(servers_list=servers) + servers_data: List[Dict[str, Any]] = [] # Default to empty list + + if config_str and config_str.strip(): # Only parse if non-empty and not just whitespace + try: + # Try with standard json.loads first, as it should handle escaped strings correctly + import json + parsed_value = json.loads(config_str) + + if isinstance(parsed_value, list): + valid_servers = [] + for item in parsed_value: + if isinstance(item, dict): + valid_servers.append(item) + else: + PrintStyle(background_color="yellow", font_color="black", padding=True).print( + f"Warning: MCP config item (from json.loads) was not a dictionary and was ignored: {item}" + ) + servers_data = valid_servers + else: + PrintStyle(background_color="red", font_color="white", padding=True).print( + f"Error: Parsed MCP config (from json.loads) top-level structure is not a list. Config string was: '{config_str}'" + ) + # servers_data remains empty + except Exception as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception + # Fallback to DirtyJson or log error if standard json.loads fails + PrintStyle(background_color="orange", font_color="black", padding=True).print( + f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback." + ) + try: + parsed_value = DirtyJson.parse_string(config_str) + if isinstance(parsed_value, list): + valid_servers = [] + for item in parsed_value: + if isinstance(item, dict): + valid_servers.append(item) + else: + PrintStyle(background_color="yellow", font_color="black", padding=True).print( + f"Warning: MCP config item (from DirtyJson) was not a dictionary and was ignored: {item}" + ) + servers_data = valid_servers + else: + PrintStyle(background_color="red", font_color="white", padding=True).print( + f"Error: Parsed MCP config (from DirtyJson) top-level structure is not a list. Config string was: '{config_str}'" + ) + # servers_data remains empty + except Exception as e_dirty: + PrintStyle(background_color="red", font_color="white", padding=True).print( + f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'" + ) + # servers_data remains empty, allowing graceful degradation + + # Initialize/update the singleton instance with the (potentially empty) list of server data + instance = cls.get_instance() + # Directly update the servers attribute of the existing instance or re-initialize carefully + # For simplicity and to ensure __init__ logic runs if needed for setup: + new_instance_data = {'servers': servers_data} # Prepare data for re-initialization or update + + # Option 1: Re-initialize the existing instance (if __init__ is idempotent for other fields) + instance.__init__(servers_list=servers_data) + + # Option 2: Or, if __init__ has side effects we don't want to repeat, + # and 'servers' is the primary thing 'update' changes: + # instance.servers = [] # Clear existing servers first + # for server_item_data in servers_data: + # try: + # if server_item_data.get("url", None): + # instance.servers.append(MCPServerRemote(server_item_data)) + # else: + # instance.servers.append(MCPServerLocal(server_item_data)) + # except Exception as e_init: + # PrintStyle(background_color="grey", font_color="red", padding=True).print( + # f"MCPConfig.update: Failed to create MCPServer from item '{server_item_data.get('name', 'Unknown')}': {e_init}" + # ) + cls.__initialized = True - return cls.get_instance() + return instance def __init__(self, servers_list: List[Dict[str, Any]]): from collections.abc import Mapping, Iterable - # This empties the servers list - super().__init__() + # DEBUG: Print the received servers_list + PrintStyle(background_color="blue", font_color="white", padding=True).print(f"MCPConfig.__init__ received servers_list: {servers_list}") + + # This empties the servers list if MCPConfig is a Pydantic model and servers is a field. + # If servers is a field like `servers: List[MCPServer] = Field(default_factory=list)`, + # then super().__init__() might try to initialize it. + # We are re-assigning self.servers later in this __init__. + super().__init__() + + # Clear any servers potentially initialized by super().__init__() before we populate based on servers_list + self.servers = [] if not isinstance(servers_list, Iterable): ( @@ -343,7 +420,7 @@ class MCPConfig(BaseModel): def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None: if not self.has_tool(tool_name): return None - return MCPTool(agent, tool_name, {}, "", **{}) + return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="") async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: """Call a tool with the given input data""" @@ -357,68 +434,87 @@ class MCPConfig(BaseModel): raise ValueError(f"Tool {tool_name} not found") -class MCPClientBase(ABC): - session: Optional[ClientSession] = None - exit_stack: AsyncExitStack = AsyncExitStack() - stdio: Optional[MemoryObjectReceiveStream[JSONRPCMessage | Exception]] = None - write: Optional[MemoryObjectSendStream[JSONRPCMessage]] = None +T = TypeVar('T') - tools: List[dict[str, Any]] = [] - server: Optional[Union[MCPServerLocal, MCPServerRemote]] = None +class MCPClientBase(ABC): + # server: Union[MCPServerLocal, MCPServerRemote] # Defined in __init__ + # tools: List[dict[str, Any]] # Defined in __init__ + # No self.session, self.exit_stack, self.stdio, self.write as persistent instance fields __lock: ClassVar[threading.Lock] = threading.Lock() def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]): self.server = server + self.tools: List[dict[str, Any]] = [] # Tools are cached on the client instance # Protected method @abstractmethod - async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: - """Connect to an MCP server, init client and save stdio/write streams""" + async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: + """Create stdio/write streams using the provided exit_stack.""" ... - async def __connect_to_server(self) -> Any: - """Connect to an MCP server""" - with self.__lock: - self.stdio, self.write = await self._connect_client() - - self.session = ( - await self.exit_stack.enter_async_context( + async def _execute_with_session(self, coro_func: Callable[[ClientSession], Awaitable[T]]) -> T: + """ + Manages the lifecycle of an MCP session for a single operation. + Creates a temporary session, executes coro_func with it, and ensures cleanup. + """ + operation_name = coro_func.__name__ # For logging + PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Creating new session for operation '{operation_name}'...") + async with AsyncExitStack() as temp_stack: + try: + stdio, write = await self._create_stdio_transport(temp_stack) + PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Transport created. Initializing session...") + session = await temp_stack.enter_async_context( ClientSession( - self.stdio, - self.write, - read_timeout_seconds=timedelta(seconds=15) + stdio, + write, + read_timeout_seconds=timedelta(seconds=600) ) ) - ) + await session.initialize() + PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Session initialized.") + + result = await coro_func(session) + + PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Operation successful.") + return result + except Exception as e: + PrintStyle(background_color="#AA4455", font_color="white", padding=False).print( + f"MCPClientBase ({self.server.name} - {operation_name}): Error during operation: {type(e).__name__}: {e}" + ) + raise # Re-raise the exception to be handled by the caller of update_tools/call_tool + finally: + PrintStyle(font_color="cyan").print( + f"MCPClientBase ({self.server.name} - {operation_name}): Session and transport will be closed by AsyncExitStack." + ) + # temp_stack.aclose() is called automatically here. - # Initialize session - await self.session.initialize() - return self - - async def update_tools(self) -> Any: - """List available tools from the server""" - try: - await self.__connect_to_server() - with self.__lock: - response: ListToolsResult = await self.session.list_tools() - available_tools = [{ + async def update_tools(self) -> "MCPClientBase": + PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Starting 'update_tools' operation...") + + async def list_tools_op(current_session: ClientSession): + response: ListToolsResult = await current_session.list_tools() + with self.__lock: + self.tools = [{ "name": tool.name, "description": tool.description, "input_schema": tool.inputSchema } for tool in response.tools] + PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tools updated. Found {len(self.tools)} tools.") - self.tools = available_tools - await self.exit_stack.aclose() - return self + try: + await self._execute_with_session(list_tools_op) except Exception as e: - PrintStyle( - background_color="#CC34C3", font_color="white", bold=True, padding=True - ).print("MCPClientLocal::Failed to update tools:") - PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(str(e)) + # Error already logged by _execute_with_session, this is for specific handling if needed + PrintStyle(background_color="#CC34C3", font_color="white", bold=True, padding=True).print( + f"MCPClientBase ({self.server.name}): 'update_tools' operation failed: {e}" + ) + with self.__lock: + self.tools = [] # Ensure tools are cleared on failure + return self def has_tool(self, tool_name: str) -> bool: - """Check if a tool is available""" + """Check if a tool is available (uses cached tools)""" with self.__lock: for tool in self.tools: if tool["name"] == tool_name: @@ -426,42 +522,44 @@ class MCPClientBase(ABC): return False def get_tools(self) -> List[dict[str, Any]]: - """Get all tools from the server""" + """Get all tools from the server (uses cached tools)""" with self.__lock: return self.tools async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: - """Call a tool with the given input data""" + PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Preparing for 'call_tool' operation for tool '{tool_name}'.") if not self.has_tool(tool_name): - await self.update_tools() + PrintStyle(font_color="orange").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not in cache for 'call_tool', refreshing tools...") + await self.update_tools() # This will use its own properly managed session + if not self.has_tool(tool_name): + PrintStyle(font_color="red").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not found after refresh. Raising ValueError.") + raise ValueError(f"Tool {tool_name} not found after refreshing tool list for server {self.server.name}.") + PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools.") - await self.__connect_to_server() + async def call_tool_op(current_session: ClientSession): + PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...") + response: CallToolResult = await current_session.call_tool(tool_name, input_data) + PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.") + return response - with self.__lock: - for tool in self.tools: - if tool["name"] == tool_name: - response: CallToolResult = await self.session.call_tool(tool_name, input_data) - # after connect have to close the stack within this function - await self.exit_stack.aclose() - return response - raise ValueError(f"Tool {tool_name} not found") + try: + return await self._execute_with_session(call_tool_op) + except Exception as e: + # Error logged by _execute_with_session. Re-raise a specific error for the caller. + PrintStyle(background_color="#AA4455", font_color="white", padding=True).print( + f"MCPClientBase ({self.server.name}): 'call_tool' operation for '{tool_name}' failed: {type(e).__name__}: {e}" + ) + raise ConnectionError(f"MCPClientBase::Failed to call tool '{tool_name}' on server '{self.server.name}'. Original error: {type(e).__name__}: {e}") class MCPClientLocal(MCPClientBase): - async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: + async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: """Connect to an MCP server, init client and save stdio/write streams""" server: MCPServerLocal = cast(MCPServerLocal, self.server) if not which(server.command): raise ValueError(f"Command {server.command} not found") - # which_args = 0 - # for arg in server.args: - # if which(arg): - # which_args = which_args + 1 - # if which_args == 0: - # raise ValueError(f"None of the arguments {server.args} is a file") - server_params = StdioServerParameters( command=server.command, args=server.args, @@ -469,15 +567,15 @@ class MCPClientLocal(MCPClientBase): encoding=server.encoding, encoding_error_handler=server.encoding_error_handler ) - stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) + stdio_transport = await current_exit_stack.enter_async_context(stdio_client(server_params)) return stdio_transport class MCPClientRemote(MCPClientBase): - async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: + async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]: """Connect to an MCP server, init client and save stdio/write streams""" server: MCPServerRemote = cast(MCPServerRemote, self.server) - stdio_transport = await self.exit_stack.enter_async_context( + stdio_transport = await current_exit_stack.enter_async_context( sse_client(url=server.url, headers=server.headers, timeout=server.timeout, sse_read_timeout=server.sse_read_timeout) ) return stdio_transport diff --git a/python/helpers/settings.py b/python/helpers/settings.py index 9e45f2b87..c163c8051 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -890,13 +890,16 @@ def _apply_settings(previous: Settings | None): async def update_mcp_settings(mcp_servers: str): PrintStyle(background_color="black", font_color="white", padding=True).print("Updating MCP config...") - AgentContext.first().log.log(type="info", content="Updating MCP settings...", temp=True) + first_context = AgentContext.first() + if first_context: + first_context.log.log(type="info", content="Updating MCP settings...", temp=True) mcp_config = MCPConfig.get_instance() try: MCPConfig.update(mcp_servers) except Exception as e: - AgentContext.first().log.log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False) + if first_context: + first_context.log.log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False) ( PrintStyle(background_color="red", font_color="black", padding=True) .print("Failed to update MCP settings") @@ -913,7 +916,8 @@ def _apply_settings(previous: Settings | None): PrintStyle(background_color="#334455", font_color="white", padding=False) .print(mcp_config.model_dump_json()) ) - AgentContext.first().log.log(type="info", content="Finished updating MCP settings :)", temp=True) + if first_context: + first_context.log.log(type="info", content="Finished updating MCP settings :)", temp=True) task2 = defer.DeferredTask().start_task( update_mcp_settings, config.mcp_servers From 1d125aae37866f2969711f0b818e43bfbeaedb03 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 13 May 2025 22:56:34 -0500 Subject: [PATCH 09/21] Added: Contextual reminder for mcps with multi-step-processes for agent to not get distracted, to continue process. --- python/helpers/mcp_handler.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/python/helpers/mcp_handler.py b/python/helpers/mcp_handler.py index 666a85362..595cdb1e6 100644 --- a/python/helpers/mcp_handler.py +++ b/python/helpers/mcp_handler.py @@ -5,6 +5,7 @@ import asyncio from contextlib import AsyncExitStack from shutil import which from datetime import timedelta +import json import os # print(f"DEBUG: Listing /opt/venv/lib/python3.11/site-packages/ before mcp import: {os.listdir('/opt/venv/lib/python3.11/site-packages/')}") # This line caused FileNotFoundError, **FOR CUDA CHANGE TO '3.12'** @@ -70,6 +71,9 @@ class MCPTool(Tool): else: text = response.message.strip() + # Add a general contextual reminder for multi-step processes + text += f"\n\n[Contextual Reminder for {self.name}: If this action is part of an ongoing sequence, consider the next step with this tool. If the sequence is complete, analyze the final output and report to the user or proceed accordingly.]" + self.agent.hist_add_tool_result(self.name, text) ( PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True) @@ -487,7 +491,9 @@ class MCPClientBase(ABC): PrintStyle(font_color="cyan").print( f"MCPClientBase ({self.server.name} - {operation_name}): Session and transport will be closed by AsyncExitStack." ) - # temp_stack.aclose() is called automatically here. + # This line should ideally be unreachable if the try/except/finally logic within the 'async with' is exhaustive. + # Adding it to satisfy linters that might not fully trace the raise/return paths through async context managers. + raise RuntimeError(f"MCPClientBase ({self.server.name} - {operation_name}): _execute_with_session exited 'async with' block unexpectedly.") async def update_tools(self) -> "MCPClientBase": PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Starting 'update_tools' operation...") From 40a67b9ad3722bbca987c69f4a375f710ea98046 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 13 May 2025 23:45:39 -0500 Subject: [PATCH 10/21] Edit: Initialize will now attempt to ensure all mcps are globally installed when the system starts. --- initialize.py | 96 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/initialize.py b/initialize.py index 0e5a467c3..0ca586999 100644 --- a/initialize.py +++ b/initialize.py @@ -1,4 +1,5 @@ import asyncio +import json import models from agent import AgentConfig, ModelConfig from python.helpers import dotenv, files, rfc_exchange, runtime, settings, docker, log @@ -7,59 +8,116 @@ import shutil from python.helpers.print_style import PrintStyle -def initialize(): - +# Helper function to ensure an MCP package is globally installed +def _ensure_mcp_package_globally_installed(package_name: str, executable_name: str): PrintStyle(background_color="blue", font_color="white", padding=True).print( - "Attempting to ensure MCP server 'mcp-server-sequential-thinking' is available..." + f"Attempting to ensure MCP server executable '{executable_name}' (from package '{package_name}') is available..." ) - # Check if npm is available first, as it's needed for the install. if shutil.which("npm"): - if not shutil.which("mcp-server-sequential-thinking"): + if not shutil.which(executable_name): PrintStyle(font_color="yellow", padding=True).print( - "'mcp-server-sequential-thinking' not found in PATH. Attempting global npm install..." + f"'{executable_name}' not found in PATH. Attempting global npm install of '{package_name}'..." ) try: - # Attempt to install @modelcontextprotocol/server-sequential-thinking globally - npm_command = ["npm", "i", "-g", "@modelcontextprotocol/server-sequential-thinking", "--no-fund", "--no-audit"] + npm_command = ["npm", "i", "-g", package_name, "--no-fund", "--no-audit"] process = subprocess.run(npm_command, capture_output=True, text=True, check=False) if process.returncode == 0: PrintStyle(font_color="green", padding=True).print( - "Successfully installed @modelcontextprotocol/server-sequential-thinking globally via npm." + f"Successfully installed '{package_name}' globally via npm." ) - # Re-check if it's available in PATH now. This depends on npm's global bin location being in PATH. - if shutil.which("mcp-server-sequential-thinking"): + if shutil.which(executable_name): PrintStyle(font_color="green", padding=True).print( - "'mcp-server-sequential-thinking' is now available in PATH after install." + f"'{executable_name}' is now available in PATH after install." ) else: PrintStyle(font_color="orange", padding=True).print( - "WARNING: npm install reported success, but 'mcp-server-sequential-thinking' still not found in PATH by shutil.which(). " + - "The 'npx' command in settings.json might still be necessary and hopefully works." + f"WARNING: npm install of '{package_name}' reported success, but '{executable_name}' still not found in PATH. " + + "The 'npx' command in settings.json might still be necessary or there might be an issue with PATH." ) else: PrintStyle(font_color="red", padding=True).print( - f"Failed to install @modelcontextprotocol/server-sequential-thinking globally via npm. Return code: {process.returncode}" + f"Failed to install '{package_name}' globally via npm. Return code: {process.returncode}" ) PrintStyle(font_color="red", padding=False).print(f"npm stdout: {process.stdout.strip()}") PrintStyle(font_color="red", padding=False).print(f"npm stderr: {process.stderr.strip()}") except FileNotFoundError: PrintStyle(font_color="red", padding=True).print( - "ERROR: 'npm' command not found. Cannot attempt to install @modelcontextprotocol/server-sequential-thinking." + f"ERROR: 'npm' command not found. Cannot attempt to install '{package_name}'." ) except Exception as e: PrintStyle(font_color="red", padding=True).print( - f"Exception during npm install of @modelcontextprotocol/server-sequential-thinking: {e}" + f"Exception during npm install of '{package_name}': {e}" ) else: PrintStyle(font_color="green", padding=True).print( - "'mcp-server-sequential-thinking' already found in PATH." + f"'{executable_name}' (from package '{package_name}') already found in PATH." ) else: PrintStyle(font_color="red", padding=True).print( - "ERROR: 'npm' command not found. Cannot check for or install 'mcp-server-sequential-thinking'." + f"ERROR: 'npm' command not found. Cannot check for or install '{executable_name}' from '{package_name}'." ) + PrintStyle().print() # For a blank line after each attempt + +def initialize(): current_settings = settings.get_settings() + mcp_servers_json_string = current_settings.get("mcp_servers", "[]") + + try: + mcp_server_configs = json.loads(mcp_servers_json_string) + if not isinstance(mcp_server_configs, list): + PrintStyle(font_color="red", padding=True).print( + f"Error: Parsed mcp_servers from settings is not a list. Value: {mcp_server_configs}" + ) + mcp_server_configs = [] + except json.JSONDecodeError as e: + PrintStyle(font_color="red", padding=True).print( + f"Error decoding mcp_servers JSON string from settings: {e}. String was: '{mcp_servers_json_string}'" + ) + mcp_server_configs = [] + + if shutil.which("npm"): + for server_config in mcp_server_configs: + if not isinstance(server_config, dict): + PrintStyle(font_color="orange", padding=True).print( + f"Warning: Skipping MCP server config item as it's not a dictionary: {server_config}" + ) + continue + + command = server_config.get("command") + args = server_config.get("args", []) + server_name = server_config.get("name", "Unknown MCP Server") + + if command == "npx" and "--package" in args: + try: + package_keyword_index = args.index("--package") + # Expect package name at +1 and executable name at +2 from "--package" + if package_keyword_index + 2 < len(args): + package_name = args[package_keyword_index + 1] + executable_name = args[package_keyword_index + 2] + if package_name and executable_name: # Ensure they are not empty strings + _ensure_mcp_package_globally_installed(package_name, executable_name) + else: + PrintStyle(font_color="orange", padding=True).print( + f"Warning: Skipping MCP server '{server_name}' due to empty package or executable name extracted from args: {args}" + ) + else: + PrintStyle(font_color="orange", padding=True).print( + f"Warning: Skipping MCP server '{server_name}' as package name or executable name could not be determined from args: {args}" + ) + except ValueError: # Should not happen if "--package" is in args, but good for safety + PrintStyle(font_color="orange", padding=True).print( + f"Warning: '--package' keyword found but .index() failed for args: {args} in server '{server_name}'" + ) + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"Error processing npx args for server '{server_name}': {e}. Args: {args}" + ) + else: + PrintStyle(font_color="red", padding=True).print( + "ERROR: 'npm' command not found. Cannot attempt to install any MCP server packages." + ) + PrintStyle().print() # Extra blank line after all attempts or npm not found message # chat model from user settings chat_llm = ModelConfig( From 2d475c2ec018ab32f90447617d633bdb63033e13 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 13 May 2025 23:45:53 -0500 Subject: [PATCH 11/21] Added: I've made sure that the user_message_text variable is explicitly cast to a string before any length checks or slicing operations are performed on it. I also renamed the variable from user_message_context to user_message_text for better clarity, as it primarily holds the textual part of the user's message. This should provide the agent with a more robust and helpful contextual block after each MCP tool execution. --- python/helpers/mcp_handler.py | 62 ++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/python/helpers/mcp_handler.py b/python/helpers/mcp_handler.py index 595cdb1e6..1393ad7be 100644 --- a/python/helpers/mcp_handler.py +++ b/python/helpers/mcp_handler.py @@ -64,23 +64,61 @@ class MCPTool(Tool): PrintStyle().print() async def after_execution(self, response: Response, **kwargs: Any): - # Check if response or message is None - if not response.message.strip(): - text = "" - PrintStyle(font_color="red").print(f"Warning: Tool '{self.name}' returned None response or message") - else: - text = response.message.strip() + raw_tool_response = response.message.strip() if response.message else "" + if not raw_tool_response: + PrintStyle(font_color="red").print(f"Warning: Tool '{self.name}' returned an empty message.") + # Even if empty, we might still want to provide context for the agent + raw_tool_response = "[Tool returned no textual content]" - # Add a general contextual reminder for multi-step processes - text += f"\n\n[Contextual Reminder for {self.name}: If this action is part of an ongoing sequence, consider the next step with this tool. If the sequence is complete, analyze the final output and report to the user or proceed accordingly.]" + # Prepare user message context + user_message_text = "No specific user message context available for this exact step." + if self.agent and self.agent.last_user_message and self.agent.last_user_message.content: + content = self.agent.last_user_message.content + if isinstance(content, dict): + # Attempt to get a 'message' field, otherwise stringify the dict + user_message_text = content.get("message", json.dumps(content, indent=2)) + elif isinstance(content, str): + user_message_text = content + else: + # Fallback for any other types (e.g. list, if that were possible for content) + user_message_text = str(content) + + # Ensure user_message_text is a string before length check and slicing + user_message_text = str(user_message_text) - self.agent.hist_add_tool_result(self.name, text) + # Truncate user message context if it's too long to avoid overwhelming the prompt + max_user_context_len = 500 # characters + if len(user_message_text) > max_user_context_len: + user_message_text = user_message_text[:max_user_context_len] + "... (truncated)" + + contextual_block = f""" +\n--- End of Results for MCP Tool: {self.name} --- + +**Original Tool Call Details:** +* **Tool:** `{self.name}` +* **Arguments Given:** + ```json +{json.dumps(self.args, indent=2)} + ``` + +**Related User Request Context:** +{user_message_text} + +**Next Steps Reminder for {self.name}:** +If this action is part of an ongoing sequence, consider the next step with this tool or another appropriate tool. If the sequence is complete or this was a one-off action, analyze the final output and report to the user or proceed with the overall plan. +""" + + final_text_for_agent = raw_tool_response + contextual_block + + self.agent.hist_add_tool_result(self.name, final_text_for_agent) ( PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True) - .print(f"{self.agent.agent_name}: Response from tool '{self.name}'") + .print(f"{self.agent.agent_name}: Response from tool '{self.name}' (plus context added)") ) - PrintStyle(font_color="#85C1E9").print(text) - self.log.update(content=text) + # Print only the raw response to console for brevity, agent gets the full context. + PrintStyle(font_color="#85C1E9").print(raw_tool_response if raw_tool_response else "[No direct textual output from tool]") + if self.log: + self.log.update(content=final_text_for_agent) # Log includes the full context class MCPServerRemote(BaseModel): From 691750cc55ac178a80480fc1a7013cfd7feaf4dd Mon Sep 17 00:00:00 2001 From: deci Date: Wed, 14 May 2025 00:01:51 -0500 Subject: [PATCH 12/21] Updated: Mcp setup documentation to help guide first time users. --- docs/mcp_setup.md | 56 ++++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/docs/mcp_setup.md b/docs/mcp_setup.md index aef7c9dd2..ee4e63a2d 100644 --- a/docs/mcp_setup.md +++ b/docs/mcp_setup.md @@ -13,33 +13,47 @@ MCP servers are external processes or services that expose a set of tools that A Agent Zero discovers and integrates MCP tools dynamically: -1. **Configuration**: You define the MCP servers Agent Zero should connect to in its configuration. -2. **Tool Discovery**: Upon initialization (or when settings are updated), Agent Zero connects to each configured and enabled MCP server and queries it for the list of available tools, their descriptions, and expected parameters. -3. **Dynamic Prompting**: The information about these discovered tools is then dynamically injected into the agent's system prompt. A placeholder like `{{tools}}` in a system prompt template (e.g., `prompts/default/agent.system.mcp_tools.md`) is replaced with a formatted list of all available MCP tools. This allows the agent's underlying Language Model (LLM) to know which external tools it can request. -4. **Tool Invocation**: When the LLM decides to use an MCP tool, Agent Zero's `process_tools` method identifies it as an MCP tool and routes the request to the appropriate `MCPConfig` helper, which then communicates with the designated MCP server to execute the tool. +1. **Configuration**: You define the MCP servers Agent Zero should connect to in its configuration. The primary way to do this is through the Agent Zero settings UI. +2. **Saving Settings**: When you save your settings via the UI, Agent Zero updates the `tmp/settings.json` file, specifically the `"mcp_servers"` key. +3. **Automatic Installation (on Restart)**: After saving your settings and restarting Agent Zero, the system will attempt to automatically install any MCP server packages defined with `command: "npx"` and the `--package` argument in their configuration (this process is managed by `initialize.py`). You can monitor the application logs (e.g., Docker logs) for details on this installation attempt. +4. **Tool Discovery**: Upon initialization (or when settings are updated), Agent Zero connects to each configured and enabled MCP server and queries it for the list of available tools, their descriptions, and expected parameters. +5. **Dynamic Prompting**: The information about these discovered tools is then dynamically injected into the agent's system prompt. A placeholder like `{{tools}}` in a system prompt template (e.g., `prompts/default/agent.system.mcp_tools.md`) is replaced with a formatted list of all available MCP tools. This allows the agent's underlying Language Model (LLM) to know which external tools it can request. +6. **Tool Invocation**: When the LLM decides to use an MCP tool, Agent Zero's `process_tools` method (handled by `mcp_handler.py`) identifies it as an MCP tool and routes the request to the appropriate `MCPConfig` helper, which then communicates with the designated MCP server to execute the tool. ## Configuration -### Configuration File +### Configuration File & Method -The primary location for user-specific Agent Zero settings, including MCP server configurations, is: +The primary method for configuring MCP servers is through **Agent Zero's settings UI**. + +When you input and save your MCP server details in the UI, these settings are written to: * `tmp/settings.json` -This file is created or updated when you save settings, typically through Agent Zero's settings UI (if available). - -### The `mcp_servers` Setting +### The `mcp_servers` Setting in `tmp/settings.json` Within `tmp/settings.json`, the MCP servers are defined under the `"mcp_servers"` key. * **Value Type**: The value for `"mcp_servers"` must be a **JSON formatted string**. This string itself contains an **array** of server configuration objects. -* **Default Value**: If `tmp/settings.json` does not exist, or if it exists but does not contain the `"mcp_servers"` key, Agent Zero will use a default value of `""` (an empty string). This means no MCP servers are configured by default. -* **Updating**: The recommended way to set or update this value is through Agent Zero's settings UI. When you save settings via the UI, the current configuration (including your MCP server definitions) is written to `tmp/settings.json`. -* **For Existing `settings.json` Files (After an Upgrade)**: If you have an existing `tmp/settings.json` from a version of Agent Zero prior to MCP server support, the `"mcp_servers"` key will likely be missing. To add this key to your file (initially with an empty string value if you don't configure any servers immediately): - 1. Ensure you are running the version of Agent Zero that includes MCP server support. If you are using Docker, this might involve rebuilding or pulling the latest image. - 2. Run Agent Zero. - 3. Access the settings UI. - 4. Save the settings. Even if you don't make any changes in the UI during this session, saving will write the complete current settings structure (including `"mcp_servers": ""`) to your `tmp/settings.json` file. You can then populate it with your server configurations as needed. +* **Default Value**: If `tmp/settings.json` does not exist, or if it exists but does not contain the `"mcp_servers"` key, Agent Zero will use a default value of `""` (an empty string), meaning no MCP servers are configured. +* **Manual Editing (Advanced)**: While UI configuration is recommended, you can also manually edit `tmp/settings.json`. If you do, ensure the `"mcp_servers"` value is a valid JSON string, with internal quotes properly escaped. + +**Example `mcp_servers` string in `tmp/settings.json`:** + +```json +{ + // ... other settings ... + "mcp_servers": "[{\\\"name\\\": \\\"sequential-thinking\\\",\\\"command\\\": \\\"npx\\\",\\\"args\\\": [\\\"--yes\\\", \\\"--package\\\", \\\"@modelcontextprotocol/server-sequential-thinking\\\", \\\"mcp-server-sequential-thinking\\\"]}, {\\\"name\\\": \\\"brave-search\\\", \\\"command\\\": \\\"npx\\\", \\\"args\\\": [\\\"--yes\\\", \\\"--package\\\", \\\"@modelcontextprotocol/server-brave-search\\\", \\\"mcp-server-brave-search\\\"], \\\"env\\\": {\\\"BRAVE_API_KEY\\\": \\\"YOUR_BRAVE_KEY_HERE\\\"}}, {\\\"name\\\": \\\"fetch\\\", \\\"command\\\": \\\"npx\\\", \\\"args\\\": [\\\"--yes\\\", \\\"--package\\\", \\\"@tokenizin/mcp-npx-fetch\\\", \\\"mcp-npx-fetch\\\", \\\"--ignore-robots-txt\\\", \\\"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\\\"]}]", + // ... other settings ... +} +``` +*Note: In the actual `settings.json` file, the entire value for `mcp_servers` is a single string, with backslashes escaping the quotes within the array structure.* + +* **Updating**: As mentioned, the recommended way to set or update this value is through Agent Zero's settings UI. +* **For Existing `settings.json` Files (After an Upgrade)**: If you have an existing `tmp/settings.json` from a version of Agent Zero prior to MCP server support, the `"mcp_servers"` key will likely be missing. To add this key: + 1. Ensure you are running a version of Agent Zero that includes MCP server support. + 2. Run Agent Zero and open its settings UI. + 3. Save the settings (even without making changes). This action will write the complete current settings structure, including a default `"mcp_servers": ""` if not otherwise populated, to `tmp/settings.json`. You can then configure your servers via the UI or by carefully editing this string. ### MCP Server Configuration Structure @@ -82,8 +96,6 @@ Here are templates for configuring individual servers within the `mcp_servers` J **Example `mcp_servers` value in `tmp/settings.json`:** -Remember, the entire array must be a string within the main JSON structure. Quotes within this string must be escaped. - ```json { // ... other settings ... @@ -103,10 +115,10 @@ Remember, the entire array must be a string within the main JSON structure. Quot ## Using MCP Tools -Once configured and discovered: +Once configured, successfully installed (if applicable, e.g., for `npx` based servers), and discovered by Agent Zero: -* **Tool Naming**: MCP tools will appear to the agent with a name prefixed by the server name you defined, e.g., if your server is named `"MyPythonTools"` and it offers a tool named `"calculate_sum"`, the agent will know it as `mypythontools.calculate_sum`. -* **Agent Interaction**: The agent's LLM can then request to use these tools like any other built-in tool, by outputting a JSON structure specifying the `tool_name` and `tool_args`. -* **Execution Flow**: Agent Zero's `process_tools` method prioritizes looking up the tool name in the `MCPConfig`. If found, the execution is delegated to the corresponding MCP server. If not found as an MCP tool, it then attempts to find a local/built-in tool with that name. +* **Tool Naming**: MCP tools will appear to the agent with a name prefixed by the server name you defined (and normalized, e.g., lowercase, underscores for spaces/hyphens). For instance, if your server is named `"sequential-thinking"` in the configuration and it offers a tool named `"run_chain"`, the agent will know it as `sequential_thinking.run_chain`. +* **Agent Interaction**: You can instruct the agent to use these tools. For example: "Agent, use the `sequential_thinking.run_chain` tool with the following input..." The agent's LLM will then formulate the appropriate JSON request. +* **Execution Flow**: Agent Zero's `process_tools` method (with logic in `python/helpers/mcp_handler.py`) prioritizes looking up the tool name in the `MCPConfig`. If found, the execution is delegated to the corresponding MCP server. If not found as an MCP tool, it then attempts to find a local/built-in tool with that name. This setup provides a flexible way to extend Agent Zero's capabilities by integrating with various external tool providers without modifying its core codebase. \ No newline at end of file From c2396409d178f0fb1378db1fe9b2ceaf66be915f Mon Sep 17 00:00:00 2001 From: deci Date: Wed, 14 May 2025 15:32:31 -0500 Subject: [PATCH 13/21] Edit: Cleaned up the unneeded mcp config in agent.py --- agent.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/agent.py b/agent.py index 288e1127d..b29923e18 100644 --- a/agent.py +++ b/agent.py @@ -183,28 +183,10 @@ class AgentConfig: utility_model: ModelConfig embeddings_model: ModelConfig browser_model: ModelConfig + mcp_servers: str prompts_subdir: str = "" memory_subdir: str = "" knowledge_subdirs: list[str] = field(default_factory=lambda: ["default", "custom"]) - mcp_servers: str = """[ - { - "name": "MCP Server 1", - "url": "https://mcp.server.com", - "headers": { - "Authorization": "Bearer 1234567890" - }, - "disabled": true, - }, - { - "name": "MCP Server 2", - "command": "python3", - "args": ["mcp.py"], - "env": { - "PYTHONPATH": "." - }, - "disabled": true, - } -]""" code_exec_docker_enabled: bool = False code_exec_docker_name: str = "A0-dev" code_exec_docker_image: str = "frdel/agent-zero-run:development" From 2160a9b1580fc8cbbba1d420cdcc5724ff313f3d Mon Sep 17 00:00:00 2001 From: deci Date: Wed, 14 May 2025 15:36:42 -0500 Subject: [PATCH 14/21] Edit: Cleaned up MCP logs and check attempts. --- initialize.py | 78 +++++++++++++++++++---------------- python/helpers/mcp_handler.py | 18 ++++---- 2 files changed, 51 insertions(+), 45 deletions(-) diff --git a/initialize.py b/initialize.py index 0ca586999..ddb6b39d6 100644 --- a/initialize.py +++ b/initialize.py @@ -8,6 +8,9 @@ import shutil from python.helpers.print_style import PrintStyle +_NPM_CHECKS_DONE = False + + # Helper function to ensure an MCP package is globally installed def _ensure_mcp_package_globally_installed(package_name: str, executable_name: str): PrintStyle(background_color="blue", font_color="white", padding=True).print( @@ -56,10 +59,11 @@ def _ensure_mcp_package_globally_installed(package_name: str, executable_name: s PrintStyle(font_color="red", padding=True).print( f"ERROR: 'npm' command not found. Cannot check for or install '{executable_name}' from '{package_name}'." ) - PrintStyle().print() # For a blank line after each attempt + # PrintStyle().print() # For a blank line after each attempt def initialize(): + global _NPM_CHECKS_DONE current_settings = settings.get_settings() mcp_servers_json_string = current_settings.get("mcp_servers", "[]") @@ -76,48 +80,50 @@ def initialize(): ) mcp_server_configs = [] - if shutil.which("npm"): - for server_config in mcp_server_configs: - if not isinstance(server_config, dict): - PrintStyle(font_color="orange", padding=True).print( - f"Warning: Skipping MCP server config item as it's not a dictionary: {server_config}" - ) - continue + if not _NPM_CHECKS_DONE: + if shutil.which("npm"): + for server_config in mcp_server_configs: + if not isinstance(server_config, dict): + PrintStyle(font_color="orange", padding=True).print( + f"Warning: Skipping MCP server config item as it's not a dictionary: {server_config}" + ) + continue - command = server_config.get("command") - args = server_config.get("args", []) - server_name = server_config.get("name", "Unknown MCP Server") + command = server_config.get("command") + args = server_config.get("args", []) + server_name = server_config.get("name", "Unknown MCP Server") - if command == "npx" and "--package" in args: - try: - package_keyword_index = args.index("--package") - # Expect package name at +1 and executable name at +2 from "--package" - if package_keyword_index + 2 < len(args): - package_name = args[package_keyword_index + 1] - executable_name = args[package_keyword_index + 2] - if package_name and executable_name: # Ensure they are not empty strings - _ensure_mcp_package_globally_installed(package_name, executable_name) + if command == "npx" and "--package" in args: + try: + package_keyword_index = args.index("--package") + # Expect package name at +1 and executable name at +2 from "--package" + if package_keyword_index + 2 < len(args): + package_name = args[package_keyword_index + 1] + executable_name = args[package_keyword_index + 2] + if package_name and executable_name: # Ensure they are not empty strings + _ensure_mcp_package_globally_installed(package_name, executable_name) + else: + PrintStyle(font_color="orange", padding=True).print( + f"Warning: Skipping MCP server '{server_name}' due to empty package or executable name extracted from args: {args}" + ) else: PrintStyle(font_color="orange", padding=True).print( - f"Warning: Skipping MCP server '{server_name}' due to empty package or executable name extracted from args: {args}" + f"Warning: Skipping MCP server '{server_name}' as package name or executable name could not be determined from args: {args}" ) - else: + except ValueError: # Should not happen if "--package" is in args, but good for safety PrintStyle(font_color="orange", padding=True).print( - f"Warning: Skipping MCP server '{server_name}' as package name or executable name could not be determined from args: {args}" + f"Warning: '--package' keyword found but .index() failed for args: {args} in server '{server_name}'" ) - except ValueError: # Should not happen if "--package" is in args, but good for safety - PrintStyle(font_color="orange", padding=True).print( - f"Warning: '--package' keyword found but .index() failed for args: {args} in server '{server_name}'" - ) - except Exception as e: - PrintStyle(font_color="red", padding=True).print( - f"Error processing npx args for server '{server_name}': {e}. Args: {args}" - ) - else: - PrintStyle(font_color="red", padding=True).print( - "ERROR: 'npm' command not found. Cannot attempt to install any MCP server packages." - ) - PrintStyle().print() # Extra blank line after all attempts or npm not found message + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"Error processing npx args for server '{server_name}': {e}. Args: {args}" + ) + else: + PrintStyle(font_color="red", padding=True).print( + "ERROR: 'npm' command not found. Cannot attempt to install any MCP server packages." + ) + PrintStyle().print() # Extra blank line after all attempts or npm not found message + _NPM_CHECKS_DONE = True # chat model from user settings chat_llm = ModelConfig( diff --git a/python/helpers/mcp_handler.py b/python/helpers/mcp_handler.py index 1393ad7be..de51ebc90 100644 --- a/python/helpers/mcp_handler.py +++ b/python/helpers/mcp_handler.py @@ -326,7 +326,7 @@ class MCPConfig(BaseModel): from collections.abc import Mapping, Iterable # DEBUG: Print the received servers_list - PrintStyle(background_color="blue", font_color="white", padding=True).print(f"MCPConfig.__init__ received servers_list: {servers_list}") + if servers_list: PrintStyle(background_color="blue", font_color="white", padding=True).print(f"MCPConfig.__init__ received servers_list: {servers_list}") # This empties the servers list if MCPConfig is a Pydantic model and servers is a field. # If servers is a field like `servers: List[MCPServer] = Field(default_factory=list)`, @@ -501,11 +501,11 @@ class MCPClientBase(ABC): Creates a temporary session, executes coro_func with it, and ensures cleanup. """ operation_name = coro_func.__name__ # For logging - PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Creating new session for operation '{operation_name}'...") + # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Creating new session for operation '{operation_name}'...") async with AsyncExitStack() as temp_stack: try: stdio, write = await self._create_stdio_transport(temp_stack) - PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Transport created. Initializing session...") + # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Transport created. Initializing session...") session = await temp_stack.enter_async_context( ClientSession( stdio, @@ -514,11 +514,11 @@ class MCPClientBase(ABC): ) ) await session.initialize() - PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Session initialized.") + # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Session initialized.") result = await coro_func(session) - PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Operation successful.") + # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Operation successful.") return result except Exception as e: PrintStyle(background_color="#AA4455", font_color="white", padding=False).print( @@ -534,7 +534,7 @@ class MCPClientBase(ABC): raise RuntimeError(f"MCPClientBase ({self.server.name} - {operation_name}): _execute_with_session exited 'async with' block unexpectedly.") async def update_tools(self) -> "MCPClientBase": - PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Starting 'update_tools' operation...") + # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Starting 'update_tools' operation...") async def list_tools_op(current_session: ClientSession): response: ListToolsResult = await current_session.list_tools() @@ -571,7 +571,7 @@ class MCPClientBase(ABC): return self.tools async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult: - PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Preparing for 'call_tool' operation for tool '{tool_name}'.") + # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Preparing for 'call_tool' operation for tool '{tool_name}'.") if not self.has_tool(tool_name): PrintStyle(font_color="orange").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not in cache for 'call_tool', refreshing tools...") await self.update_tools() # This will use its own properly managed session @@ -581,9 +581,9 @@ class MCPClientBase(ABC): PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools.") async def call_tool_op(current_session: ClientSession): - PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...") + # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...") response: CallToolResult = await current_session.call_tool(tool_name, input_data) - PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.") + # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.") return response try: From 807629038fe4b104738c11a90af72df4ccdf20f1 Mon Sep 17 00:00:00 2001 From: deci Date: Wed, 14 May 2025 16:07:02 -0500 Subject: [PATCH 15/21] Edit: Removed unneeded dependencies to slim images for dockerfile install and cuda. --- docker/run/Dockerfile.cuda | 16 +--------------- docker/run/fs/ins/pre_install.sh | 14 -------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/docker/run/Dockerfile.cuda b/docker/run/Dockerfile.cuda index 392090838..e0667dc5f 100644 --- a/docker/run/Dockerfile.cuda +++ b/docker/run/Dockerfile.cuda @@ -42,21 +42,7 @@ RUN apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force- git \ ffmpeg \ nginx \ - cron \ - libmagic-dev \ - poppler-utils \ - tesseract-ocr \ - qpdf \ - libreoffice \ - pandoc \ - libgtk-3-0 \ - libnss3 \ - libatk1.0-0 \ - libatk-bridge2.0-0 \ - libcups2 \ - libasound2 \ - libasound2-data \ - cargo + cron # Install Node.js 20.x (LTS) and a compatible npm RUN apt-get update && apt-get install -y ca-certificates curl gnupg diff --git a/docker/run/fs/ins/pre_install.sh b/docker/run/fs/ins/pre_install.sh index ff54962d6..940b9cff8 100644 --- a/docker/run/fs/ins/pre_install.sh +++ b/docker/run/fs/ins/pre_install.sh @@ -23,20 +23,6 @@ apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force-conf nginx \ supervisor \ cron \ - libmagic-dev \ - poppler-utils \ - tesseract-ocr \ - qpdf \ - libreoffice \ - pandoc \ - libgtk-3-0 \ - libnss3 \ - libatk1.0-0 \ - libatk-bridge2.0-0 \ - libcups2 \ - libasound2 \ - libasound2-data \ - cargo \ ca-certificates \ gnupg From 559408ecef727b800f0d805b242dfba33eb891a5 Mon Sep 17 00:00:00 2001 From: deci Date: Thu, 15 May 2025 10:37:18 -0500 Subject: [PATCH 16/21] cleaned up excess packages to reduce image size --- docker/run/Dockerfile.cuda | 1 - docker/run/fs/ins/pre_install.sh | 1 - 2 files changed, 2 deletions(-) diff --git a/docker/run/Dockerfile.cuda b/docker/run/Dockerfile.cuda index e0667dc5f..5823c6359 100644 --- a/docker/run/Dockerfile.cuda +++ b/docker/run/Dockerfile.cuda @@ -41,7 +41,6 @@ RUN apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force- wget \ git \ ffmpeg \ - nginx \ cron # Install Node.js 20.x (LTS) and a compatible npm diff --git a/docker/run/fs/ins/pre_install.sh b/docker/run/fs/ins/pre_install.sh index 940b9cff8..575e1f0ac 100644 --- a/docker/run/fs/ins/pre_install.sh +++ b/docker/run/fs/ins/pre_install.sh @@ -20,7 +20,6 @@ apt-get update && apt-get upgrade -y && apt-get -o Dpkg::Options::="--force-conf wget \ git \ ffmpeg \ - nginx \ supervisor \ cron \ ca-certificates \ From c065a77a0df5674917701c3e983d95e6283feaa2 Mon Sep 17 00:00:00 2001 From: deci Date: Thu, 15 May 2025 11:02:05 -0500 Subject: [PATCH 17/21] slimming requirements of unneeded packages --- requirements.txt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 102986c83..a906b43bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,8 +33,4 @@ unstructured-client==0.25.9 webcolors==24.6.0 mcp==1.3.0 nest-asyncio==1.6.0 -pdf2image==1.17.0 -crontab==1.0.1 -uv==0.6.6 -uvenv==3.6.5 -uvx==2.5.1 +crontab==1.0.1 \ No newline at end of file From 56c2ade6bf60250505649ad6608fba5687eeb121 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 20 May 2025 10:18:05 -0500 Subject: [PATCH 18/21] ignoring my agentfiles --- .gitignore | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a1d30de3b..c8377dbf0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ **/__pycache__/ **/.conda/ +# Ignore docker/run/agent-zero directory +docker/run/agent-zero/ + +#Ignore cursor rules +.cursor/ + # ignore test files in root dir /*.test.py @@ -20,6 +26,9 @@ bundle/*/ # Handle work_dir directory work_dir/* +# Handle specific docker directories +docker/run/agent-zero/** + # Handle memory directory memory/** !memory/**/ @@ -46,4 +55,4 @@ instruments/** # Global rule to include .gitkeep files anywhere !**/.gitkeep -agent_history.gif +agent_history.gif \ No newline at end of file From 425c245777795a1b0f697dd0afded04f96299a84 Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 20 May 2025 10:38:30 -0500 Subject: [PATCH 19/21] adding cuda readme setup instructions --- docs/cuda_docker_setup.md | 104 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/cuda_docker_setup.md diff --git a/docs/cuda_docker_setup.md b/docs/cuda_docker_setup.md new file mode 100644 index 000000000..08359307b --- /dev/null +++ b/docs/cuda_docker_setup.md @@ -0,0 +1,104 @@ +# Agent Zero: CUDA GPU Support 🚀 + +This guide explains how to build and run Agent Zero with NVIDIA GPU acceleration using CUDA. Running with CUDA enables faster performance for AI workloads by leveraging your GPU. + +--- + +## Prerequisites + +Before you begin, ensure you have: + +1. **NVIDIA GPU** with CUDA capability +2. **NVIDIA Driver** installed on your host system +3. **NVIDIA Container Toolkit** ([Install Guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html)) + _This enables Docker to access your GPU_ +4. **Docker** and **Docker Compose** installed + +--- + +## 1. Build the CUDA Docker Image + +Open a terminal in this directory and run: + +```bash +# Set the branch you want to build from (default: main) +$branch="main" +docker build --no-cache -t frdel/agent-zero-run-cuda:testing --build-arg BRANCH=$branch -f Dockerfile.cuda . +``` + +--- + +## 2. Run Agent Zero with CUDA Support + +You can start Agent Zero with GPU support using Docker Compose: + +```bash +# On Linux, macOS, or Windows PowerShell: +docker-compose -f docker-compose.cuda.yml up -d +``` + +- This will launch Agent Zero in the background with GPU acceleration enabled. + +--- + +## 3. Access Agent Zero + +Once the container is running, open your browser and go to: + +[http://localhost:50080](http://localhost:50080) + +--- + +## 4. Stopping Agent Zero + +To stop the CUDA-enabled Agent Zero container: + +```bash +docker-compose -f docker-compose.cuda.yml down +``` + +--- + +## 5. Switching Between CPU and GPU Versions + +You can easily switch between the CPU and GPU versions: + +1. **Stop the currently running version:** + ```bash + # For CPU version: + docker-compose down + # For GPU version: + docker-compose -f docker-compose.cuda.yml down + ``` + +2. **Start the version you want:** + ```bash + # CPU version: + docker-compose -f docker-compose.yml up -d + + # GPU (CUDA) version: + docker-compose -f docker-compose.cuda.yml up -d + ``` + +--- + +## Troubleshooting & Tips + +- **First time setup may take several minutes** as dependencies are downloaded and installed. +- If you encounter issues with GPU access, verify your NVIDIA drivers and the NVIDIA Container Toolkit are correctly installed. +- To check if CUDA is available inside the container, you can run: + ```bash + docker exec -it python3 -c "import torch; print(torch.cuda.is_available())" + ``` +- For advanced configuration, see the comments in [`Dockerfile.cuda`](mdc:docker/run/Dockerfile.cuda). + +--- + +## More Information + +- [NVIDIA Container Toolkit Documentation](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) +- [Agent Zero Project](https://github.com/frdel/agent-zero) (replace with your actual repo link if different) + +--- + +**Enjoy accelerated AI with Agent Zero and CUDA!** From 44d133f8c66b333706acf16f35a712e755edc82e Mon Sep 17 00:00:00 2001 From: deci Date: Tue, 20 May 2025 12:50:36 -0500 Subject: [PATCH 20/21] cuda dockerfile --- docker/run/docker-compose.cuda.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docker/run/docker-compose.cuda.yml diff --git a/docker/run/docker-compose.cuda.yml b/docker/run/docker-compose.cuda.yml new file mode 100644 index 000000000..2f3d7754b --- /dev/null +++ b/docker/run/docker-compose.cuda.yml @@ -0,0 +1,20 @@ +services: + agent-zero-cuda: + container_name: agent-zero-cuda + image: frdel/agent-zero-run-cuda:testing + volumes: + - ./agent-zero:/a0 + - ./agent-zero/work_dir:/root + ports: + - "50080:80" + environment: + - NVIDIA_VISIBLE_DEVICES=all + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + - PYTHONUNBUFFERED=1 + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] \ No newline at end of file From e3b75b159f67d8e9f36ae2d62c1e11fabb4c5704 Mon Sep 17 00:00:00 2001 From: deci Date: Thu, 22 May 2025 07:06:20 -0500 Subject: [PATCH 21/21] fix for auto installer package config. and disable servers if they encounter setup errors without crashing container --- initialize.py | 100 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/initialize.py b/initialize.py index ddb6b39d6..a1bab8a07 100644 --- a/initialize.py +++ b/initialize.py @@ -32,34 +32,42 @@ def _ensure_mcp_package_globally_installed(package_name: str, executable_name: s PrintStyle(font_color="green", padding=True).print( f"'{executable_name}' is now available in PATH after install." ) + return True # Successfully installed and found else: PrintStyle(font_color="orange", padding=True).print( f"WARNING: npm install of '{package_name}' reported success, but '{executable_name}' still not found in PATH. " + "The 'npx' command in settings.json might still be necessary or there might be an issue with PATH." ) + return False # Install reported success, but executable not found else: PrintStyle(font_color="red", padding=True).print( f"Failed to install '{package_name}' globally via npm. Return code: {process.returncode}" ) PrintStyle(font_color="red", padding=False).print(f"npm stdout: {process.stdout.strip()}") PrintStyle(font_color="red", padding=False).print(f"npm stderr: {process.stderr.strip()}") + return False # Install failed except FileNotFoundError: PrintStyle(font_color="red", padding=True).print( f"ERROR: 'npm' command not found. Cannot attempt to install '{package_name}'." ) + return False # npm not found except Exception as e: PrintStyle(font_color="red", padding=True).print( f"Exception during npm install of '{package_name}': {e}" ) + return False # Other exception during install else: PrintStyle(font_color="green", padding=True).print( f"'{executable_name}' (from package '{package_name}') already found in PATH." ) + return True # Already found in PATH else: PrintStyle(font_color="red", padding=True).print( f"ERROR: 'npm' command not found. Cannot check for or install '{executable_name}' from '{package_name}'." ) - # PrintStyle().print() # For a blank line after each attempt + return False # npm command not found, cannot check or install + # Fallback, though logic above should cover all paths to return explicitly + return False def initialize(): @@ -93,31 +101,83 @@ def initialize(): args = server_config.get("args", []) server_name = server_config.get("name", "Unknown MCP Server") - if command == "npx" and "--package" in args: - try: - package_keyword_index = args.index("--package") - # Expect package name at +1 and executable name at +2 from "--package" - if package_keyword_index + 2 < len(args): - package_name = args[package_keyword_index + 1] - executable_name = args[package_keyword_index + 2] - if package_name and executable_name: # Ensure they are not empty strings - _ensure_mcp_package_globally_installed(package_name, executable_name) + if command == "npx": + package_name_for_install = None + executable_name_to_check = None + + if "--package" in args: # Original logic for npx --package + try: + package_keyword_index = args.index("--package") + # Expect package name at +1 and executable name at +2 from "--package" + if package_keyword_index + 2 < len(args): + package_name_for_install = args[package_keyword_index + 1] + executable_name_to_check = args[package_keyword_index + 2] else: PrintStyle(font_color="orange", padding=True).print( - f"Warning: Skipping MCP server '{server_name}' due to empty package or executable name extracted from args: {args}" + f"Warning: Skipping MCP server '{server_name}' (npx --package) as package or executable name could not be determined from args: {args}" ) + except ValueError: # Should not happen if "--package" is in args, but good for safety + PrintStyle(font_color="orange", padding=True).print( + f"Warning: '--package' keyword found but .index() failed for args: {args} in server '{server_name}'" + ) + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"Error processing npx --package args for server '{server_name}': {e}. Args: {args}" + ) + else: # New logic for npx syntax + parsed_npx_pkg_arg = None + arg_idx = 0 + while arg_idx < len(args): + current_arg = args[arg_idx] + # npx's own -p/--package option for temporary installs, distinct from the --package marker we check above + if current_arg == "-p" or current_arg == "--package": + arg_idx += 1 # Move to the value of -p/--package + if arg_idx < len(args): # Ensure there is a value + arg_idx += 1 # Skip the value itself + continue + + if current_arg.startswith("-"): # Skip other options like -y, --yes, --no-install etc. + arg_idx += 1 + continue + + # Found what we assume is the main package argument for npx + parsed_npx_pkg_arg = current_arg + break # Found the package, stop parsing args for this purpose + + if parsed_npx_pkg_arg: + package_name_for_install = parsed_npx_pkg_arg + # Derive assumed executable name based on convention from error: "mcp-server-google-maps" for "@.../server-google-maps" + # For "@scope/pkg-name" -> "pkg-name". For "pkg-name" -> "pkg-name". + name_part = parsed_npx_pkg_arg.split("/")[-1] + executable_name_to_check = f"mcp-{name_part}" + PrintStyle(font_color="blue", padding=True).print( + f"Info: For MCP server '{server_name}' (npx type), attempting to ensure global install of package '{package_name_for_install}' and expecting executable '{executable_name_to_check}'." + ) else: PrintStyle(font_color="orange", padding=True).print( - f"Warning: Skipping MCP server '{server_name}' as package name or executable name could not be determined from args: {args}" + f"Warning: Skipping MCP server '{server_name}' (npx type) as main package argument could not be identified from args: {args}" ) - except ValueError: # Should not happen if "--package" is in args, but good for safety - PrintStyle(font_color="orange", padding=True).print( - f"Warning: '--package' keyword found but .index() failed for args: {args} in server '{server_name}'" - ) - except Exception as e: - PrintStyle(font_color="red", padding=True).print( - f"Error processing npx args for server '{server_name}': {e}. Args: {args}" - ) + + # Unified call to ensure package is installed + if package_name_for_install and executable_name_to_check: + # Ensure they are not empty strings or just whitespace + if package_name_for_install.strip() and executable_name_to_check.strip(): + install_successful = _ensure_mcp_package_globally_installed(package_name_for_install.strip(), executable_name_to_check.strip()) + if not install_successful: + PrintStyle(font_color="red", padding=True).print( + f"Disabling MCP server '{server_name}' due to failed setup/validation for package '{package_name_for_install}' and/or executable '{executable_name_to_check}'." + ) + server_config["disabled"] = True # Disable this server config + else: + PrintStyle(font_color="orange", padding=True).print( + f"Warning: Skipping MCP server '{server_name}' due to empty package or executable name derived: pkg='{package_name_for_install}', exec='{executable_name_to_check}' from args: {args}. Not attempting install." + ) + # Optionally, consider if these should also be marked as disabled, + # though current logic implies they weren't valid enough to attempt install. + # server_config["disabled"] = True + # If package_name_for_install or executable_name_to_check are None or empty, + # it means previous logic decided not to proceed or couldn't determine them, + # and appropriate warnings would have been printed. else: PrintStyle(font_color="red", padding=True).print( "ERROR: 'npm' command not found. Cannot attempt to install any MCP server packages."