mcp wip

frdel committed Jun 3, 2025 at 16:59 UTC 64a7e0f1202a689187ca51608284904f763145ff
11 files changed +573 -212
docker/base/fs/ins/install_base_packages.sh
+5 -1
@@ -3,11 +3,15 @@ set -e
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
11 -npm i -g npx shx
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
+4
@@ -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 UV ===================="
53 +
54 +curl -Ls https://astral.sh/uv/install.sh | sh
55 +
56 echo "====================PYTHON END===================="
initialize.py
+4 -3
@@ -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)
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 new
+19
@@ -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
+32 -21
@@ -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 - )
21 + if not start_entries and not end_entries:
22 + trimmed_lines = []
23 else:
37 - # If no "File" lines found, or not enough to trim, just return the original traceback
38 - trimmed_lines = lines
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 = ""
42 - for line in reversed(trimmed_lines):
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
48 - result = "Traceback (most recent call last):\n" + "\n".join(trimmed_lines)
49 - if error_message:
50 - result += f"\n\n{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
+416 -170
@@ -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
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,
@@ -26,39 +43,52 @@ from python.helpers.dirty_json import DirtyJson
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
35 - name = re.sub(r'[^\w]', '_', name, flags=re.UNICODE)
53 + name = re.sub(r"[^\w]", "_", name, flags=re.UNICODE)
54 return name
55
38 -def initialize_mcp(mcp_servers_config:str):
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
44 - first_context = AgentContext.first() # TODO replace with better reporting
63 +
64 + first_context = AgentContext.first() # TODO replace with better reporting
65 if first_context:
66 (
47 - first_context.log
48 - .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
67 + first_context.log.log(
68 + type="warning",
69 + content=f"Failed to update MCP settings: {e}",
70 + temp=False,
71 + )
72 )
73 (
51 - PrintStyle(background_color="black", font_color="red", padding=True)
52 - .print(f"Failed to update MCP settings: {e}")
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:
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"])
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:
@@ -69,7 +99,9 @@ class MCPTool(Tool):
99 PrintStyle(
100 background_color="#CC34C3", font_color="white", bold=True, padding=True
101 ).print(f"MCPTool::Failed to call mcp tool {self.name}:")
72 - PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(error)
102 + PrintStyle(
103 + background_color="#AA4455", font_color="white", padding=False
104 + ).print(error)
105
106 self.agent.context.log.log(
107 type="warning",
@@ -80,43 +112,60 @@ class MCPTool(Tool):
112
113 async def before_execution(self, **kwargs: Any):
114 (
83 - PrintStyle(font_color="#1B4F72", padding=True, background_color="white", bold=True)
84 - .print(f"{self.agent.agent_name}: Using tool '{self.name}'")
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():
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)
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:
96 - PrintStyle(font_color="red").print(f"Warning: Tool '{self.name}' returned an empty message.")
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
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:
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
106 - user_message_text = content.get("message", json.dumps(content, indent=2))
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)
111 - user_message_text = str(content)
112 -
158 + user_message_text = str(content)
159 +
160 # Ensure user_message_text is a string before length check and slicing
114 - user_message_text = str(user_message_text)
161 + user_message_text = str(user_message_text)
162
163 # Truncate user message context if it's too long to avoid overwhelming the prompt
117 - max_user_context_len = 500 # characters
164 + max_user_context_len = 500 # characters
165 if len(user_message_text) > max_user_context_len:
119 - user_message_text = user_message_text[:max_user_context_len] + "... (truncated)"
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} ---
@@ -139,13 +188,22 @@ If this action is part of an ongoing sequence, consider the next step with this
188
189 self.agent.hist_add_tool_result(self.name, final_text_for_agent)
190 (
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)")
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.
146 - PrintStyle(font_color="#85C1E9").print(raw_tool_response if raw_tool_response else "[No direct textual output from tool]")
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:
148 - self.log.update(content=final_text_for_agent) # Log includes the full context
204 + self.log.update(
205 + content=final_text_for_agent
206 + ) # Log includes the full context
207
208
209 class MCPServerRemote(BaseModel):
@@ -165,6 +223,14 @@ 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:
@@ -175,7 +241,9 @@ class MCPServerRemote(BaseModel):
241 with self.__lock:
242 return self.__client.has_tool(tool_name) # type: ignore
243
178 - async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
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
@@ -184,7 +252,15 @@ class MCPServerRemote(BaseModel):
252 def update(self, config: dict[str, Any]) -> "MCPServerRemote":
253 with self.__lock:
254 for key, value in config.items():
187 - if key in ["name", "description", "url", "headers", "timeout", "sse_read_timeout", "disabled"]:
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)
@@ -203,7 +279,9 @@ class MCPServerLocal(BaseModel):
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")
206 - encoding_error_handler: Literal["strict", "ignore", "replace"] = Field(default="strict")
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())
@@ -214,6 +292,14 @@ class MCPServerLocal(BaseModel):
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:
@@ -224,7 +310,9 @@ class MCPServerLocal(BaseModel):
310 with self.__lock:
311 return self.__client.has_tool(tool_name) # type: ignore
312
227 - async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
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
@@ -233,7 +321,16 @@ class MCPServerLocal(BaseModel):
321 def update(self, config: dict[str, Any]) -> "MCPServerLocal":
322 with self.__lock:
323 for key, value in config.items():
236 - if key in ["name", "description", "command", "args", "env", "encoding", "encoding_error_handler", "disabled"]:
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)
@@ -247,10 +344,10 @@ class MCPServerLocal(BaseModel):
344
345 MCPServer = Annotated[
346 Union[
250 - Annotated[MCPServerRemote, Tag('MCPServerRemote')],
251 - Annotated[MCPServerLocal, Tag('MCPServerLocal')]
347 + Annotated[MCPServerRemote, Tag("MCPServerRemote")],
348 + Annotated[MCPServerLocal, Tag("MCPServerLocal")],
349 ],
253 - Discriminator(lambda v: "MCPServerRemote" if "url" in v else "MCPServerLocal")
350 + Discriminator(lambda v: "MCPServerRemote" if "url" in v else "MCPServerLocal"),
351 ]
352
353
@@ -272,30 +369,42 @@ class MCPConfig(BaseModel):
369 with cls.__lock:
370 servers_data: List[Dict[str, Any]] = [] # Default to empty list
371
275 - if config_str and config_str.strip(): # Only parse if non-empty and not just whitespace
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)
280 -
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:
287 - PrintStyle(background_color="yellow", font_color="black", padding=True).print(
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:
292 - PrintStyle(background_color="red", font_color="white", padding=True).print(
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
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 -
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."
@@ -322,13 +431,15 @@ class MCPConfig(BaseModel):
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
325 -
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:
330 - new_instance_data = {'servers': servers_data} # Prepare data for re-initialization or update
331 -
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
@@ -345,7 +456,7 @@ class MCPConfig(BaseModel):
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 # )
348 -
459 +
460 cls.__initialized = True
461 return instance
462
@@ -368,21 +479,23 @@ class MCPConfig(BaseModel):
479 if isinstance(server, dict):
480 normalized.append(server)
481 else:
371 - normalized.append(servers) # single server?
482 + normalized.append(servers) # single server?
483 return normalized
373 -
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
379 - if servers_list: PrintStyle(background_color="blue", font_color="white", padding=True).print(f"MCPConfig.__init__ received servers_list: {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.
382 - # If servers is a field like `servers: List[MCPServer] = Field(default_factory=list)`,
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__.
385 - super().__init__()
498 + super().__init__()
499
500 # Clear any servers potentially initialized by super().__init__() before we populate based on servers_list
501 self.servers = []
@@ -391,8 +504,9 @@ class MCPConfig(BaseModel):
504
505 if not isinstance(servers_list, Iterable):
506 (
394 - PrintStyle(background_color="grey", font_color="red", padding=True)
395 - .print("MCPConfig::__init__::servers_list must be a list")
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
@@ -401,15 +515,22 @@ class MCPConfig(BaseModel):
515 # log the error
516 error_msg = "server_item must be a mapping"
517 (
404 - PrintStyle(background_color="grey", font_color="red", padding=True)
405 - .print(f"MCPConfig::__init__::{error_msg}")
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
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 - })
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):
@@ -418,13 +539,15 @@ class MCPConfig(BaseModel):
539 # normalize server name if it exists
540 if server_name != "unnamed_server":
541 server_name = normalize_name(server_name)
421 -
542 +
543 # add to failed servers
423 - self.disconnected_servers.append({
424 - "config": server_item,
425 - "error": "Disabled in config",
426 - "name": server_name
427 - })
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__")
@@ -432,15 +555,18 @@ class MCPConfig(BaseModel):
555 # log the error
556 error_msg = "server_name is required"
557 (
435 - PrintStyle(background_color="grey", font_color="red", padding=True)
436 - .print(f"MCPConfig::__init__::{error_msg}")
558 + PrintStyle(
559 + background_color="grey", font_color="red", padding=True
560 + ).print(f"MCPConfig::__init__::{error_msg}")
561 )
562 # add to failed servers
439 - self.disconnected_servers.append({
440 - "config": server_item,
441 - "error": error_msg,
442 - "name": "unnamed_server"
443 - })
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:
@@ -453,15 +579,23 @@ class MCPConfig(BaseModel):
579 # log the error
580 error_msg = str(e)
581 (
456 - PrintStyle(background_color="grey", font_color="red", padding=True)
457 - .print(f"MCPConfig::__init__: Failed to create MCPServer '{server_name}': {error_msg}")
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
460 - self.disconnected_servers.append({
461 - "config": server_item,
462 - "error": error_msg,
463 - "name": server_name
464 - })
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"""
@@ -474,29 +608,33 @@ class MCPConfig(BaseModel):
608 # get tool count
609 tool_count = len(server.get_tools())
610 # check if server is connected
477 - connected = tool_count > 0
611 + connected = True # tool_count > 0
612 # get error message if any
479 - error = ""
480 -
613 + error = server.get_error()
614 +
615 # add server status to result
482 - result.append({
483 - "name": name,
484 - "connected": connected,
485 - "error": error,
486 - "tool_count": tool_count
487 - })
488 -
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:
491 - result.append({
492 - "name": disconnected["name"],
493 - "connected": False,
494 - "error": disconnected["error"],
495 - "tool_count": 0
496 - })
497 -
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
499 -
637 +
638 def is_initialized(self) -> bool:
639 """Check if the client is initialized"""
640 with self.__lock:
@@ -541,7 +679,7 @@ class MCPConfig(BaseModel):
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():
544 - tool_args += f" \"{key}\": \"...\",\n"
682 + tool_args += f' "{key}": "...",\n'
683 examples = ""
684 description = ""
685 if "examples" in value:
@@ -557,11 +695,11 @@ class MCPConfig(BaseModel):
695 f"#### Usage:\n"
696 f"~~~json\n"
697 f"{{\n"
560 - f" \"observations\": [\"...\"],\n"
561 - f" \"thoughts\": [\"...\"],\n"
562 - f" \"reflection\": [\"...\"],\n"
698 + f' "observations": ["..."],\n'
699 + f' "thoughts": ["..."],\n'
700 + f' "reflection": ["..."],\n'
701 f" \"tool_name\": \"{server_name}.{tool['name']}\",\n"
564 - f" \"tool_args\": {{\n"
702 + f' "tool_args": {{\n'
703 f"{tool_args}"
704 f" }}\n"
705 f"}}\n"
@@ -586,7 +724,9 @@ class MCPConfig(BaseModel):
724 return None
725 return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="")
726
589 - async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
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")
@@ -598,7 +738,8 @@ class MCPConfig(BaseModel):
738 raise ValueError(f"Tool {tool_name} not found")
739
740
601 -T = TypeVar('T')
741 +T = TypeVar("T")
742 +
743
744 class MCPClientBase(ABC):
745 # server: Union[MCPServerLocal, MCPServerRemote] # Defined in __init__
@@ -609,81 +750,117 @@ class MCPClientBase(ABC):
750
751 def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]):
752 self.server = server
612 - self.tools: List[dict[str, Any]] = [] # Tools are cached on the client instance
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
616 - async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
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
620 - async def _execute_with_session(self, coro_func: Callable[[ClientSession], Awaitable[T]]) -> T:
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 """
625 - operation_name = coro_func.__name__ # For logging
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}'...")
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 - )
779 + # Store the original exception outside the async block
780 + original_exception = None
781 + try:
782 + async with AsyncExitStack() as temp_stack:
783 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}"
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 )
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 - )
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.
663 - raise RuntimeError(f"MCPClientBase ({self.server.name} - {operation_name}): _execute_with_session exited 'async with' block unexpectedly.")
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...")
667 -
833 +
834 async def list_tools_op(current_session: ClientSession):
835 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.")
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:
679 - await self._execute_with_session(list_tools_op)
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
682 - PrintStyle(background_color="#CC34C3", font_color="white", bold=True, padding=True).print(
683 - f"MCPClientBase ({self.server.name}): 'update_tools' operation failed: {e}"
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:
686 - self.tools = [] # Ensure tools are cleared on failure
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:
@@ -699,19 +876,32 @@ class MCPClientBase(ABC):
876 with self.__lock:
877 return self.tools
878
702 - async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
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):
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
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):
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.")
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...")
714 - response: CallToolResult = await current_session.call_tool(tool_name, input_data)
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
@@ -719,36 +909,92 @@ class MCPClientBase(ABC):
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.
722 - PrintStyle(background_color="#AA4455", font_color="white", padding=True).print(
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 )
725 - raise ConnectionError(f"MCPClientBase::Failed to call tool '{tool_name}' on server '{self.server.name}'. Original error: {type(e).__name__}: {e}")
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):
729 - async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
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):
734 - raise ValueError(f"Command {server.command} not found")
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,
741 - encoding_error_handler=server.encoding_error_handler
962 + encoding_error_handler=server.encoding_error_handler,
963 )
743 - stdio_transport = await current_exit_stack.enter_async_context(stdio_client(server_params))
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):
748 - async def _create_stdio_transport(self, current_exit_stack: AsyncExitStack) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
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(
752 - sse_client(url=server.url, headers=server.headers, timeout=server.timeout, sse_read_timeout=server.sse_read_timeout)
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
+25 -1
@@ -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
47
48 api_keys: dict[str, str]
49
@@ -64,6 +63,9 @@ class Settings(TypedDict):
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
@@ -706,6 +708,26 @@ def convert_out(settings: Settings) -> SettingsOutput:
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",
@@ -913,6 +935,8 @@ def get_default_settings() -> Settings:
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 new
+34
@@ -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>
\ No newline at end of file
webui/components/settings/mcp/client/mcp-servers-store.js
+12
@@ -8,6 +8,7 @@ const 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
@@ -104,6 +105,17 @@ const model = {
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
+14 -7
@@ -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 }">
29 + <div class="status-icon" style="margin-right: 0.5em;"
30 + x-data="{ connected: server.connected }">
31 <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" />
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) -->
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 + <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">
@@ -107,12 +110,16 @@
110 color: var(--c-fg2);
111 font-style: italic;
112 }
110 -
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
+8 -9
@@ -1,7 +1,6 @@
1 // Import the component loader and page utilities
2 import { importComponent } from "/js/components.js";
3
4 -
4 // Modal functionality
5 const modalStack = [];
6
@@ -64,6 +63,7 @@ function createModalElement(name) {
63 }
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 + // 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
@@ -90,7 +95,7 @@ function createModalElement(name) {
95 element: newModal,
96 title: newModal.querySelector(".modal-title"),
97 body: newModal.querySelector(".modal-bd"),
93 - close: newModal.querySelector(".modal-close"),
98 + close: close_button,
99 styles: [],
100 scripts: [],
101 };
@@ -113,9 +118,6 @@ export function openModal(modalPath) {
118
119 // Already added to stack above
120
116 - // Setup close button handler for this specific modal
117 - modal.close.addEventListener("click", () => closeModal());
118 -
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
@@ -140,9 +142,6 @@ export function openModal(modalPath) {
142
143 // Update modal z-indexes
144 updateModalZIndexes();
143 -
144 - // Setup close button handler for this specific modal
145 - modal.close.addEventListener("click", () => closeModal());
145 } catch (error) {
146 console.error("Error loading modal content:", error);
147 resolve();
@@ -151,7 +150,7 @@ export function openModal(modalPath) {
150 }
151
152 // Function to close modal
154 -export function closeModal(modalName=null) {
153 +export function closeModal(modalName = null) {
154 if (modalStack.length === 0) return;
155
156 let modalIndex = modalStack.length - 1; // Default to last modal