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 +}