Rafael Uzarowski commited on
Commit
3fd2e87
·
1 Parent(s): 33f91a4

feat: Implement support for MCP Servers (Claude Tools) - Part 1 Stdio Servers

Browse files

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 CHANGED
@@ -1,4 +1,7 @@
1
  import asyncio
 
 
 
2
  from collections import OrderedDict
3
  from dataclasses import dataclass, field
4
  from datetime import datetime
@@ -183,6 +186,25 @@ class AgentConfig:
183
  prompts_subdir: str = ""
184
  memory_subdir: str = ""
185
  knowledge_subdirs: list[str] = field(default_factory=lambda: ["default", "custom"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  code_exec_docker_enabled: bool = False
187
  code_exec_docker_name: str = "A0-dev"
188
  code_exec_docker_image: str = "frdel/agent-zero-run:development"
@@ -667,30 +689,68 @@ class Agent:
667
  tool_request = extract_tools.json_parse_dirty(msg)
668
 
669
  if tool_request is not None:
670
- tool_name = tool_request.get("tool_name", "")
671
- tool_method = None
672
  tool_args = tool_request.get("tool_args", {})
 
 
 
673
 
674
- if ":" in tool_name:
675
- tool_name, tool_method = tool_name.split(":", 1)
 
 
 
676
 
677
- tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
 
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
 
 
 
 
 
 
 
 
 
 
 
 
688
  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)
692
  self.context.log.log(
693
- type="error", content=f"{self.agent_name}: Message misformat"
694
  )
695
 
696
  def log_from_stream(self, stream: str, logItem: Log.LogItem):
 
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
 
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"
 
689
  tool_request = extract_tools.json_parse_dirty(msg)
690
 
691
  if tool_request is not 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
 
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
 
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
 
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:
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(
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 ADDED
@@ -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 CHANGED
@@ -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
 
23
  pip install -r /git/agent-zero/requirements.txt
24
 
25
  # Preload A0
26
+ python /git/agent-zero/preload.py --dockerized=true
docker/run/fs/ins/pre_install.sh CHANGED
@@ -18,6 +18,7 @@ apt-get update && apt-get upgrade -y && apt-get install -y \
18
  wget \
19
  git \
20
  ffmpeg \
 
21
  supervisor \
22
  cron
23
 
@@ -43,5 +44,8 @@ echo "=====AFTER UPDATE====="
43
  # python3 -m pip install --upgrade pip
44
  # fi
45
 
 
 
 
46
  # Prepare SSH daemon
47
  bash /ins/setup_ssh.sh "$@"
 
18
  wget \
19
  git \
20
  ffmpeg \
21
+ nginx\
22
  supervisor \
23
  cron
24
 
 
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 CHANGED
@@ -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
  code_exec_docker_enabled=False,
57
  # code_exec_docker_name = "A0-dev",
58
  # code_exec_docker_image = "frdel/agent-zero-run:development",
@@ -75,6 +76,23 @@ def initialize():
75
  # update config with runtime args
76
  args_override(config)
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  # return config object
79
  return config
80
 
 
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",
 
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 ADDED
@@ -0,0 +1 @@
 
 
1
+ {{tools}}
python/api/message.py CHANGED
@@ -85,4 +85,4 @@ class Message(ApiHandler):
85
  id=message_id,
86
  )
87
 
88
- return context.communicate(UserMessage(message, attachment_paths)), context
 
85
  id=message_id,
86
  )
87
 
88
+ return context.communicate(UserMessage(message, attachment_paths)), context
python/api/settings_set.py CHANGED
@@ -3,9 +3,11 @@ from flask import Request, Response
3
 
4
  from python.helpers import settings
5
 
 
 
6
 
7
  class SetSettings(ApiHandler):
8
- async def process(self, input: dict, request: Request) -> dict | Response:
9
  set = settings.convert_in(input)
10
  set = settings.set_settings(set)
11
  return {"settings": set}
 
3
 
4
  from python.helpers import settings
5
 
6
+ from typing import Any
7
+
8
 
9
  class SetSettings(ApiHandler):
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 CHANGED
@@ -1,17 +1,22 @@
1
- from datetime import datetime, timezone
 
2
  from python.helpers.extension import Extension
 
3
  from agent import Agent, LoopData
4
  from python.helpers.localization import Localization
5
 
6
 
7
  class SystemPrompt(Extension):
8
 
9
- async def execute(self, system_prompt: list[str]=[], loop_data: LoopData = LoopData(), **kwargs):
10
  # append main system prompt and tools
11
  main = get_main_prompt(self.agent)
12
  tools = get_tools_prompt(self.agent)
 
 
13
  system_prompt.append(main)
14
  system_prompt.append(tools)
 
15
 
16
 
17
  def get_main_prompt(agent: Agent):
@@ -22,4 +27,8 @@ def get_tools_prompt(agent: Agent):
22
  prompt = agent.read_prompt("agent.system.tools.md")
23
  if agent.config.chat_model.vision:
24
  prompt += '\n' + agent.read_prompt("agent.system.tools_vision.md")
25
- return prompt
 
 
 
 
 
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
 
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):
 
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")
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 ADDED
@@ -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 CHANGED
@@ -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
 
11
 
12
  class Settings(TypedDict):
@@ -43,6 +45,7 @@ class Settings(TypedDict):
43
  agent_prompts_subdir: str
44
  agent_memory_subdir: str
45
  agent_knowledge_subdir: str
 
46
 
47
  api_keys: dict[str, str]
48
 
@@ -528,6 +531,16 @@ def convert_out(settings: Settings) -> SettingsOutput:
528
  }
529
  )
530
 
 
 
 
 
 
 
 
 
 
 
531
  agent_section: SettingsSection = {
532
  "id": "agent",
533
  "title": "Agent Config",
@@ -826,6 +839,7 @@ def get_default_settings() -> Settings:
826
  agent_prompts_subdir="default",
827
  agent_memory_subdir="default",
828
  agent_knowledge_subdir="custom",
 
829
  rfc_auto_docker=True,
830
  rfc_url="localhost",
831
  rfc_password="",
@@ -845,8 +859,9 @@ def _apply_settings(previous: Settings | None):
845
  from agent import AgentContext
846
  from initialize import initialize
847
 
 
848
  for ctx in AgentContext._contexts.values():
849
- ctx.config = initialize() # reinitialize context config with new settings
850
  # apply config to agents
851
  agent = ctx.agent0
852
  while agent:
@@ -867,6 +882,40 @@ def _apply_settings(previous: Settings | None):
867
  from python.helpers.memory import reload as memory_reload
868
  memory_reload()
869
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
 
871
  def _env_to_dict(data: str):
872
  env_dict = {}
 
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):
 
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
  }
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",
 
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="",
 
859
  from agent import AgentContext
860
  from initialize import initialize
861
 
862
+ config = initialize()
863
  for ctx in AgentContext._contexts.values():
864
+ ctx.config = config # reinitialize context config with new settings
865
  # apply config to agents
866
  agent = ctx.agent0
867
  while agent:
 
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 CHANGED
@@ -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
  crontab==1.0.1
 
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