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 @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>