mcp wip
frdel committed
Jun 2, 2025 at 21:21 UTC
d3b798cb476edbe64b9dbb4898bb28f16d598a9d
7 files changed
+298
-253
initialize.py
+24
-191
@@ -2,188 +2,16 @@ import asyncio
2
import json
3
import models
4
from agent import AgentConfig, ModelConfig
5
-from python.helpers import dotenv, files, rfc_exchange, runtime, settings, docker, log
5
+from python.helpers import dotenv, files, rfc_exchange, runtime, settings, docker, log, defer
6
import subprocess
7
import shutil
8
from python.helpers.print_style import PrintStyle
9
+from python.helpers.mcp_handler import initialize_mcp
10
11
11
-# _NPM_CHECKS_DONE = False
12
-
13
-
14
-# # Helper function to ensure an MCP package is globally installed
15
-# def _ensure_mcp_package_globally_installed(package_name: str, executable_name: str):
16
-# PrintStyle(background_color="blue", font_color="white", padding=True).print(
17
-# f"Attempting to ensure MCP server executable '{executable_name}' (from package '{package_name}') is available..."
18
-# )
19
-# if shutil.which("npm"):
20
-# if not shutil.which(executable_name):
21
-# PrintStyle(font_color="yellow", padding=True).print(
22
-# f"'{executable_name}' not found in PATH. Attempting global npm install of '{package_name}'..."
23
-# )
24
-# try:
25
-# npm_command = ["npm", "i", "-g", package_name, "--no-fund", "--no-audit"]
26
-# process = subprocess.run(npm_command, capture_output=True, text=True, check=False)
27
-# if process.returncode == 0:
28
-# PrintStyle(font_color="green", padding=True).print(
29
-# f"Successfully installed '{package_name}' globally via npm."
30
-# )
31
-# if shutil.which(executable_name):
32
-# PrintStyle(font_color="green", padding=True).print(
33
-# f"'{executable_name}' is now available in PATH after install."
34
-# )
35
-# return True # Successfully installed and found
36
-# else:
37
-# PrintStyle(font_color="orange", padding=True).print(
38
-# f"WARNING: npm install of '{package_name}' reported success, but '{executable_name}' still not found in PATH. " +
39
-# "The 'npx' command in settings.json might still be necessary or there might be an issue with PATH."
40
-# )
41
-# return False # Install reported success, but executable not found
42
-# else:
43
-# PrintStyle(font_color="red", padding=True).print(
44
-# f"Failed to install '{package_name}' globally via npm. Return code: {process.returncode}"
45
-# )
46
-# PrintStyle(font_color="red", padding=False).print(f"npm stdout: {process.stdout.strip()}")
47
-# PrintStyle(font_color="red", padding=False).print(f"npm stderr: {process.stderr.strip()}")
48
-# return False # Install failed
49
-# except FileNotFoundError:
50
-# PrintStyle(font_color="red", padding=True).print(
51
-# f"ERROR: 'npm' command not found. Cannot attempt to install '{package_name}'."
52
-# )
53
-# return False # npm not found
54
-# except Exception as e:
55
-# PrintStyle(font_color="red", padding=True).print(
56
-# f"Exception during npm install of '{package_name}': {e}"
57
-# )
58
-# return False # Other exception during install
59
-# else:
60
-# PrintStyle(font_color="green", padding=True).print(
61
-# f"'{executable_name}' (from package '{package_name}') already found in PATH."
62
-# )
63
-# return True # Already found in PATH
64
-# else:
65
-# PrintStyle(font_color="red", padding=True).print(
66
-# f"ERROR: 'npm' command not found. Cannot check for or install '{executable_name}' from '{package_name}'."
67
-# )
68
-# return False # npm command not found, cannot check or install
69
-# # Fallback, though logic above should cover all paths to return explicitly
70
-# return False
71
-
12
13
def initialize():
74
- # global _NPM_CHECKS_DONE
14
current_settings = settings.get_settings()
76
- # mcp_servers_json_string = current_settings.get("mcp_servers", "[]")
77
-
78
- # try:
79
- # mcp_server_configs = json.loads(mcp_servers_json_string)
80
- # if not isinstance(mcp_server_configs, list):
81
- # PrintStyle(font_color="red", padding=True).print(
82
- # f"Error: Parsed mcp_servers from settings is not a list. Value: {mcp_server_configs}"
83
- # )
84
- # mcp_server_configs = []
85
- # except json.JSONDecodeError as e:
86
- # PrintStyle(font_color="red", padding=True).print(
87
- # f"Error decoding mcp_servers JSON string from settings: {e}. String was: '{mcp_servers_json_string}'"
88
- # )
89
- # mcp_server_configs = []
90
-
91
- # if not _NPM_CHECKS_DONE:
92
- # if shutil.which("npm"):
93
- # for server_config in mcp_server_configs:
94
- # if not isinstance(server_config, dict):
95
- # PrintStyle(font_color="orange", padding=True).print(
96
- # f"Warning: Skipping MCP server config item as it's not a dictionary: {server_config}"
97
- # )
98
- # continue
99
-
100
- # command = server_config.get("command")
101
- # args = server_config.get("args", [])
102
- # server_name = server_config.get("name", "Unknown MCP Server")
103
-
104
- # if command == "npx":
105
- # package_name_for_install = None
106
- # executable_name_to_check = None
107
-
108
- # if "--package" in args: # Original logic for npx --package <pkg> <exec>
109
- # try:
110
- # package_keyword_index = args.index("--package")
111
- # # Expect package name at +1 and executable name at +2 from "--package"
112
- # if package_keyword_index + 2 < len(args):
113
- # package_name_for_install = args[package_keyword_index + 1]
114
- # executable_name_to_check = args[package_keyword_index + 2]
115
- # else:
116
- # PrintStyle(font_color="orange", padding=True).print(
117
- # f"Warning: Skipping MCP server '{server_name}' (npx --package) as package or executable name could not be determined from args: {args}"
118
- # )
119
- # except ValueError: # Should not happen if "--package" is in args, but good for safety
120
- # PrintStyle(font_color="orange", padding=True).print(
121
- # f"Warning: '--package' keyword found but .index() failed for args: {args} in server '{server_name}'"
122
- # )
123
- # except Exception as e:
124
- # PrintStyle(font_color="red", padding=True).print(
125
- # f"Error processing npx --package args for server '{server_name}': {e}. Args: {args}"
126
- # )
127
- # else: # New logic for npx <pkg_arg> syntax
128
- # parsed_npx_pkg_arg = None
129
- # arg_idx = 0
130
- # while arg_idx < len(args):
131
- # current_arg = args[arg_idx]
132
- # # npx's own -p/--package option for temporary installs, distinct from the --package marker we check above
133
- # if current_arg == "-p" or current_arg == "--package":
134
- # arg_idx += 1 # Move to the value of -p/--package
135
- # if arg_idx < len(args): # Ensure there is a value
136
- # arg_idx += 1 # Skip the value itself
137
- # continue
138
-
139
- # if current_arg.startswith("-"): # Skip other options like -y, --yes, --no-install etc.
140
- # arg_idx += 1
141
- # continue
142
-
143
- # # Found what we assume is the main package argument for npx
144
- # parsed_npx_pkg_arg = current_arg
145
- # break # Found the package, stop parsing args for this purpose
146
-
147
- # if parsed_npx_pkg_arg:
148
- # package_name_for_install = parsed_npx_pkg_arg
149
- # # Derive assumed executable name based on convention from error: "mcp-server-google-maps" for "@.../server-google-maps"
150
- # # For "@scope/pkg-name" -> "pkg-name". For "pkg-name" -> "pkg-name".
151
- # name_part = parsed_npx_pkg_arg.split("/")[-1]
152
- # executable_name_to_check = f"mcp-{name_part}"
153
- # PrintStyle(font_color="blue", padding=True).print(
154
- # f"Info: For MCP server '{server_name}' (npx <pkg_arg> type), attempting to ensure global install of package '{package_name_for_install}' and expecting executable '{executable_name_to_check}'."
155
- # )
156
- # else:
157
- # PrintStyle(font_color="orange", padding=True).print(
158
- # f"Warning: Skipping MCP server '{server_name}' (npx <pkg_arg> type) as main package argument could not be identified from args: {args}"
159
- # )
160
-
161
- # # Unified call to ensure package is installed
162
- # if package_name_for_install and executable_name_to_check:
163
- # # Ensure they are not empty strings or just whitespace
164
- # if package_name_for_install.strip() and executable_name_to_check.strip():
165
- # install_successful = _ensure_mcp_package_globally_installed(package_name_for_install.strip(), executable_name_to_check.strip())
166
- # if not install_successful:
167
- # PrintStyle(font_color="red", padding=True).print(
168
- # f"Disabling MCP server '{server_name}' due to failed setup/validation for package '{package_name_for_install}' and/or executable '{executable_name_to_check}'."
169
- # )
170
- # server_config["disabled"] = True # Disable this server config
171
- # else:
172
- # PrintStyle(font_color="orange", padding=True).print(
173
- # f"Warning: Skipping MCP server '{server_name}' due to empty package or executable name derived: pkg='{package_name_for_install}', exec='{executable_name_to_check}' from args: {args}. Not attempting install."
174
- # )
175
- # # Optionally, consider if these should also be marked as disabled,
176
- # # though current logic implies they weren't valid enough to attempt install.
177
- # # server_config["disabled"] = True
178
- # # If package_name_for_install or executable_name_to_check are None or empty,
179
- # # it means previous logic decided not to proceed or couldn't determine them,
180
- # # and appropriate warnings would have been printed.
181
- # else:
182
- # PrintStyle(font_color="red", padding=True).print(
183
- # "ERROR: 'npm' command not found. Cannot attempt to install any MCP server packages."
184
- # )
185
- # PrintStyle().print() # Extra blank line after all attempts or npm not found message
186
- # _NPM_CHECKS_DONE = True
15
16
# chat model from user settings
17
chat_llm = ModelConfig(
@@ -253,23 +81,28 @@ def initialize():
81
# update config with runtime args
82
args_override(config)
83
256
- import python.helpers.mcp_handler as mcp_helper
257
- import agent as agent_helper
258
- import python.helpers.print_style as print_style_helper
259
- if not mcp_helper.MCPConfig.get_instance().is_initialized():
260
- try:
261
- mcp_helper.MCPConfig.update(config.mcp_servers)
262
- except Exception as e:
263
- first_context = agent_helper.AgentContext.first()
264
- if first_context:
265
- (
266
- first_context.log
267
- .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
268
- )
269
- (
270
- print_style_helper.PrintStyle(background_color="black", font_color="red", padding=True)
271
- .print(f"Failed to update MCP settings: {e}")
272
- )
84
+ # initialize MCP in deferred task to prevent blocking the main thread
85
+ async def initialize_mcp_async(mcp_servers_config: str):
86
+ return initialize_mcp(mcp_servers_config)
87
+ defer.DeferredTask(thread_name="mcp-initializer").start_task(initialize_mcp_async, config.mcp_servers)
88
+
89
+ # import python.helpers.mcp_handler as mcp_helper
90
+ # import agent as agent_helper
91
+ # import python.helpers.print_style as print_style_helper
92
+ # if not mcp_helper.MCPConfig.get_instance().is_initialized():
93
+ # try:
94
+ # mcp_helper.MCPConfig.update(config.mcp_servers)
95
+ # except Exception as e:
96
+ # first_context = agent_helper.AgentContext.first()
97
+ # if first_context:
98
+ # (
99
+ # first_context.log
100
+ # .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
101
+ # )
102
+ # (
103
+ # print_style_helper.PrintStyle(background_color="black", font_color="red", padding=True)
104
+ # .print(f"Failed to update MCP settings: {e}")
105
+ # )
106
107
# return config object
108
return config
python/api/mcp_servers_status.py
new
+16
@@ -0,0 +1,16 @@
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 McpServersStatuss(ApiHandler):
10
+ async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
11
+
12
+ # try:
13
+ status = MCPConfig.get_instance().get_servers_status()
14
+ return {"success": True, "status": status}
15
+ # except Exception as e:
16
+ # return {"success": False, "error": str(e)}
python/helpers/mcp_handler.py
+172
-44
@@ -1,4 +1,5 @@
1
from abc import ABC, abstractmethod
2
+import re
3
from typing import List, Dict, Optional, Any, Union, Literal, Annotated, ClassVar, cast, Callable, Awaitable, TypeVar
4
import threading
5
import asyncio
@@ -20,10 +21,36 @@ from anyio.streams.memory import (
21
)
22
23
from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr
24
+from python.helpers import dirty_json
25
from python.helpers.dirty_json import DirtyJson
26
from python.helpers.print_style import PrintStyle
27
from python.helpers.tool import Tool, Response
28
29
+def normalize_name(name: str) -> str:
30
+ # Lowercase and strip whitespace
31
+ name = name.strip().lower()
32
+ # Replace all non-alphanumeric (unicode) chars with underscore
33
+ # \W matches non-alphanumeric, but also matches underscore, so use [^\w] with re.UNICODE
34
+ # To also replace underscores from non-latin chars, use [^a-zA-Z0-9] with re.UNICODE
35
+ name = re.sub(r'[^\w]', '_', name, flags=re.UNICODE)
36
+ return name
37
+
38
+def initialize_mcp(mcp_servers_config:str):
39
+ if not MCPConfig.get_instance().is_initialized():
40
+ try:
41
+ MCPConfig.update(mcp_servers_config)
42
+ except Exception as e:
43
+ from agent import AgentContext
44
+ first_context = AgentContext.first() # TODO replace with better reporting
45
+ if first_context:
46
+ (
47
+ first_context.log
48
+ .log(type="warning", content=f"Failed to update MCP settings: {e}", temp=False)
49
+ )
50
+ (
51
+ PrintStyle(background_color="black", font_color="red", padding=True)
52
+ .print(f"Failed to update MCP settings: {e}")
53
+ )
54
55
class MCPTool(Tool):
56
"""MCP Tool wrapper"""
@@ -159,7 +186,7 @@ class MCPServerRemote(BaseModel):
186
for key, value in config.items():
187
if key in ["name", "description", "url", "headers", "timeout", "sse_read_timeout", "disabled"]:
188
if key == "name":
162
- value = value.strip().lower().replace(" ", "_").replace("-", "_").replace(".", "_")
189
+ value = normalize_name(value)
190
setattr(self, key, value)
191
# We already run in an event loop, dont believe Pylance
192
return asyncio.run(self.__on_update())
@@ -208,7 +235,7 @@ class MCPServerLocal(BaseModel):
235
for key, value in config.items():
236
if key in ["name", "description", "command", "args", "env", "encoding", "encoding_error_handler", "disabled"]:
237
if key == "name":
211
- value = value.strip().lower().replace(" ", "_").replace("-", "_").replace(".", "_")
238
+ value = normalize_name(value)
239
setattr(self, key, value)
240
# We already run in an event loop, dont believe Pylance
241
return asyncio.run(self.__on_update())
@@ -228,11 +255,9 @@ MCPServer = Annotated[
255
256
257
class MCPConfig(BaseModel):
231
- servers: List[MCPServer] = Field(default_factory=list[MCPServer])
232
-
258
+ servers: list[MCPServer] = Field(default_factory=list)
259
+ disconnected_servers: list[dict[str, Any]] = Field(default_factory=list)
260
__lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
234
-
235
- # Singleton instance
261
__instance: ClassVar[Any] = PrivateAttr(default=None)
262
__initialized: ClassVar[bool] = PrivateAttr(default=False)
263
@@ -250,12 +275,12 @@ class MCPConfig(BaseModel):
275
if config_str and config_str.strip(): # Only parse if non-empty and not just whitespace
276
try:
277
# Try with standard json.loads first, as it should handle escaped strings correctly
253
- import json
254
- parsed_value = json.loads(config_str)
278
+ parsed_value = dirty_json.try_parse(config_str)
279
+ normalized = cls.normalize_config(parsed_value)
280
256
- if isinstance(parsed_value, list):
281
+ if isinstance(normalized, list):
282
valid_servers = []
258
- for item in parsed_value:
283
+ for item in normalized:
284
if isinstance(item, dict):
285
valid_servers.append(item)
286
else:
@@ -269,32 +294,34 @@ class MCPConfig(BaseModel):
294
)
295
# servers_data remains empty
296
except Exception as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception
272
- # Fallback to DirtyJson or log error if standard json.loads fails
273
- PrintStyle(background_color="orange", font_color="black", padding=True).print(
274
- f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback."
275
- )
276
- try:
277
- parsed_value = DirtyJson.parse_string(config_str)
278
- if isinstance(parsed_value, list):
279
- valid_servers = []
280
- for item in parsed_value:
281
- if isinstance(item, dict):
282
- valid_servers.append(item)
283
- else:
284
- PrintStyle(background_color="yellow", font_color="black", padding=True).print(
285
- f"Warning: MCP config item (from DirtyJson) was not a dictionary and was ignored: {item}"
286
- )
287
- servers_data = valid_servers
288
- else:
289
- PrintStyle(background_color="red", font_color="white", padding=True).print(
290
- f"Error: Parsed MCP config (from DirtyJson) top-level structure is not a list. Config string was: '{config_str}'"
291
- )
292
- # servers_data remains empty
293
- except Exception as e_dirty:
294
- PrintStyle(background_color="red", font_color="white", padding=True).print(
295
- f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'"
296
- )
297
- # servers_data remains empty, allowing graceful degradation
297
+ PrintStyle.error(f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'")
298
+
299
+ # # Fallback to DirtyJson or log error if standard json.loads fails
300
+ # PrintStyle(background_color="orange", font_color="black", padding=True).print(
301
+ # f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback."
302
+ # )
303
+ # try:
304
+ # parsed_value = DirtyJson.parse_string(config_str)
305
+ # if isinstance(parsed_value, list):
306
+ # valid_servers = []
307
+ # for item in parsed_value:
308
+ # if isinstance(item, dict):
309
+ # valid_servers.append(item)
310
+ # else:
311
+ # PrintStyle(background_color="yellow", font_color="black", padding=True).print(
312
+ # f"Warning: MCP config item (from DirtyJson) was not a dictionary and was ignored: {item}"
313
+ # )
314
+ # servers_data = valid_servers
315
+ # else:
316
+ # PrintStyle(background_color="red", font_color="white", padding=True).print(
317
+ # f"Error: Parsed MCP config (from DirtyJson) top-level structure is not a list. Config string was: '{config_str}'"
318
+ # )
319
+ # # servers_data remains empty
320
+ # except Exception as e_dirty:
321
+ # PrintStyle(background_color="red", font_color="white", padding=True).print(
322
+ # f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'"
323
+ # )
324
+ # # servers_data remains empty, allowing graceful degradation
325
326
# Initialize/update the singleton instance with the (potentially empty) list of server data
327
instance = cls.get_instance()
@@ -322,6 +349,29 @@ class MCPConfig(BaseModel):
349
cls.__initialized = True
350
return instance
351
352
+ @classmethod
353
+ def normalize_config(cls, servers: Any):
354
+ normalized = []
355
+ if isinstance(servers, list):
356
+ for server in servers:
357
+ if isinstance(server, dict):
358
+ normalized.append(server)
359
+ elif isinstance(servers, dict):
360
+ if "mcpServers" in servers:
361
+ if isinstance(servers["mcpServers"], dict):
362
+ for key, value in servers["mcpServers"].items():
363
+ if isinstance(value, dict):
364
+ value["name"] = key
365
+ normalized.append(value)
366
+ elif isinstance(servers["mcpServers"], list):
367
+ for server in servers["mcpServers"]:
368
+ if isinstance(server, dict):
369
+ normalized.append(server)
370
+ else:
371
+ normalized.append(servers) # single server?
372
+ return normalized
373
+
374
+
375
def __init__(self, servers_list: List[Dict[str, Any]]):
376
from collections.abc import Mapping, Iterable
377
@@ -335,7 +385,9 @@ class MCPConfig(BaseModel):
385
super().__init__()
386
387
# Clear any servers potentially initialized by super().__init__() before we populate based on servers_list
338
- self.servers = []
388
+ self.servers = []
389
+ # initialize failed servers list
390
+ self.disconnected_servers = []
391
392
if not isinstance(servers_list, Iterable):
393
(
@@ -346,21 +398,49 @@ class MCPConfig(BaseModel):
398
399
for server_item in servers_list:
400
if not isinstance(server_item, Mapping):
401
+ # log the error
402
+ error_msg = "server_item must be a mapping"
403
(
404
PrintStyle(background_color="grey", font_color="red", padding=True)
351
- .print("MCPConfig::__init__::server_item must be a mapping")
405
+ .print(f"MCPConfig::__init__::{error_msg}")
406
)
407
+ # add to failed servers with generic name
408
+ self.disconnected_servers.append({
409
+ "config": server_item if isinstance(server_item, dict) else {"raw": str(server_item)},
410
+ "error": error_msg,
411
+ "name": "invalid_server_config"
412
+ })
413
continue
414
415
if server_item.get("disabled", False):
416
+ # get server name if available
417
+ server_name = server_item.get("name", "unnamed_server")
418
+ # normalize server name if it exists
419
+ if server_name != "unnamed_server":
420
+ server_name = normalize_name(server_name)
421
+
422
+ # add to failed servers
423
+ self.disconnected_servers.append({
424
+ "config": server_item,
425
+ "error": "Disabled in config",
426
+ "name": server_name
427
+ })
428
continue
429
430
server_name = server_item.get("name", "__not__found__")
431
if server_name == "__not__found__":
432
+ # log the error
433
+ error_msg = "server_name is required"
434
(
435
PrintStyle(background_color="grey", font_color="red", padding=True)
362
- .print("MCPConfig::__init__::server_name is required")
436
+ .print(f"MCPConfig::__init__::{error_msg}")
437
)
438
+ # add to failed servers
439
+ self.disconnected_servers.append({
440
+ "config": server_item,
441
+ "error": error_msg,
442
+ "name": "unnamed_server"
443
+ })
444
continue
445
446
try:
@@ -370,12 +450,53 @@ class MCPConfig(BaseModel):
450
else:
451
self.servers.append(MCPServerLocal(server_item))
452
except Exception as e:
453
+ # log the error
454
+ error_msg = str(e)
455
(
456
PrintStyle(background_color="grey", font_color="red", padding=True)
375
- .print(f"MCPConfig::__init__: Failedto create MCPServer '{server_name}': {e}")
457
+ .print(f"MCPConfig::__init__: Failed to create MCPServer '{server_name}': {error_msg}")
458
)
377
- continue
378
-
459
+ # add to failed servers
460
+ self.disconnected_servers.append({
461
+ "config": server_item,
462
+ "error": error_msg,
463
+ "name": server_name
464
+ })
465
+
466
+ def get_servers_status(self) -> list[dict[str, Any]]:
467
+ """Get status of all servers"""
468
+ result = []
469
+ with self.__lock:
470
+ # add connected/working servers
471
+ for server in self.servers:
472
+ # get server name
473
+ name = server.name
474
+ # get tool count
475
+ tool_count = len(server.get_tools())
476
+ # check if server is connected
477
+ connected = tool_count > 0
478
+ # get error message if any
479
+ error = ""
480
+
481
+ # add server status to result
482
+ result.append({
483
+ "name": name,
484
+ "connected": connected,
485
+ "error": error,
486
+ "tool_count": tool_count
487
+ })
488
+
489
+ # add failed servers
490
+ for disconnected in self.disconnected_servers:
491
+ result.append({
492
+ "name": disconnected["name"],
493
+ "connected": False,
494
+ "error": disconnected["error"],
495
+ "tool_count": 0
496
+ })
497
+
498
+ return result
499
+
500
def is_initialized(self) -> bool:
501
"""Check if the client is initialized"""
502
with self.__lock:
@@ -511,10 +632,17 @@ class MCPClientBase(ABC):
632
ClientSession(
633
stdio,
634
write,
514
- read_timeout_seconds=timedelta(seconds=600)
635
+ read_timeout_seconds=timedelta(seconds=600)
636
)
637
)
517
- await session.initialize()
638
+ try:
639
+ # Add timeout to session.initialize
640
+ await asyncio.wait_for(session.initialize(), timeout=10)
641
+ except Exception as e:
642
+ PrintStyle(background_color="#AA4455", font_color="white", padding=False).print(
643
+ f"MCPClientBase ({self.server.name} - {operation_name}): Session initialization failed: {type(e).__name__}: {e}"
644
+ )
645
+ raise
646
# PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name} - {operation_name}): Session initialized.")
647
648
result = await coro_func(session)
python/helpers/settings.py
+1
-1
@@ -912,7 +912,7 @@ def get_default_settings() -> Settings:
912
stt_silence_threshold=0.3,
913
stt_silence_duration=1000,
914
stt_waiting_timeout=2000,
915
- mcp_servers="",
915
+ mcp_servers='{\n "mcpServers": {}\n}',
916
mcp_server_enabled=False,
917
)
918
webui/components/settings/mcp/client/mcp-servers-store.js
+48
-12
@@ -1,17 +1,13 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import { scrollModal } from "/js/modals.js";
3
import sleep from "/js/sleep.js";
4
+import * as API from "/js/api.js";
5
6
const model = {
7
editor: null,
7
- settingsJsonField: settingsModalProxy.settings.sections
8
- .filter((x) => x.id == "mcp_client")[0]
9
- .fields.filter((x) => x.id == "mcp_servers")[0],
10
- servers: [
11
- { connected: true, name: "Server 1", tools: 10 },
12
- { connected: false, name: "Server 2", tools: 0, error: "Server is disabled in configuration" },
13
- ],
8
+ servers: [],
9
loading: false,
10
+ statusCheck: false,
11
12
async initialize() {
13
// Initialize the JSON Viewer after the modal is rendered
@@ -27,11 +23,13 @@ const model = {
23
}
24
25
editor.session.setMode("ace/mode/json");
30
- const json = this.settingsJsonField.value;
26
+ const json = this.getSettingsFieldConfigJson().value;
27
editor.setValue(json);
28
editor.clearSelection();
29
this.editor = editor;
30
}
31
+
32
+ this.startStatusCheck();
33
},
34
35
formatJson() {
@@ -55,18 +53,56 @@ const model = {
53
}
54
},
55
56
+ getEditorValue() {
57
+ return this.editor.getValue();
58
+ },
59
+
60
+ getSettingsFieldConfigJson() {
61
+ return settingsModalProxy.settings.sections
62
+ .filter((x) => x.id == "mcp_client")[0]
63
+ .fields.filter((x) => x.id == "mcp_servers")[0];
64
+ },
65
+
66
onClose() {
59
- const val = this.editor.getValue();
60
- this.settingsJsonField.value = val;
67
+ const val = this.getEditorValue();
68
+ this.getSettingsFieldConfigJson().value = val;
69
+ this.stopStatusCheck();
70
+ },
71
+
72
+ async startStatusCheck() {
73
+ this.statusCheck = true;
74
+
75
+ while (this.statusCheck) {
76
+ await this._statusCheck();
77
+ await sleep(3000);
78
+ }
79
+ },
80
+
81
+ async _statusCheck() {
82
+ const resp = await API.callJsonApi("mcp_servers_status", null);
83
+ if (resp.success) {
84
+ this.servers = resp.status;
85
+ }
86
+ },
87
+
88
+ async stopStatusCheck() {
89
+ this.statusCheck = false;
90
},
91
92
async applyNow() {
93
if (this.loading) return;
94
this.loading = true;
95
+ try {
96
scrollModal("mcp-servers-status");
67
- await sleep(1000);
97
+ await API.callJsonApi("mcp_servers_apply", {
98
+ mcp_servers: this.getEditorValue(),
99
+ });
100
scrollModal("mcp-servers-status");
69
- this.loading = false;
101
+ } catch (error) {
102
+ console.error("Failed to apply MCP servers:", error);
103
+ alert("Failed to apply MCP servers: " + error.message);
104
+ }
105
+ this.loading = false;
106
},
107
};
108
webui/components/settings/mcp/client/mcp-servers.html
+14
-5
@@ -21,21 +21,30 @@
21
</h3>
22
<div id="mcp-servers-config-json"></div>
23
24
- <h3 id="mcp-servers-status">Status</h3>
24
+ <h3 id="mcp-servers-status">Servers status (refreshing automatically)</h3>
25
26
<div class="server-list">
27
<template x-for="server in $store.mcpServersStore.servers" :key="server.name">
28
<div class="server-item">
29
- <span class="status-indicator"
30
- :class="server.connected ? 'connected' : 'disconnected'"></span>
29
+ <div class="status-icon" style="margin-right: 0.5em;" x-data="{ connected: server.connected }">
30
+ <svg viewBox="0 0 30 30">
31
+ <!-- Connected State (filled circle) -->
32
+ <circle class="connected-circle" cx="15" cy="15" r="8"
33
+ x-bind:fill="server.connected ? '#00c340' : 'none'" x-bind:opacity="server.connected ? 1 : 0" />
34
+
35
+ <!-- Disconnected State (outline circle) -->
36
+ <circle class="disconnected-circle" cx="15" cy="15" r="9" fill="none" stroke="#e40138"
37
+ stroke-width="3" x-bind:opacity="server.connected ? 0 : 1" />
38
+ </svg>
39
+ </div>
40
<span class="server-name" x-text="server.name"></span>
41
<span class="server-tools"
33
- x-text="'- ' + (server.tools ? server.tools : 0) + ' tools'"></span>
42
+ x-text="'- ' + (server.tool_count ? server.tool_count : 0) + ' tools'"></span>
43
<span class="server-error" x-show="server.error" x-text="server.error"></span>
44
</div>
45
</template>
46
<div x-show="$store.mcpServersStore.servers.length === 0" class="no-servers">
38
- No servers connected
47
+ No servers
48
</div>
49
</div>
50
webui/js/api.js
new
+23
@@ -0,0 +1,23 @@
1
+/**
2
+ * Call a JSON-in JSON-out API endpoint
3
+ * Data is automatically serialized
4
+ * @param {string} endpoint - The API endpoint to call
5
+ * @param {any} data - The data to send to the API
6
+ * @returns {Promise<any>} The JSON response from the API
7
+ */
8
+export async function callJsonApi(endpoint, data) {
9
+ const response = await fetch(endpoint, {
10
+ method: "POST",
11
+ headers: {
12
+ "Content-Type": "application/json",
13
+ },
14
+ body: JSON.stringify(data),
15
+ });
16
+
17
+ if (!response.ok) {
18
+ const error = await response.text();
19
+ throw new Error(error);
20
+ }
21
+ const jsonResponse = await response.json();
22
+ return jsonResponse;
23
+}