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)

Rafael Uzarowski committed Feb 21, 2025 at 17:23 UTC e671b496ec76e98f9b0962e7cc1c1b2bf5483c33
12 files changed +657 -25
agent.py
+78 -18
@@ -1,4 +1,7 @@
1 import asyncio
2 +import nest_asyncio
3 +nest_asyncio.apply()
4 +
5 from collections import OrderedDict
6 from dataclasses import dataclass, field
7 from datetime import datetime
@@ -183,6 +186,25 @@ class AgentConfig:
186 prompts_subdir: str = ""
187 memory_subdir: str = ""
188 knowledge_subdirs: list[str] = field(default_factory=lambda: ["default", "custom"])
189 + mcp_servers: str = """[
190 + {
191 + "name": "MCP Server 1",
192 + "url": "https://mcp.server.com",
193 + "headers": {
194 + "Authorization": "Bearer 1234567890"
195 + },
196 + "disabled": true,
197 + },
198 + {
199 + "name": "MCP Server 2",
200 + "command": "python3",
201 + "args": ["mcp.py"],
202 + "env": {
203 + "PYTHONPATH": "."
204 + },
205 + "disabled": true,
206 + }
207 +]"""
208 code_exec_docker_enabled: bool = False
209 code_exec_docker_name: str = "A0-dev"
210 code_exec_docker_image: str = "frdel/agent-zero-run:development"
@@ -667,30 +689,68 @@ class Agent:
689 tool_request = extract_tools.json_parse_dirty(msg)
690
691 if tool_request is not None:
670 - tool_name = tool_request.get("tool_name", "")
671 - tool_method = None
692 + raw_tool_name = tool_request.get("tool_name", "") # Get the raw tool name
693 tool_args = tool_request.get("tool_args", {})
694 +
695 + tool_name = raw_tool_name # Initialize tool_name with raw_tool_name
696 + tool_method = None # Initialize tool_method
697
674 - if ":" in tool_name:
675 - tool_name, tool_method = tool_name.split(":", 1)
698 + # Split raw_tool_name into tool_name and tool_method if applicable
699 + if ":" in raw_tool_name:
700 + tool_name, tool_method = raw_tool_name.split(":", 1)
701 +
702 + tool = None # Initialize tool to None
703
677 - tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg)
704 + # Try getting tool from MCP first
705 + try:
706 + import python.helpers.mcp as mcp_helper
707 + mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool(self, tool_name)
708 + if mcp_tool_candidate:
709 + tool = mcp_tool_candidate
710 + except ImportError:
711 + # Get context safely
712 + current_context = AgentContext.first()
713 + if current_context:
714 + current_context.log.log(type="warning", content="MCP helper module not found. Skipping MCP tool lookup.", temp=True)
715 + PrintStyle(background_color="black", font_color="yellow", padding=True).print(
716 + "MCP helper module not found. Skipping MCP tool lookup."
717 + )
718 + except Exception as e:
719 + # Get context safely
720 + current_context = AgentContext.first()
721 + if current_context:
722 + current_context.log.log(type="warning", content=f"Failed to get MCP tool '{tool_name}': {e}", temp=True)
723 + PrintStyle(background_color="black", font_color="red", padding=True).print(
724 + f"Failed to get MCP tool '{tool_name}': {e}"
725 + )
726
679 - await self.handle_intervention() # wait if paused and handle intervention message if needed
680 - await tool.before_execution(**tool_args)
681 - await self.handle_intervention() # wait if paused and handle intervention message if needed
682 - response = await tool.execute(**tool_args)
683 - await self.handle_intervention() # wait if paused and handle intervention message if needed
684 - await tool.after_execution(response)
685 - await self.handle_intervention() # wait if paused and handle intervention message if needed
686 - if response.break_loop:
687 - return response.message
727 + # Fallback to local get_tool if MCP tool was not found or MCP lookup failed
728 + if not tool:
729 + tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg)
730 +
731 + if tool:
732 + await self.handle_intervention()
733 + await tool.before_execution(**tool_args)
734 + await self.handle_intervention()
735 + response = await tool.execute(**tool_args)
736 + await self.handle_intervention()
737 + await tool.after_execution(response)
738 + await self.handle_intervention()
739 + if response.break_loop:
740 + return response.message
741 + else:
742 + error_detail = f"Tool '{raw_tool_name}' not found or could not be initialized."
743 + self.hist_add_warning(error_detail)
744 + PrintStyle(font_color="red", padding=True).print(error_detail)
745 + self.context.log.log(
746 + type="error", content=f"{self.agent_name}: {error_detail}"
747 + )
748 else:
689 - msg = self.read_prompt("fw.msg_misformat.md")
690 - self.hist_add_warning(msg)
691 - PrintStyle(font_color="red", padding=True).print(msg)
749 + warning_msg_misformat = self.read_prompt("fw.msg_misformat.md")
750 + self.hist_add_warning(warning_msg_misformat)
751 + PrintStyle(font_color="red", padding=True).print(warning_msg_misformat)
752 self.context.log.log(
693 - type="error", content=f"{self.agent_name}: Message misformat"
753 + type="error", content=f"{self.agent_name}: Message misformat, no valid tool request found."
754 )
755
756 def log_from_stream(self, stream: str, logItem: Log.LogItem):
docker/run/fs/etc/nginx/nginx.conf new
+31
@@ -0,0 +1,31 @@
1 +daemon off;
2 +worker_processes 2;
3 +user www-data;
4 +
5 +events {
6 + use epoll;
7 + worker_connections 128;
8 +}
9 +
10 +error_log /var/log/nginx/error.log info;
11 +
12 +http {
13 + server_tokens off;
14 + include mime.types;
15 + charset utf-8;
16 +
17 + access_log /var/log/nginx/access.log combined;
18 +
19 + server {
20 + server_name 127.0.0.1:31735;
21 + listen 127.0.0.1:31735;
22 +
23 + error_page 500 502 503 504 /50x.html;
24 +
25 + location / {
26 + root /;
27 + }
28 +
29 + }
30 +
31 +}
docker/run/fs/ins/install_A0.sh
+1 -1
@@ -23,4 +23,4 @@ pip install torch --index-url https://download.pytorch.org/whl/cpu
23 pip install -r /git/agent-zero/requirements.txt
24
25 # Preload A0
26 -python /git/agent-zero/preload.py --dockerized=true
\ No newline at end of file
26 +python /git/agent-zero/preload.py --dockerized=true
docker/run/fs/ins/pre_install.sh
+4
@@ -18,6 +18,7 @@ apt-get update && apt-get upgrade -y && apt-get install -y \
18 wget \
19 git \
20 ffmpeg \
21 + nginx\
22 supervisor \
23 cron
24
@@ -43,5 +44,8 @@ echo "=====AFTER UPDATE====="
44 # python3 -m pip install --upgrade pip
45 # fi
46
47 +# Install npx for use by local MCP Servers
48 +npm i -g npx shx
49 +
50 # Prepare SSH daemon
51 bash /ins/setup_ssh.sh "$@"
initialize.py
+18
@@ -53,6 +53,7 @@ def initialize():
53 prompts_subdir=current_settings["agent_prompts_subdir"],
54 memory_subdir=current_settings["agent_memory_subdir"],
55 knowledge_subdirs=["default", current_settings["agent_knowledge_subdir"]],
56 + mcp_servers=current_settings["mcp_servers"],
57 code_exec_docker_enabled=False,
58 # code_exec_docker_name = "A0-dev",
59 # code_exec_docker_image = "frdel/agent-zero-run:development",
@@ -75,6 +76,23 @@ def initialize():
76 # update config with runtime args
77 args_override(config)
78
79 + import python.helpers.mcp as mcp_helper
80 + import agent as agent_helper
81 + import python.helpers.print_style as print_style_helper
82 + if not mcp_helper.MCPConfig.get_instance().is_initialized():
83 + try:
84 + mcp_helper.MCPConfig.update(config.mcp_servers)
85 + except Exception as e:
86 + if agent_helper.AgentContext.first():
87 + (
88 + agent_helper.AgentContext.first().log
89 + .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
90 + )
91 + (
92 + print_style_helper.PrintStyle(background_color="black", font_color="red", padding=True)
93 + .print(f"Failed to update MCP settings: {e}")
94 + )
95 +
96 # return config object
97 return config
98
prompts/default/agent.system.mcp_tools.md new
+1
@@ -0,0 +1 @@
1 +{{tools}}
python/api/message.py
+1 -1
@@ -85,4 +85,4 @@ class Message(ApiHandler):
85 id=message_id,
86 )
87
88 - return context.communicate(UserMessage(message, attachment_paths)), context
\ No newline at end of file
88 + return context.communicate(UserMessage(message, attachment_paths)), context
python/api/settings_set.py
+3 -1
@@ -3,9 +3,11 @@ from flask import Request, Response
3
4 from python.helpers import settings
5
6 +from typing import Any
7 +
8
9 class SetSettings(ApiHandler):
8 - async def process(self, input: dict, request: Request) -> dict | Response:
10 + async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
11 set = settings.convert_in(input)
12 set = settings.set_settings(set)
13 return {"settings": set}
python/extensions/system_prompt/_10_system_prompt.py
+12 -3
@@ -1,17 +1,22 @@
1 -from datetime import datetime, timezone
1 +from datetime import datetime
2 +from typing import Any, Optional
3 from python.helpers.extension import Extension
4 +from python.helpers.mcp import MCPConfig
5 from agent import Agent, LoopData
6 from python.helpers.localization import Localization
7
8
9 class SystemPrompt(Extension):
10
9 - async def execute(self, system_prompt: list[str]=[], loop_data: LoopData = LoopData(), **kwargs):
11 + async def execute(self, system_prompt: list[str] = [], loop_data: LoopData = LoopData(), **kwargs: Any):
12 # append main system prompt and tools
13 main = get_main_prompt(self.agent)
14 tools = get_tools_prompt(self.agent)
15 + mcp_tools = get_mcp_tools_prompt(self.agent)
16 +
17 system_prompt.append(main)
18 system_prompt.append(tools)
19 + system_prompt.append(mcp_tools)
20
21
22 def get_main_prompt(agent: Agent):
@@ -22,4 +27,8 @@ def get_tools_prompt(agent: Agent):
27 prompt = agent.read_prompt("agent.system.tools.md")
28 if agent.config.chat_model.vision:
29 prompt += '\n' + agent.read_prompt("agent.system.tools_vision.md")
25 - return prompt
\ No newline at end of file
30 + return prompt
31 +
32 +
33 +def get_mcp_tools_prompt(agent: Agent):
34 + return MCPConfig.get_instance().get_tools_prompt()
python/helpers/mcp.py new
+455
@@ -0,0 +1,455 @@
1 +from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr
2 +from typing import List, Dict, Optional, Any, Union, Literal, Annotated
3 +from typing import (
4 + List, Dict, Optional, Any,
5 + Union, Literal, Annotated, ClassVar,
6 +)
7 +import threading
8 +import asyncio
9 +from contextlib import AsyncExitStack
10 +from shutil import which
11 +from mcp import ClientSession, StdioServerParameters
12 +from mcp.client.stdio import stdio_client
13 +from mcp.types import CallToolResult, ListToolsResult, JSONRPCMessage
14 +from anyio.streams.memory import (
15 + MemoryObjectReceiveStream,
16 + MemoryObjectSendStream,
17 +)
18 +from python.helpers.dirty_json import DirtyJson
19 +from python.helpers.print_style import PrintStyle
20 +import dirtyjson
21 +
22 +from python.helpers.tool import Tool, Response
23 +from datetime import timedelta
24 +
25 +from abc import ABC, abstractmethod
26 +
27 +
28 +class MCPTool(Tool):
29 + """MCP Tool wrapper"""
30 + async def execute(self, **kwargs: Any):
31 + error = ""
32 + try:
33 + response: CallToolResult = await MCPConfig.get_instance().call_tool(self.name, kwargs)
34 + message = "\n\n".join([item.text for item in response.content if item.type == "text"])
35 + if response.isError:
36 + error = message
37 + except Exception as e:
38 + error = f"MCP Tool Exception: {str(e)}"
39 + message = f"ERROR: {str(e)}"
40 +
41 + if error:
42 + PrintStyle(
43 + background_color="#CC34C3", font_color="white", bold=True, padding=True
44 + ).print(f"MCPTool::Failed to call mcp tool {self.name}:")
45 + PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(error)
46 +
47 + self.agent.context.log.log(
48 + type="warning",
49 + content=f"{self.name}: {error}",
50 + )
51 +
52 + return Response(message=message, break_loop=False)
53 +
54 + async def before_execution(self, **kwargs: Any):
55 + (
56 + PrintStyle(font_color="#1B4F72", padding=True, background_color="white", bold=True)
57 + .print(f"{self.agent.agent_name}: Using tool '{self.name}'")
58 + )
59 + self.log = self.get_log_object()
60 +
61 + for key, value in self.args.items():
62 + PrintStyle(font_color="#85C1E9", bold=True).stream(self.nice_key(key)+": ")
63 + PrintStyle(font_color="#85C1E9", padding=isinstance(value, str) and "\n" in value).stream(value)
64 + PrintStyle().print()
65 +
66 + async def after_execution(self, response: Response, **kwargs: Any):
67 + # Check if response or message is None
68 + if not response.message.strip():
69 + text = ""
70 + PrintStyle(font_color="red").print(f"Warning: Tool '{self.name}' returned None response or message")
71 + else:
72 + text = response.message.strip()
73 +
74 + await self.agent.hist_add_tool_result(self.name, text)
75 + (
76 + PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True)
77 + .print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
78 + )
79 + PrintStyle(font_color="#85C1E9").print(text)
80 + self.log.update(content=text)
81 +
82 +
83 +class MCPServerRemote(BaseModel):
84 + name: str = Field(default_factory=str)
85 + description: Optional[str] = Field(default="Remote SSE Server")
86 + url: str = Field(default_factory=str)
87 + headers: dict[str, Any] | None = Field(default_factory=dict[str, Any])
88 + timeout: float = 5.0
89 + sse_read_timeout: float = 60.0 * 5.0
90 + disabled: bool = False
91 +
92 + __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
93 +
94 + def __init__(self, config: dict[str, Any]):
95 + super().__init__()
96 + self.update(config)
97 +
98 + def get_tools(self) -> List[dict[str, Any]]:
99 + """Get all tools from the server"""
100 + return []
101 +
102 + def has_tool(self, tool_name: str) -> bool:
103 + """Check if a tool is available"""
104 + return False
105 +
106 + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
107 + """Call a tool with the given input data"""
108 + raise NotImplementedError("MCPServerRemote does not support calling tools")
109 +
110 + def update(self, config: dict[str, Any]) -> "MCPServerRemote":
111 + with self.__lock:
112 + for key, value in config.items():
113 + if key in ["name", "description", "url", "headers", "timeout", "sse_read_timeout", "disabled"]:
114 + setattr(self, key, value)
115 + # We already run in an event loop, dont believe Pylance
116 + return asyncio.run(self.__on_update())
117 +
118 + async def __on_update(self) -> "MCPServerRemote":
119 + return self
120 +
121 +
122 +class MCPServerLocal(BaseModel):
123 + name: str = Field(default_factory=str)
124 + description: Optional[str] = Field(default="Local StdIO Server")
125 + command: str = Field(default_factory=str)
126 + args: list[str] = Field(default_factory=list)
127 + env: dict[str, str] | None = Field(default_factory=dict[str, str])
128 + encoding: str = "utf-8"
129 + encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
130 + disabled: bool = False
131 +
132 + __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
133 + __client: Optional["MCPClientLocal"] = PrivateAttr(default=None)
134 +
135 + def __init__(self, config: dict[str, Any]):
136 + super().__init__()
137 + self.__client = MCPClientLocal(self)
138 + self.update(config)
139 +
140 + def get_tools(self) -> List[dict[str, Any]]:
141 + """Get all tools from the server"""
142 + with self.__lock:
143 + return self.__client.tools
144 +
145 + def has_tool(self, tool_name: str) -> bool:
146 + """Check if a tool is available"""
147 + with self.__lock:
148 + return self.__client.has_tool(tool_name)
149 +
150 + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
151 + """Call a tool with the given input data"""
152 + with self.__lock:
153 + # We already run in an event loop, dont believe Pylance
154 + return await self.__client.call_tool(tool_name, input_data)
155 +
156 + def update(self, config: dict[str, Any]) -> "MCPServerLocal":
157 + with self.__lock:
158 + for key, value in config.items():
159 + if key in ["name", "description", "command", "args", "env", "encoding", "encoding_error_handler", "disabled"]:
160 + if key == "name":
161 + value = value.strip().lower().replace(" ", "_").replace("-", "_").replace(".", "_")
162 + setattr(self, key, value)
163 + # We already run in an event loop, dont believe Pylance
164 + return asyncio.run(self.__on_update())
165 +
166 + async def __on_update(self) -> "MCPServerLocal":
167 + await self.__client.update_tools()
168 + return self
169 +
170 +
171 +MCPServer = Annotated[
172 + Union[
173 + Annotated[MCPServerRemote, Tag('MCPServerRemote')],
174 + Annotated[MCPServerLocal, Tag('MCPServerLocal')]
175 + ],
176 + Discriminator(lambda v: "MCPServerRemote" if "url" in v else "MCPServerLocal")
177 +]
178 +
179 +
180 +class MCPConfig(BaseModel):
181 + servers: List[MCPServer] = Field(default_factory=list[MCPServer])
182 +
183 + __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
184 +
185 + # Singleton instance
186 + __instance: ClassVar[Any] = PrivateAttr(default=None)
187 + __initialized: ClassVar[bool] = PrivateAttr(default=False)
188 +
189 + @classmethod
190 + def get_instance(cls) -> "MCPConfig":
191 + if cls.__instance is None:
192 + cls.__instance = cls(servers_list=[])
193 + return cls.__instance
194 +
195 + @classmethod
196 + def update(cls, config_str: str) -> Any:
197 + """Parse the MCP config string into a MCPConfig object."""
198 + with cls.__lock:
199 + try:
200 + servers = dirtyjson.loads(config_str)
201 + except Exception:
202 + try:
203 + servers = DirtyJson.parse_string(config_str)
204 + except Exception as e:
205 + raise ValueError(f"Failed to parse MCP config: {e}")
206 + cls.get_instance().__init__(servers_list=servers)
207 + cls.__initialized = True
208 + return cls.get_instance()
209 +
210 + def __init__(self, servers_list: List[Dict[str, Any]]):
211 + from collections.abc import Mapping, Iterable
212 +
213 + # This empties the servers list
214 + super().__init__()
215 +
216 + if not isinstance(servers_list, Iterable):
217 + (
218 + PrintStyle(background_color="grey", font_color="red", padding=True)
219 + .print("MCPConfig::__init__::servers_list must be a list")
220 + )
221 + return
222 +
223 + for server_item in servers_list:
224 + if not isinstance(server_item, Mapping):
225 + (
226 + PrintStyle(background_color="grey", font_color="red", padding=True)
227 + .print("MCPConfig::__init__::server_item must be a mapping")
228 + )
229 + continue
230 +
231 + if server_item.get("disabled", False):
232 + continue
233 +
234 + server_name = server_item.get("name", "__not__found__")
235 + if server_name == "__not__found__":
236 + (
237 + PrintStyle(background_color="grey", font_color="red", padding=True)
238 + .print("MCPConfig::__init__::server_name is required")
239 + )
240 + continue
241 +
242 + try:
243 + # not generic MCPServer because: "Annotated can not be instatioated"
244 + if server_item.get("url", None):
245 + self.servers.append(MCPServerRemote(server_item))
246 + else:
247 + self.servers.append(MCPServerLocal(server_item))
248 + except Exception as e:
249 + (
250 + PrintStyle(background_color="grey", font_color="red", padding=True)
251 + .print(f"MCPConfig::__init__: Failedto create MCPServer '{server_name}': {e}")
252 + )
253 + continue
254 +
255 + def is_initialized(self) -> bool:
256 + """Check if the client is initialized"""
257 + with self.__lock:
258 + return self.__initialized
259 +
260 + def get_tools(self) -> List[dict[str, str | dict[str, Any] | None]]:
261 + """Get all tools from all servers"""
262 + with self.__lock:
263 + tools = []
264 + for server in self.servers:
265 + for tool in server.get_tools():
266 + tool_copy = tool.copy()
267 + tool_copy["server"] = server.name
268 + tools.append({f"{server.name}.{tool['name']}": tool_copy})
269 + return tools
270 +
271 + def get_tools_prompt(self, server_name: str = "") -> str:
272 + """Get a prompt for all tools"""
273 + prompt = '## "Remote (MCP Server) Agent Tools" available:\n\n'
274 + server_names = []
275 + for server in self.servers:
276 + if not server_name or server.name == server_name:
277 + server_names.append(server.name)
278 +
279 + if server_name and server_name not in server_names:
280 + raise ValueError(f"Server {server_name} not found")
281 +
282 + for server in self.servers:
283 + if server.name in server_names:
284 + server_name = server.name
285 + for tool in server.get_tools():
286 + prompt += (
287 + f"### {server_name}.{tool['name']}:\n"
288 + f"{tool['description']}\n\n"
289 + f"#### Categories:\n"
290 + f"* kind: MCP Server Tool\n"
291 + f'* server: "{server_name}" ({server.description})\n\n'
292 + f"#### Arguments:\n"
293 + )
294 +
295 + tool_args = ""
296 + properties: dict[str, Any] = tool["input_schema"]["properties"]
297 + for key, value in properties.items():
298 + tool_args += f" \"{key}\": \"...\",\n"
299 + if "examples" in value:
300 + prompt += (
301 + f" * {key} ({value['type']}): {value['description']} (examples: {value['examples']})\n"
302 + )
303 + else:
304 + prompt += (
305 + f" * {key} ({value['type']}): {value['description']}\n"
306 + )
307 + prompt += "\n"
308 +
309 + prompt += (
310 + f"#### Usage:\n"
311 + f"~~~json\n"
312 + f"{{\n"
313 + f" \"observations\": [\"...\"],\n"
314 + f" \"thoughts\": [\"...\"],\n"
315 + f" \"reflection\": [\"...\"],\n"
316 + f" \"tool_name\": \"{server_name}.{tool['name']}\",\n"
317 + f" \"tool_args\": {{\n"
318 + f"{tool_args}"
319 + f" }}\n"
320 + f"}}\n"
321 + f"~~~\n"
322 + )
323 +
324 + return prompt
325 +
326 + def has_tool(self, tool_name: str) -> bool:
327 + """Check if a tool is available"""
328 + if "." not in tool_name:
329 + return False
330 + server_name_part, tool_name_part = tool_name.split(".")
331 + with self.__lock:
332 + for server in self.servers:
333 + if server.name == server_name_part:
334 + return server.has_tool(tool_name_part)
335 + return False
336 +
337 + def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None:
338 + if not self.has_tool(tool_name):
339 + return None
340 + return MCPTool(agent, tool_name, {}, "", **{})
341 +
342 + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
343 + """Call a tool with the given input data"""
344 + if "." not in tool_name:
345 + raise ValueError(f"Tool {tool_name} not found")
346 + server_name_part, tool_name_part = tool_name.split(".")
347 + with self.__lock:
348 + for server in self.servers:
349 + if server.name == server_name_part and server.has_tool(tool_name_part):
350 + return await server.call_tool(tool_name_part, input_data)
351 + raise ValueError(f"Tool {tool_name} not found")
352 +
353 +
354 +class MCPClientLocal:
355 + session: Optional[ClientSession] = None
356 + exit_stack: AsyncExitStack = AsyncExitStack()
357 + stdio: Optional[MemoryObjectReceiveStream[JSONRPCMessage | Exception]] = None
358 + write: Optional[MemoryObjectSendStream[JSONRPCMessage]] = None
359 +
360 + tools: List[dict[str, Any]] = []
361 + server: Optional[MCPServerLocal] = None
362 +
363 + __lock: ClassVar[threading.Lock] = threading.Lock()
364 +
365 + def __init__(self, server: MCPServerLocal):
366 + self.server = server
367 +
368 + async def __connect_to_server(self) -> Any:
369 + """Connect to an MCP server"""
370 +
371 + if not which(self.server.command):
372 + raise ValueError(f"Command {self.server.command} not found")
373 +
374 + which_args = 0
375 + for arg in self.server.args:
376 + if which(arg):
377 + which_args = which_args + 1
378 + if which_args == 0:
379 + raise ValueError(f"None of the arguments {self.server.args} is a file")
380 +
381 + with self.__lock:
382 + server_params = StdioServerParameters(
383 + command=self.server.command,
384 + args=self.server.args,
385 + env=self.server.env,
386 + encoding=self.server.encoding,
387 + encoding_error_handler=self.server.encoding_error_handler
388 + )
389 + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
390 + self.stdio, self.write = stdio_transport
391 +
392 + self.session = (
393 + await self.exit_stack.enter_async_context(
394 + ClientSession(
395 + self.stdio,
396 + self.write,
397 + read_timeout_seconds=timedelta(seconds=15)
398 + )
399 + )
400 + )
401 +
402 + # Initialize session
403 + await self.session.initialize()
404 + return self
405 +
406 + async def update_tools(self) -> Any:
407 + """List available tools from the server"""
408 + try:
409 + await self.__connect_to_server()
410 +
411 + with self.__lock:
412 + response: ListToolsResult = await self.session.list_tools()
413 + available_tools = [{
414 + "name": tool.name,
415 + "description": tool.description,
416 + "input_schema": tool.inputSchema
417 + } for tool in response.tools]
418 +
419 + self.tools = available_tools
420 + await self.exit_stack.aclose()
421 + return self
422 + except Exception as e:
423 + PrintStyle(
424 + background_color="#CC34C3", font_color="white", bold=True, padding=True
425 + ).print("MCPClientLocal::Failed to update tools:")
426 + PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(str(e))
427 +
428 + def has_tool(self, tool_name: str) -> bool:
429 + """Check if a tool is available"""
430 + with self.__lock:
431 + for tool in self.tools:
432 + if tool["name"] == tool_name:
433 + return True
434 + return False
435 +
436 + def get_tools(self) -> List[dict[str, Any]]:
437 + """Get all tools from the server"""
438 + with self.__lock:
439 + return self.tools
440 +
441 + async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
442 + """Call a tool with the given input data"""
443 + if not self.has_tool(tool_name):
444 + await self.update_tools()
445 +
446 + await self.__connect_to_server()
447 +
448 + with self.__lock:
449 + for tool in self.tools:
450 + if tool["name"] == tool_name:
451 + response: CallToolResult = await self.session.call_tool(tool_name, input_data)
452 + # after connect have to close the stack within this function
453 + await self.exit_stack.aclose()
454 + return response
455 + raise ValueError(f"Tool {tool_name} not found")
python/helpers/settings.py
+50 -1
@@ -7,6 +7,8 @@ from typing import Any, Literal, TypedDict
7 import models
8 from python.helpers import runtime, whisper, defer
9 from . import files, dotenv
10 +from python.helpers.print_style import PrintStyle
11 +
12
13
14 class Settings(TypedDict):
@@ -43,6 +45,7 @@ class Settings(TypedDict):
45 agent_prompts_subdir: str
46 agent_memory_subdir: str
47 agent_knowledge_subdir: str
48 + mcp_servers: str
49
50 api_keys: dict[str, str]
51
@@ -528,6 +531,16 @@ def convert_out(settings: Settings) -> SettingsOutput:
531 }
532 )
533
534 + agent_fields.append(
535 + {
536 + "id": "mcp_servers",
537 + "title": "MCP Servers",
538 + "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)]",
539 + "type": "textarea",
540 + "value": settings["mcp_servers"],
541 + }
542 + )
543 +
544 agent_section: SettingsSection = {
545 "id": "agent",
546 "title": "Agent Config",
@@ -826,6 +839,7 @@ def get_default_settings() -> Settings:
839 agent_prompts_subdir="default",
840 agent_memory_subdir="default",
841 agent_knowledge_subdir="custom",
842 + mcp_servers="",
843 rfc_auto_docker=True,
844 rfc_url="localhost",
845 rfc_password="",
@@ -845,8 +859,9 @@ def _apply_settings(previous: Settings | None):
859 from agent import AgentContext
860 from initialize import initialize
861
862 + config = initialize()
863 for ctx in AgentContext._contexts.values():
849 - ctx.config = initialize() # reinitialize context config with new settings
864 + ctx.config = config # reinitialize context config with new settings
865 # apply config to agents
866 agent = ctx.agent0
867 while agent:
@@ -867,6 +882,40 @@ def _apply_settings(previous: Settings | None):
882 from python.helpers.memory import reload as memory_reload
883 memory_reload()
884
885 + # update mcp settings if necessary
886 + from python.helpers.mcp import MCPConfig
887 +
888 + async def update_mcp_settings(mcp_servers: str):
889 + PrintStyle(background_color="black", font_color="white", padding=True).print("Updating MCP config...")
890 + AgentContext.first().log.log(type="info", content="Updating MCP settings...", temp=True)
891 +
892 + mcp_config = MCPConfig.get_instance()
893 + try:
894 + MCPConfig.update(mcp_servers)
895 + except Exception as e:
896 + AgentContext.first().log.log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
897 + (
898 + PrintStyle(background_color="red", font_color="black", padding=True)
899 + .print("Failed to update MCP settings")
900 + )
901 + (
902 + PrintStyle(background_color="black", font_color="red", padding=True)
903 + .print(f"{e}")
904 + )
905 +
906 + PrintStyle(
907 + background_color="#6734C3", font_color="white", padding=True
908 + ).print("Parsed MCP config:")
909 + (
910 + PrintStyle(background_color="#334455", font_color="white", padding=False)
911 + .print(mcp_config.model_dump_json())
912 + )
913 + AgentContext.first().log.log(type="info", content="Finished updating MCP settings :)", temp=True)
914 +
915 + task2 = defer.DeferredTask().start_task(
916 + update_mcp_settings, config.mcp_servers
917 + ) # TODO overkill, replace with background task
918 +
919
920 def _env_to_dict(data: str):
921 env_dict = {}
requirements.txt
+3
@@ -29,4 +29,7 @@ tiktoken==0.8.0
29 unstructured==0.15.13
30 unstructured-client==0.25.9
31 webcolors==24.6.0
32 +mcp==1.3.0
33 +nest-asyncio==1.6.0
34 +pdf2image==1.17.0
35 crontab==1.0.1