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
3518b96ea8c23e6ba554877064b19c3767b19448
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"
@@ -655,30 +677,68 @@ class Agent:
677
tool_request = extract_tools.json_parse_dirty(msg)
678
679
if tool_request is not None:
658
- tool_name = tool_request.get("tool_name", "")
659
- tool_method = None
680
+ raw_tool_name = tool_request.get("tool_name", "") # Get the raw tool name
681
tool_args = tool_request.get("tool_args", {})
682
+
683
+ tool_name = raw_tool_name # Initialize tool_name with raw_tool_name
684
+ tool_method = None # Initialize tool_method
685
662
- if ":" in tool_name:
663
- tool_name, tool_method = tool_name.split(":", 1)
686
+ # Split raw_tool_name into tool_name and tool_method if applicable
687
+ if ":" in raw_tool_name:
688
+ tool_name, tool_method = raw_tool_name.split(":", 1)
689
+
690
+ tool = None # Initialize tool to None
691
665
- tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg)
692
+ # Try getting tool from MCP first
693
+ try:
694
+ import python.helpers.mcp as mcp_helper
695
+ mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool(self, tool_name)
696
+ if mcp_tool_candidate:
697
+ tool = mcp_tool_candidate
698
+ except ImportError:
699
+ # Get context safely
700
+ current_context = AgentContext.first()
701
+ if current_context:
702
+ current_context.log.log(type="warning", content="MCP helper module not found. Skipping MCP tool lookup.", temp=True)
703
+ PrintStyle(background_color="black", font_color="yellow", padding=True).print(
704
+ "MCP helper module not found. Skipping MCP tool lookup."
705
+ )
706
+ except Exception as e:
707
+ # Get context safely
708
+ current_context = AgentContext.first()
709
+ if current_context:
710
+ current_context.log.log(type="warning", content=f"Failed to get MCP tool '{tool_name}': {e}", temp=True)
711
+ PrintStyle(background_color="black", font_color="red", padding=True).print(
712
+ f"Failed to get MCP tool '{tool_name}': {e}"
713
+ )
714
667
- await self.handle_intervention() # wait if paused and handle intervention message if needed
668
- await tool.before_execution(**tool_args)
669
- await self.handle_intervention() # wait if paused and handle intervention message if needed
670
- response = await tool.execute(**tool_args)
671
- await self.handle_intervention() # wait if paused and handle intervention message if needed
672
- await tool.after_execution(response)
673
- await self.handle_intervention() # wait if paused and handle intervention message if needed
674
- if response.break_loop:
675
- return response.message
715
+ # Fallback to local get_tool if MCP tool was not found or MCP lookup failed
716
+ if not tool:
717
+ tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg)
718
+
719
+ if tool:
720
+ await self.handle_intervention()
721
+ await tool.before_execution(**tool_args)
722
+ await self.handle_intervention()
723
+ response = await tool.execute(**tool_args)
724
+ await self.handle_intervention()
725
+ await tool.after_execution(response)
726
+ await self.handle_intervention()
727
+ if response.break_loop:
728
+ return response.message
729
+ else:
730
+ error_detail = f"Tool '{raw_tool_name}' not found or could not be initialized."
731
+ self.hist_add_warning(error_detail)
732
+ PrintStyle(font_color="red", padding=True).print(error_detail)
733
+ self.context.log.log(
734
+ type="error", content=f"{self.agent_name}: {error_detail}"
735
+ )
736
else:
677
- msg = self.read_prompt("fw.msg_misformat.md")
678
- self.hist_add_warning(msg)
679
- PrintStyle(font_color="red", padding=True).print(msg)
737
+ warning_msg_misformat = self.read_prompt("fw.msg_misformat.md")
738
+ self.hist_add_warning(warning_msg_misformat)
739
+ PrintStyle(font_color="red", padding=True).print(warning_msg_misformat)
740
self.context.log.log(
681
- type="error", content=f"{self.agent_name}: Message misformat"
741
+ type="error", content=f"{self.agent_name}: Message misformat, no valid tool request found."
742
)
743
744
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
@@ -26,4 +26,4 @@ pip install -r /git/agent-zero/requirements.txt
26
bash /ins/install_playwright.sh "$@"
27
28
# Preload A0
29
-python /git/agent-zero/preload.py --dockerized=true
\ No newline at end of file
29
+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
@@ -531,6 +534,16 @@ def convert_out(settings: Settings) -> SettingsOutput:
534
}
535
)
536
537
+ agent_fields.append(
538
+ {
539
+ "id": "mcp_servers",
540
+ "title": "MCP Servers",
541
+ "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)]",
542
+ "type": "textarea",
543
+ "value": settings["mcp_servers"],
544
+ }
545
+ )
546
+
547
agent_section: SettingsSection = {
548
"id": "agent",
549
"title": "Agent Config",
@@ -829,6 +842,7 @@ def get_default_settings() -> Settings:
842
agent_prompts_subdir="default",
843
agent_memory_subdir="default",
844
agent_knowledge_subdir="custom",
845
+ mcp_servers="",
846
rfc_auto_docker=True,
847
rfc_url="localhost",
848
rfc_password="",
@@ -848,8 +862,9 @@ def _apply_settings(previous: Settings | None):
862
from agent import AgentContext
863
from initialize import initialize
864
865
+ config = initialize()
866
for ctx in AgentContext._contexts.values():
852
- ctx.config = initialize() # reinitialize context config with new settings
867
+ ctx.config = config # reinitialize context config with new settings
868
# apply config to agents
869
agent = ctx.agent0
870
while agent:
@@ -870,6 +885,40 @@ def _apply_settings(previous: Settings | None):
885
from python.helpers.memory import reload as memory_reload
886
memory_reload()
887
888
+ # update mcp settings if necessary
889
+ from python.helpers.mcp import MCPConfig
890
+
891
+ async def update_mcp_settings(mcp_servers: str):
892
+ PrintStyle(background_color="black", font_color="white", padding=True).print("Updating MCP config...")
893
+ AgentContext.first().log.log(type="info", content="Updating MCP settings...", temp=True)
894
+
895
+ mcp_config = MCPConfig.get_instance()
896
+ try:
897
+ MCPConfig.update(mcp_servers)
898
+ except Exception as e:
899
+ AgentContext.first().log.log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
900
+ (
901
+ PrintStyle(background_color="red", font_color="black", padding=True)
902
+ .print("Failed to update MCP settings")
903
+ )
904
+ (
905
+ PrintStyle(background_color="black", font_color="red", padding=True)
906
+ .print(f"{e}")
907
+ )
908
+
909
+ PrintStyle(
910
+ background_color="#6734C3", font_color="white", padding=True
911
+ ).print("Parsed MCP config:")
912
+ (
913
+ PrintStyle(background_color="#334455", font_color="white", padding=False)
914
+ .print(mcp_config.model_dump_json())
915
+ )
916
+ AgentContext.first().log.log(type="info", content="Finished updating MCP settings :)", temp=True)
917
+
918
+ task2 = defer.DeferredTask().start_task(
919
+ update_mcp_settings, config.mcp_servers
920
+ ) # TODO overkill, replace with background task
921
+
922
923
def _env_to_dict(data: str):
924
env_dict = {}
requirements.txt
+3
@@ -31,4 +31,7 @@ tiktoken==0.8.0
31
unstructured==0.15.13
32
unstructured-client==0.25.9
33
webcolors==24.6.0
34
+mcp==1.3.0
35
+nest-asyncio==1.6.0
36
+pdf2image==1.17.0
37
crontab==1.0.1