Revamp MCP server configuration

Add project-scoped MCP server configuration with global/project merge semantics, a richer settings UI, and chat composer access. Introduce MCP config scanning plus project-aware status/detail/log/apply APIs while preserving the raw JSON editor. Strengthen MCP runtime handling for dotted tool names, timeouts, status accuracy, and project-aware tool execution, with focused regression coverage.

Alessandro committed Jun 9, 2026 at 16:07 UTC 521172b48903ee818c21ff7d1eef0b735bf9e69d
23 files changed +2023 -325
api/mcp_server_get_detail.py
+3 -1
@@ -9,9 +9,11 @@ class McpServerGetDetail(ApiHandler):
9
10 # try:
11 server_name = input.get("server_name")
12 + project_name = str(input.get("project_name", "") or "").strip()
13 if not server_name:
14 return {"success": False, "error": "Missing server_name"}
14 - detail = MCPConfig.get_instance().get_server_detail(server_name)
15 + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance()
16 + detail = config.get_server_detail(server_name)
17 return {"success": True, "detail": detail}
18 # except Exception as e:
19 # return {"success": False, "error": str(e)}
api/mcp_server_get_detail.py.dox.md
+3 -2
@@ -3,7 +3,7 @@
3 ## Purpose
4
5 - Own the `mcp_server_get_detail.py` API endpoint.
6 -- This module handles MCP server server get detail requests.
6 +- This module handles MCP server detail requests for global or project scope.
7 - Keep this file-level DOX profile synchronized with `mcp_server_get_detail.py` because this directory is intentionally flat.
8
9 ## Ownership
@@ -17,6 +17,7 @@
17 ## Runtime Contracts
18
19 - HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
20 +- The request accepts `server_name` and optional `project_name`; when `project_name` is present, detail resolves through the project-scoped MCP configuration.
21 - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
22 - `McpServerGetDetail` is an `ApiHandler`.
23 - `McpServerGetDetail` defines `process(...)`.
@@ -24,7 +25,7 @@
25
26 ## Key Concepts
27
27 -- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_detail`, `MCPConfig.get_instance`.
28 +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_detail`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`.
29 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
30
31 ## Work Guidance
api/mcp_server_get_log.py
+3 -1
@@ -9,9 +9,11 @@ class McpServerGetLog(ApiHandler):
9
10 # try:
11 server_name = input.get("server_name")
12 + project_name = str(input.get("project_name", "") or "").strip()
13 if not server_name:
14 return {"success": False, "error": "Missing server_name"}
14 - log = MCPConfig.get_instance().get_server_log(server_name)
15 + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance()
16 + log = config.get_server_log(server_name)
17 return {"success": True, "log": log}
18 # except Exception as e:
19 # return {"success": False, "error": str(e)}
api/mcp_server_get_log.py.dox.md
+3 -2
@@ -3,7 +3,7 @@
3 ## Purpose
4
5 - Own the `mcp_server_get_log.py` API endpoint.
6 -- This module handles MCP server server get log requests.
6 +- This module handles MCP server log requests for global or project scope.
7 - Keep this file-level DOX profile synchronized with `mcp_server_get_log.py` because this directory is intentionally flat.
8
9 ## Ownership
@@ -17,6 +17,7 @@
17 ## Runtime Contracts
18
19 - HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
20 +- The request accepts `server_name` and optional `project_name`; when `project_name` is present, logs resolve through the project-scoped MCP configuration.
21 - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
22 - `McpServerGetLog` is an `ApiHandler`.
23 - `McpServerGetLog` defines `process(...)`.
@@ -24,7 +25,7 @@
25
26 ## Key Concepts
27
27 -- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_log`, `MCPConfig.get_instance`.
28 +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_log`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`.
29 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
30
31 ## Work Guidance
api/mcp_server_scan.py new
+232
@@ -0,0 +1,232 @@
1 +import asyncio
2 +from shutil import which
3 +from typing import Any
4 +from urllib.parse import urlparse
5 +
6 +from helpers.api import ApiHandler, Request, Response
7 +from helpers.mcp_handler import MCPConfig, normalize_name
8 +
9 +
10 +_PROMPT_INJECTION_MARKERS = (
11 + "ignore previous",
12 + "ignore all previous",
13 + "system prompt",
14 + "developer message",
15 + "hidden instruction",
16 + "exfiltrate",
17 + "leak secret",
18 + "credential",
19 +)
20 +
21 +
22 +class McpServerScan(ApiHandler):
23 + async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
24 + server = dict(input.get("server") or {})
25 + allow_local_execution = bool(input.get("allow_local_execution", False))
26 + allow_remote_network = bool(input.get("allow_remote_network", False))
27 + inspect_runtime = input.get("inspect_runtime", True) is not False
28 +
29 + server = self._normalize_server(server)
30 + warnings = self._static_warnings(server)
31 + is_local = not (server.get("url") or server.get("serverUrl"))
32 + has_static_errors = any(warning.get("level") == "error" for warning in warnings)
33 +
34 + runtime_status: list[dict[str, Any]] = []
35 + runtime_detail: dict[str, Any] = {}
36 + runtime_error = ""
37 +
38 + should_inspect_runtime = (
39 + inspect_runtime
40 + and not has_static_errors
41 + and ((is_local and allow_local_execution) or (not is_local and allow_remote_network))
42 + )
43 +
44 + if should_inspect_runtime:
45 + try:
46 + scan_config = await asyncio.to_thread(
47 + lambda: MCPConfig(servers_list=[server], config_scope="scan")
48 + )
49 + runtime_status = scan_config.get_servers_status()
50 + runtime_detail = scan_config.get_server_detail(server.get("name", ""))
51 + warnings.extend(self._tool_warnings(runtime_detail.get("tools", [])))
52 + except Exception as exc:
53 + runtime_error = str(exc)
54 + warnings.append(
55 + {
56 + "level": "error",
57 + "title": "Runtime inspection failed",
58 + "message": runtime_error,
59 + }
60 + )
61 + elif is_local and inspect_runtime:
62 + warnings.append(
63 + {
64 + "level": "warning",
65 + "title": "Local command not executed",
66 + "message": "Local stdio MCP inspection requires explicit trust because it runs the configured command.",
67 + }
68 + )
69 + elif not is_local and inspect_runtime and has_static_errors:
70 + warnings.append(
71 + {
72 + "level": "info",
73 + "title": "Runtime inspection skipped",
74 + "message": "Fix static scan errors before attempting runtime MCP inspection.",
75 + }
76 + )
77 + elif not is_local and inspect_runtime:
78 + warnings.append(
79 + {
80 + "level": "info",
81 + "title": "Remote runtime inspection skipped",
82 + "message": "Enable trusted remote inspection to contact the MCP URL and list exposed tools.",
83 + }
84 + )
85 +
86 + return {
87 + "success": True,
88 + "server": self._redact_server(server),
89 + "risk_level": self._risk_level(warnings),
90 + "warnings": warnings,
91 + "status": runtime_status,
92 + "detail": runtime_detail,
93 + "runtime_error": runtime_error,
94 + }
95 +
96 + def _normalize_server(self, server: dict[str, Any]) -> dict[str, Any]:
97 + name = str(server.get("name") or "").strip()
98 + url = str(server.get("url") or server.get("serverUrl") or "").strip()
99 + command = str(server.get("command") or "").strip()
100 +
101 + if not name:
102 + name = self._derive_name(url, command)
103 + server["name"] = normalize_name(name or "mcp_server")
104 +
105 + if url:
106 + server["url"] = url
107 + server.setdefault("type", "streamable-http")
108 + elif command:
109 + server["command"] = command
110 + server["type"] = "stdio"
111 +
112 + return server
113 +
114 + def _derive_name(self, url: str, command: str) -> str:
115 + if url:
116 + parsed = urlparse(url)
117 + parts = [part for part in parsed.path.split("/") if part]
118 + return parts[-1] if parts else parsed.hostname or "remote_mcp"
119 + if command:
120 + return command.rsplit("/", 1)[-1]
121 + return "mcp_server"
122 +
123 + def _static_warnings(self, server: dict[str, Any]) -> list[dict[str, str]]:
124 + warnings: list[dict[str, str]] = []
125 + url = str(server.get("url") or "").strip()
126 + command = str(server.get("command") or "").strip()
127 +
128 + if url:
129 + parsed = urlparse(url)
130 + if parsed.scheme not in {"http", "https"}:
131 + warnings.append(
132 + {
133 + "level": "error",
134 + "title": "Unsupported URL scheme",
135 + "message": "Remote MCP URLs should use http or https.",
136 + }
137 + )
138 + elif parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
139 + warnings.append(
140 + {
141 + "level": "warning",
142 + "title": "Unencrypted remote URL",
143 + "message": "Prefer HTTPS for remote MCP servers outside localhost.",
144 + }
145 + )
146 + if not parsed.netloc:
147 + warnings.append(
148 + {
149 + "level": "error",
150 + "title": "Invalid remote URL",
151 + "message": "The remote MCP URL is missing a host.",
152 + }
153 + )
154 + elif command:
155 + if which(command) is None:
156 + warnings.append(
157 + {
158 + "level": "warning",
159 + "title": "Command not found",
160 + "message": f"'{command}' is not currently available on PATH.",
161 + }
162 + )
163 + if command in {"bash", "sh", "zsh", "fish", "python", "python3", "node"}:
164 + warnings.append(
165 + {
166 + "level": "warning",
167 + "title": "General-purpose interpreter",
168 + "message": "Review the command and arguments carefully before running this local MCP server.",
169 + }
170 + )
171 + else:
172 + warnings.append(
173 + {
174 + "level": "error",
175 + "title": "Missing connection target",
176 + "message": "Provide either a remote URL or a local command.",
177 + }
178 + )
179 +
180 + if isinstance(server.get("headers"), dict) and server["headers"]:
181 + warnings.append(
182 + {
183 + "level": "info",
184 + "title": "Headers configured",
185 + "message": "Header values are redacted in scan output. Keep tokens in trusted settings only.",
186 + }
187 + )
188 +
189 + if isinstance(server.get("env"), dict) and server["env"]:
190 + warnings.append(
191 + {
192 + "level": "info",
193 + "title": "Environment configured",
194 + "message": "Environment values are redacted in scan output. Avoid hardcoding secrets in MCP configs.",
195 + }
196 + )
197 +
198 + return warnings
199 +
200 + def _tool_warnings(self, tools: Any) -> list[dict[str, str]]:
201 + warnings: list[dict[str, str]] = []
202 + if not isinstance(tools, list):
203 + return warnings
204 +
205 + for tool in tools:
206 + if not isinstance(tool, dict):
207 + continue
208 + haystack = f"{tool.get('name', '')}\n{tool.get('description', '')}".lower()
209 + if any(marker in haystack for marker in _PROMPT_INJECTION_MARKERS):
210 + warnings.append(
211 + {
212 + "level": "warning",
213 + "title": "Suspicious tool description",
214 + "message": f"Review tool '{tool.get('name', 'unknown')}' for prompt-injection style language.",
215 + }
216 + )
217 + return warnings
218 +
219 + def _redact_server(self, server: dict[str, Any]) -> dict[str, Any]:
220 + redacted = dict(server)
221 + for key in ("headers", "env"):
222 + if isinstance(redacted.get(key), dict):
223 + redacted[key] = {name: "***" for name in redacted[key]}
224 + return redacted
225 +
226 + def _risk_level(self, warnings: list[dict[str, str]]) -> str:
227 + levels = {warning.get("level", "info") for warning in warnings}
228 + if "error" in levels:
229 + return "error"
230 + if "warning" in levels:
231 + return "warning"
232 + return "ok"
api/mcp_server_scan.py.dox.md new
+45
@@ -0,0 +1,45 @@
1 +# mcp_server_scan.py DOX
2 +
3 +## Purpose
4 +
5 +- Own the `mcp_server_scan.py` API endpoint.
6 +- Provide static and optional runtime inspection for a single MCP server draft before it is added to global or project MCP config.
7 +- Keep this file-level DOX profile synchronized with `mcp_server_scan.py` because this directory is intentionally flat.
8 +
9 +## Ownership
10 +
11 +- `mcp_server_scan.py` owns the runtime implementation.
12 +- `mcp_server_scan.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13 +- Classes:
14 +- `McpServerScan` (`ApiHandler`)
15 + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response`
16 +
17 +## Runtime Contracts
18 +
19 +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
20 +- The request accepts a `server` draft object, `inspect_runtime`, `allow_remote_network`, and `allow_local_execution`.
21 +- Remote runtime inspection may contact the configured MCP URL to list tools only when `allow_remote_network` is true and static checks have no errors.
22 +- Local stdio runtime inspection must not execute unless `allow_local_execution` is true.
23 +- Response data redacts `headers` and `env` values.
24 +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
25 +- Imported dependency areas include: `asyncio`, `helpers.api`, `helpers.mcp_handler`, `shutil`, `typing`, `urllib.parse`.
26 +
27 +## Key Concepts
28 +
29 +- Static checks report invalid URLs, non-HTTPS remote URLs, missing local commands, interpreter-style local commands, headers/env presence, and obvious prompt-injection markers in inspected tool descriptions.
30 +- Static errors skip runtime inspection; remote network inspection and local command execution both require explicit trust flags.
31 +- Runtime inspection creates a temporary `MCPConfig` in a worker thread so stdio/remote tool listing does not call `asyncio.run()` inside the request event loop.
32 +
33 +## Work Guidance
34 +
35 +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes.
36 +- Do not return secret values, raw environment values, or private files.
37 +- Keep scanner warnings explicit about local command execution risk.
38 +
39 +## Verification
40 +
41 +- Run endpoint-specific or MCP helper tests for changed behavior; smoke-test remote URL and local-command scan paths when practical.
42 +
43 +## Child DOX Index
44 +
45 +No child DOX files.
api/mcp_servers_apply.py
+14 -7
@@ -5,20 +5,27 @@ from typing import Any
5
6 from helpers.mcp_handler import MCPConfig
7 from helpers.settings import set_settings_delta
8 +from helpers import projects
9
10
11 class McpServersApply(ApiHandler):
12 async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
13 mcp_servers = input["mcp_servers"]
14 + project_name = str(input.get("project_name", "") or "").strip()
15 try:
14 - # MCPConfig.update(mcp_servers) # done in settings automatically
15 - set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization
16 - set_settings_delta({"mcp_servers": mcp_servers})
16 + if project_name:
17 + projects.save_project_mcp_servers(project_name, mcp_servers)
18 + config = MCPConfig.refresh_project(project_name)
19 + else:
20 + # MCPConfig.update(mcp_servers) # done in settings automatically
21 + set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization
22 + set_settings_delta({"mcp_servers": mcp_servers})
23
18 - time.sleep(1) # wait at least a second
19 - # MCPConfig.wait_for_lock() # wait until config lock is released
20 - status = MCPConfig.get_instance().get_servers_status()
21 - return {"success": True, "status": status}
24 + time.sleep(1) # wait at least a second
25 + # MCPConfig.wait_for_lock() # wait until config lock is released
26 + config = MCPConfig.get_instance()
27 + status = config.get_servers_status()
28 + return {"success": True, "status": status, "mcp_servers": mcp_servers, "project_name": project_name}
29
30 except Exception as e:
31 return {"success": False, "error": str(e)}
api/mcp_servers_apply.py.dox.md
+7 -4
@@ -3,7 +3,7 @@
3 ## Purpose
4
5 - Own the `mcp_servers_apply.py` API endpoint.
6 -- This module handles MCP server servers apply requests.
6 +- This module handles MCP servers apply requests for global or project scope.
7 - Keep this file-level DOX profile synchronized with `mcp_servers_apply.py` because this directory is intentionally flat.
8
9 ## Ownership
@@ -17,15 +17,18 @@
17 ## Runtime Contracts
18
19 - HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
20 +- The request accepts `config` and optional `project_name`.
21 +- Without `project_name`, the endpoint persists global `mcp_servers_config` through settings and refreshes the global `MCPConfig`.
22 +- With `project_name`, the endpoint saves `.a0proj/mcp_servers.json` through `helpers.projects.save_project_mcp_servers(...)` and refreshes that project's merged MCP config.
23 - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
24 - `McpServersApply` is an `ApiHandler`.
25 - `McpServersApply` defines `process(...)`.
23 -- Observed side-effect areas: settings/state persistence.
24 -- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `helpers.settings`, `time`, `typing`.
26 +- Observed side-effect areas: filesystem writes, settings/state persistence.
27 +- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `helpers.projects`, `helpers.settings`, `time`, `typing`.
28
29 ## Key Concepts
30
28 -- Important called helpers/classes observed in the source: `set_settings_delta`, `time.sleep`, `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`.
31 +- Important called helpers/classes observed in the source: `set_settings_delta`, `projects.save_project_mcp_servers`, `MCPConfig.refresh_project`, `time.sleep`, `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`.
32 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
33
34 ## Work Guidance
api/mcp_servers_status.py
+3 -1
@@ -9,7 +9,9 @@ class McpServersStatuss(ApiHandler):
9 async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
10
11 # try:
12 - status = MCPConfig.get_instance().get_servers_status()
12 + project_name = (input or {}).get("project_name") if isinstance(input, dict) else None
13 + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance()
14 + status = config.get_servers_status()
15 return {"success": True, "status": status}
16 # except Exception as e:
17 # return {"success": False, "error": str(e)}
api/mcp_servers_status.py.dox.md
+3 -2
@@ -3,7 +3,7 @@
3 ## Purpose
4
5 - Own the `mcp_servers_status.py` API endpoint.
6 -- This module handles MCP server servers status requests.
6 +- This module handles MCP servers status requests for global or project scope.
7 - Keep this file-level DOX profile synchronized with `mcp_servers_status.py` because this directory is intentionally flat.
8
9 ## Ownership
@@ -17,6 +17,7 @@
17 ## Runtime Contracts
18
19 - HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
20 +- The request accepts optional `project_name`; when present, status resolves through the merged project-scoped MCP configuration.
21 - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
22 - `McpServersStatuss` is an `ApiHandler`.
23 - `McpServersStatuss` defines `process(...)`.
@@ -24,7 +25,7 @@
25
26 ## Key Concepts
27
27 -- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`.
28 +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`.
29 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
30
31 ## Work Guidance
extensions/python/system_prompt/_12_mcp_prompt.py
+1 -1
@@ -22,7 +22,7 @@ class MCPToolsPrompt(Extension):
22
23 @extensible
24 async def build_prompt(agent: Agent) -> str:
25 - mcp_config = MCPConfig.get_instance()
25 + mcp_config = MCPConfig.get_for_agent(agent)
26 if not mcp_config.servers:
27 return ""
28
helpers/mcp_handler.py
+205 -96
@@ -47,6 +47,7 @@ from helpers.tool import Tool, Response
47
48 MCP_MEDIA_TOKENS_ESTIMATE = 1500
49 MAX_MCP_RESOURCE_TEXT_CHARS = 12_000
50 +DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
51
52
53 def _mcp_get(item: Any, key: str, default: Any = None) -> Any:
@@ -92,6 +93,16 @@ def _is_streaming_http_type(server_type: str) -> bool:
93 return server_type.lower() in ["http-stream", "streaming-http", "streamable-http", "http-streaming"]
94
95
96 +def _split_qualified_tool_name(tool_name: str) -> tuple[str, str]:
97 + """Split Agent Zero's server.tool MCP name while preserving dots in MCP tool names."""
98 + if "." not in tool_name:
99 + raise ValueError(f"Tool {tool_name} not found")
100 + server_name_part, tool_name_part = tool_name.split(".", 1)
101 + if not server_name_part or not tool_name_part:
102 + raise ValueError(f"Tool {tool_name} not found")
103 + return server_name_part, tool_name_part
104 +
105 +
106 def initialize_mcp(mcp_servers_config: str):
107 if not MCPConfig.get_instance().is_initialized():
108 try:
@@ -344,7 +355,7 @@ class MCPTool(Tool):
355 error = ""
356 additional: dict[str, Any] | None = None
357 try:
347 - response: CallToolResult = await MCPConfig.get_instance().call_tool(
358 + response: CallToolResult = await MCPConfig.get_for_agent(self.agent).call_tool(
359 self.name, kwargs
360 )
361 message, additional = self._format_tool_result(response)
@@ -439,6 +450,7 @@ class MCPServerRemote(BaseModel):
450 tool_timeout: int = Field(default=0)
451 verify: bool = Field(default=True, description="Verify SSL certificates")
452 disabled: bool = Field(default=False)
453 + scope: str = Field(default="global")
454
455 __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
456 __client: Optional["MCPClientRemote"] = PrivateAttr(default=None)
@@ -459,7 +471,7 @@ class MCPServerRemote(BaseModel):
471 def get_tools(self) -> List[dict[str, Any]]:
472 """Get all tools from the server"""
473 with self.__lock:
462 - return self.__client.tools # type: ignore
474 + return self.__client.get_tools() # type: ignore
475
476 def has_tool(self, tool_name: str) -> bool:
477 """Check if a tool is available"""
@@ -470,9 +482,10 @@ class MCPServerRemote(BaseModel):
482 self, tool_name: str, input_data: Dict[str, Any]
483 ) -> CallToolResult:
484 """Call a tool with the given input data"""
473 - with self.__lock:
474 - # We already run in an event loop, dont believe Pylance
475 - return await self.__client.call_tool(tool_name, input_data) # type: ignore
485 + client = self.__client
486 + if client is None:
487 + raise RuntimeError("MCP remote client is not initialized")
488 + return await client.call_tool(tool_name, input_data)
489
490 def update(self, config: dict[str, Any]) -> "MCPServerRemote":
491 with self.__lock:
@@ -488,6 +501,7 @@ class MCPServerRemote(BaseModel):
501 "tool_timeout",
502 "disabled",
503 "verify",
504 + "scope",
505 ]:
506 if key == "name":
507 value = normalize_name(value)
@@ -517,6 +531,7 @@ class MCPServerLocal(BaseModel):
531 tool_timeout: int = Field(default=0)
532 verify: bool = Field(default=True, description="Verify SSL certificates")
533 disabled: bool = Field(default=False)
534 + scope: str = Field(default="global")
535
536 __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
537 __client: Optional["MCPClientLocal"] = PrivateAttr(default=None)
@@ -537,7 +552,7 @@ class MCPServerLocal(BaseModel):
552 def get_tools(self) -> List[dict[str, Any]]:
553 """Get all tools from the server"""
554 with self.__lock:
540 - return self.__client.tools # type: ignore
555 + return self.__client.get_tools() # type: ignore
556
557 def has_tool(self, tool_name: str) -> bool:
558 """Check if a tool is available"""
@@ -548,9 +563,10 @@ class MCPServerLocal(BaseModel):
563 self, tool_name: str, input_data: Dict[str, Any]
564 ) -> CallToolResult:
565 """Call a tool with the given input data"""
551 - with self.__lock:
552 - # We already run in an event loop, dont believe Pylance
553 - return await self.__client.call_tool(tool_name, input_data) # type: ignore
566 + client = self.__client
567 + if client is None:
568 + raise RuntimeError("MCP local client is not initialized")
569 + return await client.call_tool(tool_name, input_data)
570
571 def update(self, config: dict[str, Any]) -> "MCPServerLocal":
572 with self.__lock:
@@ -567,6 +583,7 @@ class MCPServerLocal(BaseModel):
583 "init_timeout",
584 "tool_timeout",
585 "disabled",
586 + "scope",
587 ]:
588 if key == "name":
589 value = normalize_name(value)
@@ -590,89 +607,146 @@ MCPServer = Annotated[
607 class MCPConfig(BaseModel):
608 servers: list[MCPServer] = Field(default_factory=list)
609 disconnected_servers: list[dict[str, Any]] = Field(default_factory=list)
610 + config_scope: str = Field(default="global")
611 __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
612 __instance: ClassVar[Any] = PrivateAttr(default=None)
613 __initialized: ClassVar[bool] = PrivateAttr(default=False)
614 + __project_instances: ClassVar[dict[str, tuple[str, "MCPConfig"]]] = {}
615
616 @classmethod
617 def get_instance(cls) -> "MCPConfig":
618 # with cls.__lock:
619 if cls.__instance is None:
601 - cls.__instance = cls(servers_list=[])
620 + cls.__instance = cls(servers_list=[], config_scope="global")
621 return cls.__instance
622
623 @classmethod
605 - def wait_for_lock(cls):
624 + def clear_project_instances(cls):
625 with cls.__lock:
607 - return
626 + cls.__project_instances = {}
627
628 @classmethod
610 - def update(cls, config_str: str) -> Any:
611 - with cls.__lock:
612 - servers_data: List[Dict[str, Any]] = [] # Default to empty list
629 + def parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]:
630 + servers_data: List[Dict[str, Any]] = []
631
614 - if (
615 - config_str and config_str.strip()
616 - ): # Only parse if non-empty and not just whitespace
617 - try:
618 - # Try with standard json.loads first, as it should handle escaped strings correctly
619 - parsed_value = dirty_json.try_parse(config_str)
620 - normalized = cls.normalize_config(parsed_value)
621 -
622 - if isinstance(normalized, list):
623 - valid_servers = []
624 - for item in normalized:
625 - if isinstance(item, dict):
626 - valid_servers.append(item)
627 - else:
628 - PrintStyle(
629 - background_color="yellow",
630 - font_color="black",
631 - padding=True,
632 - ).print(
633 - f"Warning: MCP config item (from json.loads) was not a dictionary and was ignored: {item}"
634 - )
635 - servers_data = valid_servers
632 + if not (config_str and config_str.strip()):
633 + return servers_data
634 +
635 + try:
636 + parsed_value = dirty_json.try_parse(config_str)
637 + normalized = cls.normalize_config(parsed_value)
638 +
639 + if isinstance(normalized, list):
640 + for item in normalized:
641 + if isinstance(item, dict):
642 + servers_data.append(dict(item))
643 else:
644 PrintStyle(
638 - background_color="red", font_color="white", padding=True
645 + background_color="yellow",
646 + font_color="black",
647 + padding=True,
648 ).print(
640 - f"Error: Parsed MCP config (from json.loads) top-level structure is not a list. Config string was: '{config_str}'"
649 + f"Warning: MCP config item was not a dictionary and was ignored: {item}"
650 )
642 - # servers_data remains empty
643 - except (
644 - Exception
645 - ) as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception
646 - PrintStyle.error(
647 - f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'"
648 - )
651 + else:
652 + PrintStyle(
653 + background_color="red", font_color="white", padding=True
654 + ).print(
655 + f"Error: Parsed MCP config top-level structure is not a list. Config string was: '{config_str}'"
656 + )
657 + except Exception as e_json:
658 + PrintStyle.error(
659 + f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'"
660 + )
661 +
662 + return servers_data
663 +
664 + @classmethod
665 + def merge_config_strings(
666 + cls, global_config: str, project_config: str
667 + ) -> tuple[list[dict[str, Any]], str]:
668 + merged: dict[str, dict[str, Any]] = {}
669 + unnamed: list[dict[str, Any]] = []
670 +
671 + def add_servers(config_str: str, scope: str):
672 + for server in cls.parse_config_string(config_str):
673 + server_copy = dict(server)
674 + server_copy["scope"] = scope
675 + name = str(server_copy.get("name", "") or "").strip()
676 + if not name:
677 + unnamed.append(server_copy)
678 + continue
679 + normalized_name = normalize_name(name)
680 + server_copy["name"] = normalized_name
681 + merged[normalized_name] = server_copy
682 +
683 + add_servers(global_config or DEFAULT_MCP_SERVERS_CONFIG, "global")
684 + add_servers(project_config or DEFAULT_MCP_SERVERS_CONFIG, "project")
685 +
686 + servers = [*unnamed, *merged.values()]
687 + cache_key = dirty_json.stringify(
688 + {
689 + "mcpServers": {
690 + s.get("name", f"unnamed_{i}"): s
691 + for i, s in enumerate(servers)
692 + }
693 + }
694 + )
695 + return servers, cache_key
696 +
697 + @classmethod
698 + def get_project_instance(cls, project_name: str | None, *, force: bool = False) -> "MCPConfig":
699 + project_key = str(project_name or "").strip()
700 + if not project_key:
701 + return cls.get_instance()
702 +
703 + from helpers import projects
704 + project_key = projects.validate_project_name(project_key)
705 +
706 + global_config = settings.get_settings().get(
707 + "mcp_servers", DEFAULT_MCP_SERVERS_CONFIG
708 + )
709 + project_config = projects.load_project_mcp_servers(project_key)
710 + servers_data, cache_key = cls.merge_config_strings(global_config, project_config)
711
650 - # # Fallback to DirtyJson or log error if standard json.loads fails
651 - # PrintStyle(background_color="orange", font_color="black", padding=True).print(
652 - # f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback."
653 - # )
654 - # try:
655 - # parsed_value = DirtyJson.parse_string(config_str)
656 - # if isinstance(parsed_value, list):
657 - # valid_servers = []
658 - # for item in parsed_value:
659 - # if isinstance(item, dict):
660 - # valid_servers.append(item)
661 - # else:
662 - # PrintStyle(background_color="yellow", font_color="black", padding=True).print(
663 - # f"Warning: MCP config item (from DirtyJson) was not a dictionary and was ignored: {item}"
664 - # )
665 - # servers_data = valid_servers
666 - # else:
667 - # PrintStyle(background_color="red", font_color="white", padding=True).print(
668 - # f"Error: Parsed MCP config (from DirtyJson) top-level structure is not a list. Config string was: '{config_str}'"
669 - # )
670 - # # servers_data remains empty
671 - # except Exception as e_dirty:
672 - # PrintStyle(background_color="red", font_color="white", padding=True).print(
673 - # f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'"
674 - # )
675 - # # servers_data remains empty, allowing graceful degradation
712 + with cls.__lock:
713 + cached = cls.__project_instances.get(project_key)
714 + if cached and cached[0] == cache_key and not force:
715 + return cached[1]
716 +
717 + instance = cls(servers_list=servers_data, config_scope=f"project:{project_key}")
718 + with cls.__lock:
719 + cls.__project_instances[project_key] = (cache_key, instance)
720 + return instance
721 +
722 + @classmethod
723 + def refresh_project(cls, project_name: str) -> "MCPConfig":
724 + project_key = str(project_name or "").strip()
725 + with cls.__lock:
726 + cls.__project_instances.pop(project_key, None)
727 + return cls.get_project_instance(project_key, force=True)
728 +
729 + @classmethod
730 + def get_for_agent(cls, agent: Any) -> "MCPConfig":
731 + try:
732 + from helpers import projects
733 +
734 + project_name = projects.get_context_project_name(agent.context)
735 + if project_name:
736 + return cls.get_project_instance(project_name)
737 + except Exception:
738 + pass
739 + return cls.get_instance()
740 +
741 + @classmethod
742 + def wait_for_lock(cls):
743 + with cls.__lock:
744 + return
745 +
746 + @classmethod
747 + def update(cls, config_str: str) -> Any:
748 + with cls.__lock:
749 + servers_data = cls.parse_config_string(config_str)
750
751 # Initialize/update the singleton instance with the (potentially empty) list of server data
752 instance = cls.get_instance()
@@ -683,7 +757,8 @@ class MCPConfig(BaseModel):
757 } # Prepare data for re-initialization or update
758
759 # Option 1: Re-initialize the existing instance (if __init__ is idempotent for other fields)
686 - instance.__init__(servers_list=servers_data)
760 + instance.__init__(servers_list=servers_data, config_scope="global")
761 + cls.__project_instances = {}
762
763 # Option 2: Or, if __init__ has side effects we don't want to repeat,
764 # and 'servers' is the primary thing 'update' changes:
@@ -708,23 +783,24 @@ class MCPConfig(BaseModel):
783 if isinstance(servers, list):
784 for server in servers:
785 if isinstance(server, dict):
711 - normalized.append(server)
786 + normalized.append(dict(server))
787 elif isinstance(servers, dict):
788 if "mcpServers" in servers:
789 if isinstance(servers["mcpServers"], dict):
790 for key, value in servers["mcpServers"].items():
791 if isinstance(value, dict):
717 - value["name"] = key
718 - normalized.append(value)
792 + server = dict(value)
793 + server["name"] = key
794 + normalized.append(server)
795 elif isinstance(servers["mcpServers"], list):
796 for server in servers["mcpServers"]:
797 if isinstance(server, dict):
722 - normalized.append(server)
798 + normalized.append(dict(server))
799 else:
724 - normalized.append(servers) # single server?
800 + normalized.append(dict(servers)) # single server?
801 return normalized
802
727 - def __init__(self, servers_list: List[Dict[str, Any]]):
803 + def __init__(self, servers_list: List[Dict[str, Any]], config_scope: str = "global"):
804 from collections.abc import Mapping, Iterable
805
806 # # DEBUG: Print the received servers_list
@@ -741,6 +817,7 @@ class MCPConfig(BaseModel):
817
818 # Clear any servers potentially initialized by super().__init__() before we populate based on servers_list
819 self.servers = []
820 + self.config_scope = config_scope
821 # initialize failed servers list
822 self.disconnected_servers = []
823
@@ -867,10 +944,10 @@ class MCPConfig(BaseModel):
944 name = server.name
945 # get tool count
946 tool_count = len(server.get_tools())
870 - # check if server is connected
871 - connected = True # tool_count > 0
947 # get error message if any
948 error = server.get_error()
949 + # A server object can exist while its initialization failed.
950 + connected = not bool(error)
951 # get log bool
952 has_log = server.get_log() != ""
953
@@ -878,6 +955,9 @@ class MCPConfig(BaseModel):
955 result.append(
956 {
957 "name": name,
958 + "scope": getattr(server, "scope", self.config_scope),
959 + "type": getattr(server, "type", ""),
960 + "description": getattr(server, "description", ""),
961 "connected": connected,
962 "error": error,
963 "tool_count": tool_count,
@@ -890,6 +970,9 @@ class MCPConfig(BaseModel):
970 result.append(
971 {
972 "name": disconnected["name"],
973 + "scope": disconnected.get("config", {}).get("scope", self.config_scope),
974 + "type": disconnected.get("config", {}).get("type", ""),
975 + "description": disconnected.get("config", {}).get("description", ""),
976 "connected": False,
977 "error": disconnected["error"],
978 "tool_count": 0,
@@ -910,6 +993,8 @@ class MCPConfig(BaseModel):
993 return {
994 "name": server.name,
995 "description": server.description,
996 + "scope": getattr(server, "scope", self.config_scope),
997 + "type": getattr(server, "type", ""),
998 "tools": tools,
999 }
1000 return {}
@@ -986,9 +1071,10 @@ class MCPConfig(BaseModel):
1071
1072 def has_tool(self, tool_name: str) -> bool:
1073 """Check if a tool is available"""
989 - if "." not in tool_name:
1074 + try:
1075 + server_name_part, tool_name_part = _split_qualified_tool_name(tool_name)
1076 + except ValueError:
1077 return False
991 - server_name_part, tool_name_part = tool_name.split(".")
1078 with self.__lock:
1079 for server in self.servers:
1080 if server.name == server_name_part:
@@ -996,6 +1082,9 @@ class MCPConfig(BaseModel):
1082 return False
1083
1084 def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None:
1085 + effective_config = MCPConfig.get_for_agent(agent)
1086 + if effective_config is not self:
1087 + return effective_config.get_tool(agent, tool_name)
1088 if not self.has_tool(tool_name):
1089 return None
1090 return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="", loop_data=None)
@@ -1004,9 +1093,7 @@ class MCPConfig(BaseModel):
1093 self, tool_name: str, input_data: Dict[str, Any]
1094 ) -> CallToolResult:
1095 """Call a tool with the given input data"""
1007 - if "." not in tool_name:
1008 - raise ValueError(f"Tool {tool_name} not found")
1009 - server_name_part, tool_name_part = tool_name.split(".")
1096 + server_name_part, tool_name_part = _split_qualified_tool_name(tool_name)
1097 with self.__lock:
1098 for server in self.servers:
1099 if server.name == server_name_part and server.has_tool(tool_name_part):
@@ -1119,16 +1206,21 @@ class MCPClientBase(ABC):
1206 }
1207 for tool in response.tools
1208 ]
1209 + self.error = ""
1210 PrintStyle(font_color="green").print(
1211 f"MCPClientBase ({self.server.name}): Tools updated. Found {len(self.tools)} tools."
1212 )
1213
1214 try:
1127 - set = settings.get_settings()
1215 + current_settings = settings.get_settings()
1216 + init_timeout = (
1217 + self.server.init_timeout
1218 + or current_settings.get("mcp_client_init_timeout", 10)
1219 + or 10
1220 + )
1221 await self._execute_with_session(
1222 list_tools_op,
1130 - read_timeout_seconds=self.server.init_timeout
1131 - or set["mcp_client_init_timeout"],
1223 + read_timeout_seconds=init_timeout,
1224 )
1225 except Exception as e:
1226 # e = eg.exceptions[0]
@@ -1155,7 +1247,7 @@ class MCPClientBase(ABC):
1247 def get_tools(self) -> List[dict[str, Any]]:
1248 """Get all tools from the server (uses cached tools)"""
1249 with self.__lock:
1158 - return self.tools
1250 + return [dict(tool) for tool in self.tools]
1251
1252 async def call_tool(
1253 self, tool_name: str, input_data: Dict[str, Any]
@@ -1177,19 +1269,28 @@ class MCPClientBase(ABC):
1269 f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools."
1270 )
1271
1272 + current_settings = settings.get_settings()
1273 + tool_timeout = (
1274 + self.server.tool_timeout
1275 + or current_settings.get("mcp_client_tool_timeout", 120)
1276 + or 120
1277 + )
1278 +
1279 async def call_tool_op(current_session: ClientSession):
1181 - set = settings.get_settings()
1280 # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...")
1281 response: CallToolResult = await current_session.call_tool(
1282 tool_name,
1283 input_data,
1186 - read_timeout_seconds=timedelta(seconds=set["mcp_client_tool_timeout"]),
1284 + read_timeout_seconds=timedelta(seconds=tool_timeout),
1285 )
1286 # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.")
1287 return response
1288
1289 try:
1192 - return await self._execute_with_session(call_tool_op)
1290 + return await self._execute_with_session(
1291 + call_tool_op,
1292 + read_timeout_seconds=tool_timeout,
1293 + )
1294 except Exception as e:
1295 # Error logged by _execute_with_session. Re-raise a specific error for the caller.
1296 PrintStyle(
@@ -1304,11 +1405,19 @@ class MCPClientRemote(MCPClientBase):
1405 ]:
1406 """Connect to an MCP server, init client and save stdio/write streams"""
1407 server: MCPServerRemote = cast(MCPServerRemote, self.server)
1307 - set = settings.get_settings()
1408 + current_settings = settings.get_settings()
1409
1410 # Resolve timeout: check server config first, then settings, defaulting to 5s/10s
1310 - init_timeout = server.init_timeout or set["mcp_client_init_timeout"] or 5
1311 - tool_timeout = server.tool_timeout or set["mcp_client_tool_timeout"] or 10
1411 + init_timeout = (
1412 + server.init_timeout
1413 + or current_settings.get("mcp_client_init_timeout", 10)
1414 + or 10
1415 + )
1416 + tool_timeout = (
1417 + server.tool_timeout
1418 + or current_settings.get("mcp_client_tool_timeout", 120)
1419 + or 120
1420 + )
1421
1422 client_factory = CustomHTTPClientFactory(verify=server.verify)
1423 # Check if this is a streaming HTTP type
helpers/mcp_handler.py.dox.md
+16 -3
@@ -3,7 +3,7 @@
3 ## Purpose
4
5 - Own the `mcp_handler.py` helper module.
6 -- This module loads MCP server configuration and exposes MCP tools to agents.
6 +- This module loads global and project-scoped MCP server configuration and exposes MCP tools to agents.
7 - Keep this file-level DOX profile synchronized with `mcp_handler.py` because this directory is intentionally flat.
8
9 ## Ownership
@@ -34,6 +34,12 @@
34 - `async initialize(self) -> 'MCPServerLocal'`
35 - `MCPConfig` (`BaseModel`)
36 - `get_instance(cls) -> 'MCPConfig'`
37 + - `clear_project_instances(cls)`
38 + - `parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]`
39 + - `merge_config_strings(cls, global_config: str, project_config: str) -> tuple[List[Dict[str, Any]], str]`
40 + - `get_project_instance(cls, project_name: str | None, *, force: bool = False) -> 'MCPConfig'`
41 + - `refresh_project(cls, project_name: str) -> 'MCPConfig'`
42 + - `get_for_agent(cls, agent: Any) -> 'MCPConfig'`
43 - `wait_for_lock(cls)`
44 - `update(cls, config_str: str) -> Any`
45 - `normalize_config(cls, servers: Any)`
@@ -56,8 +62,9 @@
62 - `normalize_name(name: str) -> str`
63 - `_determine_server_type(config_dict: dict) -> str`: Determine the server type based on configuration, with backward compatibility.
64 - `_is_streaming_http_type(server_type: str) -> bool`: Check if the server type is a streaming HTTP variant.
65 +- `_split_qualified_tool_name(tool_name: str) -> tuple[str, str]`: Split `server.tool` names while preserving dots inside MCP tool names.
66 - `initialize_mcp(mcp_servers_config: str)`
60 -- Notable constants/configuration names: `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `T`.
67 +- Notable constants/configuration names: `DEFAULT_MCP_SERVERS_CONFIG`, `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `T`.
68
69 ## Runtime Contracts
70
@@ -65,12 +72,18 @@
72 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
73 - `MCPTool` is a `Tool`.
74 - `MCPTool` defines `execute(...)`.
75 +- Global MCP configuration remains backed by settings; project MCP configuration is loaded through `helpers.projects` and merged with global config when an active agent context has `context.project`.
76 +- Project-scoped MCP servers overlay global servers by normalized name. The resulting `MCPConfig` cache key is derived from both config strings so project instances refresh when either scope changes.
77 +- Server status and detail responses include `scope`, and MCP tools resolve through `MCPConfig.get_for_agent(agent)` before execution.
78 +- MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
79 +- Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
80 +- Server status marks initialized server objects with cached initialization errors as disconnected, even if the config object exists.
81 - Observed side-effect areas: filesystem writes, network calls, WebSocket state, settings/state persistence, secret handling.
82 - Imported dependency areas include: `abc`, `anyio.streams.memory`, `asyncio`, `contextlib`, `datetime`, `helpers`, `helpers.log`, `helpers.print_style`, `helpers.tool`, `httpx`, `json`, `mcp`, `mcp.client.sse`, `mcp.client.stdio`, `mcp.client.streamable_http`, `mcp.shared.message`.
83
84 ## Key Concepts
85
73 -- Important called helpers/classes observed in the source: `TypeVar`, `name.strip.lower`, `re.sub`, `Field`, `PrivateAttr`, `threading.Lock`, `config_dict.lower`, `server_type.lower`, `MCPConfig.get_instance.is_initialized`, `self.agent.context.log.log`, `str.strip`, `media_artifacts.guess_extension`, `callable`, `self._content_item_dump`, `join`, `Response`, `self.get_log_object`, `self._raw_tool_response`, `additional.pop`, `self._coerce_media_token_estimate`.
86 +- Important called helpers/classes observed in the source: `TypeVar`, `name.strip.lower`, `re.sub`, `Field`, `PrivateAttr`, `threading.Lock`, `_split_qualified_tool_name`, `config_dict.lower`, `server_type.lower`, `MCPConfig.get_instance.is_initialized`, `MCPConfig.get_for_agent`, `projects.validate_project_name`, `projects.load_project_mcp_servers`, `settings.get_settings`, `self.agent.context.log.log`, `str.strip`, `media_artifacts.guess_extension`, `callable`, `self._content_item_dump`, `join`, `Response`, `self.get_log_object`, `self._raw_tool_response`, `additional.pop`, `self._coerce_media_token_estimate`.
87 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
88
89 ## Work Guidance
helpers/projects.py
+38
@@ -13,7 +13,9 @@ PROJECT_META_DIR = ".a0proj"
13 PROJECT_INSTRUCTIONS_DIR = "instructions"
14 PROJECT_KNOWLEDGE_DIR = "knowledge"
15 PROJECT_HEADER_FILE = "project.json"
16 +PROJECT_MCP_SERVERS_FILE = "mcp_servers.json"
17 PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md")
18 +DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
19
20 CONTEXT_DATA_KEY_PROJECT = "project"
21
@@ -34,6 +36,7 @@ class BasicProjectData(TypedDict):
36 description: str
37 instructions: str
38 include_agents_md: NotRequired[bool]
39 + mcp_servers: NotRequired[str]
40 color: str
41 git_url: str
42 file_structure: FileStructureInjectionSettings
@@ -53,6 +56,7 @@ class EditProjectData(BasicProjectData):
56 knowledge_files_count: int
57 variables: str
58 secrets: str
59 + mcp_servers: str
60 subagents: dict[str, SubAgentSettings]
61 git_status: GitStatusData
62
@@ -70,6 +74,17 @@ def get_project_meta(name: str, *sub_dirs: str):
74 return files.get_abs_path(get_project_folder(name), PROJECT_META_DIR, *sub_dirs)
75
76
77 +def validate_project_name(name: str | None) -> str:
78 + candidate = str(name or "").strip()
79 + if (
80 + not candidate
81 + or candidate in {".", ".."}
82 + or os.path.basename(candidate) != candidate
83 + ):
84 + raise ValueError("Invalid project name")
85 + return candidate
86 +
87 +
88 def delete_project(name: str):
89 abs_path = files.get_abs_path(PROJECTS_PARENT_DIR, name)
90 files.delete_dir(abs_path)
@@ -79,12 +94,14 @@ def delete_project(name: str):
94
95 def create_project(name: str, data: BasicProjectData):
96 llm_data = data.get("llm") if isinstance(data, dict) else None
97 + mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
98 abs_path = files.create_dir_safe(
99 files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
100 )
101 create_project_meta_folders(name)
102 data = _normalizeBasicData(data)
103 save_project_header(name, data)
104 + save_project_mcp_servers(name, mcp_servers or DEFAULT_MCP_SERVERS_CONFIG)
105 save_project_llm_settings(name, llm_data)
106 return name
107
@@ -94,6 +111,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
111 from helpers import git
112
113 llm_data = data.get("llm") if isinstance(data, dict) else None
114 + mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
115
116 abs_path = files.create_dir_safe(
117 files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
@@ -121,6 +139,8 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
139 data["git_url"] = clean_url
140 save_project_header(actual_name, data)
141
142 + if mcp_servers:
143 + save_project_mcp_servers(actual_name, mcp_servers)
144 save_project_llm_settings(actual_name, llm_data)
145
146 return actual_name
@@ -183,6 +203,7 @@ def _normalizeEditData(data: EditProjectData) -> EditProjectData:
203 data.get("include_agents_md", True)
204 ),
205 "variables": data.get("variables", ""),
206 + "mcp_servers": data.get("mcp_servers", DEFAULT_MCP_SERVERS_CONFIG),
207 "color": data.get("color", ""),
208 "git_url": data.get("git_url", ""),
209 "git_status": data.get("git_status", {"is_git_repo": False}),
@@ -234,6 +255,7 @@ def update_project(name: str, data: EditProjectData):
255 # save secrets
256 save_project_variables(name, current["variables"])
257 save_project_secrets(name, current["secrets"])
258 + save_project_mcp_servers(name, current["mcp_servers"])
259 save_project_subagents(name, current["subagents"])
260 save_project_llm_settings(name, llm_data)
261
@@ -253,6 +275,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
275 data = load_basic_project_data(name)
276 additional_instructions = get_additional_instructions_files(name)
277 variables = load_project_variables(name)
278 + mcp_servers = load_project_mcp_servers(name)
279 secrets = load_project_secrets_masked(name)
280 subagents = load_project_subagents(name)
281 knowledge_files_count = get_knowledge_files_count(name)
@@ -266,6 +289,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
289 "instruction_files_count": len(additional_instructions),
290 "knowledge_files_count": knowledge_files_count,
291 "variables": variables,
292 + "mcp_servers": mcp_servers,
293 "secrets": secrets,
294 "subagents": subagents,
295 "git_status": git_status,
@@ -335,6 +359,20 @@ def save_project_llm_settings(name: str, llm_data: object):
359 plugins.save_plugin_config("_model_config", name, "", config_to_save)
360
361
362 +def load_project_mcp_servers(name: str) -> str:
363 + project_name = validate_project_name(name)
364 + try:
365 + return files.read_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE))
366 + except Exception:
367 + return DEFAULT_MCP_SERVERS_CONFIG
368 +
369 +
370 +def save_project_mcp_servers(name: str, mcp_servers: str):
371 + project_name = validate_project_name(name)
372 + content = mcp_servers if isinstance(mcp_servers, str) else DEFAULT_MCP_SERVERS_CONFIG
373 + files.write_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE), content)
374 +
375 +
376 def get_active_projects_list():
377 return _get_projects_list(get_projects_parent_folder())
378
helpers/projects.py.dox.md
+9 -3
@@ -3,7 +3,7 @@
3 ## Purpose
4
5 - Own the `projects.py` helper module.
6 -- This module owns project metadata, workspace creation, Git status, and project-scoped settings.
6 +- This module owns project metadata, workspace creation, Git status, and project-scoped settings including per-project MCP server config.
7 - Keep this file-level DOX profile synchronized with `projects.py` because this directory is intentionally flat.
8
9 ## Ownership
@@ -20,6 +20,7 @@
20 - `get_projects_parent_folder()`
21 - `get_project_folder(name: str)`
22 - `get_project_meta(name: str, *sub_dirs)`
23 +- `validate_project_name(name: str | None) -> str`
24 - `delete_project(name: str)`
25 - `create_project(name: str, data: BasicProjectData)`
26 - `clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData)`: Clone a git repository as a new A0 project. Token is used only for cloning via http header.
@@ -35,6 +36,8 @@
36 - `save_project_header(name: str, data: BasicProjectData)`
37 - `load_project_llm_data(name: str) -> dict`
38 - `save_project_llm_settings(name: str, llm_data: object)`
39 +- `load_project_mcp_servers(name: str) -> str`
40 +- `save_project_mcp_servers(name: str, mcp_servers: str)`
41 - `get_active_projects_list()`
42 - `_get_projects_list(parent_dir)`
43 - `activate_project(context_id: str, name: str, mark_dirty: bool=...)`
@@ -47,18 +50,21 @@
50 - `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None`
51 - `_format_project_instruction_files(instruction_files: list[tuple[str, str]]) -> str`
52 - `_normalize_include_agents_md(value: object) -> bool`
50 -- Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_AGENTS_MD_FILES`, `CONTEXT_DATA_KEY_PROJECT`.
53 +- Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_MCP_SERVERS_FILE`, `PROJECT_AGENTS_MD_FILES`, `DEFAULT_MCP_SERVERS_CONFIG`, `CONTEXT_DATA_KEY_PROJECT`.
54
55 ## Runtime Contracts
56
57 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
58 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
59 +- Per-project MCP server configuration is persisted as `.a0proj/mcp_servers.json`, exposed through `load_edit_project_data(...)`, and saved during project create/clone/update flows.
60 +- Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
61 +- Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
62 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
63 - Imported dependency areas include: `helpers`, `helpers.print_style`, `os`, `typing`.
64
65 ## Key Concepts
66
61 -- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_llm_settings`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`.
67 +- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_mcp_servers`, `load_project_mcp_servers`, `save_project_llm_settings`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`.
68 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
69
70 ## Work Guidance
tests/test_mcp_handler_multimodal.py
+107 -4
@@ -153,6 +153,109 @@ def _agent_recorder(context_id: str = "ctx-mcp"):
153 return agent, log, tool_results, messages, updates, warnings
154
155
156 +def test_mcp_config_preserves_dotted_tool_names(mcp_handler_module):
157 + module, _tmp_path = mcp_handler_module
158 + called: list[tuple[str, dict]] = []
159 +
160 + class _FakeServer:
161 + name = "server"
162 + description = "Fake MCP server"
163 + type = "stdio"
164 + scope = "global"
165 +
166 + def get_tools(self):
167 + return [
168 + {
169 + "name": "alpha.beta",
170 + "description": "Dotted MCP tool",
171 + "input_schema": {},
172 + }
173 + ]
174 +
175 + def has_tool(self, tool_name):
176 + return tool_name == "alpha.beta"
177 +
178 + async def call_tool(self, tool_name, input_data):
179 + called.append((tool_name, input_data))
180 + return _FakeCallToolResult(content=[], isError=False)
181 +
182 + def get_error(self):
183 + return ""
184 +
185 + def get_log(self):
186 + return ""
187 +
188 + config = module.MCPConfig(servers_list=[])
189 + config.servers = [_FakeServer()]
190 +
191 + assert config.has_tool("server.alpha.beta") is True
192 + asyncio.run(config.call_tool("server.alpha.beta", {"value": 7}))
193 +
194 + assert called == [("alpha.beta", {"value": 7})]
195 +
196 +
197 +def test_mcp_status_marks_servers_with_errors_disconnected(mcp_handler_module):
198 + module, _tmp_path = mcp_handler_module
199 +
200 + class _FakeServer:
201 + name = "broken"
202 + description = "Broken MCP server"
203 + type = "stdio"
204 + scope = "global"
205 +
206 + def get_tools(self):
207 + return []
208 +
209 + def get_error(self):
210 + return "Failed to initialize"
211 +
212 + def get_log(self):
213 + return "stderr"
214 +
215 + config = module.MCPConfig(servers_list=[])
216 + config.servers = [_FakeServer()]
217 +
218 + status = config.get_servers_status()
219 +
220 + assert status[0]["connected"] is False
221 + assert status[0]["error"] == "Failed to initialize"
222 + assert status[0]["has_log"] is True
223 +
224 +
225 +def test_mcp_client_call_tool_uses_server_tool_timeout(mcp_handler_module, monkeypatch):
226 + module, _tmp_path = mcp_handler_module
227 + session_timeouts = []
228 + call_timeouts = []
229 +
230 + monkeypatch.setattr(
231 + module.settings,
232 + "get_settings",
233 + lambda: {"mcp_client_init_timeout": 10, "mcp_client_tool_timeout": 120},
234 + raising=False,
235 + )
236 +
237 + class _FakeSession:
238 + async def call_tool(self, tool_name, input_data, read_timeout_seconds=None):
239 + call_timeouts.append(read_timeout_seconds)
240 + return _FakeCallToolResult(content=[], isError=False)
241 +
242 + class _FakeClient(module.MCPClientBase):
243 + async def _create_stdio_transport(self, current_exit_stack):
244 + raise AssertionError("transport should be bypassed by fake session")
245 +
246 + async def _execute_with_session(self, coro_func, read_timeout_seconds=60):
247 + session_timeouts.append(read_timeout_seconds)
248 + return await coro_func(_FakeSession())
249 +
250 + client = _FakeClient(SimpleNamespace(name="server", tool_timeout=7, init_timeout=0))
251 + client.tools = [{"name": "run"}]
252 +
253 + asyncio.run(client.call_tool("run", {"x": 1}))
254 +
255 + assert session_timeouts == [7]
256 + assert call_timeouts[0].total_seconds() == 7
257 +
258 +
259 def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module, monkeypatch):
260 module, _tmp_path = mcp_handler_module
261 agent, log, tool_results, messages, updates, warnings = _agent_recorder()
@@ -166,7 +269,7 @@ def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module,
269 async def call_tool(self, name, kwargs):
270 return result
271
169 - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
272 + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
273
274 tool = module.MCPTool(
275 agent=agent,
@@ -209,7 +312,7 @@ def test_mcp_audio_content_is_saved_instead_of_discarded(mcp_handler_module, mon
312 async def call_tool(self, name, kwargs):
313 return result
314
212 - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
315 + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
316
317 tool = module.MCPTool(
318 agent=agent,
@@ -259,7 +362,7 @@ def test_mcp_image_resource_blob_becomes_history_image_attachment(mcp_handler_mo
362 async def call_tool(self, name, kwargs):
363 return result
364
262 - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
365 + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
366
367 tool = module.MCPTool(
368 agent=agent,
@@ -308,7 +411,7 @@ def test_mcp_resource_text_is_preserved(mcp_handler_module, monkeypatch):
411 async def call_tool(self, name, kwargs):
412 return result
413
311 - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
414 + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
415
416 tool = module.MCPTool(
417 agent=agent,
tests/test_projects.py
+35
@@ -6,6 +6,8 @@ from helpers import dirty_json, files, projects
6 def _prepare_project_tree(monkeypatch, tmp_path: Path) -> None:
7 monkeypatch.setattr(files, "_base_dir", str(tmp_path))
8 (tmp_path / "usr" / "projects").mkdir(parents=True, exist_ok=True)
9 + (tmp_path / "usr" / "plugins").mkdir(parents=True, exist_ok=True)
10 + (tmp_path / "plugins").mkdir(parents=True, exist_ok=True)
11
12
13 def test_project_include_agents_md_defaults_true_and_saves(monkeypatch, tmp_path):
@@ -24,6 +26,39 @@ def test_project_include_agents_md_defaults_true_and_saves(monkeypatch, tmp_path
26 assert saved["include_agents_md"] is True
27
28
29 +def test_project_mcp_servers_persist_in_project_meta(monkeypatch, tmp_path):
30 + _prepare_project_tree(monkeypatch, tmp_path)
31 + config = '{"mcpServers":{"demo":{"url":"https://example.com/mcp"}}}'
32 +
33 + projects.create_project(
34 + "demo",
35 + {
36 + "title": "Demo",
37 + "mcp_servers": config,
38 + },
39 + )
40 +
41 + assert projects.load_project_mcp_servers("demo") == config
42 + assert projects.load_edit_project_data("demo")["mcp_servers"] == config
43 +
44 + updated = '{"mcpServers":{"other":{"command":"uvx","args":["pkg"]}}}'
45 + projects.save_project_mcp_servers("demo", updated)
46 +
47 + assert projects.load_project_mcp_servers("demo") == updated
48 +
49 +
50 +def test_project_mcp_servers_reject_path_names(monkeypatch, tmp_path):
51 + _prepare_project_tree(monkeypatch, tmp_path)
52 +
53 + for name in ("../escape", "nested/project", ".", "..", ""):
54 + try:
55 + projects.save_project_mcp_servers(name, '{"mcpServers":{}}')
56 + except ValueError:
57 + pass
58 + else:
59 + raise AssertionError(f"Expected invalid project name: {name!r}")
60 +
61 +
62 def test_project_system_prompt_includes_root_agents_md_with_path(monkeypatch, tmp_path):
63 _prepare_project_tree(monkeypatch, tmp_path)
64 projects.create_project(
webui/components/chat/input/bottom-actions.html
+10
@@ -6,6 +6,7 @@
6 import { store as historyStore } from "/components/modals/history/history-store.js";
7 import { store as contextStore } from "/components/modals/context/context-store.js";
8 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9 + import { store as mcpServersStore } from "/components/settings/mcp/client/mcp-servers-store.js";
10 </script>
11 </head>
12 <body>
@@ -48,6 +49,15 @@
49 <span>Attach folder</span>
50 </button>
51
52 + <button
53 + type="button"
54 + class="chat-bottom-menu-item"
55 + @click="$store.mcpServersStore.openFromComposer($store.chats.selectedContext?.project?.name || ''); $store.chatInput.closeChatMoreMenu()"
56 + >
57 + <span class="material-symbols-outlined" aria-hidden="true">hub</span>
58 + <span>MCP Servers</span>
59 + </button>
60 +
61 <button
62 type="button"
63 class="chat-bottom-menu-item"
webui/components/projects/AGENTS.md
+3 -3
@@ -2,7 +2,7 @@
2
3 ## Purpose
4
5 -- Own WebUI project creation, selection, editing, secrets, model, skill, and file-structure components.
5 +- Own WebUI project creation, selection, editing, secrets, model, skill, MCP server, and file-structure components.
6
7 ## Ownership
8
@@ -15,7 +15,7 @@
15
16 - Keep project API payloads synchronized with backend project handlers.
17 - Do not expose project secrets in logs, URLs, or long-lived frontend state unnecessarily.
18 -- Preserve scoped settings interactions with plugins, models, and skills.
18 +- Preserve scoped settings interactions with plugins, models, skills, and MCP servers.
19
20 ## Work Guidance
21
@@ -23,7 +23,7 @@
23
24 ## Verification
25
26 -- Smoke-test create, select, edit, secrets, LLM, skills, and file-structure flows after changes.
26 +- Smoke-test create, select, edit, secrets, LLM, skills, MCP servers, and file-structure flows after changes.
27
28 ## Child DOX Index
29
webui/components/projects/project-edit-mcp.html new
+75
@@ -0,0 +1,75 @@
1 +<html>
2 +
3 +<head>
4 + <title>Project MCP servers</title>
5 + <script type="module">
6 + import { store as mcpServersStore } from "/components/settings/mcp/client/mcp-servers-store.js";
7 + </script>
8 +</head>
9 +
10 +<body>
11 + <div x-data>
12 + <template x-if="$store.projects && $store.projects.selectedProject && $store.mcpServersStore">
13 + <div class="project-mcp-section">
14 + <div>
15 + <p class="project-mcp-description">
16 + Project MCP servers are inherited on chats using this project. Global MCP servers remain available unless a project server with the same name overrides them.
17 + </p>
18 + <p class="project-mcp-count">
19 + <span x-text="$store.mcpServersStore.countServersInConfig($store.projects.selectedProject.mcp_servers)"></span>
20 + <span>project servers configured</span>
21 + </p>
22 + </div>
23 + <button type="button" class="button" @click="$store.mcpServersStore.openProjectConfig($store.projects.selectedProject)">
24 + <span class="icon material-symbols-outlined">hub</span>
25 + MCP Servers
26 + </button>
27 + </div>
28 + </template>
29 + </div>
30 +
31 + <style>
32 + .project-mcp-section {
33 + display: flex;
34 + align-items: flex-start;
35 + justify-content: space-between;
36 + gap: 1rem;
37 + }
38 +
39 + .project-mcp-description {
40 + margin: 0;
41 + color: var(--color-text-muted);
42 + font-size: 0.9rem;
43 + line-height: 1.45;
44 + }
45 +
46 + .project-mcp-count {
47 + margin: 0.5rem 0 0;
48 + color: var(--color-text);
49 + font-size: 0.85rem;
50 + font-weight: 600;
51 + }
52 +
53 + .project-mcp-count span + span {
54 + margin-left: 0.25rem;
55 + color: var(--color-text-muted);
56 + font-weight: 500;
57 + }
58 +
59 + .project-mcp-section .button {
60 + display: inline-flex;
61 + align-items: center;
62 + gap: 0.35rem;
63 + flex-shrink: 0;
64 + }
65 +
66 + @media (max-width: 760px) {
67 + .project-mcp-section {
68 + flex-direction: column;
69 + }
70 + }
71 + </style>
72 +
73 +</body>
74 +
75 +</html>
webui/components/projects/project-edit.html
+8
@@ -63,6 +63,14 @@
63 </x-component>
64 </div>
65
66 + <div class="project-detail">
67 + <div class="project-detail-header">
68 + <span class="projects-project-card-title">MCP Servers</span>
69 + </div>
70 + <x-component path="projects/project-edit-mcp.html">
71 + </x-component>
72 + </div>
73 +
74 <div class="project-detail">
75 <div class="project-detail-header">
76 <span class="projects-project-card-title">File structure</span>
webui/components/settings/mcp/client/mcp-servers-store.js
+555 -73
@@ -1,144 +1,626 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import sleep from "/js/sleep.js";
3 import * as API from "/js/api.js";
4 +import { openModal } from "/js/modals.js";
5 import { store as settingsStore } from "/components/settings/settings-store.js";
6 +import {
7 + toastFrontendError,
8 + toastFrontendSuccess,
9 + toastFrontendWarning,
10 +} from "/components/notifications/notification-store.js";
11 +
12 +const EMPTY_CONFIG = '{\n "mcpServers": {}\n}';
13 +const STATUS_INTERVAL_MS = 3000;
14 +
15 +function normalizeName(value) {
16 + return String(value || "mcp_server")
17 + .trim()
18 + .toLowerCase()
19 + .replace(/[^\w]/gu, "_")
20 + .replace(/_+/g, "_")
21 + .replace(/^_+|_+$/g, "") || "mcp_server";
22 +}
23 +
24 +function parseJsonConfig(value) {
25 + const text = String(value || "").trim() || EMPTY_CONFIG;
26 + const parsed = JSON.parse(text);
27 + if (Array.isArray(parsed)) return { mcpServers: parsed };
28 + if (parsed && typeof parsed === "object") {
29 + if (!parsed.mcpServers) parsed.mcpServers = {};
30 + return parsed;
31 + }
32 + return { mcpServers: {} };
33 +}
34 +
35 +function stringifyConfig(config) {
36 + return JSON.stringify(config || { mcpServers: {} }, null, 2);
37 +}
38 +
39 +function parseKeyValueText(text) {
40 + const raw = String(text || "").trim();
41 + if (!raw) return {};
42 + if (raw.startsWith("{")) {
43 + const parsed = JSON.parse(raw);
44 + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
45 + }
46 +
47 + return raw.split(/\r?\n/)
48 + .map((line) => line.trim())
49 + .filter(Boolean)
50 + .reduce((acc, line) => {
51 + const idx = line.indexOf("=");
52 + if (idx <= 0) return acc;
53 + const key = line.slice(0, idx).trim();
54 + if (!key) return acc;
55 + acc[key] = line.slice(idx + 1).trim();
56 + return acc;
57 + }, {});
58 +}
59 +
60 +function formatKeyValueText(value) {
61 + if (!value || typeof value !== "object" || Array.isArray(value)) return "";
62 + return Object.entries(value)
63 + .map(([key, val]) => `${key}=${val ?? ""}`)
64 + .join("\n");
65 +}
66 +
67 +function parseArgsText(text) {
68 + const raw = String(text || "").trim();
69 + if (!raw) return [];
70 + if (raw.startsWith("[")) {
71 + const parsed = JSON.parse(raw);
72 + return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
73 + }
74 + return raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
75 +}
76 +
77 +function formatArgsText(value) {
78 + return Array.isArray(value) ? value.join("\n") : "";
79 +}
80 +
81 +function deriveNameFromUrl(url) {
82 + try {
83 + const parsed = new URL(url);
84 + const parts = parsed.pathname.split("/").filter(Boolean);
85 + return normalizeName(parts.at(-1) || parsed.hostname || "remote_mcp");
86 + } catch {
87 + return "remote_mcp";
88 + }
89 +}
90 +
91 +function createEmptyForm() {
92 + return {
93 + mode: "remote",
94 + name: "",
95 + description: "",
96 + url: "",
97 + type: "streamable-http",
98 + command: "",
99 + argsText: "",
100 + headersText: "",
101 + envText: "",
102 + init_timeout: "",
103 + tool_timeout: "",
104 + verify: true,
105 + disabled: false,
106 + allow_local_execution: false,
107 + allow_remote_network: false,
108 + };
109 +}
110
111 const model = {
112 editor: null,
113 servers: [],
114 loading: true,
115 + applying: false,
116 statusCheck: false,
117 serverLog: "",
118 + serverDetail: null,
119 + activeView: "visual",
120 + addOpen: false,
121 + advancedOpen: false,
122 + serverForm: createEmptyForm(),
123 + scanLoading: false,
124 + scanResult: null,
125 + scope: "global",
126 + projectName: "",
127 + projectModel: null,
128 + standaloneProject: false,
129
130 async initialize() {
14 - // Initialize the JSON Viewer after the modal is rendered
131 + this.loading = true;
132 + await this.ensureScopeLoaded();
133 + this.setupEditor();
134 + await this.loadStatus();
135 + this.loading = false;
136 + this.startStatusCheck();
137 + },
138 +
139 + setupEditor() {
140 const container = document.getElementById("mcp-servers-config-json");
16 - if (container) {
17 - const editor = ace.edit("mcp-servers-config-json");
141 + if (!container) return;
142
19 - const dark = localStorage.getItem("darkMode");
20 - if (dark != "false") {
21 - editor.setTheme("ace/theme/github_dark");
22 - } else {
23 - editor.setTheme("ace/theme/tomorrow");
143 + const editor = ace.edit("mcp-servers-config-json");
144 + const dark = localStorage.getItem("darkMode");
145 + editor.setTheme(dark !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow");
146 + editor.session.setMode("ace/mode/json");
147 + editor.setValue(this.getScopeConfigJson());
148 + editor.clearSelection();
149 + this.editor = editor;
150 + requestAnimationFrame(() => this.editor?.resize());
151 + },
152 +
153 + async ensureScopeLoaded() {
154 + if (this.scope === "project") return;
155 + if (settingsStore.settings) return;
156 +
157 + try {
158 + const response = await API.callJsonApi("settings_get", null);
159 + if (response?.settings) {
160 + settingsStore.settings = response.settings;
161 + settingsStore.additional = response.additional || null;
162 }
163 + } catch (error) {
164 + console.error("Failed to load settings for MCP manager:", error);
165 + void toastFrontendError("Failed to load settings for MCP manager", "MCP Servers");
166 + }
167 + },
168 +
169 + async openGlobalConfig() {
170 + this.configureGlobalScope();
171 + await openModal("settings/mcp/client/mcp-servers.html");
172 + },
173 +
174 + async openProjectConfig(projectModel) {
175 + if (!projectModel?.name) return;
176 + this.configureProjectScope(projectModel.name, projectModel, false);
177 + await openModal("settings/mcp/client/mcp-servers.html");
178 + },
179
26 - editor.session.setMode("ace/mode/json");
27 - const json = this.getSettingsFieldConfigJson();
28 - editor.setValue(json);
29 - editor.clearSelection();
30 - this.editor = editor;
180 + async openFromComposer(projectName = "") {
181 + const normalizedProject = String(projectName || "").trim();
182 + if (normalizedProject) {
183 + try {
184 + const response = await API.callJsonApi("projects", {
185 + action: "load",
186 + name: normalizedProject,
187 + });
188 + if (!response?.ok) throw new Error(response?.error || "Project load failed");
189 + this.configureProjectScope(normalizedProject, response.data, true);
190 + } catch (error) {
191 + console.error("Failed to load project MCP config:", error);
192 + void toastFrontendError("Failed to load project MCP config", "MCP Servers");
193 + return;
194 + }
195 + } else {
196 + this.configureGlobalScope();
197 + await this.ensureScopeLoaded();
198 }
199 + await openModal("settings/mcp/client/mcp-servers.html");
200 + },
201
33 - this.startStatusCheck();
202 + configureGlobalScope() {
203 + this.scope = "global";
204 + this.projectName = "";
205 + this.projectModel = null;
206 + this.standaloneProject = false;
207 },
208
36 - formatJson() {
37 - try {
38 - // get current content
39 - const currentContent = this.editor.getValue();
209 + configureProjectScope(projectName, projectModel, standaloneProject = false) {
210 + this.scope = "project";
211 + this.projectName = projectName;
212 + this.projectModel = projectModel || null;
213 + this.standaloneProject = !!standaloneProject;
214 + },
215 +
216 + resetScope() {
217 + this.configureGlobalScope();
218 + },
219
41 - // parse and format with 2 spaces indentation
42 - const parsed = JSON.parse(currentContent);
43 - const formatted = JSON.stringify(parsed, null, 2);
220 + get scopeTitle() {
221 + if (this.scope === "project") return `Project MCP servers`;
222 + return "Global MCP servers";
223 + },
224
45 - // update editor content
46 - this.editor.setValue(formatted);
47 - this.editor.clearSelection();
225 + get scopeSubtitle() {
226 + if (this.scope === "project") {
227 + return this.projectName ? `Project: ${this.projectName}` : "Project scope";
228 + }
229 + return "Available to every chat unless a project overrides a server.";
230 + },
231
49 - // move cursor to start
232 + getStatusPayload() {
233 + return this.scope === "project" && this.projectName
234 + ? { project_name: this.projectName }
235 + : null;
236 + },
237 +
238 + getApplyPayload() {
239 + const payload = { mcp_servers: this.getEditorValue() };
240 + if (this.scope === "project" && this.projectName) payload.project_name = this.projectName;
241 + return payload;
242 + },
243 +
244 + getScopeConfigJson() {
245 + if (this.scope === "project") {
246 + return this.projectModel?.mcp_servers || EMPTY_CONFIG;
247 + }
248 + return settingsStore.settings?.mcp_servers
249 + ?? settingsStore.settings?.mcpServers
250 + ?? EMPTY_CONFIG;
251 + },
252 +
253 + setScopeConfigJson(value) {
254 + if (this.scope === "project") {
255 + if (this.projectModel) this.projectModel.mcp_servers = value;
256 + return;
257 + }
258 + if (settingsStore.settings) settingsStore.settings.mcp_servers = value;
259 + },
260 +
261 + getEditorValue() {
262 + return this.editor?.getValue() ?? this.getScopeConfigJson();
263 + },
264 +
265 + setEditorValue(value) {
266 + if (this.editor) {
267 + this.editor.setValue(value);
268 + this.editor.clearSelection();
269 this.editor.navigateFileStart();
270 + requestAnimationFrame(() => this.editor?.resize());
271 + }
272 + this.setScopeConfigJson(value);
273 + },
274 +
275 + getConfigObject() {
276 + return parseJsonConfig(this.getEditorValue());
277 + },
278 +
279 + get configuredServers() {
280 + try {
281 + const config = this.getConfigObject();
282 + const servers = config.mcpServers;
283 + if (Array.isArray(servers)) {
284 + return servers.map((server, index) => ({
285 + name: server?.name || `server_${index + 1}`,
286 + config: server || {},
287 + }));
288 + }
289 + if (servers && typeof servers === "object") {
290 + return Object.entries(servers).map(([name, config]) => ({
291 + name,
292 + config: config || {},
293 + }));
294 + }
295 + } catch {
296 + return [];
297 + }
298 + return [];
299 + },
300 +
301 + countServersInConfig(configText) {
302 + try {
303 + const config = parseJsonConfig(configText || EMPTY_CONFIG);
304 + if (Array.isArray(config.mcpServers)) return config.mcpServers.length;
305 + return Object.keys(config.mcpServers || {}).length;
306 + } catch {
307 + return 0;
308 + }
309 + },
310 +
311 + formatJson() {
312 + try {
313 + this.setEditorValue(stringifyConfig(this.getConfigObject()));
314 + void toastFrontendSuccess("MCP JSON reformatted", "MCP Servers");
315 } catch (error) {
316 console.error("Failed to format JSON:", error);
53 - alert("Invalid JSON: " + error.message);
317 + void toastFrontendError(`Invalid JSON: ${error.message}`, "MCP Servers");
318 }
319 },
320
57 - getEditorValue() {
58 - return this.editor.getValue();
321 + setActiveView(view) {
322 + this.activeView = view || "visual";
323 + if (this.activeView === "raw") {
324 + requestAnimationFrame(() => this.editor?.resize());
325 + }
326 },
327
61 - getSettingsFieldConfigJson() {
62 - return settingsStore.settings?.mcp_servers
63 - ?? settingsStore.settings?.mcpServers
64 - ?? "{\n \"mcpServers\": {}\n}";
328 + setFormMode(mode) {
329 + this.serverForm.mode = mode === "local" ? "local" : "remote";
330 + this.scanResult = null;
331 },
332
67 - onClose() {
68 - const val = this.getEditorValue();
69 - if (settingsStore.settings) {
70 - settingsStore.settings.mcp_servers = val;
333 + resetForm() {
334 + this.serverForm = createEmptyForm();
335 + this.advancedOpen = false;
336 + this.scanResult = null;
337 + },
338 +
339 + buildServerFromForm() {
340 + const form = this.serverForm;
341 + const name = normalizeName(form.name || (form.mode === "remote" ? deriveNameFromUrl(form.url) : form.command));
342 + if (!name) throw new Error("Name is required");
343 +
344 + const server = {
345 + name,
346 + disabled: !!form.disabled,
347 + };
348 +
349 + if (form.description.trim()) server.description = form.description.trim();
350 +
351 + if (form.init_timeout !== "" && form.init_timeout !== null) {
352 + const timeout = Number(form.init_timeout);
353 + if (Number.isFinite(timeout) && timeout > 0) server.init_timeout = timeout;
354 + }
355 + if (form.tool_timeout !== "" && form.tool_timeout !== null) {
356 + const timeout = Number(form.tool_timeout);
357 + if (Number.isFinite(timeout) && timeout > 0) server.tool_timeout = timeout;
358 + }
359 +
360 + if (form.mode === "remote") {
361 + if (!form.url.trim()) throw new Error("Remote MCP server URL is required");
362 + server.url = form.url.trim();
363 + server.type = form.type || "streamable-http";
364 + server.verify = form.verify !== false;
365 + const headers = parseKeyValueText(form.headersText);
366 + if (Object.keys(headers).length) server.headers = headers;
367 + } else {
368 + if (!form.command.trim()) throw new Error("Local command is required");
369 + server.type = "stdio";
370 + server.command = form.command.trim();
371 + const args = parseArgsText(form.argsText);
372 + if (args.length) server.args = args;
373 + const env = parseKeyValueText(form.envText);
374 + if (Object.keys(env).length) server.env = env;
375 + }
376 +
377 + return server;
378 + },
379 +
380 + async scanForm() {
381 + let server;
382 + try {
383 + server = this.buildServerFromForm();
384 + } catch (error) {
385 + void toastFrontendError(error.message || String(error), "MCP Scanner");
386 + return;
387 + }
388 +
389 + this.scanLoading = true;
390 + this.scanResult = null;
391 + try {
392 + const response = await API.callJsonApi("mcp_server_scan", {
393 + server,
394 + inspect_runtime: true,
395 + allow_local_execution: !!this.serverForm.allow_local_execution,
396 + allow_remote_network: !!this.serverForm.allow_remote_network,
397 + });
398 + if (!response?.success) throw new Error(response?.error || "Scan failed");
399 + this.scanResult = response;
400 + } catch (error) {
401 + console.error("MCP scan failed:", error);
402 + void toastFrontendError(`MCP scan failed: ${error.message || error}`, "MCP Scanner");
403 + } finally {
404 + this.scanLoading = false;
405 + }
406 + },
407 +
408 + addServerFromForm() {
409 + let server;
410 + try {
411 + server = this.buildServerFromForm();
412 + } catch (error) {
413 + void toastFrontendError(error.message || String(error), "MCP Servers");
414 + return;
415 + }
416 +
417 + try {
418 + const config = this.getConfigObject();
419 + if (Array.isArray(config.mcpServers)) {
420 + const index = config.mcpServers.findIndex((item) => normalizeName(item?.name || "") === server.name);
421 + if (index >= 0) config.mcpServers.splice(index, 1, server);
422 + else config.mcpServers.push(server);
423 + } else {
424 + const stored = { ...server };
425 + delete stored.name;
426 + config.mcpServers[server.name] = stored;
427 + }
428 + this.setEditorValue(stringifyConfig(config));
429 + this.addOpen = false;
430 + this.resetForm();
431 + void toastFrontendSuccess("MCP server added to draft config", "MCP Servers");
432 + } catch (error) {
433 + console.error("Failed to add MCP server:", error);
434 + void toastFrontendError(`Failed to add MCP server: ${error.message || error}`, "MCP Servers");
435 + }
436 + },
437 +
438 + editConfigServer(name) {
439 + const entry = this.configuredServers.find((item) => item.name === name);
440 + if (!entry) return;
441 + const cfg = entry.config || {};
442 + const isRemote = !!(cfg.url || cfg.serverUrl);
443 + this.serverForm = {
444 + ...createEmptyForm(),
445 + mode: isRemote ? "remote" : "local",
446 + name,
447 + description: cfg.description || "",
448 + url: cfg.url || cfg.serverUrl || "",
449 + type: cfg.type || "streamable-http",
450 + command: cfg.command || "",
451 + argsText: formatArgsText(cfg.args),
452 + headersText: formatKeyValueText(cfg.headers),
453 + envText: formatKeyValueText(cfg.env),
454 + init_timeout: cfg.init_timeout || "",
455 + tool_timeout: cfg.tool_timeout || "",
456 + verify: cfg.verify !== false,
457 + disabled: !!cfg.disabled,
458 + allow_local_execution: false,
459 + allow_remote_network: false,
460 + };
461 + this.addOpen = true;
462 + this.scanResult = null;
463 + },
464 +
465 + removeConfigServer(name) {
466 + try {
467 + const config = this.getConfigObject();
468 + if (Array.isArray(config.mcpServers)) {
469 + config.mcpServers = config.mcpServers.filter((server) => normalizeName(server?.name || "") !== normalizeName(name));
470 + } else {
471 + delete config.mcpServers[name];
472 + }
473 + this.setEditorValue(stringifyConfig(config));
474 + } catch (error) {
475 + void toastFrontendError(`Failed to remove MCP server: ${error.message || error}`, "MCP Servers");
476 + }
477 + },
478 +
479 + toggleConfigServer(name) {
480 + try {
481 + const config = this.getConfigObject();
482 + if (Array.isArray(config.mcpServers)) {
483 + const server = config.mcpServers.find((item) => normalizeName(item?.name || "") === normalizeName(name));
484 + if (server) server.disabled = !server.disabled;
485 + } else if (config.mcpServers[name]) {
486 + config.mcpServers[name].disabled = !config.mcpServers[name].disabled;
487 + }
488 + this.setEditorValue(stringifyConfig(config));
489 + } catch (error) {
490 + void toastFrontendError(`Failed to update MCP server: ${error.message || error}`, "MCP Servers");
491 }
72 - this.stopStatusCheck();
492 },
493
494 async startStatusCheck() {
495 this.statusCheck = true;
77 - let firstLoad = true;
78 -
496 while (this.statusCheck) {
80 - await this._statusCheck();
81 - if (firstLoad) {
82 - this.loading = false;
83 - firstLoad = false;
84 - }
85 - await sleep(3000);
497 + await sleep(STATUS_INTERVAL_MS);
498 + if (this.statusCheck) await this.loadStatus({ silent: true });
499 }
500 },
501
89 - async _statusCheck() {
90 - const resp = await API.callJsonApi("mcp_servers_status", null);
91 - if (resp.success) {
92 - this.servers = resp.status;
93 - this.servers.sort((a, b) => a.name.localeCompare(b.name));
502 + async loadStatus(options = {}) {
503 + try {
504 + const resp = await API.callJsonApi("mcp_servers_status", this.getStatusPayload());
505 + if (resp?.success) {
506 + this.servers = resp.status || [];
507 + this.servers.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
508 + } else if (!options.silent) {
509 + void toastFrontendWarning(resp?.error || "Unable to load MCP status", "MCP Servers");
510 + }
511 + } catch (error) {
512 + if (!options.silent) {
513 + console.error("Failed to load MCP status:", error);
514 + void toastFrontendError("Failed to load MCP status", "MCP Servers");
515 + }
516 }
517 },
518
97 - async stopStatusCheck() {
519 + stopStatusCheck() {
520 this.statusCheck = false;
521 },
522
523 async applyNow() {
102 - if (this.loading) return;
103 - this.loading = true;
524 + if (this.applying) return;
525 try {
105 - scrollModal("mcp-servers-status");
106 - const resp = await API.callJsonApi("mcp_servers_apply", {
107 - mcp_servers: this.getEditorValue(),
108 - });
109 - if (resp.success) {
110 - this.servers = resp.status;
111 - this.servers.sort((a, b) => a.name.localeCompare(b.name));
112 - }
113 - this.loading = false;
114 - await sleep(100); // wait for ui and scroll
115 - scrollModal("mcp-servers-status");
526 + const formatted = stringifyConfig(this.getConfigObject());
527 + this.setEditorValue(formatted);
528 + } catch (error) {
529 + void toastFrontendError(`Invalid JSON: ${error.message || error}`, "MCP Servers");
530 + return;
531 + }
532 +
533 + this.applying = true;
534 + try {
535 + const resp = await API.callJsonApi("mcp_servers_apply", this.getApplyPayload());
536 + if (!resp?.success) throw new Error(resp?.error || "Apply failed");
537 + this.setScopeConfigJson(resp.mcp_servers || this.getEditorValue());
538 + this.servers = resp.status || [];
539 + this.servers.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
540 + void toastFrontendSuccess("MCP servers applied", "MCP Servers");
541 + await sleep(100);
542 + if (globalThis.scrollModal) globalThis.scrollModal("mcp-servers-status");
543 } catch (error) {
544 console.error("Failed to apply MCP servers:", error);
545 + void toastFrontendError(`Failed to apply MCP servers: ${error.message || error}`, "MCP Servers");
546 + } finally {
547 + this.applying = false;
548 }
119 - this.loading = false;
549 },
550
551 async getServerLog(serverName) {
552 this.serverLog = "";
124 - const resp = await API.callJsonApi("mcp_server_get_log", {
125 - server_name: serverName,
126 - });
127 - if (resp.success) {
553 + const payload = { server_name: serverName, ...(this.getStatusPayload() || {}) };
554 + const resp = await API.callJsonApi("mcp_server_get_log", payload);
555 + if (resp?.success) {
556 this.serverLog = resp.log;
557 openModal("settings/mcp/client/mcp-servers-log.html");
558 }
559 },
560
561 async onToolCountClick(serverName) {
134 - const resp = await API.callJsonApi("mcp_server_get_detail", {
135 - server_name: serverName,
136 - });
137 - if (resp.success) {
562 + const payload = { server_name: serverName, ...(this.getStatusPayload() || {}) };
563 + const resp = await API.callJsonApi("mcp_server_get_detail", payload);
564 + if (resp?.success) {
565 this.serverDetail = resp.detail;
566 openModal("settings/mcp/client/mcp-server-tools.html");
567 }
568 },
569 +
570 + statusLabel(server) {
571 + if (!server.connected) return "Unavailable";
572 + if (server.error) return "Needs attention";
573 + if ((server.tool_count || 0) > 0) return "Ready";
574 + return "Connected";
575 + },
576 +
577 + statusClass(server) {
578 + if (!server.connected || server.error) return "danger";
579 + if ((server.tool_count || 0) > 0) return "ok";
580 + return "idle";
581 + },
582 +
583 + configModeLabel(config) {
584 + if (config?.disabled) return "Disabled";
585 + if (config?.url || config?.serverUrl) return "Remote";
586 + return "Local";
587 + },
588 +
589 + configSummary(config) {
590 + if (config?.url || config?.serverUrl) return config.url || config.serverUrl;
591 + const args = Array.isArray(config?.args) && config.args.length ? ` ${config.args.join(" ")}` : "";
592 + return `${config?.command || "command"}${args}`;
593 + },
594 +
595 + get scanWarnings() {
596 + return this.scanResult?.warnings || [];
597 + },
598 +
599 + get scanRiskLabel() {
600 + const risk = this.scanResult?.risk_level || "";
601 + if (risk === "ok") return "No major issues found";
602 + if (risk === "warning") return "Review warnings";
603 + if (risk === "error") return "Action needed";
604 + return "";
605 + },
606 +
607 + onClose() {
608 + try {
609 + this.setScopeConfigJson(this.getEditorValue());
610 + } catch {}
611 + this.stopStatusCheck();
612 + if (this.editor) {
613 + try { this.editor.destroy(); } catch {}
614 + this.editor = null;
615 + }
616 + this.servers = [];
617 + this.loading = true;
618 + this.applying = false;
619 + this.addOpen = false;
620 + this.activeView = "visual";
621 + this.resetForm();
622 + this.resetScope();
623 + },
624 };
625
626 const store = createStore("mcpServersStore", model);
webui/components/settings/mcp/client/mcp-servers.html
+645 -122
@@ -1,7 +1,7 @@
1 <html>
2
3 <head>
4 - <title>MCP Servers Configuration</title>
4 + <title>MCP Servers</title>
5
6 <script type="module">
7 import { store } from "/components/settings/mcp/client/mcp-servers-store.js";
@@ -11,181 +11,704 @@
11 <body>
12 <div x-data>
13 <template x-if="$store.mcpServersStore">
14 - <div x-init="$store.mcpServersStore.initialize()" x-destroy="$store.mcpServersStore.onClose()">
15 -
16 - <h3>MCP Servers Configuration JSON
17 - <button class="btn slim" style="margin-left: 0.5em;"
18 - onclick="openModal('settings/mcp/client/example.html')">Examples</button>
19 - <button class="btn slim" style="margin-left: 0.5em;"
20 - @click="$store.mcpServersStore.formatJson()">Reformat</button>
21 - <button class="btn slim primary" :disabled="$store.mcpServersStore.loading"
22 - style="margin-left: 0.5em;" @click="$store.mcpServersStore.applyNow()">Apply now</button>
23 - </h3>
24 - <div id="mcp-servers-config-json"></div>
25 -
26 - <h3 id="mcp-servers-status">Servers status (refreshing automatically)</h3>
27 -
28 -
29 - <div class="server-list" x-show="!$store.mcpServersStore.loading">
30 - <template x-for="server in $store.mcpServersStore.servers" :key="server.name">
31 - <div class="server-item">
32 - <div class="server-main-row">
33 - <!-- Status indicator -->
34 - <div class="status-dot" x-data="{ connected: server.connected }">
35 - <svg viewBox="0 0 16 16" width="12" height="12">
36 - <circle cx="8" cy="8" r="6" x-bind:fill="server.connected
37 - ? (server.error ? '#e40138' : (server.tool_count > 0 ? '#00c340' : '#e40138'))
38 - : 'none'" x-bind:opacity="server.connected ? 1 : 0" />
39 - <circle cx="8" cy="8" r="6" fill="none" stroke="#e40138" stroke-width="2"
40 - x-bind:opacity="server.connected ? 0 : 1" />
41 - </svg>
42 - </div>
14 + <div class="mcp-manager" x-create="$store.mcpServersStore.initialize()" x-destroy="$store.mcpServersStore.onClose()">
15 + <header class="mcp-manager-header">
16 + <div class="mcp-manager-heading">
17 + <div class="mcp-manager-title-row">
18 + <h2 x-text="$store.mcpServersStore.scopeTitle"></h2>
19 + <span class="mcp-scope-pill" x-text="$store.mcpServersStore.scope === 'project' ? 'Project' : 'Global'"></span>
20 + </div>
21 + <p x-text="$store.mcpServersStore.scopeSubtitle"></p>
22 + </div>
23
44 - <!-- Server name -->
45 - <span class="server-name" x-text="server.name"></span>
24 + <div class="mcp-manager-actions">
25 + <button type="button" class="button" title="Add MCP server" @click="$store.mcpServersStore.addOpen = !$store.mcpServersStore.addOpen">
26 + <span class="material-symbols-outlined" aria-hidden="true">add</span>
27 + <span>Add</span>
28 + </button>
29 + <button type="button" class="button" title="Examples" onclick="openModal('settings/mcp/client/example.html')">
30 + <span class="material-symbols-outlined" aria-hidden="true">library_books</span>
31 + </button>
32 + <button type="button" class="button" title="Refresh status" @click="$store.mcpServersStore.loadStatus()">
33 + <span class="material-symbols-outlined" aria-hidden="true">refresh</span>
34 + </button>
35 + <button type="button" class="button confirm" :disabled="$store.mcpServersStore.applying" @click="$store.mcpServersStore.applyNow()">
36 + <span class="material-symbols-outlined" aria-hidden="true">check</span>
37 + <span x-text="$store.mcpServersStore.applying ? 'Applying' : 'Apply'"></span>
38 + </button>
39 + </div>
40 + </header>
41 +
42 + <section class="mcp-add-panel" x-show="$store.mcpServersStore.addOpen" x-transition.opacity style="display: none;">
43 + <div class="mcp-add-header">
44 + <h3>Add MCP server</h3>
45 + <button type="button" class="mcp-icon-button" title="Clear form" @click="$store.mcpServersStore.resetForm()">
46 + <span class="material-symbols-outlined" aria-hidden="true">backspace</span>
47 + </button>
48 + </div>
49
47 - <!-- Tool count (clickable if > 0, only for connected servers without errors) -->
48 - <span class="tool-count" x-show="server.tool_count > 0"
49 - @click="$store.mcpServersStore.onToolCountClick && $store.mcpServersStore.onToolCountClick(server.name)"
50 - x-text="server.tool_count + ' tools'"></span>
50 + <div class="mcp-segmented" role="tablist" aria-label="MCP server type">
51 + <button type="button" :class="{ active: $store.mcpServersStore.serverForm.mode === 'remote' }"
52 + @click="$store.mcpServersStore.setFormMode('remote')">Remote URL</button>
53 + <button type="button" :class="{ active: $store.mcpServersStore.serverForm.mode === 'local' }"
54 + @click="$store.mcpServersStore.setFormMode('local')">Local command</button>
55 + </div>
56
52 - <!-- Log button (only shown if has_log is true) -->
53 - <span class="log-btn" x-show="server.has_log"
54 - @click="$store.mcpServersStore.getServerLog(server.name)">Log</span>
55 - </div>
57 + <div class="mcp-form-grid">
58 + <label class="mcp-field">
59 + <span>Name</span>
60 + <input type="text" x-model="$store.mcpServersStore.serverForm.name" placeholder="github" />
61 + </label>
62 +
63 + <label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
64 + <span>Remote MCP server URL</span>
65 + <input type="url" x-model="$store.mcpServersStore.serverForm.url" placeholder="https://example.com/mcp" />
66 + </label>
67 +
68 + <label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
69 + <span>Command</span>
70 + <input type="text" x-model="$store.mcpServersStore.serverForm.command" placeholder="uvx" />
71 + </label>
72 +
73 + <label class="mcp-field">
74 + <span>Description</span>
75 + <input type="text" x-model="$store.mcpServersStore.serverForm.description" placeholder="Optional" />
76 + </label>
77 + </div>
78
57 - <!-- Error message (if any) -->
58 - <div class="server-error-row" x-show="server.error">
59 - <span class="server-error" x-text="server.error"></span>
60 - </div>
79 + <button type="button" class="mcp-advanced-toggle" @click="$store.mcpServersStore.advancedOpen = !$store.mcpServersStore.advancedOpen">
80 + <span class="material-symbols-outlined" :style="$store.mcpServersStore.advancedOpen ? 'transform:rotate(90deg)' : ''">chevron_right</span>
81 + <span>Advanced settings</span>
82 + </button>
83 +
84 + <div class="mcp-advanced-body" x-show="$store.mcpServersStore.advancedOpen" x-transition.opacity style="display: none;">
85 + <div class="mcp-form-grid">
86 + <label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
87 + <span>Transport</span>
88 + <select x-model="$store.mcpServersStore.serverForm.type">
89 + <option value="streamable-http">Streamable HTTP</option>
90 + <option value="sse">SSE</option>
91 + </select>
92 + </label>
93 +
94 + <label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
95 + <span>Arguments</span>
96 + <textarea x-model="$store.mcpServersStore.serverForm.argsText" rows="4" placeholder="--yes&#10;@modelcontextprotocol/server-filesystem"></textarea>
97 + </label>
98 +
99 + <label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
100 + <span>Headers</span>
101 + <textarea x-model="$store.mcpServersStore.serverForm.headersText" rows="4" placeholder="Authorization=Bearer ..."></textarea>
102 + </label>
103 +
104 + <label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
105 + <span>Environment</span>
106 + <textarea x-model="$store.mcpServersStore.serverForm.envText" rows="4" placeholder="TOKEN=..."></textarea>
107 + </label>
108 +
109 + <label class="mcp-field">
110 + <span>Startup timeout</span>
111 + <input type="number" min="0" x-model="$store.mcpServersStore.serverForm.init_timeout" />
112 + </label>
113 +
114 + <label class="mcp-field">
115 + <span>Tool timeout</span>
116 + <input type="number" min="0" x-model="$store.mcpServersStore.serverForm.tool_timeout" />
117 + </label>
118 + </div>
119 +
120 + <div class="mcp-toggle-row">
121 + <label>
122 + <input type="checkbox" x-model="$store.mcpServersStore.serverForm.disabled" />
123 + <span>Disabled</span>
124 + </label>
125 + <label x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
126 + <input type="checkbox" x-model="$store.mcpServersStore.serverForm.verify" />
127 + <span>Verify SSL</span>
128 + </label>
129 + <label x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
130 + <input type="checkbox" x-model="$store.mcpServersStore.serverForm.allow_remote_network" />
131 + <span>Trust remote inspection</span>
132 + </label>
133 + <label x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
134 + <input type="checkbox" x-model="$store.mcpServersStore.serverForm.allow_local_execution" />
135 + <span>Trust local inspection</span>
136 + </label>
137 + </div>
138 + </div>
139 +
140 + <div class="mcp-scan-result" x-show="$store.mcpServersStore.scanResult">
141 + <div class="mcp-scan-heading">
142 + <span class="material-symbols-outlined" aria-hidden="true"
143 + x-text="$store.mcpServersStore.scanResult?.risk_level === 'ok' ? 'verified' : ($store.mcpServersStore.scanResult?.risk_level === 'error' ? 'error' : 'warning')"></span>
144 + <span x-text="$store.mcpServersStore.scanRiskLabel"></span>
145 + </div>
146 + <div class="mcp-scan-warnings">
147 + <template x-for="warning in $store.mcpServersStore.scanWarnings" :key="warning.title + warning.message">
148 + <div class="mcp-scan-warning" :class="warning.level">
149 + <strong x-text="warning.title"></strong>
150 + <span x-text="warning.message"></span>
151 + </div>
152 + </template>
153 </div>
62 - </template>
63 - <div x-show="$store.mcpServersStore.servers.length === 0" class="mcp-servers-loading">
64 - No servers
154 </div>
66 - </div>
155
68 - <div x-show="$store.mcpServersStore.loading" class="mcp-servers-loading">
69 - Loading servers status...
156 + <div class="mcp-add-actions">
157 + <button type="button" class="button" :disabled="$store.mcpServersStore.scanLoading" @click="$store.mcpServersStore.scanForm()">
158 + <span class="material-symbols-outlined" aria-hidden="true" x-text="$store.mcpServersStore.scanLoading ? 'progress_activity' : 'radar'"></span>
159 + <span x-text="$store.mcpServersStore.scanLoading ? 'Scanning' : 'Scan'"></span>
160 + </button>
161 + <button type="button" class="button confirm" @click="$store.mcpServersStore.addServerFromForm()">
162 + <span class="material-symbols-outlined" aria-hidden="true">add_circle</span>
163 + <span>Add to config</span>
164 + </button>
165 + </div>
166 + </section>
167 +
168 + <div class="mcp-view-tabs">
169 + <button type="button" :class="{ active: $store.mcpServersStore.activeView === 'visual' }"
170 + @click="$store.mcpServersStore.setActiveView('visual')">
171 + <span class="material-symbols-outlined" aria-hidden="true">dashboard_customize</span>
172 + <span>Manager</span>
173 + </button>
174 + <button type="button" :class="{ active: $store.mcpServersStore.activeView === 'raw' }"
175 + @click="$store.mcpServersStore.setActiveView('raw')">
176 + <span class="material-symbols-outlined" aria-hidden="true">data_object</span>
177 + <span>Raw JSON</span>
178 + </button>
179 </div>
180 +
181 + <section x-show="$store.mcpServersStore.activeView === 'visual'" class="mcp-config-list">
182 + <div class="mcp-section-title">
183 + <h3>Configured servers</h3>
184 + <span x-text="$store.mcpServersStore.configuredServers.length + ' total'"></span>
185 + </div>
186 + <template x-if="$store.mcpServersStore.configuredServers.length === 0">
187 + <div class="mcp-empty">No MCP servers configured.</div>
188 + </template>
189 + <template x-for="entry in $store.mcpServersStore.configuredServers" :key="entry.name">
190 + <article class="mcp-config-card" :class="{ disabled: entry.config.disabled }">
191 + <div class="mcp-config-card-main">
192 + <div>
193 + <div class="mcp-config-name" x-text="entry.name"></div>
194 + <div class="mcp-config-summary" x-text="$store.mcpServersStore.configSummary(entry.config)"></div>
195 + </div>
196 + <span class="mcp-config-mode" x-text="$store.mcpServersStore.configModeLabel(entry.config)"></span>
197 + </div>
198 + <div class="mcp-config-actions">
199 + <button type="button" class="mcp-icon-button" title="Edit" @click="$store.mcpServersStore.editConfigServer(entry.name)">
200 + <span class="material-symbols-outlined" aria-hidden="true">edit</span>
201 + </button>
202 + <button type="button" class="mcp-icon-button" title="Enable or disable" @click="$store.mcpServersStore.toggleConfigServer(entry.name)">
203 + <span class="material-symbols-outlined" aria-hidden="true" x-text="entry.config.disabled ? 'toggle_off' : 'toggle_on'"></span>
204 + </button>
205 + <button type="button" class="mcp-icon-button danger" title="Remove" @click="$store.mcpServersStore.removeConfigServer(entry.name)">
206 + <span class="material-symbols-outlined" aria-hidden="true">delete</span>
207 + </button>
208 + </div>
209 + </article>
210 + </template>
211 + </section>
212 +
213 + <section x-show="$store.mcpServersStore.activeView === 'raw'" class="mcp-raw-panel" style="display: none;">
214 + <div class="mcp-raw-toolbar">
215 + <h3>MCP Servers Configuration JSON</h3>
216 + <button type="button" class="button" @click="$store.mcpServersStore.formatJson()">
217 + <span class="material-symbols-outlined" aria-hidden="true">format_indent_increase</span>
218 + <span>Reformat</span>
219 + </button>
220 + </div>
221 + <div id="mcp-servers-config-json"></div>
222 + </section>
223 +
224 + <section class="mcp-status-section" id="mcp-servers-status">
225 + <div class="mcp-section-title">
226 + <h3>Servers status</h3>
227 + <span x-text="$store.mcpServersStore.loading ? 'Loading' : ($store.mcpServersStore.servers.length + ' visible')"></span>
228 + </div>
229 +
230 + <div x-show="$store.mcpServersStore.loading" class="mcp-empty">
231 + Loading MCP server status...
232 + </div>
233 +
234 + <div class="mcp-status-list" x-show="!$store.mcpServersStore.loading">
235 + <template x-for="server in $store.mcpServersStore.servers" :key="server.scope + ':' + server.name">
236 + <article class="mcp-status-row" :class="$store.mcpServersStore.statusClass(server)">
237 + <div class="mcp-status-main">
238 + <span class="mcp-status-dot"></span>
239 + <div>
240 + <div class="mcp-status-name">
241 + <span x-text="server.name"></span>
242 + <span class="mcp-status-scope" x-text="server.scope || 'global'"></span>
243 + </div>
244 + <div class="mcp-status-meta">
245 + <span x-text="$store.mcpServersStore.statusLabel(server)"></span>
246 + <span x-show="server.type" x-text="server.type"></span>
247 + </div>
248 + </div>
249 + </div>
250 + <div class="mcp-status-actions">
251 + <button type="button" class="button" x-show="server.tool_count > 0"
252 + @click="$store.mcpServersStore.onToolCountClick(server.name)"
253 + x-text="server.tool_count + ' tools'"></button>
254 + <button type="button" class="button" x-show="server.has_log"
255 + @click="$store.mcpServersStore.getServerLog(server.name)">Log</button>
256 + </div>
257 + <div class="mcp-status-error" x-show="server.error" x-text="server.error"></div>
258 + </article>
259 + </template>
260 +
261 + <div x-show="$store.mcpServersStore.servers.length === 0" class="mcp-empty">
262 + No servers.
263 + </div>
264 + </div>
265 + </section>
266 </div>
267 </template>
268 </div>
269
270 <style>
271 + .mcp-manager {
272 + display: flex;
273 + flex-direction: column;
274 + gap: 1rem;
275 + color: var(--color-text);
276 + font-family: var(--font-family-main);
277 + }
278
77 - .modal-inner .modal-scroll {
78 - scrollbar-width: none;
79 - -ms-overflow-style: none;
80 - }
81 -
82 - .modal-inner .modal-scroll::-webkit-scrollbar {
83 - display: none;
84 - }
85 -
86 - .mcp-servers-loading {
87 - width: 100%;
88 - text-align: center;
89 - margin-top: 2rem;
90 - margin-bottom: 2rem;
91 - }
92 - #mcp-servers-config-json {
279 + .mcp-manager button,
280 + .mcp-manager input,
281 + .mcp-manager textarea,
282 + .mcp-manager select {
283 + font-family: inherit;
284 + }
285 +
286 + .mcp-manager-header,
287 + .mcp-add-header,
288 + .mcp-section-title,
289 + .mcp-raw-toolbar {
290 + display: flex;
291 + align-items: flex-start;
292 + justify-content: space-between;
293 + gap: 1rem;
294 + }
295 +
296 + .mcp-manager-heading {
297 + min-width: 0;
298 + }
299 +
300 + .mcp-manager-title-row {
301 + display: flex;
302 + flex-wrap: wrap;
303 + align-items: center;
304 + gap: 0.5rem;
305 + }
306 +
307 + .mcp-manager h2,
308 + .mcp-manager h3,
309 + .mcp-manager p {
310 + margin: 0;
311 + }
312 +
313 + .mcp-manager h2 {
314 + font-size: 1.35rem;
315 + line-height: 1.2;
316 + }
317 +
318 + .mcp-manager h3 {
319 + font-size: 1rem;
320 + line-height: 1.2;
321 + }
322 +
323 + .mcp-manager p,
324 + .mcp-config-summary,
325 + .mcp-status-meta,
326 + .mcp-empty {
327 + color: var(--color-text-muted);
328 + font-size: 0.86rem;
329 + line-height: 1.4;
330 + }
331 +
332 + .mcp-scope-pill,
333 + .mcp-status-scope,
334 + .mcp-config-mode,
335 + .mcp-section-title > span {
336 + display: inline-flex;
337 + align-items: center;
338 + min-height: 1.45rem;
339 + padding: 0.15rem 0.45rem;
340 + border: 1px solid var(--color-border);
341 + border-radius: 999px;
342 + color: var(--color-text-muted);
343 + font-size: 0.72rem;
344 + font-weight: 600;
345 + }
346 +
347 + .mcp-manager-actions,
348 + .mcp-add-actions,
349 + .mcp-config-actions,
350 + .mcp-status-actions,
351 + .mcp-toggle-row,
352 + .mcp-raw-toolbar {
353 + display: flex;
354 + flex-wrap: wrap;
355 + align-items: center;
356 + gap: 0.5rem;
357 + }
358 +
359 + .mcp-manager .button {
360 + display: inline-flex;
361 + align-items: center;
362 + justify-content: center;
363 + gap: 0.35rem;
364 + min-height: 2rem;
365 + padding: 0.35rem 0.65rem;
366 + border: 1px solid var(--color-border);
367 + border-radius: 6px;
368 + background: var(--color-panel);
369 + color: var(--color-text);
370 + cursor: pointer;
371 + }
372 +
373 + .mcp-manager .button.confirm {
374 + border-color: color-mix(in srgb, var(--color-highlight) 65%, var(--color-border));
375 + background: var(--color-highlight);
376 + color: #fff;
377 + }
378 +
379 + .mcp-manager .button:disabled {
380 + opacity: 0.55;
381 + cursor: not-allowed;
382 + }
383 +
384 + .mcp-manager .material-symbols-outlined {
385 + font-size: 1.05rem;
386 + line-height: 1;
387 + }
388 +
389 + .mcp-add-panel,
390 + .mcp-config-card,
391 + .mcp-status-row,
392 + .mcp-scan-result,
393 + .mcp-empty {
394 + border: 1px solid var(--color-border);
395 + border-radius: 8px;
396 + background: color-mix(in srgb, var(--color-panel) 88%, transparent);
397 + }
398 +
399 + .mcp-add-panel {
400 + display: flex;
401 + flex-direction: column;
402 + gap: 0.85rem;
403 + padding: 1rem;
404 + }
405 +
406 + .mcp-segmented,
407 + .mcp-view-tabs {
408 + display: inline-flex;
409 + width: fit-content;
410 + padding: 0.18rem;
411 + border: 1px solid var(--color-border);
412 + border-radius: 7px;
413 + background: var(--color-input);
414 + }
415 +
416 + .mcp-segmented button,
417 + .mcp-view-tabs button {
418 + display: inline-flex;
419 + align-items: center;
420 + gap: 0.35rem;
421 + min-height: 1.9rem;
422 + padding: 0.3rem 0.65rem;
423 + border: 0;
424 + border-radius: 5px;
425 + background: transparent;
426 + color: var(--color-text-muted);
427 + cursor: pointer;
428 + }
429 +
430 + .mcp-segmented button.active,
431 + .mcp-view-tabs button.active {
432 + background: var(--color-panel);
433 + color: var(--color-text);
434 + }
435 +
436 + .mcp-form-grid {
437 + display: grid;
438 + grid-template-columns: repeat(2, minmax(0, 1fr));
439 + gap: 0.75rem;
440 + }
441 +
442 + .mcp-field {
443 + display: flex;
444 + flex-direction: column;
445 + gap: 0.35rem;
446 + min-width: 0;
447 + }
448 +
449 + .mcp-field span {
450 + color: var(--color-text-muted);
451 + font-size: 0.78rem;
452 + font-weight: 600;
453 + }
454 +
455 + .mcp-field input,
456 + .mcp-field textarea,
457 + .mcp-field select {
458 width: 100%;
94 - height: 40em;
459 + border: 1px solid var(--color-border);
460 + border-radius: 7px;
461 + background: var(--color-input);
462 + color: var(--color-text);
463 + padding: 0.55rem 0.65rem;
464 + font-size: 0.88rem;
465 + outline: none;
466 + }
467 +
468 + .mcp-field textarea {
469 + resize: vertical;
470 + min-height: 5.5rem;
471 + font-family: var(--font-family-code);
472 + font-size: 0.8rem;
473 + }
474 +
475 + .mcp-field input:focus,
476 + .mcp-field textarea:focus,
477 + .mcp-field select:focus {
478 + border-color: var(--color-highlight);
479 + }
480 +
481 + .mcp-advanced-toggle {
482 + display: inline-flex;
483 + align-items: center;
484 + gap: 0.35rem;
485 + width: fit-content;
486 + padding: 0.2rem 0;
487 + border: 0;
488 + background: transparent;
489 + color: var(--color-text);
490 + cursor: pointer;
491 + font-weight: 600;
492 + }
493 +
494 + .mcp-advanced-toggle .material-symbols-outlined {
495 + transition: transform 0.12s ease;
496 + }
497 +
498 + .mcp-advanced-body {
499 + display: flex;
500 + flex-direction: column;
501 + gap: 0.75rem;
502 + }
503 +
504 + .mcp-toggle-row label {
505 + display: inline-flex;
506 + align-items: center;
507 + gap: 0.35rem;
508 + color: var(--color-text-muted);
509 + font-size: 0.85rem;
510 + }
511 +
512 + .mcp-scan-result {
513 + padding: 0.75rem;
514 + }
515 +
516 + .mcp-scan-heading {
517 + display: flex;
518 + align-items: center;
519 + gap: 0.4rem;
520 + font-weight: 700;
521 }
522
97 - .server-list {
98 - margin-top: 0.5em;
99 - margin-bottom: 1em;
523 + .mcp-scan-warnings {
524 + display: flex;
525 + flex-direction: column;
526 + gap: 0.4rem;
527 + margin-top: 0.65rem;
528 }
529
102 - .server-item {
530 + .mcp-scan-warning {
531 display: flex;
532 flex-direction: column;
105 - padding: 0.5em 0.7em;
106 - margin-bottom: 0.4em;
107 - min-height: 2.2em;
108 - /* Ensure consistent height even without errors */
109 - border: 1px solid rgba(192, 192, 192, 0.161);
110 - /* Silver with 30% opacity */
111 - border-radius: 4px;
533 + gap: 0.12rem;
534 + padding: 0.55rem 0.65rem;
535 + border-left: 3px solid var(--color-border);
536 + background: var(--color-input);
537 + border-radius: 5px;
538 + font-size: 0.82rem;
539 + }
540 +
541 + .mcp-scan-warning.warning {
542 + border-left-color: var(--color-warning-text);
543 }
544
114 - .server-list {
115 - margin-top: 0.5em;
116 - margin-bottom: 1em;
545 + .mcp-scan-warning.error {
546 + border-left-color: var(--color-error-text);
547 + }
548 +
549 + .mcp-config-list,
550 + .mcp-status-section,
551 + .mcp-raw-panel {
552 display: flex;
553 flex-direction: column;
119 - gap: 0.2em;
554 + gap: 0.65rem;
555 }
556
122 - .server-main-row {
557 + .mcp-config-card {
558 display: flex;
559 align-items: center;
125 - gap: 0.8em;
126 - width: 100%;
560 + justify-content: space-between;
561 + gap: 1rem;
562 + padding: 0.75rem;
563 + }
564 +
565 + .mcp-config-card.disabled {
566 + opacity: 0.62;
567 }
568
129 - .status-dot {
569 + .mcp-config-card-main {
570 display: flex;
571 + align-items: flex-start;
572 + justify-content: space-between;
573 + gap: 0.75rem;
574 + min-width: 0;
575 + flex: 1;
576 + }
577 +
578 + .mcp-config-name,
579 + .mcp-status-name {
580 + display: flex;
581 + align-items: center;
582 + gap: 0.4rem;
583 + min-width: 0;
584 + font-weight: 700;
585 + }
586 +
587 + .mcp-config-summary {
588 + margin-top: 0.2rem;
589 + overflow-wrap: anywhere;
590 + }
591 +
592 + .mcp-icon-button {
593 + display: inline-flex;
594 align-items: center;
595 justify-content: center;
596 + width: 2rem;
597 + height: 2rem;
598 + border: 1px solid var(--color-border);
599 + border-radius: 6px;
600 + background: var(--color-panel);
601 + color: var(--color-text);
602 + cursor: pointer;
603 }
604
135 - .server-name {
136 - font-weight: 600;
137 - min-width: 12em;
605 + .mcp-icon-button.danger {
606 + color: var(--color-error-text);
607 }
608
140 - .tool-count {
141 - color: var(--c-fg2);
142 - font-size: 0.9em;
143 - user-select: none;
609 + .mcp-raw-toolbar {
610 + align-items: center;
611 }
612
146 - .tool-count {
147 - cursor: default;
613 + #mcp-servers-config-json {
614 + width: 100%;
615 + height: 28rem;
616 + border: 1px solid var(--color-border);
617 + border-radius: 8px;
618 + overflow: hidden;
619 }
620
150 - .tool-count:hover {
151 - opacity: 0.8;
152 - cursor: pointer;
621 + .mcp-status-list {
622 + display: flex;
623 + flex-direction: column;
624 + gap: 0.5rem;
625 }
626
155 - .config-status {
156 - color: #e40138;
157 - font-size: 0.85em;
158 - opacity: 0.8;
627 + .mcp-status-row {
628 + display: grid;
629 + grid-template-columns: minmax(0, 1fr) auto;
630 + gap: 0.6rem 1rem;
631 + padding: 0.75rem;
632 }
633
161 - .log-btn {
162 - margin-left: auto;
163 - font-size: 0.9em;
164 - cursor: pointer;
165 - text-decoration: none;
166 - opacity: 0.85;
634 + .mcp-status-main {
635 + display: flex;
636 + align-items: center;
637 + gap: 0.65rem;
638 + min-width: 0;
639 }
640
169 - .log-btn:hover {
170 - opacity: 1;
641 + .mcp-status-dot {
642 + width: 0.72rem;
643 + height: 0.72rem;
644 + border-radius: 50%;
645 + border: 2px solid var(--color-border);
646 + flex-shrink: 0;
647 }
648
173 - .server-error-row {
174 - margin-left: 1.8em;
175 - margin-top: 0.1em;
176 - font-size: 0.8em;
177 - color: #F44336;
178 - opacity: 0.85;
179 - line-height: 1.2;
649 + .mcp-status-row.ok .mcp-status-dot {
650 + border-color: #22c55e;
651 + background: #22c55e;
652 + }
653 +
654 + .mcp-status-row.idle .mcp-status-dot {
655 + border-color: var(--color-warning-text);
656 + }
657 +
658 + .mcp-status-row.danger .mcp-status-dot {
659 + border-color: var(--color-error-text);
660 + background: var(--color-error-text);
661 + }
662 +
663 + .mcp-status-meta {
664 + display: flex;
665 + flex-wrap: wrap;
666 + gap: 0.35rem 0.7rem;
667 + margin-top: 0.18rem;
668 + }
669 +
670 + .mcp-status-error {
671 + grid-column: 1 / -1;
672 + color: var(--color-error-text);
673 + font-size: 0.8rem;
674 + line-height: 1.35;
675 + overflow-wrap: anywhere;
676 + }
677 +
678 + .mcp-empty {
679 + padding: 0.9rem;
680 + text-align: center;
681 }
682
182 - .no-servers {
183 - padding: 0.5em;
184 - color: var(--c-fg2);
185 - font-style: italic;
683 + @media (max-width: 760px) {
684 + .mcp-manager-header,
685 + .mcp-config-card,
686 + .mcp-status-row {
687 + align-items: stretch;
688 + grid-template-columns: 1fr;
689 + flex-direction: column;
690 + }
691 +
692 + .mcp-form-grid {
693 + grid-template-columns: 1fr;
694 + }
695 +
696 + .mcp-manager-actions,
697 + .mcp-status-actions {
698 + justify-content: flex-start;
699 + }
700 +
701 + .mcp-view-tabs {
702 + width: 100%;
703 + }
704 +
705 + .mcp-view-tabs button {
706 + flex: 1;
707 + justify-content: center;
708 + }
709 }
710 </style>
711
712 </body>
713
191 -</html>
\ No newline at end of file
714 +</html>