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