frdel commited on
Commit
df8761f
·
1 Parent(s): 41390d5
docker/base/fs/ins/install_base_packages.sh CHANGED
@@ -3,11 +3,15 @@ set -e
3
 
4
  echo "====================BASE PACKAGES START===================="
5
 
 
 
6
  apt-get install -y --no-install-recommends \
7
  nodejs npm openssh-server sudo curl wget git ffmpeg supervisor cron
8
 
9
  echo "====================BASE PACKAGES NPM===================="
10
 
11
- npm i -g npx shx
 
 
12
 
13
  echo "====================BASE PACKAGES END===================="
 
3
 
4
  echo "====================BASE PACKAGES START===================="
5
 
6
+ apt-get update && apt-get upgrade -y
7
+
8
  apt-get install -y --no-install-recommends \
9
  nodejs npm openssh-server sudo curl wget git ffmpeg supervisor cron
10
 
11
  echo "====================BASE PACKAGES NPM===================="
12
 
13
+ # we shall not install npx separately, it's discontinued and some versions are broken
14
+ # npm i -g npx
15
+ npm i -g shx
16
 
17
  echo "====================BASE PACKAGES END===================="
docker/base/fs/ins/install_python.sh CHANGED
@@ -49,4 +49,8 @@ pip install --upgrade pip ipython requests
49
  pip install torch==2.7.0+cpu torchvision==0.22.0+cpu --index-url https://download.pytorch.org/whl/cpu
50
 
51
 
 
 
 
 
52
  echo "====================PYTHON END===================="
 
49
  pip install torch==2.7.0+cpu torchvision==0.22.0+cpu --index-url https://download.pytorch.org/whl/cpu
50
 
51
 
52
+ echo "====================PYTHON UV ===================="
53
+
54
+ curl -Ls https://astral.sh/uv/install.sh | sh
55
+
56
  echo "====================PYTHON END===================="
initialize.py CHANGED
@@ -82,9 +82,10 @@ def initialize():
82
  args_override(config)
83
 
84
  # initialize MCP in deferred task to prevent blocking the main thread
85
- async def initialize_mcp_async(mcp_servers_config: str):
86
- return initialize_mcp(mcp_servers_config)
87
- defer.DeferredTask(thread_name="mcp-initializer").start_task(initialize_mcp_async, config.mcp_servers)
 
88
 
89
  # import python.helpers.mcp_handler as mcp_helper
90
  # import agent as agent_helper
 
82
  args_override(config)
83
 
84
  # initialize MCP in deferred task to prevent blocking the main thread
85
+ # async def initialize_mcp_async(mcp_servers_config: str):
86
+ # return initialize_mcp(mcp_servers_config)
87
+ # defer.DeferredTask(thread_name="mcp-initializer").start_task(initialize_mcp_async, config.mcp_servers)
88
+ initialize_mcp(config.mcp_servers)
89
 
90
  # import python.helpers.mcp_handler as mcp_helper
91
  # import agent as agent_helper
python/api/mcp_server_get_log.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from python.helpers.api import ApiHandler
2
+ from flask import Request, Response
3
+
4
+ from typing import Any
5
+
6
+ from python.helpers.mcp_handler import MCPConfig
7
+
8
+
9
+ class McpServerGetLog(ApiHandler):
10
+ async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
11
+
12
+ # try:
13
+ server_name = input.get("server_name")
14
+ if not server_name:
15
+ return {"success": False, "error": "Missing server_name"}
16
+ log = MCPConfig.get_instance().get_server_log(server_name)
17
+ return {"success": True, "log": log}
18
+ # except Exception as e:
19
+ # return {"success": False, "error": str(e)}
python/helpers/errors.py CHANGED
@@ -18,35 +18,46 @@ def format_error(e: Exception, start_entries=6, end_entries=4):
18
  # Split the traceback into lines
19
  lines = traceback_text.split("\n")
20
 
21
- # Find all "File" lines
22
- file_indices = [
23
- i for i, line in enumerate(lines) if line.strip().startswith("File ")
24
- ]
25
-
26
- # If we found at least one "File" line, trim the middle if there are more than start_entries+end_entries lines
27
- if len(file_indices) > start_entries + end_entries:
28
- start_index = max(0, len(file_indices) - start_entries - end_entries)
29
- trimmed_lines = (
30
- lines[: file_indices[start_index]]
31
- + [
32
- f"\n>>> {len(file_indices) - start_entries - end_entries} stack lines skipped <<<\n"
33
- ]
34
- + lines[file_indices[start_index + end_entries] :]
35
- )
36
  else:
37
- # If no "File" lines found, or not enough to trim, just return the original traceback
38
- trimmed_lines = lines
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  # Find the error message at the end
41
  error_message = ""
42
- for line in reversed(trimmed_lines):
43
  if re.match(r"\w+Error:", line):
44
  error_message = line
45
  break
46
 
47
  # Combine the trimmed traceback with the error message
48
- result = "Traceback (most recent call last):\n" + "\n".join(trimmed_lines)
49
- if error_message:
50
- result += f"\n\n{error_message}"
 
 
 
 
 
 
 
51
 
52
  return result
 
18
  # Split the traceback into lines
19
  lines = traceback_text.split("\n")
20
 
21
+ if not start_entries and not end_entries:
22
+ trimmed_lines = []
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  else:
24
+
25
+ # Find all "File" lines
26
+ file_indices = [
27
+ i for i, line in enumerate(lines) if line.strip().startswith("File ")
28
+ ]
29
+
30
+ # If we found at least one "File" line, trim the middle if there are more than start_entries+end_entries lines
31
+ if len(file_indices) > start_entries + end_entries:
32
+ start_index = max(0, len(file_indices) - start_entries - end_entries)
33
+ trimmed_lines = (
34
+ lines[: file_indices[start_index]]
35
+ + [
36
+ f"\n>>> {len(file_indices) - start_entries - end_entries} stack lines skipped <<<\n"
37
+ ]
38
+ + lines[file_indices[start_index + end_entries] :]
39
+ )
40
+ else:
41
+ # If no "File" lines found, or not enough to trim, just return the original traceback
42
+ trimmed_lines = lines
43
 
44
  # Find the error message at the end
45
  error_message = ""
46
+ for line in reversed(lines):
47
  if re.match(r"\w+Error:", line):
48
  error_message = line
49
  break
50
 
51
  # Combine the trimmed traceback with the error message
52
+ if not trimmed_lines:
53
+ result = error_message
54
+ else:
55
+ result = "Traceback (most recent call last):\n" + "\n".join(trimmed_lines)
56
+ if error_message:
57
+ result += f"\n\n{error_message}"
58
+
59
+ # at least something
60
+ if not result:
61
+ result = str(e)
62
 
63
  return result
python/helpers/mcp_handler.py CHANGED
@@ -1,19 +1,36 @@
1
  from abc import ABC, abstractmethod
2
  import re
3
- from typing import List, Dict, Optional, Any, Union, Literal, Annotated, ClassVar, cast, Callable, Awaitable, TypeVar
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import threading
5
  import asyncio
6
  from contextlib import AsyncExitStack
7
  from shutil import which
8
  from datetime import timedelta
9
  import json
 
 
10
 
11
  import os
 
12
  # print(f"DEBUG: Listing /opt/venv/lib/python3.11/site-packages/ before mcp import: {os.listdir('/opt/venv/lib/python3.11/site-packages/')}") # This line caused FileNotFoundError, **FOR CUDA CHANGE TO '3.12'**
13
 
14
  from mcp import ClientSession, StdioServerParameters
15
  from mcp.client.stdio import stdio_client
16
  from mcp.client.sse import sse_client
 
17
  from mcp.types import CallToolResult, ListToolsResult, JSONRPCMessage
18
  from anyio.streams.memory import (
19
  MemoryObjectReceiveStream,
@@ -26,39 +43,52 @@ from python.helpers.dirty_json import DirtyJson
26
  from python.helpers.print_style import PrintStyle
27
  from python.helpers.tool import Tool, Response
28
 
 
29
  def normalize_name(name: str) -> str:
30
  # Lowercase and strip whitespace
31
  name = name.strip().lower()
32
  # Replace all non-alphanumeric (unicode) chars with underscore
33
  # \W matches non-alphanumeric, but also matches underscore, so use [^\w] with re.UNICODE
34
  # To also replace underscores from non-latin chars, use [^a-zA-Z0-9] with re.UNICODE
35
- name = re.sub(r'[^\w]', '_', name, flags=re.UNICODE)
36
  return name
37
 
38
- def initialize_mcp(mcp_servers_config:str):
 
39
  if not MCPConfig.get_instance().is_initialized():
40
  try:
41
  MCPConfig.update(mcp_servers_config)
42
  except Exception as e:
43
  from agent import AgentContext
44
- first_context = AgentContext.first() # TODO replace with better reporting
 
45
  if first_context:
46
  (
47
- first_context.log
48
- .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
 
 
 
49
  )
50
  (
51
- PrintStyle(background_color="black", font_color="red", padding=True)
52
- .print(f"Failed to update MCP settings: {e}")
 
53
  )
54
 
 
55
  class MCPTool(Tool):
56
  """MCP Tool wrapper"""
 
57
  async def execute(self, **kwargs: Any):
58
  error = ""
59
  try:
60
- response: CallToolResult = await MCPConfig.get_instance().call_tool(self.name, kwargs)
61
- message = "\n\n".join([item.text for item in response.content if item.type == "text"])
 
 
 
 
62
  if response.isError:
63
  error = message
64
  except Exception as e:
@@ -69,7 +99,9 @@ class MCPTool(Tool):
69
  PrintStyle(
70
  background_color="#CC34C3", font_color="white", bold=True, padding=True
71
  ).print(f"MCPTool::Failed to call mcp tool {self.name}:")
72
- PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(error)
 
 
73
 
74
  self.agent.context.log.log(
75
  type="warning",
@@ -80,43 +112,60 @@ class MCPTool(Tool):
80
 
81
  async def before_execution(self, **kwargs: Any):
82
  (
83
- PrintStyle(font_color="#1B4F72", padding=True, background_color="white", bold=True)
84
- .print(f"{self.agent.agent_name}: Using tool '{self.name}'")
 
85
  )
86
  self.log = self.get_log_object()
87
 
88
  for key, value in self.args.items():
89
- PrintStyle(font_color="#85C1E9", bold=True).stream(self.nice_key(key) + ": ")
90
- PrintStyle(font_color="#85C1E9", padding=isinstance(value, str) and "\n" in value).stream(value)
 
 
 
 
91
  PrintStyle().print()
92
 
93
  async def after_execution(self, response: Response, **kwargs: Any):
94
  raw_tool_response = response.message.strip() if response.message else ""
95
  if not raw_tool_response:
96
- PrintStyle(font_color="red").print(f"Warning: Tool '{self.name}' returned an empty message.")
 
 
97
  # Even if empty, we might still want to provide context for the agent
98
  raw_tool_response = "[Tool returned no textual content]"
99
 
100
  # Prepare user message context
101
- user_message_text = "No specific user message context available for this exact step."
102
- if self.agent and self.agent.last_user_message and self.agent.last_user_message.content:
 
 
 
 
 
 
103
  content = self.agent.last_user_message.content
104
  if isinstance(content, dict):
105
  # Attempt to get a 'message' field, otherwise stringify the dict
106
- user_message_text = content.get("message", json.dumps(content, indent=2))
 
 
107
  elif isinstance(content, str):
108
  user_message_text = content
109
  else:
110
  # Fallback for any other types (e.g. list, if that were possible for content)
111
- user_message_text = str(content)
112
-
113
  # Ensure user_message_text is a string before length check and slicing
114
- user_message_text = str(user_message_text)
115
 
116
  # Truncate user message context if it's too long to avoid overwhelming the prompt
117
- max_user_context_len = 500 # characters
118
  if len(user_message_text) > max_user_context_len:
119
- user_message_text = user_message_text[:max_user_context_len] + "... (truncated)"
 
 
120
 
121
  contextual_block = f"""
122
  \n--- End of Results for MCP Tool: {self.name} ---
@@ -139,13 +188,22 @@ If this action is part of an ongoing sequence, consider the next step with this
139
 
140
  self.agent.hist_add_tool_result(self.name, final_text_for_agent)
141
  (
142
- PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True)
143
- .print(f"{self.agent.agent_name}: Response from tool '{self.name}' (plus context added)")
 
 
 
144
  )
145
  # Print only the raw response to console for brevity, agent gets the full context.
146
- PrintStyle(font_color="#85C1E9").print(raw_tool_response if raw_tool_response else "[No direct textual output from tool]")
 
 
 
 
147
  if self.log:
148
- self.log.update(content=final_text_for_agent) # Log includes the full context
 
 
149
 
150
 
151
  class MCPServerRemote(BaseModel):
@@ -165,6 +223,14 @@ class MCPServerRemote(BaseModel):
165
  self.__client = MCPClientRemote(self)
166
  self.update(config)
167
 
 
 
 
 
 
 
 
 
168
  def get_tools(self) -> List[dict[str, Any]]:
169
  """Get all tools from the server"""
170
  with self.__lock:
@@ -175,7 +241,9 @@ class MCPServerRemote(BaseModel):
175
  with self.__lock:
176
  return self.__client.has_tool(tool_name) # type: ignore
177
 
178
- async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
 
 
179
  """Call a tool with the given input data"""
180
  with self.__lock:
181
  # We already run in an event loop, dont believe Pylance
@@ -184,7 +252,15 @@ class MCPServerRemote(BaseModel):
184
  def update(self, config: dict[str, Any]) -> "MCPServerRemote":
185
  with self.__lock:
186
  for key, value in config.items():
187
- if key in ["name", "description", "url", "headers", "timeout", "sse_read_timeout", "disabled"]:
 
 
 
 
 
 
 
 
188
  if key == "name":
189
  value = normalize_name(value)
190
  setattr(self, key, value)
@@ -203,7 +279,9 @@ class MCPServerLocal(BaseModel):
203
  args: list[str] = Field(default_factory=list)
204
  env: dict[str, str] | None = Field(default_factory=dict[str, str])
205
  encoding: str = Field(default="utf-8")
206
- encoding_error_handler: Literal["strict", "ignore", "replace"] = Field(default="strict")
 
 
207
  disabled: bool = Field(default=False)
208
 
209
  __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
@@ -214,6 +292,14 @@ class MCPServerLocal(BaseModel):
214
  self.__client = MCPClientLocal(self)
215
  self.update(config)
216
 
 
 
 
 
 
 
 
 
217
  def get_tools(self) -> List[dict[str, Any]]:
218
  """Get all tools from the server"""
219
  with self.__lock:
@@ -224,7 +310,9 @@ class MCPServerLocal(BaseModel):
224
  with self.__lock:
225
  return self.__client.has_tool(tool_name) # type: ignore
226
 
227
- async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
 
 
228
  """Call a tool with the given input data"""
229
  with self.__lock:
230
  # We already run in an event loop, dont believe Pylance
@@ -233,7 +321,16 @@ class MCPServerLocal(BaseModel):
233
  def update(self, config: dict[str, Any]) -> "MCPServerLocal":
234
  with self.__lock:
235
  for key, value in config.items():
236
- if key in ["name", "description", "command", "args", "env", "encoding", "encoding_error_handler", "disabled"]:
 
 
 
 
 
 
 
 
 
237
  if key == "name":
238
  value = normalize_name(value)
239
  setattr(self, key, value)
@@ -247,10 +344,10 @@ class MCPServerLocal(BaseModel):
247
 
248
  MCPServer = Annotated[
249
  Union[
250
- Annotated[MCPServerRemote, Tag('MCPServerRemote')],
251
- Annotated[MCPServerLocal, Tag('MCPServerLocal')]
252
  ],
253
- Discriminator(lambda v: "MCPServerRemote" if "url" in v else "MCPServerLocal")
254
  ]
255
 
256
 
@@ -272,30 +369,42 @@ class MCPConfig(BaseModel):
272
  with cls.__lock:
273
  servers_data: List[Dict[str, Any]] = [] # Default to empty list
274
 
275
- if config_str and config_str.strip(): # Only parse if non-empty and not just whitespace
 
 
276
  try:
277
  # Try with standard json.loads first, as it should handle escaped strings correctly
278
  parsed_value = dirty_json.try_parse(config_str)
279
  normalized = cls.normalize_config(parsed_value)
280
-
281
  if isinstance(normalized, list):
282
  valid_servers = []
283
  for item in normalized:
284
  if isinstance(item, dict):
285
  valid_servers.append(item)
286
  else:
287
- PrintStyle(background_color="yellow", font_color="black", padding=True).print(
 
 
 
 
288
  f"Warning: MCP config item (from json.loads) was not a dictionary and was ignored: {item}"
289
  )
290
  servers_data = valid_servers
291
  else:
292
- PrintStyle(background_color="red", font_color="white", padding=True).print(
 
 
293
  f"Error: Parsed MCP config (from json.loads) top-level structure is not a list. Config string was: '{config_str}'"
294
  )
295
  # servers_data remains empty
296
- except Exception as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception
297
- PrintStyle.error(f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'")
298
-
 
 
 
 
299
  # # Fallback to DirtyJson or log error if standard json.loads fails
300
  # PrintStyle(background_color="orange", font_color="black", padding=True).print(
301
  # f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback."
@@ -322,13 +431,15 @@ class MCPConfig(BaseModel):
322
  # f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'"
323
  # )
324
  # # servers_data remains empty, allowing graceful degradation
325
-
326
  # Initialize/update the singleton instance with the (potentially empty) list of server data
327
  instance = cls.get_instance()
328
  # Directly update the servers attribute of the existing instance or re-initialize carefully
329
  # For simplicity and to ensure __init__ logic runs if needed for setup:
330
- new_instance_data = {'servers': servers_data} # Prepare data for re-initialization or update
331
-
 
 
332
  # Option 1: Re-initialize the existing instance (if __init__ is idempotent for other fields)
333
  instance.__init__(servers_list=servers_data)
334
 
@@ -345,7 +456,7 @@ class MCPConfig(BaseModel):
345
  # PrintStyle(background_color="grey", font_color="red", padding=True).print(
346
  # f"MCPConfig.update: Failed to create MCPServer from item '{server_item_data.get('name', 'Unknown')}': {e_init}"
347
  # )
348
-
349
  cls.__initialized = True
350
  return instance
351
 
@@ -368,21 +479,23 @@ class MCPConfig(BaseModel):
368
  if isinstance(server, dict):
369
  normalized.append(server)
370
  else:
371
- normalized.append(servers) # single server?
372
  return normalized
373
-
374
 
375
  def __init__(self, servers_list: List[Dict[str, Any]]):
376
  from collections.abc import Mapping, Iterable
377
 
378
  # DEBUG: Print the received servers_list
379
- if servers_list: PrintStyle(background_color="blue", font_color="white", padding=True).print(f"MCPConfig.__init__ received servers_list: {servers_list}")
 
 
 
380
 
381
  # This empties the servers list if MCPConfig is a Pydantic model and servers is a field.
382
- # If servers is a field like `servers: List[MCPServer] = Field(default_factory=list)`,
383
  # then super().__init__() might try to initialize it.
384
  # We are re-assigning self.servers later in this __init__.
385
- super().__init__()
386
 
387
  # Clear any servers potentially initialized by super().__init__() before we populate based on servers_list
388
  self.servers = []
@@ -391,8 +504,9 @@ class MCPConfig(BaseModel):
391
 
392
  if not isinstance(servers_list, Iterable):
393
  (
394
- PrintStyle(background_color="grey", font_color="red", padding=True)
395
- .print("MCPConfig::__init__::servers_list must be a list")
 
396
  )
397
  return
398
 
@@ -401,15 +515,22 @@ class MCPConfig(BaseModel):
401
  # log the error
402
  error_msg = "server_item must be a mapping"
403
  (
404
- PrintStyle(background_color="grey", font_color="red", padding=True)
405
- .print(f"MCPConfig::__init__::{error_msg}")
 
406
  )
407
  # add to failed servers with generic name
408
- self.disconnected_servers.append({
409
- "config": server_item if isinstance(server_item, dict) else {"raw": str(server_item)},
410
- "error": error_msg,
411
- "name": "invalid_server_config"
412
- })
 
 
 
 
 
 
413
  continue
414
 
415
  if server_item.get("disabled", False):
@@ -418,13 +539,15 @@ class MCPConfig(BaseModel):
418
  # normalize server name if it exists
419
  if server_name != "unnamed_server":
420
  server_name = normalize_name(server_name)
421
-
422
  # add to failed servers
423
- self.disconnected_servers.append({
424
- "config": server_item,
425
- "error": "Disabled in config",
426
- "name": server_name
427
- })
 
 
428
  continue
429
 
430
  server_name = server_item.get("name", "__not__found__")
@@ -432,15 +555,18 @@ class MCPConfig(BaseModel):
432
  # log the error
433
  error_msg = "server_name is required"
434
  (
435
- PrintStyle(background_color="grey", font_color="red", padding=True)
436
- .print(f"MCPConfig::__init__::{error_msg}")
 
437
  )
438
  # add to failed servers
439
- self.disconnected_servers.append({
440
- "config": server_item,
441
- "error": error_msg,
442
- "name": "unnamed_server"
443
- })
 
 
444
  continue
445
 
446
  try:
@@ -453,15 +579,23 @@ class MCPConfig(BaseModel):
453
  # log the error
454
  error_msg = str(e)
455
  (
456
- PrintStyle(background_color="grey", font_color="red", padding=True)
457
- .print(f"MCPConfig::__init__: Failed to create MCPServer '{server_name}': {error_msg}")
 
 
 
458
  )
459
  # add to failed servers
460
- self.disconnected_servers.append({
461
- "config": server_item,
462
- "error": error_msg,
463
- "name": server_name
464
- })
 
 
 
 
 
465
 
466
  def get_servers_status(self) -> list[dict[str, Any]]:
467
  """Get status of all servers"""
@@ -474,29 +608,33 @@ class MCPConfig(BaseModel):
474
  # get tool count
475
  tool_count = len(server.get_tools())
476
  # check if server is connected
477
- connected = tool_count > 0
478
  # get error message if any
479
- error = ""
480
-
481
  # add server status to result
482
- result.append({
483
- "name": name,
484
- "connected": connected,
485
- "error": error,
486
- "tool_count": tool_count
487
- })
488
-
 
 
489
  # add failed servers
490
  for disconnected in self.disconnected_servers:
491
- result.append({
492
- "name": disconnected["name"],
493
- "connected": False,
494
- "error": disconnected["error"],
495
- "tool_count": 0
496
- })
497
-
 
 
498
  return result
499
-
500
  def is_initialized(self) -> bool:
501
  """Check if the client is initialized"""
502
  with self.__lock:
@@ -541,7 +679,7 @@ class MCPConfig(BaseModel):
541
  if "input_schema" in tool and "properties" in tool["input_schema"]:
542
  properties: dict[str, Any] = tool["input_schema"]["properties"]
543
  for key, value in properties.items():
544
- tool_args += f" \"{key}\": \"...\",\n"
545
  examples = ""
546
  description = ""
547
  if "examples" in value:
@@ -557,11 +695,11 @@ class MCPConfig(BaseModel):
557
  f"#### Usage:\n"
558
  f"~~~json\n"
559
  f"{{\n"
560
- f" \"observations\": [\"...\"],\n"
561
- f" \"thoughts\": [\"...\"],\n"
562
- f" \"reflection\": [\"...\"],\n"
563
  f" \"tool_name\": \"{server_name}.{tool['name']}\",\n"
564
- f" \"tool_args\": {{\n"
565
  f"{tool_args}"
566
  f" }}\n"
567
  f"}}\n"
@@ -586,7 +724,9 @@ class MCPConfig(BaseModel):
586
  return None
587
  return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="")
588
 
589
- async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
 
 
590
  """Call a tool with the given input data"""
591
  if "." not in tool_name:
592
  raise ValueError(f"Tool {tool_name} not found")
@@ -598,7 +738,8 @@ class MCPConfig(BaseModel):
598
  raise ValueError(f"Tool {tool_name} not found")
599
 
600
 
601
- T = TypeVar('T')
 
602
 
603
  class MCPClientBase(ABC):
604
  # server: Union[MCPServerLocal, MCPServerRemote] # Defined in __init__
@@ -609,81 +750,117 @@ class MCPClientBase(ABC):
609
 
610
  def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]):
611
  self.server = server
612
- self.tools: List[dict[str, Any]] = [] # Tools are cached on the client instance
 
 
613
 
614
  # Protected method
615
  @abstractmethod
616
- async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
 
 
 
 
 
617
  """Create stdio/write streams using the provided exit_stack."""
618
  ...
619
 
620
- async def _execute_with_session(self, coro_func: Callable[[ClientSession], Awaitable[T]]) -> T:
 
 
 
 
621
  """
622
  Manages the lifecycle of an MCP session for a single operation.
623
  Creates a temporary session, executes coro_func with it, and ensures cleanup.
624
  """
625
- operation_name = coro_func.__name__ # For logging
626
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Creating new session for operation '{operation_name}'...")
627
- async with AsyncExitStack() as temp_stack:
628
- try:
629
- stdio, write = await self._create_stdio_transport(temp_stack)
630
- # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Transport created. Initializing session...")
631
- session = await temp_stack.enter_async_context(
632
- ClientSession(
633
- stdio,
634
- write,
635
- read_timeout_seconds=timedelta(seconds=600)
636
- )
637
- )
638
  try:
639
- # Add timeout to session.initialize
640
- await asyncio.wait_for(session.initialize(), timeout=10)
641
- except Exception as e:
642
- PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(
643
- f"MCPClientBase ({self.server.name} - {operation_name}): Session initialization failed: {type(e).__name__}: {e}"
 
 
 
 
 
 
 
 
644
  )
645
- raise
646
- # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Session initialized.")
647
-
648
- result = await coro_func(session)
649
-
650
- # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Operation successful.")
651
- return result
652
- except Exception as e:
653
- PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(
654
- f"MCPClientBase ({self.server.name} - {operation_name}): Error during operation: {type(e).__name__}: {e}"
655
- )
656
- raise # Re-raise the exception to be handled by the caller of update_tools/call_tool
657
- finally:
658
- PrintStyle(font_color="cyan").print(
659
- f"MCPClientBase ({self.server.name} - {operation_name}): Session and transport will be closed by AsyncExitStack."
660
- )
 
 
 
 
 
 
 
 
 
 
 
661
  # This line should ideally be unreachable if the try/except/finally logic within the 'async with' is exhaustive.
662
  # Adding it to satisfy linters that might not fully trace the raise/return paths through async context managers.
663
- raise RuntimeError(f"MCPClientBase ({self.server.name} - {operation_name}): _execute_with_session exited 'async with' block unexpectedly.")
 
 
664
 
665
  async def update_tools(self) -> "MCPClientBase":
666
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Starting 'update_tools' operation...")
667
-
668
  async def list_tools_op(current_session: ClientSession):
669
  response: ListToolsResult = await current_session.list_tools()
670
- with self.__lock:
671
- self.tools = [{
672
- "name": tool.name,
673
- "description": tool.description,
674
- "input_schema": tool.inputSchema
675
- } for tool in response.tools]
676
- PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tools updated. Found {len(self.tools)} tools.")
 
 
 
 
 
677
 
678
  try:
679
- await self._execute_with_session(list_tools_op)
 
680
  except Exception as e:
 
 
681
  # Error already logged by _execute_with_session, this is for specific handling if needed
682
- PrintStyle(background_color="#CC34C3", font_color="white", bold=True, padding=True).print(
683
- f"MCPClientBase ({self.server.name}): 'update_tools' operation failed: {e}"
 
 
684
  )
685
  with self.__lock:
686
- self.tools = [] # Ensure tools are cleared on failure
 
687
  return self
688
 
689
  def has_tool(self, tool_name: str) -> bool:
@@ -699,19 +876,32 @@ class MCPClientBase(ABC):
699
  with self.__lock:
700
  return self.tools
701
 
702
- async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
 
 
703
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Preparing for 'call_tool' operation for tool '{tool_name}'.")
704
  if not self.has_tool(tool_name):
705
- PrintStyle(font_color="orange").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not in cache for 'call_tool', refreshing tools...")
706
- await self.update_tools() # This will use its own properly managed session
 
 
707
  if not self.has_tool(tool_name):
708
- PrintStyle(font_color="red").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not found after refresh. Raising ValueError.")
709
- raise ValueError(f"Tool {tool_name} not found after refreshing tool list for server {self.server.name}.")
710
- PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools.")
 
 
 
 
 
 
711
 
712
  async def call_tool_op(current_session: ClientSession):
 
713
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...")
714
- response: CallToolResult = await current_session.call_tool(tool_name, input_data)
 
 
715
  # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.")
716
  return response
717
 
@@ -719,36 +909,92 @@ class MCPClientBase(ABC):
719
  return await self._execute_with_session(call_tool_op)
720
  except Exception as e:
721
  # Error logged by _execute_with_session. Re-raise a specific error for the caller.
722
- PrintStyle(background_color="#AA4455", font_color="white", padding=True).print(
 
 
723
  f"MCPClientBase ({self.server.name}): 'call_tool' operation for '{tool_name}' failed: {type(e).__name__}: {e}"
724
  )
725
- raise ConnectionError(f"MCPClientBase::Failed to call tool '{tool_name}' on server '{self.server.name}'. Original error: {type(e).__name__}: {e}")
 
 
726
 
727
 
728
  class MCPClientLocal(MCPClientBase):
729
- async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  """Connect to an MCP server, init client and save stdio/write streams"""
731
  server: MCPServerLocal = cast(MCPServerLocal, self.server)
732
 
 
 
733
  if not which(server.command):
734
- raise ValueError(f"Command {server.command} not found")
735
 
736
  server_params = StdioServerParameters(
737
  command=server.command,
738
  args=server.args,
739
  env=server.env,
740
  encoding=server.encoding,
741
- encoding_error_handler=server.encoding_error_handler
742
  )
743
- stdio_transport = await current_exit_stack.enter_async_context(stdio_client(server_params))
 
 
 
 
 
 
 
 
 
 
 
744
  return stdio_transport
745
 
746
 
747
  class MCPClientRemote(MCPClientBase):
748
- async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
 
 
 
 
 
 
 
 
 
749
  """Connect to an MCP server, init client and save stdio/write streams"""
750
  server: MCPServerRemote = cast(MCPServerRemote, self.server)
751
  stdio_transport = await current_exit_stack.enter_async_context(
752
- sse_client(url=server.url, headers=server.headers, timeout=server.timeout, sse_read_timeout=server.sse_read_timeout)
 
 
 
 
 
753
  )
754
  return stdio_transport
 
1
  from abc import ABC, abstractmethod
2
  import re
3
+ from typing import (
4
+ List,
5
+ Dict,
6
+ Optional,
7
+ Any,
8
+ Union,
9
+ Literal,
10
+ Annotated,
11
+ ClassVar,
12
+ cast,
13
+ Callable,
14
+ Awaitable,
15
+ TypeVar,
16
+ )
17
  import threading
18
  import asyncio
19
  from contextlib import AsyncExitStack
20
  from shutil import which
21
  from datetime import timedelta
22
  import json
23
+ from python.helpers import errors
24
+ from python.helpers import settings
25
 
26
  import os
27
+
28
  # print(f"DEBUG: Listing /opt/venv/lib/python3.11/site-packages/ before mcp import: {os.listdir('/opt/venv/lib/python3.11/site-packages/')}") # This line caused FileNotFoundError, **FOR CUDA CHANGE TO '3.12'**
29
 
30
  from mcp import ClientSession, StdioServerParameters
31
  from mcp.client.stdio import stdio_client
32
  from mcp.client.sse import sse_client
33
+ from mcp.shared.message import SessionMessage
34
  from mcp.types import CallToolResult, ListToolsResult, JSONRPCMessage
35
  from anyio.streams.memory import (
36
  MemoryObjectReceiveStream,
 
43
  from python.helpers.print_style import PrintStyle
44
  from python.helpers.tool import Tool, Response
45
 
46
+
47
  def normalize_name(name: str) -> str:
48
  # Lowercase and strip whitespace
49
  name = name.strip().lower()
50
  # Replace all non-alphanumeric (unicode) chars with underscore
51
  # \W matches non-alphanumeric, but also matches underscore, so use [^\w] with re.UNICODE
52
  # To also replace underscores from non-latin chars, use [^a-zA-Z0-9] with re.UNICODE
53
+ name = re.sub(r"[^\w]", "_", name, flags=re.UNICODE)
54
  return name
55
 
56
+
57
+ def initialize_mcp(mcp_servers_config: str):
58
  if not MCPConfig.get_instance().is_initialized():
59
  try:
60
  MCPConfig.update(mcp_servers_config)
61
  except Exception as e:
62
  from agent import AgentContext
63
+
64
+ first_context = AgentContext.first() # TODO replace with better reporting
65
  if first_context:
66
  (
67
+ first_context.log.log(
68
+ type="warning",
69
+ content=f"Failed to update MCP settings: {e}",
70
+ temp=False,
71
+ )
72
  )
73
  (
74
+ PrintStyle(
75
+ background_color="black", font_color="red", padding=True
76
+ ).print(f"Failed to update MCP settings: {e}")
77
  )
78
 
79
+
80
  class MCPTool(Tool):
81
  """MCP Tool wrapper"""
82
+
83
  async def execute(self, **kwargs: Any):
84
  error = ""
85
  try:
86
+ response: CallToolResult = await MCPConfig.get_instance().call_tool(
87
+ self.name, kwargs
88
+ )
89
+ message = "\n\n".join(
90
+ [item.text for item in response.content if item.type == "text"]
91
+ )
92
  if response.isError:
93
  error = message
94
  except Exception as e:
 
99
  PrintStyle(
100
  background_color="#CC34C3", font_color="white", bold=True, padding=True
101
  ).print(f"MCPTool::Failed to call mcp tool {self.name}:")
102
+ PrintStyle(
103
+ background_color="#AA4455", font_color="white", padding=False
104
+ ).print(error)
105
 
106
  self.agent.context.log.log(
107
  type="warning",
 
112
 
113
  async def before_execution(self, **kwargs: Any):
114
  (
115
+ PrintStyle(
116
+ font_color="#1B4F72", padding=True, background_color="white", bold=True
117
+ ).print(f"{self.agent.agent_name}: Using tool '{self.name}'")
118
  )
119
  self.log = self.get_log_object()
120
 
121
  for key, value in self.args.items():
122
+ PrintStyle(font_color="#85C1E9", bold=True).stream(
123
+ self.nice_key(key) + ": "
124
+ )
125
+ PrintStyle(
126
+ font_color="#85C1E9", padding=isinstance(value, str) and "\n" in value
127
+ ).stream(value)
128
  PrintStyle().print()
129
 
130
  async def after_execution(self, response: Response, **kwargs: Any):
131
  raw_tool_response = response.message.strip() if response.message else ""
132
  if not raw_tool_response:
133
+ PrintStyle(font_color="red").print(
134
+ f"Warning: Tool '{self.name}' returned an empty message."
135
+ )
136
  # Even if empty, we might still want to provide context for the agent
137
  raw_tool_response = "[Tool returned no textual content]"
138
 
139
  # Prepare user message context
140
+ user_message_text = (
141
+ "No specific user message context available for this exact step."
142
+ )
143
+ if (
144
+ self.agent
145
+ and self.agent.last_user_message
146
+ and self.agent.last_user_message.content
147
+ ):
148
  content = self.agent.last_user_message.content
149
  if isinstance(content, dict):
150
  # Attempt to get a 'message' field, otherwise stringify the dict
151
+ user_message_text = content.get(
152
+ "message", json.dumps(content, indent=2)
153
+ )
154
  elif isinstance(content, str):
155
  user_message_text = content
156
  else:
157
  # Fallback for any other types (e.g. list, if that were possible for content)
158
+ user_message_text = str(content)
159
+
160
  # Ensure user_message_text is a string before length check and slicing
161
+ user_message_text = str(user_message_text)
162
 
163
  # Truncate user message context if it's too long to avoid overwhelming the prompt
164
+ max_user_context_len = 500 # characters
165
  if len(user_message_text) > max_user_context_len:
166
+ user_message_text = (
167
+ user_message_text[:max_user_context_len] + "... (truncated)"
168
+ )
169
 
170
  contextual_block = f"""
171
  \n--- End of Results for MCP Tool: {self.name} ---
 
188
 
189
  self.agent.hist_add_tool_result(self.name, final_text_for_agent)
190
  (
191
+ PrintStyle(
192
+ font_color="#1B4F72", background_color="white", padding=True, bold=True
193
+ ).print(
194
+ f"{self.agent.agent_name}: Response from tool '{self.name}' (plus context added)"
195
+ )
196
  )
197
  # Print only the raw response to console for brevity, agent gets the full context.
198
+ PrintStyle(font_color="#85C1E9").print(
199
+ raw_tool_response
200
+ if raw_tool_response
201
+ else "[No direct textual output from tool]"
202
+ )
203
  if self.log:
204
+ self.log.update(
205
+ content=final_text_for_agent
206
+ ) # Log includes the full context
207
 
208
 
209
  class MCPServerRemote(BaseModel):
 
223
  self.__client = MCPClientRemote(self)
224
  self.update(config)
225
 
226
+ def get_error(self) -> str:
227
+ with self.__lock:
228
+ return self.__client.error # type: ignore
229
+
230
+ def get_log(self) -> str:
231
+ with self.__lock:
232
+ return self.__client.get_log() # type: ignore
233
+
234
  def get_tools(self) -> List[dict[str, Any]]:
235
  """Get all tools from the server"""
236
  with self.__lock:
 
241
  with self.__lock:
242
  return self.__client.has_tool(tool_name) # type: ignore
243
 
244
+ async def call_tool(
245
+ self, tool_name: str, input_data: Dict[str, Any]
246
+ ) -> CallToolResult:
247
  """Call a tool with the given input data"""
248
  with self.__lock:
249
  # We already run in an event loop, dont believe Pylance
 
252
  def update(self, config: dict[str, Any]) -> "MCPServerRemote":
253
  with self.__lock:
254
  for key, value in config.items():
255
+ if key in [
256
+ "name",
257
+ "description",
258
+ "url",
259
+ "headers",
260
+ "timeout",
261
+ "sse_read_timeout",
262
+ "disabled",
263
+ ]:
264
  if key == "name":
265
  value = normalize_name(value)
266
  setattr(self, key, value)
 
279
  args: list[str] = Field(default_factory=list)
280
  env: dict[str, str] | None = Field(default_factory=dict[str, str])
281
  encoding: str = Field(default="utf-8")
282
+ encoding_error_handler: Literal["strict", "ignore", "replace"] = Field(
283
+ default="strict"
284
+ )
285
  disabled: bool = Field(default=False)
286
 
287
  __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
 
292
  self.__client = MCPClientLocal(self)
293
  self.update(config)
294
 
295
+ def get_error(self) -> str:
296
+ with self.__lock:
297
+ return self.__client.error # type: ignore
298
+
299
+ def get_log(self) -> str:
300
+ with self.__lock:
301
+ return self.__client.get_log() # type: ignore
302
+
303
  def get_tools(self) -> List[dict[str, Any]]:
304
  """Get all tools from the server"""
305
  with self.__lock:
 
310
  with self.__lock:
311
  return self.__client.has_tool(tool_name) # type: ignore
312
 
313
+ async def call_tool(
314
+ self, tool_name: str, input_data: Dict[str, Any]
315
+ ) -> CallToolResult:
316
  """Call a tool with the given input data"""
317
  with self.__lock:
318
  # We already run in an event loop, dont believe Pylance
 
321
  def update(self, config: dict[str, Any]) -> "MCPServerLocal":
322
  with self.__lock:
323
  for key, value in config.items():
324
+ if key in [
325
+ "name",
326
+ "description",
327
+ "command",
328
+ "args",
329
+ "env",
330
+ "encoding",
331
+ "encoding_error_handler",
332
+ "disabled",
333
+ ]:
334
  if key == "name":
335
  value = normalize_name(value)
336
  setattr(self, key, value)
 
344
 
345
  MCPServer = Annotated[
346
  Union[
347
+ Annotated[MCPServerRemote, Tag("MCPServerRemote")],
348
+ Annotated[MCPServerLocal, Tag("MCPServerLocal")],
349
  ],
350
+ Discriminator(lambda v: "MCPServerRemote" if "url" in v else "MCPServerLocal"),
351
  ]
352
 
353
 
 
369
  with cls.__lock:
370
  servers_data: List[Dict[str, Any]] = [] # Default to empty list
371
 
372
+ if (
373
+ config_str and config_str.strip()
374
+ ): # Only parse if non-empty and not just whitespace
375
  try:
376
  # Try with standard json.loads first, as it should handle escaped strings correctly
377
  parsed_value = dirty_json.try_parse(config_str)
378
  normalized = cls.normalize_config(parsed_value)
379
+
380
  if isinstance(normalized, list):
381
  valid_servers = []
382
  for item in normalized:
383
  if isinstance(item, dict):
384
  valid_servers.append(item)
385
  else:
386
+ PrintStyle(
387
+ background_color="yellow",
388
+ font_color="black",
389
+ padding=True,
390
+ ).print(
391
  f"Warning: MCP config item (from json.loads) was not a dictionary and was ignored: {item}"
392
  )
393
  servers_data = valid_servers
394
  else:
395
+ PrintStyle(
396
+ background_color="red", font_color="white", padding=True
397
+ ).print(
398
  f"Error: Parsed MCP config (from json.loads) top-level structure is not a list. Config string was: '{config_str}'"
399
  )
400
  # servers_data remains empty
401
+ except (
402
+ Exception
403
+ ) as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception
404
+ PrintStyle.error(
405
+ f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'"
406
+ )
407
+
408
  # # Fallback to DirtyJson or log error if standard json.loads fails
409
  # PrintStyle(background_color="orange", font_color="black", padding=True).print(
410
  # f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback."
 
431
  # f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'"
432
  # )
433
  # # servers_data remains empty, allowing graceful degradation
434
+
435
  # Initialize/update the singleton instance with the (potentially empty) list of server data
436
  instance = cls.get_instance()
437
  # Directly update the servers attribute of the existing instance or re-initialize carefully
438
  # For simplicity and to ensure __init__ logic runs if needed for setup:
439
+ new_instance_data = {
440
+ "servers": servers_data
441
+ } # Prepare data for re-initialization or update
442
+
443
  # Option 1: Re-initialize the existing instance (if __init__ is idempotent for other fields)
444
  instance.__init__(servers_list=servers_data)
445
 
 
456
  # PrintStyle(background_color="grey", font_color="red", padding=True).print(
457
  # f"MCPConfig.update: Failed to create MCPServer from item '{server_item_data.get('name', 'Unknown')}': {e_init}"
458
  # )
459
+
460
  cls.__initialized = True
461
  return instance
462
 
 
479
  if isinstance(server, dict):
480
  normalized.append(server)
481
  else:
482
+ normalized.append(servers) # single server?
483
  return normalized
 
484
 
485
  def __init__(self, servers_list: List[Dict[str, Any]]):
486
  from collections.abc import Mapping, Iterable
487
 
488
  # DEBUG: Print the received servers_list
489
+ if servers_list:
490
+ PrintStyle(background_color="blue", font_color="white", padding=True).print(
491
+ f"MCPConfig.__init__ received servers_list: {servers_list}"
492
+ )
493
 
494
  # This empties the servers list if MCPConfig is a Pydantic model and servers is a field.
495
+ # If servers is a field like `servers: List[MCPServer] = Field(default_factory=list)`,
496
  # then super().__init__() might try to initialize it.
497
  # We are re-assigning self.servers later in this __init__.
498
+ super().__init__()
499
 
500
  # Clear any servers potentially initialized by super().__init__() before we populate based on servers_list
501
  self.servers = []
 
504
 
505
  if not isinstance(servers_list, Iterable):
506
  (
507
+ PrintStyle(
508
+ background_color="grey", font_color="red", padding=True
509
+ ).print("MCPConfig::__init__::servers_list must be a list")
510
  )
511
  return
512
 
 
515
  # log the error
516
  error_msg = "server_item must be a mapping"
517
  (
518
+ PrintStyle(
519
+ background_color="grey", font_color="red", padding=True
520
+ ).print(f"MCPConfig::__init__::{error_msg}")
521
  )
522
  # add to failed servers with generic name
523
+ self.disconnected_servers.append(
524
+ {
525
+ "config": (
526
+ server_item
527
+ if isinstance(server_item, dict)
528
+ else {"raw": str(server_item)}
529
+ ),
530
+ "error": error_msg,
531
+ "name": "invalid_server_config",
532
+ }
533
+ )
534
  continue
535
 
536
  if server_item.get("disabled", False):
 
539
  # normalize server name if it exists
540
  if server_name != "unnamed_server":
541
  server_name = normalize_name(server_name)
542
+
543
  # add to failed servers
544
+ self.disconnected_servers.append(
545
+ {
546
+ "config": server_item,
547
+ "error": "Disabled in config",
548
+ "name": server_name,
549
+ }
550
+ )
551
  continue
552
 
553
  server_name = server_item.get("name", "__not__found__")
 
555
  # log the error
556
  error_msg = "server_name is required"
557
  (
558
+ PrintStyle(
559
+ background_color="grey", font_color="red", padding=True
560
+ ).print(f"MCPConfig::__init__::{error_msg}")
561
  )
562
  # add to failed servers
563
+ self.disconnected_servers.append(
564
+ {
565
+ "config": server_item,
566
+ "error": error_msg,
567
+ "name": "unnamed_server",
568
+ }
569
+ )
570
  continue
571
 
572
  try:
 
579
  # log the error
580
  error_msg = str(e)
581
  (
582
+ PrintStyle(
583
+ background_color="grey", font_color="red", padding=True
584
+ ).print(
585
+ f"MCPConfig::__init__: Failed to create MCPServer '{server_name}': {error_msg}"
586
+ )
587
  )
588
  # add to failed servers
589
+ self.disconnected_servers.append(
590
+ {"config": server_item, "error": error_msg, "name": server_name}
591
+ )
592
+
593
+ def get_server_log(self, server_name: str) -> str:
594
+ with self.__lock:
595
+ for server in self.servers:
596
+ if server.name == server_name:
597
+ return server.get_log() # type: ignore
598
+ return ""
599
 
600
  def get_servers_status(self) -> list[dict[str, Any]]:
601
  """Get status of all servers"""
 
608
  # get tool count
609
  tool_count = len(server.get_tools())
610
  # check if server is connected
611
+ connected = True # tool_count > 0
612
  # get error message if any
613
+ error = server.get_error()
614
+
615
  # add server status to result
616
+ result.append(
617
+ {
618
+ "name": name,
619
+ "connected": connected,
620
+ "error": error,
621
+ "tool_count": tool_count,
622
+ }
623
+ )
624
+
625
  # add failed servers
626
  for disconnected in self.disconnected_servers:
627
+ result.append(
628
+ {
629
+ "name": disconnected["name"],
630
+ "connected": False,
631
+ "error": disconnected["error"],
632
+ "tool_count": 0,
633
+ }
634
+ )
635
+
636
  return result
637
+
638
  def is_initialized(self) -> bool:
639
  """Check if the client is initialized"""
640
  with self.__lock:
 
679
  if "input_schema" in tool and "properties" in tool["input_schema"]:
680
  properties: dict[str, Any] = tool["input_schema"]["properties"]
681
  for key, value in properties.items():
682
+ tool_args += f' "{key}": "...",\n'
683
  examples = ""
684
  description = ""
685
  if "examples" in value:
 
695
  f"#### Usage:\n"
696
  f"~~~json\n"
697
  f"{{\n"
698
+ f' "observations": ["..."],\n'
699
+ f' "thoughts": ["..."],\n'
700
+ f' "reflection": ["..."],\n'
701
  f" \"tool_name\": \"{server_name}.{tool['name']}\",\n"
702
+ f' "tool_args": {{\n'
703
  f"{tool_args}"
704
  f" }}\n"
705
  f"}}\n"
 
724
  return None
725
  return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="")
726
 
727
+ async def call_tool(
728
+ self, tool_name: str, input_data: Dict[str, Any]
729
+ ) -> CallToolResult:
730
  """Call a tool with the given input data"""
731
  if "." not in tool_name:
732
  raise ValueError(f"Tool {tool_name} not found")
 
738
  raise ValueError(f"Tool {tool_name} not found")
739
 
740
 
741
+ T = TypeVar("T")
742
+
743
 
744
  class MCPClientBase(ABC):
745
  # server: Union[MCPServerLocal, MCPServerRemote] # Defined in __init__
 
750
 
751
  def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]):
752
  self.server = server
753
+ self.tools: List[dict[str, Any]] = [] # Tools are cached on the client instance
754
+ self.error: str = ""
755
+ self.log: List[str] = []
756
 
757
  # Protected method
758
  @abstractmethod
759
+ async def _create_stdio_transport(
760
+ self, current_exit_stack: AsyncExitStack
761
+ ) -> tuple[
762
+ MemoryObjectReceiveStream[SessionMessage | Exception],
763
+ MemoryObjectSendStream[SessionMessage],
764
+ ]:
765
  """Create stdio/write streams using the provided exit_stack."""
766
  ...
767
 
768
+ async def _execute_with_session(
769
+ self,
770
+ coro_func: Callable[[ClientSession], Awaitable[T]],
771
+ read_timeout_seconds=60,
772
+ ) -> T:
773
  """
774
  Manages the lifecycle of an MCP session for a single operation.
775
  Creates a temporary session, executes coro_func with it, and ensures cleanup.
776
  """
777
+ operation_name = coro_func.__name__ # For logging
778
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Creating new session for operation '{operation_name}'...")
779
+ # Store the original exception outside the async block
780
+ original_exception = None
781
+ try:
782
+ async with AsyncExitStack() as temp_stack:
 
 
 
 
 
 
 
783
  try:
784
+ async def log_callback(params):
785
+ msg = getattr(params, "message", str(params))
786
+ self.log.append(f"[{self.server.name}] [session]: {msg}")
787
+
788
+ stdio, write = await self._create_stdio_transport(temp_stack)
789
+ # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name} - {operation_name}): Transport created. Initializing session...")
790
+ session = await temp_stack.enter_async_context(
791
+ ClientSession(
792
+ stdio, # type: ignore
793
+ write, # type: ignore
794
+ read_timeout_seconds=timedelta(seconds=read_timeout_seconds),
795
+ # logging_callback=log_callback,
796
+ )
797
  )
798
+ await session.initialize()
799
+ # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Session initialized.")
800
+
801
+ result = await coro_func(session)
802
+
803
+ # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Operation successful.")
804
+ return result
805
+ except Exception as e:
806
+ # Store the original exception and raise a dummy exception
807
+ original_exception = e
808
+ # Create a dummy exception to break out of the async block
809
+ raise RuntimeError("Dummy exception to break out of async block")
810
+ except Exception as e:
811
+ # Check if this is our dummy exception
812
+ if original_exception is not None:
813
+ e = original_exception
814
+ # We have the original exception stored
815
+ PrintStyle(
816
+ background_color="#AA4455", font_color="white", padding=False
817
+ ).print(
818
+ f"MCPClientBase ({self.server.name} - {operation_name}): Error during operation: {type(e).__name__}: {e}"
819
+ )
820
+ raise e # Re-raise the original exception
821
+ finally:
822
+ PrintStyle(font_color="cyan").print(
823
+ f"MCPClientBase ({self.server.name} - {operation_name}): Session and transport will be closed by AsyncExitStack."
824
+ )
825
  # This line should ideally be unreachable if the try/except/finally logic within the 'async with' is exhaustive.
826
  # Adding it to satisfy linters that might not fully trace the raise/return paths through async context managers.
827
+ raise RuntimeError(
828
+ f"MCPClientBase ({self.server.name} - {operation_name}): _execute_with_session exited 'async with' block unexpectedly."
829
+ )
830
 
831
  async def update_tools(self) -> "MCPClientBase":
832
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Starting 'update_tools' operation...")
833
+
834
  async def list_tools_op(current_session: ClientSession):
835
  response: ListToolsResult = await current_session.list_tools()
836
+ with self.__lock:
837
+ self.tools = [
838
+ {
839
+ "name": tool.name,
840
+ "description": tool.description,
841
+ "input_schema": tool.inputSchema,
842
+ }
843
+ for tool in response.tools
844
+ ]
845
+ PrintStyle(font_color="green").print(
846
+ f"MCPClientBase ({self.server.name}): Tools updated. Found {len(self.tools)} tools."
847
+ )
848
 
849
  try:
850
+ set = settings.get_settings()
851
+ await self._execute_with_session(list_tools_op, read_timeout_seconds=set["mcp_client_init_timeout"])
852
  except Exception as e:
853
+ # e = eg.exceptions[0]
854
+ error_text = errors.format_error(e, 0, 0)
855
  # Error already logged by _execute_with_session, this is for specific handling if needed
856
+ PrintStyle(
857
+ background_color="#CC34C3", font_color="white", bold=True, padding=True
858
+ ).print(
859
+ f"MCPClientBase ({self.server.name}): 'update_tools' operation failed: {error_text}"
860
  )
861
  with self.__lock:
862
+ self.tools = [] # Ensure tools are cleared on failure
863
+ self.error = f"Failed to initialize. {error_text}" # store error from tools fetch
864
  return self
865
 
866
  def has_tool(self, tool_name: str) -> bool:
 
876
  with self.__lock:
877
  return self.tools
878
 
879
+ async def call_tool(
880
+ self, tool_name: str, input_data: Dict[str, Any]
881
+ ) -> CallToolResult:
882
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Preparing for 'call_tool' operation for tool '{tool_name}'.")
883
  if not self.has_tool(tool_name):
884
+ PrintStyle(font_color="orange").print(
885
+ f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not in cache for 'call_tool', refreshing tools..."
886
+ )
887
+ await self.update_tools() # This will use its own properly managed session
888
  if not self.has_tool(tool_name):
889
+ PrintStyle(font_color="red").print(
890
+ f"MCPClientBase ({self.server.name}): Tool '{tool_name}' not found after refresh. Raising ValueError."
891
+ )
892
+ raise ValueError(
893
+ f"Tool {tool_name} not found after refreshing tool list for server {self.server.name}."
894
+ )
895
+ PrintStyle(font_color="green").print(
896
+ f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools."
897
+ )
898
 
899
  async def call_tool_op(current_session: ClientSession):
900
+ set = settings.get_settings()
901
  # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...")
902
+ response: CallToolResult = await current_session.call_tool(
903
+ tool_name, input_data, read_timeout_seconds=timedelta(seconds=set["mcp_client_tool_timeout"])
904
+ )
905
  # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.")
906
  return response
907
 
 
909
  return await self._execute_with_session(call_tool_op)
910
  except Exception as e:
911
  # Error logged by _execute_with_session. Re-raise a specific error for the caller.
912
+ PrintStyle(
913
+ background_color="#AA4455", font_color="white", padding=True
914
+ ).print(
915
  f"MCPClientBase ({self.server.name}): 'call_tool' operation for '{tool_name}' failed: {type(e).__name__}: {e}"
916
  )
917
+ raise ConnectionError(
918
+ f"MCPClientBase::Failed to call tool '{tool_name}' on server '{self.server.name}'. Original error: {type(e).__name__}: {e}"
919
+ )
920
 
921
 
922
  class MCPClientLocal(MCPClientBase):
923
+ def get_log(self):
924
+ # read and return lines from self.log_file, do not close it
925
+ if not hasattr(self, 'log_file') or self.log_file is None:
926
+ return ""
927
+ self.log_file.seek(0)
928
+ try:
929
+ log = self.log_file.read()
930
+ except Exception:
931
+ log = ""
932
+ return log
933
+
934
+ def __del__(self):
935
+ # close the log file if it exists
936
+ if hasattr(self, 'log_file') and self.log_file is not None:
937
+ try:
938
+ self.log_file.close()
939
+ except Exception:
940
+ pass
941
+ self.log_file = None
942
+
943
+ async def _create_stdio_transport(
944
+ self, current_exit_stack: AsyncExitStack
945
+ ) -> tuple[
946
+ MemoryObjectReceiveStream[SessionMessage | Exception],
947
+ MemoryObjectSendStream[SessionMessage],
948
+ ]:
949
  """Connect to an MCP server, init client and save stdio/write streams"""
950
  server: MCPServerLocal = cast(MCPServerLocal, self.server)
951
 
952
+ if not server.command:
953
+ raise ValueError("Command not specified")
954
  if not which(server.command):
955
+ raise ValueError(f"Command '{server.command}' not found")
956
 
957
  server_params = StdioServerParameters(
958
  command=server.command,
959
  args=server.args,
960
  env=server.env,
961
  encoding=server.encoding,
962
+ encoding_error_handler=server.encoding_error_handler,
963
  )
964
+ # create a custom error log handler that will capture error output
965
+ import tempfile
966
+
967
+ # use a temporary file for error logging (text mode) if not already present
968
+ if not hasattr(self, 'log_file') or self.log_file is None:
969
+ self.log_file = tempfile.TemporaryFile(mode="w+", encoding="utf-8")
970
+
971
+ # use the stdio_client with our error log file
972
+ stdio_transport = await current_exit_stack.enter_async_context(
973
+ stdio_client(server_params, errlog=self.log_file)
974
+ )
975
+ # do not read or close the file here, as stdio is async
976
  return stdio_transport
977
 
978
 
979
  class MCPClientRemote(MCPClientBase):
980
+
981
+ def get_log(self):
982
+ return "Logging not implemented for remote servers yet"
983
+
984
+ async def _create_stdio_transport(
985
+ self, current_exit_stack: AsyncExitStack
986
+ ) -> tuple[
987
+ MemoryObjectReceiveStream[SessionMessage | Exception],
988
+ MemoryObjectSendStream[SessionMessage],
989
+ ]:
990
  """Connect to an MCP server, init client and save stdio/write streams"""
991
  server: MCPServerRemote = cast(MCPServerRemote, self.server)
992
  stdio_transport = await current_exit_stack.enter_async_context(
993
+ sse_client(
994
+ url=server.url,
995
+ headers=server.headers,
996
+ timeout=server.timeout,
997
+ sse_read_timeout=server.sse_read_timeout,
998
+ )
999
  )
1000
  return stdio_transport
python/helpers/settings.py CHANGED
@@ -44,7 +44,6 @@ class Settings(TypedDict):
44
  agent_prompts_subdir: str
45
  agent_memory_subdir: str
46
  agent_knowledge_subdir: str
47
- mcp_servers: str
48
 
49
  api_keys: dict[str, str]
50
 
@@ -64,6 +63,9 @@ class Settings(TypedDict):
64
  stt_silence_duration: int
65
  stt_waiting_timeout: int
66
 
 
 
 
67
  mcp_server_enabled: bool
68
 
69
 
@@ -706,6 +708,26 @@ def convert_out(settings: Settings) -> SettingsOutput:
706
  }
707
  )
708
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  mcp_client_section: SettingsSection = {
710
  "id": "mcp_client",
711
  "title": "External MCP Servers",
@@ -913,6 +935,8 @@ def get_default_settings() -> Settings:
913
  stt_silence_duration=1000,
914
  stt_waiting_timeout=2000,
915
  mcp_servers='{\n "mcpServers": {}\n}',
 
 
916
  mcp_server_enabled=False,
917
  )
918
 
 
44
  agent_prompts_subdir: str
45
  agent_memory_subdir: str
46
  agent_knowledge_subdir: str
 
47
 
48
  api_keys: dict[str, str]
49
 
 
63
  stt_silence_duration: int
64
  stt_waiting_timeout: int
65
 
66
+ mcp_servers: str
67
+ mcp_client_init_timeout: int
68
+ mcp_client_tool_timeout: int
69
  mcp_server_enabled: bool
70
 
71
 
 
708
  }
709
  )
710
 
711
+ mcp_client_fields.append(
712
+ {
713
+ "id": "mcp_client_init_timeout",
714
+ "title": "MCP Client Init Timeout",
715
+ "description": "Timeout for MCP client initialization (in seconds). Higher values might be required for complex MCPs, but might also slowdown system startup.",
716
+ "type": "number",
717
+ "value": settings["mcp_client_init_timeout"],
718
+ }
719
+ )
720
+
721
+ mcp_client_fields.append(
722
+ {
723
+ "id": "mcp_client_tool_timeout",
724
+ "title": "MCP Client Tool Timeout",
725
+ "description": "Timeout for MCP client tool execution. Higher values might be required for complex tools, but might also result in long responses with failing tools.",
726
+ "type": "number",
727
+ "value": settings["mcp_client_tool_timeout"],
728
+ }
729
+ )
730
+
731
  mcp_client_section: SettingsSection = {
732
  "id": "mcp_client",
733
  "title": "External MCP Servers",
 
935
  stt_silence_duration=1000,
936
  stt_waiting_timeout=2000,
937
  mcp_servers='{\n "mcpServers": {}\n}',
938
+ mcp_client_init_timeout=5,
939
+ mcp_client_tool_timeout=120,
940
  mcp_server_enabled=False,
941
  )
942
 
webui/components/settings/mcp/client/mcp-servers-log.html ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+
3
+ <head>
4
+ <title>MCP Server Log</title>
5
+
6
+ <script type="module">
7
+ import { store } from "/components/settings/mcp/client/mcp-servers-store.js";
8
+ </script>
9
+ </head>
10
+
11
+ <body>
12
+ <div x-data>
13
+ <template x-if="$store.mcpServersStore">
14
+ <div id="mcp-servers-log">
15
+ <p x-text="$store.mcpServersStore.serverLog && $store.mcpServersStore.serverLog.trim() ? $store.mcpServersStore.serverLog : 'Log empty'"></p>
16
+ </div>
17
+ </template>
18
+ </div>
19
+
20
+ <style>
21
+ #mcp-servers-log {
22
+ width: 100%;
23
+ }
24
+
25
+ #mcp-servers-log p {
26
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
27
+ font-size: 0.8em;
28
+ white-space: pre-wrap;
29
+ }
30
+ </style>
31
+
32
+ </body>
33
+
34
+ </html>
webui/components/settings/mcp/client/mcp-servers-store.js CHANGED
@@ -8,6 +8,7 @@ const model = {
8
  servers: [],
9
  loading: false,
10
  statusCheck: false,
 
11
 
12
  async initialize() {
13
  // Initialize the JSON Viewer after the modal is rendered
@@ -104,6 +105,17 @@ const model = {
104
  }
105
  this.loading = false;
106
  },
 
 
 
 
 
 
 
 
 
 
 
107
  };
108
 
109
  const store = createStore("mcpServersStore", model);
 
8
  servers: [],
9
  loading: false,
10
  statusCheck: false,
11
+ serverLog: "",
12
 
13
  async initialize() {
14
  // Initialize the JSON Viewer after the modal is rendered
 
105
  }
106
  this.loading = false;
107
  },
108
+
109
+ async getServerLog(serverName) {
110
+ this.serverLog = "";
111
+ const resp = await API.callJsonApi("mcp_server_get_log", {
112
+ server_name: serverName,
113
+ });
114
+ if (resp.success) {
115
+ this.serverLog = resp.log;
116
+ openModal("settings/mcp/client/mcp-servers-log.html")
117
+ }
118
+ },
119
  };
120
 
121
  const store = createStore("mcpServersStore", model);
webui/components/settings/mcp/client/mcp-servers.html CHANGED
@@ -26,21 +26,24 @@
26
  <div class="server-list">
27
  <template x-for="server in $store.mcpServersStore.servers" :key="server.name">
28
  <div class="server-item">
29
- <div class="status-icon" style="margin-right: 0.5em;" x-data="{ connected: server.connected }">
 
30
  <svg viewBox="0 0 30 30">
31
- <!-- Connected State (filled circle) -->
32
- <circle class="connected-circle" cx="15" cy="15" r="8"
33
- x-bind:fill="server.connected ? '#00c340' : 'none'" x-bind:opacity="server.connected ? 1 : 0" />
 
34
 
35
  <!-- Disconnected State (outline circle) -->
36
- <circle class="disconnected-circle" cx="15" cy="15" r="9" fill="none" stroke="#e40138"
37
- stroke-width="3" x-bind:opacity="server.connected ? 0 : 1" />
38
  </svg>
39
  </div>
40
  <span class="server-name" x-text="server.name"></span>
41
  <span class="server-tools"
42
  x-text="'- ' + (server.tool_count ? server.tool_count : 0) + ' tools'"></span>
43
  <span class="server-error" x-show="server.error" x-text="server.error"></span>
 
44
  </div>
45
  </template>
46
  <div x-show="$store.mcpServersStore.servers.length === 0" class="no-servers">
@@ -107,12 +110,16 @@
107
  color: var(--c-fg2);
108
  font-style: italic;
109
  }
110
-
111
  .server-error {
112
  color: #F44336;
113
  font-size: 0.9em;
114
  margin-left: 0.8em;
115
  }
 
 
 
 
116
  </style>
117
 
118
  </body>
 
26
  <div class="server-list">
27
  <template x-for="server in $store.mcpServersStore.servers" :key="server.name">
28
  <div class="server-item">
29
+ <div class="status-icon" style="margin-right: 0.5em;"
30
+ x-data="{ connected: server.connected }">
31
  <svg viewBox="0 0 30 30">
32
+ <!-- Connected State (filled circle, green or orange) -->
33
+ <circle x-bind:class="server.error ? 'disconnected-circle' : 'connected-circle'" cx="15" cy="15" r="8" x-bind:fill="server.connected
34
+ ? (server.error ? '#e40138' : (server.tool_count === 0 ? '#e40138' : '#00c340'))
35
+ : 'none'" x-bind:opacity="server.connected ? 1 : 0" />
36
 
37
  <!-- Disconnected State (outline circle) -->
38
+ <circle x-bind:class="server.error ? 'disconnected-circle' : 'connected-circle'" cx="15" cy="15" r="9" fill="none"
39
+ stroke="#e40138" stroke-width="3" x-bind:opacity="server.connected ? 0 : 1" />
40
  </svg>
41
  </div>
42
  <span class="server-name" x-text="server.name"></span>
43
  <span class="server-tools"
44
  x-text="'- ' + (server.tool_count ? server.tool_count : 0) + ' tools'"></span>
45
  <span class="server-error" x-show="server.error" x-text="server.error"></span>
46
+ <span class="server-links"><a href="#" @click="$store.mcpServersStore.getServerLog(server.name)">View Log</a></span>
47
  </div>
48
  </template>
49
  <div x-show="$store.mcpServersStore.servers.length === 0" class="no-servers">
 
110
  color: var(--c-fg2);
111
  font-style: italic;
112
  }
113
+
114
  .server-error {
115
  color: #F44336;
116
  font-size: 0.9em;
117
  margin-left: 0.8em;
118
  }
119
+
120
+ .server-links {
121
+ margin-left: 0.8em;
122
+ }
123
  </style>
124
 
125
  </body>
webui/js/modals.js CHANGED
@@ -1,7 +1,6 @@
1
  // Import the component loader and page utilities
2
  import { importComponent } from "/js/components.js";
3
 
4
-
5
  // Modal functionality
6
  const modalStack = [];
7
 
@@ -64,6 +63,7 @@ function createModalElement(name) {
64
  }
65
  });
66
 
 
67
  // Create modal structure
68
  newModal.innerHTML = `
69
  <div class="modal-inner">
@@ -77,6 +77,11 @@ function createModalElement(name) {
77
  </div>
78
  `;
79
 
 
 
 
 
 
80
  // Add modal to DOM
81
  document.body.appendChild(newModal);
82
 
@@ -90,7 +95,7 @@ function createModalElement(name) {
90
  element: newModal,
91
  title: newModal.querySelector(".modal-title"),
92
  body: newModal.querySelector(".modal-bd"),
93
- close: newModal.querySelector(".modal-close"),
94
  styles: [],
95
  scripts: [],
96
  };
@@ -113,9 +118,6 @@ export function openModal(modalPath) {
113
 
114
  // Already added to stack above
115
 
116
- // Setup close button handler for this specific modal
117
- modal.close.addEventListener("click", () => closeModal());
118
-
119
  // Use importComponent to load the modal content
120
  // This handles all HTML, styles, scripts and nested components
121
  // Updated path to use the new folder structure with modal.html
@@ -140,9 +142,6 @@ export function openModal(modalPath) {
140
 
141
  // Update modal z-indexes
142
  updateModalZIndexes();
143
-
144
- // Setup close button handler for this specific modal
145
- modal.close.addEventListener("click", () => closeModal());
146
  } catch (error) {
147
  console.error("Error loading modal content:", error);
148
  resolve();
@@ -151,7 +150,7 @@ export function openModal(modalPath) {
151
  }
152
 
153
  // Function to close modal
154
- export function closeModal(modalName=null) {
155
  if (modalStack.length === 0) return;
156
 
157
  let modalIndex = modalStack.length - 1; // Default to last modal
 
1
  // Import the component loader and page utilities
2
  import { importComponent } from "/js/components.js";
3
 
 
4
  // Modal functionality
5
  const modalStack = [];
6
 
 
63
  }
64
  });
65
 
66
+
67
  // Create modal structure
68
  newModal.innerHTML = `
69
  <div class="modal-inner">
 
77
  </div>
78
  `;
79
 
80
+ // Setup close button handler for this specific modal
81
+ const close_button = newModal.querySelector(".modal-close");
82
+ close_button.addEventListener("click", () => closeModal());
83
+
84
+
85
  // Add modal to DOM
86
  document.body.appendChild(newModal);
87
 
 
95
  element: newModal,
96
  title: newModal.querySelector(".modal-title"),
97
  body: newModal.querySelector(".modal-bd"),
98
+ close: close_button,
99
  styles: [],
100
  scripts: [],
101
  };
 
118
 
119
  // Already added to stack above
120
 
 
 
 
121
  // Use importComponent to load the modal content
122
  // This handles all HTML, styles, scripts and nested components
123
  // Updated path to use the new folder structure with modal.html
 
142
 
143
  // Update modal z-indexes
144
  updateModalZIndexes();
 
 
 
145
  } catch (error) {
146
  console.error("Error loading modal content:", error);
147
  resolve();
 
150
  }
151
 
152
  // Function to close modal
153
+ export function closeModal(modalName = null) {
154
  if (modalStack.length === 0) return;
155
 
156
  let modalIndex = modalStack.length - 1; // Default to last modal