Revamp MCP server configuration
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions

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.
This commit is contained in:
Alessandro 2026-06-09 16:07:24 +02:00
parent 77f7aa0274
commit 521172b489
23 changed files with 2066 additions and 368 deletions

View file

@ -9,9 +9,11 @@ class McpServerGetDetail(ApiHandler):
# try:
server_name = input.get("server_name")
project_name = str(input.get("project_name", "") or "").strip()
if not server_name:
return {"success": False, "error": "Missing server_name"}
detail = MCPConfig.get_instance().get_server_detail(server_name)
config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance()
detail = config.get_server_detail(server_name)
return {"success": True, "detail": detail}
# except Exception as e:
# return {"success": False, "error": str(e)}

View file

@ -3,7 +3,7 @@
## Purpose
- Own the `mcp_server_get_detail.py` API endpoint.
- This module handles MCP server server get detail requests.
- This module handles MCP server detail requests for global or project scope.
- Keep this file-level DOX profile synchronized with `mcp_server_get_detail.py` because this directory is intentionally flat.
## Ownership
@ -17,6 +17,7 @@
## Runtime Contracts
- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
- The request accepts `server_name` and optional `project_name`; when `project_name` is present, detail resolves through the project-scoped MCP configuration.
- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
- `McpServerGetDetail` is an `ApiHandler`.
- `McpServerGetDetail` defines `process(...)`.
@ -24,7 +25,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_detail`, `MCPConfig.get_instance`.
- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_detail`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -9,9 +9,11 @@ class McpServerGetLog(ApiHandler):
# try:
server_name = input.get("server_name")
project_name = str(input.get("project_name", "") or "").strip()
if not server_name:
return {"success": False, "error": "Missing server_name"}
log = MCPConfig.get_instance().get_server_log(server_name)
config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance()
log = config.get_server_log(server_name)
return {"success": True, "log": log}
# except Exception as e:
# return {"success": False, "error": str(e)}

View file

@ -3,7 +3,7 @@
## Purpose
- Own the `mcp_server_get_log.py` API endpoint.
- This module handles MCP server server get log requests.
- This module handles MCP server log requests for global or project scope.
- Keep this file-level DOX profile synchronized with `mcp_server_get_log.py` because this directory is intentionally flat.
## Ownership
@ -17,6 +17,7 @@
## Runtime Contracts
- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
- The request accepts `server_name` and optional `project_name`; when `project_name` is present, logs resolve through the project-scoped MCP configuration.
- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
- `McpServerGetLog` is an `ApiHandler`.
- `McpServerGetLog` defines `process(...)`.
@ -24,7 +25,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_log`, `MCPConfig.get_instance`.
- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_log`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

232
api/mcp_server_scan.py Normal file
View file

@ -0,0 +1,232 @@
import asyncio
from shutil import which
from typing import Any
from urllib.parse import urlparse
from helpers.api import ApiHandler, Request, Response
from helpers.mcp_handler import MCPConfig, normalize_name
_PROMPT_INJECTION_MARKERS = (
"ignore previous",
"ignore all previous",
"system prompt",
"developer message",
"hidden instruction",
"exfiltrate",
"leak secret",
"credential",
)
class McpServerScan(ApiHandler):
async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
server = dict(input.get("server") or {})
allow_local_execution = bool(input.get("allow_local_execution", False))
allow_remote_network = bool(input.get("allow_remote_network", False))
inspect_runtime = input.get("inspect_runtime", True) is not False
server = self._normalize_server(server)
warnings = self._static_warnings(server)
is_local = not (server.get("url") or server.get("serverUrl"))
has_static_errors = any(warning.get("level") == "error" for warning in warnings)
runtime_status: list[dict[str, Any]] = []
runtime_detail: dict[str, Any] = {}
runtime_error = ""
should_inspect_runtime = (
inspect_runtime
and not has_static_errors
and ((is_local and allow_local_execution) or (not is_local and allow_remote_network))
)
if should_inspect_runtime:
try:
scan_config = await asyncio.to_thread(
lambda: MCPConfig(servers_list=[server], config_scope="scan")
)
runtime_status = scan_config.get_servers_status()
runtime_detail = scan_config.get_server_detail(server.get("name", ""))
warnings.extend(self._tool_warnings(runtime_detail.get("tools", [])))
except Exception as exc:
runtime_error = str(exc)
warnings.append(
{
"level": "error",
"title": "Runtime inspection failed",
"message": runtime_error,
}
)
elif is_local and inspect_runtime:
warnings.append(
{
"level": "warning",
"title": "Local command not executed",
"message": "Local stdio MCP inspection requires explicit trust because it runs the configured command.",
}
)
elif not is_local and inspect_runtime and has_static_errors:
warnings.append(
{
"level": "info",
"title": "Runtime inspection skipped",
"message": "Fix static scan errors before attempting runtime MCP inspection.",
}
)
elif not is_local and inspect_runtime:
warnings.append(
{
"level": "info",
"title": "Remote runtime inspection skipped",
"message": "Enable trusted remote inspection to contact the MCP URL and list exposed tools.",
}
)
return {
"success": True,
"server": self._redact_server(server),
"risk_level": self._risk_level(warnings),
"warnings": warnings,
"status": runtime_status,
"detail": runtime_detail,
"runtime_error": runtime_error,
}
def _normalize_server(self, server: dict[str, Any]) -> dict[str, Any]:
name = str(server.get("name") or "").strip()
url = str(server.get("url") or server.get("serverUrl") or "").strip()
command = str(server.get("command") or "").strip()
if not name:
name = self._derive_name(url, command)
server["name"] = normalize_name(name or "mcp_server")
if url:
server["url"] = url
server.setdefault("type", "streamable-http")
elif command:
server["command"] = command
server["type"] = "stdio"
return server
def _derive_name(self, url: str, command: str) -> str:
if url:
parsed = urlparse(url)
parts = [part for part in parsed.path.split("/") if part]
return parts[-1] if parts else parsed.hostname or "remote_mcp"
if command:
return command.rsplit("/", 1)[-1]
return "mcp_server"
def _static_warnings(self, server: dict[str, Any]) -> list[dict[str, str]]:
warnings: list[dict[str, str]] = []
url = str(server.get("url") or "").strip()
command = str(server.get("command") or "").strip()
if url:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
warnings.append(
{
"level": "error",
"title": "Unsupported URL scheme",
"message": "Remote MCP URLs should use http or https.",
}
)
elif parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
warnings.append(
{
"level": "warning",
"title": "Unencrypted remote URL",
"message": "Prefer HTTPS for remote MCP servers outside localhost.",
}
)
if not parsed.netloc:
warnings.append(
{
"level": "error",
"title": "Invalid remote URL",
"message": "The remote MCP URL is missing a host.",
}
)
elif command:
if which(command) is None:
warnings.append(
{
"level": "warning",
"title": "Command not found",
"message": f"'{command}' is not currently available on PATH.",
}
)
if command in {"bash", "sh", "zsh", "fish", "python", "python3", "node"}:
warnings.append(
{
"level": "warning",
"title": "General-purpose interpreter",
"message": "Review the command and arguments carefully before running this local MCP server.",
}
)
else:
warnings.append(
{
"level": "error",
"title": "Missing connection target",
"message": "Provide either a remote URL or a local command.",
}
)
if isinstance(server.get("headers"), dict) and server["headers"]:
warnings.append(
{
"level": "info",
"title": "Headers configured",
"message": "Header values are redacted in scan output. Keep tokens in trusted settings only.",
}
)
if isinstance(server.get("env"), dict) and server["env"]:
warnings.append(
{
"level": "info",
"title": "Environment configured",
"message": "Environment values are redacted in scan output. Avoid hardcoding secrets in MCP configs.",
}
)
return warnings
def _tool_warnings(self, tools: Any) -> list[dict[str, str]]:
warnings: list[dict[str, str]] = []
if not isinstance(tools, list):
return warnings
for tool in tools:
if not isinstance(tool, dict):
continue
haystack = f"{tool.get('name', '')}\n{tool.get('description', '')}".lower()
if any(marker in haystack for marker in _PROMPT_INJECTION_MARKERS):
warnings.append(
{
"level": "warning",
"title": "Suspicious tool description",
"message": f"Review tool '{tool.get('name', 'unknown')}' for prompt-injection style language.",
}
)
return warnings
def _redact_server(self, server: dict[str, Any]) -> dict[str, Any]:
redacted = dict(server)
for key in ("headers", "env"):
if isinstance(redacted.get(key), dict):
redacted[key] = {name: "***" for name in redacted[key]}
return redacted
def _risk_level(self, warnings: list[dict[str, str]]) -> str:
levels = {warning.get("level", "info") for warning in warnings}
if "error" in levels:
return "error"
if "warning" in levels:
return "warning"
return "ok"

View file

@ -0,0 +1,45 @@
# mcp_server_scan.py DOX
## Purpose
- Own the `mcp_server_scan.py` API endpoint.
- Provide static and optional runtime inspection for a single MCP server draft before it is added to global or project MCP config.
- Keep this file-level DOX profile synchronized with `mcp_server_scan.py` because this directory is intentionally flat.
## Ownership
- `mcp_server_scan.py` owns the runtime implementation.
- `mcp_server_scan.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
- Classes:
- `McpServerScan` (`ApiHandler`)
- `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response`
## Runtime Contracts
- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
- The request accepts a `server` draft object, `inspect_runtime`, `allow_remote_network`, and `allow_local_execution`.
- 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.
- Local stdio runtime inspection must not execute unless `allow_local_execution` is true.
- Response data redacts `headers` and `env` values.
- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
- Imported dependency areas include: `asyncio`, `helpers.api`, `helpers.mcp_handler`, `shutil`, `typing`, `urllib.parse`.
## Key Concepts
- 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.
- Static errors skip runtime inspection; remote network inspection and local command execution both require explicit trust flags.
- 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.
## Work Guidance
- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes.
- Do not return secret values, raw environment values, or private files.
- Keep scanner warnings explicit about local command execution risk.
## Verification
- Run endpoint-specific or MCP helper tests for changed behavior; smoke-test remote URL and local-command scan paths when practical.
## Child DOX Index
No child DOX files.

View file

@ -5,20 +5,27 @@ from typing import Any
from helpers.mcp_handler import MCPConfig
from helpers.settings import set_settings_delta
from helpers import projects
class McpServersApply(ApiHandler):
async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
mcp_servers = input["mcp_servers"]
project_name = str(input.get("project_name", "") or "").strip()
try:
# MCPConfig.update(mcp_servers) # done in settings automatically
set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization
set_settings_delta({"mcp_servers": mcp_servers})
if project_name:
projects.save_project_mcp_servers(project_name, mcp_servers)
config = MCPConfig.refresh_project(project_name)
else:
# MCPConfig.update(mcp_servers) # done in settings automatically
set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization
set_settings_delta({"mcp_servers": mcp_servers})
time.sleep(1) # wait at least a second
# MCPConfig.wait_for_lock() # wait until config lock is released
status = MCPConfig.get_instance().get_servers_status()
return {"success": True, "status": status}
time.sleep(1) # wait at least a second
# MCPConfig.wait_for_lock() # wait until config lock is released
config = MCPConfig.get_instance()
status = config.get_servers_status()
return {"success": True, "status": status, "mcp_servers": mcp_servers, "project_name": project_name}
except Exception as e:
return {"success": False, "error": str(e)}

View file

@ -3,7 +3,7 @@
## Purpose
- Own the `mcp_servers_apply.py` API endpoint.
- This module handles MCP server servers apply requests.
- This module handles MCP servers apply requests for global or project scope.
- Keep this file-level DOX profile synchronized with `mcp_servers_apply.py` because this directory is intentionally flat.
## Ownership
@ -17,15 +17,18 @@
## Runtime Contracts
- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
- The request accepts `config` and optional `project_name`.
- Without `project_name`, the endpoint persists global `mcp_servers_config` through settings and refreshes the global `MCPConfig`.
- 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.
- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
- `McpServersApply` is an `ApiHandler`.
- `McpServersApply` defines `process(...)`.
- Observed side-effect areas: settings/state persistence.
- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `helpers.settings`, `time`, `typing`.
- Observed side-effect areas: filesystem writes, settings/state persistence.
- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `helpers.projects`, `helpers.settings`, `time`, `typing`.
## Key Concepts
- Important called helpers/classes observed in the source: `set_settings_delta`, `time.sleep`, `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`.
- 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`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -9,7 +9,9 @@ class McpServersStatuss(ApiHandler):
async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
# try:
status = MCPConfig.get_instance().get_servers_status()
project_name = (input or {}).get("project_name") if isinstance(input, dict) else None
config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance()
status = config.get_servers_status()
return {"success": True, "status": status}
# except Exception as e:
# return {"success": False, "error": str(e)}

View file

@ -3,7 +3,7 @@
## Purpose
- Own the `mcp_servers_status.py` API endpoint.
- This module handles MCP server servers status requests.
- This module handles MCP servers status requests for global or project scope.
- Keep this file-level DOX profile synchronized with `mcp_servers_status.py` because this directory is intentionally flat.
## Ownership
@ -17,6 +17,7 @@
## Runtime Contracts
- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`.
- The request accepts optional `project_name`; when present, status resolves through the merged project-scoped MCP configuration.
- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
- `McpServersStatuss` is an `ApiHandler`.
- `McpServersStatuss` defines `process(...)`.
@ -24,7 +25,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`.
- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -22,7 +22,7 @@ class MCPToolsPrompt(Extension):
@extensible
async def build_prompt(agent: Agent) -> str:
mcp_config = MCPConfig.get_instance()
mcp_config = MCPConfig.get_for_agent(agent)
if not mcp_config.servers:
return ""

View file

@ -47,6 +47,7 @@ from helpers.tool import Tool, Response
MCP_MEDIA_TOKENS_ESTIMATE = 1500
MAX_MCP_RESOURCE_TEXT_CHARS = 12_000
DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
def _mcp_get(item: Any, key: str, default: Any = None) -> Any:
@ -92,6 +93,16 @@ def _is_streaming_http_type(server_type: str) -> bool:
return server_type.lower() in ["http-stream", "streaming-http", "streamable-http", "http-streaming"]
def _split_qualified_tool_name(tool_name: str) -> tuple[str, str]:
"""Split Agent Zero's server.tool MCP name while preserving dots in MCP tool names."""
if "." not in tool_name:
raise ValueError(f"Tool {tool_name} not found")
server_name_part, tool_name_part = tool_name.split(".", 1)
if not server_name_part or not tool_name_part:
raise ValueError(f"Tool {tool_name} not found")
return server_name_part, tool_name_part
def initialize_mcp(mcp_servers_config: str):
if not MCPConfig.get_instance().is_initialized():
try:
@ -344,7 +355,7 @@ class MCPTool(Tool):
error = ""
additional: dict[str, Any] | None = None
try:
response: CallToolResult = await MCPConfig.get_instance().call_tool(
response: CallToolResult = await MCPConfig.get_for_agent(self.agent).call_tool(
self.name, kwargs
)
message, additional = self._format_tool_result(response)
@ -439,6 +450,7 @@ class MCPServerRemote(BaseModel):
tool_timeout: int = Field(default=0)
verify: bool = Field(default=True, description="Verify SSL certificates")
disabled: bool = Field(default=False)
scope: str = Field(default="global")
__lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
__client: Optional["MCPClientRemote"] = PrivateAttr(default=None)
@ -459,7 +471,7 @@ class MCPServerRemote(BaseModel):
def get_tools(self) -> List[dict[str, Any]]:
"""Get all tools from the server"""
with self.__lock:
return self.__client.tools # type: ignore
return self.__client.get_tools() # type: ignore
def has_tool(self, tool_name: str) -> bool:
"""Check if a tool is available"""
@ -470,9 +482,10 @@ class MCPServerRemote(BaseModel):
self, tool_name: str, input_data: Dict[str, Any]
) -> CallToolResult:
"""Call a tool with the given input data"""
with self.__lock:
# We already run in an event loop, dont believe Pylance
return await self.__client.call_tool(tool_name, input_data) # type: ignore
client = self.__client
if client is None:
raise RuntimeError("MCP remote client is not initialized")
return await client.call_tool(tool_name, input_data)
def update(self, config: dict[str, Any]) -> "MCPServerRemote":
with self.__lock:
@ -488,6 +501,7 @@ class MCPServerRemote(BaseModel):
"tool_timeout",
"disabled",
"verify",
"scope",
]:
if key == "name":
value = normalize_name(value)
@ -517,6 +531,7 @@ class MCPServerLocal(BaseModel):
tool_timeout: int = Field(default=0)
verify: bool = Field(default=True, description="Verify SSL certificates")
disabled: bool = Field(default=False)
scope: str = Field(default="global")
__lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
__client: Optional["MCPClientLocal"] = PrivateAttr(default=None)
@ -537,7 +552,7 @@ class MCPServerLocal(BaseModel):
def get_tools(self) -> List[dict[str, Any]]:
"""Get all tools from the server"""
with self.__lock:
return self.__client.tools # type: ignore
return self.__client.get_tools() # type: ignore
def has_tool(self, tool_name: str) -> bool:
"""Check if a tool is available"""
@ -548,9 +563,10 @@ class MCPServerLocal(BaseModel):
self, tool_name: str, input_data: Dict[str, Any]
) -> CallToolResult:
"""Call a tool with the given input data"""
with self.__lock:
# We already run in an event loop, dont believe Pylance
return await self.__client.call_tool(tool_name, input_data) # type: ignore
client = self.__client
if client is None:
raise RuntimeError("MCP local client is not initialized")
return await client.call_tool(tool_name, input_data)
def update(self, config: dict[str, Any]) -> "MCPServerLocal":
with self.__lock:
@ -567,6 +583,7 @@ class MCPServerLocal(BaseModel):
"init_timeout",
"tool_timeout",
"disabled",
"scope",
]:
if key == "name":
value = normalize_name(value)
@ -590,17 +607,137 @@ MCPServer = Annotated[
class MCPConfig(BaseModel):
servers: list[MCPServer] = Field(default_factory=list)
disconnected_servers: list[dict[str, Any]] = Field(default_factory=list)
config_scope: str = Field(default="global")
__lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
__instance: ClassVar[Any] = PrivateAttr(default=None)
__initialized: ClassVar[bool] = PrivateAttr(default=False)
__project_instances: ClassVar[dict[str, tuple[str, "MCPConfig"]]] = {}
@classmethod
def get_instance(cls) -> "MCPConfig":
# with cls.__lock:
if cls.__instance is None:
cls.__instance = cls(servers_list=[])
cls.__instance = cls(servers_list=[], config_scope="global")
return cls.__instance
@classmethod
def clear_project_instances(cls):
with cls.__lock:
cls.__project_instances = {}
@classmethod
def parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]:
servers_data: List[Dict[str, Any]] = []
if not (config_str and config_str.strip()):
return servers_data
try:
parsed_value = dirty_json.try_parse(config_str)
normalized = cls.normalize_config(parsed_value)
if isinstance(normalized, list):
for item in normalized:
if isinstance(item, dict):
servers_data.append(dict(item))
else:
PrintStyle(
background_color="yellow",
font_color="black",
padding=True,
).print(
f"Warning: MCP config item was not a dictionary and was ignored: {item}"
)
else:
PrintStyle(
background_color="red", font_color="white", padding=True
).print(
f"Error: Parsed MCP config top-level structure is not a list. Config string was: '{config_str}'"
)
except Exception as e_json:
PrintStyle.error(
f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'"
)
return servers_data
@classmethod
def merge_config_strings(
cls, global_config: str, project_config: str
) -> tuple[list[dict[str, Any]], str]:
merged: dict[str, dict[str, Any]] = {}
unnamed: list[dict[str, Any]] = []
def add_servers(config_str: str, scope: str):
for server in cls.parse_config_string(config_str):
server_copy = dict(server)
server_copy["scope"] = scope
name = str(server_copy.get("name", "") or "").strip()
if not name:
unnamed.append(server_copy)
continue
normalized_name = normalize_name(name)
server_copy["name"] = normalized_name
merged[normalized_name] = server_copy
add_servers(global_config or DEFAULT_MCP_SERVERS_CONFIG, "global")
add_servers(project_config or DEFAULT_MCP_SERVERS_CONFIG, "project")
servers = [*unnamed, *merged.values()]
cache_key = dirty_json.stringify(
{
"mcpServers": {
s.get("name", f"unnamed_{i}"): s
for i, s in enumerate(servers)
}
}
)
return servers, cache_key
@classmethod
def get_project_instance(cls, project_name: str | None, *, force: bool = False) -> "MCPConfig":
project_key = str(project_name or "").strip()
if not project_key:
return cls.get_instance()
from helpers import projects
project_key = projects.validate_project_name(project_key)
global_config = settings.get_settings().get(
"mcp_servers", DEFAULT_MCP_SERVERS_CONFIG
)
project_config = projects.load_project_mcp_servers(project_key)
servers_data, cache_key = cls.merge_config_strings(global_config, project_config)
with cls.__lock:
cached = cls.__project_instances.get(project_key)
if cached and cached[0] == cache_key and not force:
return cached[1]
instance = cls(servers_list=servers_data, config_scope=f"project:{project_key}")
with cls.__lock:
cls.__project_instances[project_key] = (cache_key, instance)
return instance
@classmethod
def refresh_project(cls, project_name: str) -> "MCPConfig":
project_key = str(project_name or "").strip()
with cls.__lock:
cls.__project_instances.pop(project_key, None)
return cls.get_project_instance(project_key, force=True)
@classmethod
def get_for_agent(cls, agent: Any) -> "MCPConfig":
try:
from helpers import projects
project_name = projects.get_context_project_name(agent.context)
if project_name:
return cls.get_project_instance(project_name)
except Exception:
pass
return cls.get_instance()
@classmethod
def wait_for_lock(cls):
with cls.__lock:
@ -609,70 +746,7 @@ class MCPConfig(BaseModel):
@classmethod
def update(cls, config_str: str) -> Any:
with cls.__lock:
servers_data: List[Dict[str, Any]] = [] # Default to empty list
if (
config_str and config_str.strip()
): # Only parse if non-empty and not just whitespace
try:
# Try with standard json.loads first, as it should handle escaped strings correctly
parsed_value = dirty_json.try_parse(config_str)
normalized = cls.normalize_config(parsed_value)
if isinstance(normalized, list):
valid_servers = []
for item in normalized:
if isinstance(item, dict):
valid_servers.append(item)
else:
PrintStyle(
background_color="yellow",
font_color="black",
padding=True,
).print(
f"Warning: MCP config item (from json.loads) was not a dictionary and was ignored: {item}"
)
servers_data = valid_servers
else:
PrintStyle(
background_color="red", font_color="white", padding=True
).print(
f"Error: Parsed MCP config (from json.loads) top-level structure is not a list. Config string was: '{config_str}'"
)
# servers_data remains empty
except (
Exception
) as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception
PrintStyle.error(
f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'"
)
# # Fallback to DirtyJson or log error if standard json.loads fails
# PrintStyle(background_color="orange", font_color="black", padding=True).print(
# f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback."
# )
# try:
# parsed_value = DirtyJson.parse_string(config_str)
# if isinstance(parsed_value, list):
# valid_servers = []
# for item in parsed_value:
# if isinstance(item, dict):
# valid_servers.append(item)
# else:
# PrintStyle(background_color="yellow", font_color="black", padding=True).print(
# f"Warning: MCP config item (from DirtyJson) was not a dictionary and was ignored: {item}"
# )
# servers_data = valid_servers
# else:
# PrintStyle(background_color="red", font_color="white", padding=True).print(
# f"Error: Parsed MCP config (from DirtyJson) top-level structure is not a list. Config string was: '{config_str}'"
# )
# # servers_data remains empty
# except Exception as e_dirty:
# PrintStyle(background_color="red", font_color="white", padding=True).print(
# f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'"
# )
# # servers_data remains empty, allowing graceful degradation
servers_data = cls.parse_config_string(config_str)
# Initialize/update the singleton instance with the (potentially empty) list of server data
instance = cls.get_instance()
@ -683,7 +757,8 @@ class MCPConfig(BaseModel):
} # Prepare data for re-initialization or update
# Option 1: Re-initialize the existing instance (if __init__ is idempotent for other fields)
instance.__init__(servers_list=servers_data)
instance.__init__(servers_list=servers_data, config_scope="global")
cls.__project_instances = {}
# Option 2: Or, if __init__ has side effects we don't want to repeat,
# and 'servers' is the primary thing 'update' changes:
@ -708,23 +783,24 @@ class MCPConfig(BaseModel):
if isinstance(servers, list):
for server in servers:
if isinstance(server, dict):
normalized.append(server)
normalized.append(dict(server))
elif isinstance(servers, dict):
if "mcpServers" in servers:
if isinstance(servers["mcpServers"], dict):
for key, value in servers["mcpServers"].items():
if isinstance(value, dict):
value["name"] = key
normalized.append(value)
server = dict(value)
server["name"] = key
normalized.append(server)
elif isinstance(servers["mcpServers"], list):
for server in servers["mcpServers"]:
if isinstance(server, dict):
normalized.append(server)
normalized.append(dict(server))
else:
normalized.append(servers) # single server?
normalized.append(dict(servers)) # single server?
return normalized
def __init__(self, servers_list: List[Dict[str, Any]]):
def __init__(self, servers_list: List[Dict[str, Any]], config_scope: str = "global"):
from collections.abc import Mapping, Iterable
# # DEBUG: Print the received servers_list
@ -741,6 +817,7 @@ class MCPConfig(BaseModel):
# Clear any servers potentially initialized by super().__init__() before we populate based on servers_list
self.servers = []
self.config_scope = config_scope
# initialize failed servers list
self.disconnected_servers = []
@ -867,10 +944,10 @@ class MCPConfig(BaseModel):
name = server.name
# get tool count
tool_count = len(server.get_tools())
# check if server is connected
connected = True # tool_count > 0
# get error message if any
error = server.get_error()
# A server object can exist while its initialization failed.
connected = not bool(error)
# get log bool
has_log = server.get_log() != ""
@ -878,6 +955,9 @@ class MCPConfig(BaseModel):
result.append(
{
"name": name,
"scope": getattr(server, "scope", self.config_scope),
"type": getattr(server, "type", ""),
"description": getattr(server, "description", ""),
"connected": connected,
"error": error,
"tool_count": tool_count,
@ -890,6 +970,9 @@ class MCPConfig(BaseModel):
result.append(
{
"name": disconnected["name"],
"scope": disconnected.get("config", {}).get("scope", self.config_scope),
"type": disconnected.get("config", {}).get("type", ""),
"description": disconnected.get("config", {}).get("description", ""),
"connected": False,
"error": disconnected["error"],
"tool_count": 0,
@ -910,6 +993,8 @@ class MCPConfig(BaseModel):
return {
"name": server.name,
"description": server.description,
"scope": getattr(server, "scope", self.config_scope),
"type": getattr(server, "type", ""),
"tools": tools,
}
return {}
@ -986,9 +1071,10 @@ class MCPConfig(BaseModel):
def has_tool(self, tool_name: str) -> bool:
"""Check if a tool is available"""
if "." not in tool_name:
try:
server_name_part, tool_name_part = _split_qualified_tool_name(tool_name)
except ValueError:
return False
server_name_part, tool_name_part = tool_name.split(".")
with self.__lock:
for server in self.servers:
if server.name == server_name_part:
@ -996,6 +1082,9 @@ class MCPConfig(BaseModel):
return False
def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None:
effective_config = MCPConfig.get_for_agent(agent)
if effective_config is not self:
return effective_config.get_tool(agent, tool_name)
if not self.has_tool(tool_name):
return None
return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="", loop_data=None)
@ -1004,9 +1093,7 @@ class MCPConfig(BaseModel):
self, tool_name: str, input_data: Dict[str, Any]
) -> CallToolResult:
"""Call a tool with the given input data"""
if "." not in tool_name:
raise ValueError(f"Tool {tool_name} not found")
server_name_part, tool_name_part = tool_name.split(".")
server_name_part, tool_name_part = _split_qualified_tool_name(tool_name)
with self.__lock:
for server in self.servers:
if server.name == server_name_part and server.has_tool(tool_name_part):
@ -1119,16 +1206,21 @@ class MCPClientBase(ABC):
}
for tool in response.tools
]
self.error = ""
PrintStyle(font_color="green").print(
f"MCPClientBase ({self.server.name}): Tools updated. Found {len(self.tools)} tools."
)
try:
set = settings.get_settings()
current_settings = settings.get_settings()
init_timeout = (
self.server.init_timeout
or current_settings.get("mcp_client_init_timeout", 10)
or 10
)
await self._execute_with_session(
list_tools_op,
read_timeout_seconds=self.server.init_timeout
or set["mcp_client_init_timeout"],
read_timeout_seconds=init_timeout,
)
except Exception as e:
# e = eg.exceptions[0]
@ -1155,7 +1247,7 @@ class MCPClientBase(ABC):
def get_tools(self) -> List[dict[str, Any]]:
"""Get all tools from the server (uses cached tools)"""
with self.__lock:
return self.tools
return [dict(tool) for tool in self.tools]
async def call_tool(
self, tool_name: str, input_data: Dict[str, Any]
@ -1177,19 +1269,28 @@ class MCPClientBase(ABC):
f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools."
)
current_settings = settings.get_settings()
tool_timeout = (
self.server.tool_timeout
or current_settings.get("mcp_client_tool_timeout", 120)
or 120
)
async def call_tool_op(current_session: ClientSession):
set = settings.get_settings()
# PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...")
response: CallToolResult = await current_session.call_tool(
tool_name,
input_data,
read_timeout_seconds=timedelta(seconds=set["mcp_client_tool_timeout"]),
read_timeout_seconds=timedelta(seconds=tool_timeout),
)
# PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.")
return response
try:
return await self._execute_with_session(call_tool_op)
return await self._execute_with_session(
call_tool_op,
read_timeout_seconds=tool_timeout,
)
except Exception as e:
# Error logged by _execute_with_session. Re-raise a specific error for the caller.
PrintStyle(
@ -1304,11 +1405,19 @@ class MCPClientRemote(MCPClientBase):
]:
"""Connect to an MCP server, init client and save stdio/write streams"""
server: MCPServerRemote = cast(MCPServerRemote, self.server)
set = settings.get_settings()
current_settings = settings.get_settings()
# Resolve timeout: check server config first, then settings, defaulting to 5s/10s
init_timeout = server.init_timeout or set["mcp_client_init_timeout"] or 5
tool_timeout = server.tool_timeout or set["mcp_client_tool_timeout"] or 10
init_timeout = (
server.init_timeout
or current_settings.get("mcp_client_init_timeout", 10)
or 10
)
tool_timeout = (
server.tool_timeout
or current_settings.get("mcp_client_tool_timeout", 120)
or 120
)
client_factory = CustomHTTPClientFactory(verify=server.verify)
# Check if this is a streaming HTTP type

View file

@ -3,7 +3,7 @@
## Purpose
- Own the `mcp_handler.py` helper module.
- This module loads MCP server configuration and exposes MCP tools to agents.
- This module loads global and project-scoped MCP server configuration and exposes MCP tools to agents.
- Keep this file-level DOX profile synchronized with `mcp_handler.py` because this directory is intentionally flat.
## Ownership
@ -34,6 +34,12 @@
- `async initialize(self) -> 'MCPServerLocal'`
- `MCPConfig` (`BaseModel`)
- `get_instance(cls) -> 'MCPConfig'`
- `clear_project_instances(cls)`
- `parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]`
- `merge_config_strings(cls, global_config: str, project_config: str) -> tuple[List[Dict[str, Any]], str]`
- `get_project_instance(cls, project_name: str | None, *, force: bool = False) -> 'MCPConfig'`
- `refresh_project(cls, project_name: str) -> 'MCPConfig'`
- `get_for_agent(cls, agent: Any) -> 'MCPConfig'`
- `wait_for_lock(cls)`
- `update(cls, config_str: str) -> Any`
- `normalize_config(cls, servers: Any)`
@ -56,8 +62,9 @@
- `normalize_name(name: str) -> str`
- `_determine_server_type(config_dict: dict) -> str`: Determine the server type based on configuration, with backward compatibility.
- `_is_streaming_http_type(server_type: str) -> bool`: Check if the server type is a streaming HTTP variant.
- `_split_qualified_tool_name(tool_name: str) -> tuple[str, str]`: Split `server.tool` names while preserving dots inside MCP tool names.
- `initialize_mcp(mcp_servers_config: str)`
- Notable constants/configuration names: `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `T`.
- Notable constants/configuration names: `DEFAULT_MCP_SERVERS_CONFIG`, `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `T`.
## Runtime Contracts
@ -65,12 +72,18 @@
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
- `MCPTool` is a `Tool`.
- `MCPTool` defines `execute(...)`.
- 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`.
- 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.
- Server status and detail responses include `scope`, and MCP tools resolve through `MCPConfig.get_for_agent(agent)` before execution.
- MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
- Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
- Server status marks initialized server objects with cached initialization errors as disconnected, even if the config object exists.
- Observed side-effect areas: filesystem writes, network calls, WebSocket state, settings/state persistence, secret handling.
- 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`.
## Key Concepts
- 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`.
- 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`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -13,7 +13,9 @@ PROJECT_META_DIR = ".a0proj"
PROJECT_INSTRUCTIONS_DIR = "instructions"
PROJECT_KNOWLEDGE_DIR = "knowledge"
PROJECT_HEADER_FILE = "project.json"
PROJECT_MCP_SERVERS_FILE = "mcp_servers.json"
PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md")
DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
CONTEXT_DATA_KEY_PROJECT = "project"
@ -34,6 +36,7 @@ class BasicProjectData(TypedDict):
description: str
instructions: str
include_agents_md: NotRequired[bool]
mcp_servers: NotRequired[str]
color: str
git_url: str
file_structure: FileStructureInjectionSettings
@ -53,6 +56,7 @@ class EditProjectData(BasicProjectData):
knowledge_files_count: int
variables: str
secrets: str
mcp_servers: str
subagents: dict[str, SubAgentSettings]
git_status: GitStatusData
@ -70,6 +74,17 @@ def get_project_meta(name: str, *sub_dirs: str):
return files.get_abs_path(get_project_folder(name), PROJECT_META_DIR, *sub_dirs)
def validate_project_name(name: str | None) -> str:
candidate = str(name or "").strip()
if (
not candidate
or candidate in {".", ".."}
or os.path.basename(candidate) != candidate
):
raise ValueError("Invalid project name")
return candidate
def delete_project(name: str):
abs_path = files.get_abs_path(PROJECTS_PARENT_DIR, name)
files.delete_dir(abs_path)
@ -79,12 +94,14 @@ def delete_project(name: str):
def create_project(name: str, data: BasicProjectData):
llm_data = data.get("llm") if isinstance(data, dict) else None
mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
abs_path = files.create_dir_safe(
files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
)
create_project_meta_folders(name)
data = _normalizeBasicData(data)
save_project_header(name, data)
save_project_mcp_servers(name, mcp_servers or DEFAULT_MCP_SERVERS_CONFIG)
save_project_llm_settings(name, llm_data)
return name
@ -94,6 +111,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
from helpers import git
llm_data = data.get("llm") if isinstance(data, dict) else None
mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None
abs_path = files.create_dir_safe(
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
data["git_url"] = clean_url
save_project_header(actual_name, data)
if mcp_servers:
save_project_mcp_servers(actual_name, mcp_servers)
save_project_llm_settings(actual_name, llm_data)
return actual_name
@ -183,6 +203,7 @@ def _normalizeEditData(data: EditProjectData) -> EditProjectData:
data.get("include_agents_md", True)
),
"variables": data.get("variables", ""),
"mcp_servers": data.get("mcp_servers", DEFAULT_MCP_SERVERS_CONFIG),
"color": data.get("color", ""),
"git_url": data.get("git_url", ""),
"git_status": data.get("git_status", {"is_git_repo": False}),
@ -234,6 +255,7 @@ def update_project(name: str, data: EditProjectData):
# save secrets
save_project_variables(name, current["variables"])
save_project_secrets(name, current["secrets"])
save_project_mcp_servers(name, current["mcp_servers"])
save_project_subagents(name, current["subagents"])
save_project_llm_settings(name, llm_data)
@ -253,6 +275,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
data = load_basic_project_data(name)
additional_instructions = get_additional_instructions_files(name)
variables = load_project_variables(name)
mcp_servers = load_project_mcp_servers(name)
secrets = load_project_secrets_masked(name)
subagents = load_project_subagents(name)
knowledge_files_count = get_knowledge_files_count(name)
@ -266,6 +289,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
"instruction_files_count": len(additional_instructions),
"knowledge_files_count": knowledge_files_count,
"variables": variables,
"mcp_servers": mcp_servers,
"secrets": secrets,
"subagents": subagents,
"git_status": git_status,
@ -335,6 +359,20 @@ def save_project_llm_settings(name: str, llm_data: object):
plugins.save_plugin_config("_model_config", name, "", config_to_save)
def load_project_mcp_servers(name: str) -> str:
project_name = validate_project_name(name)
try:
return files.read_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE))
except Exception:
return DEFAULT_MCP_SERVERS_CONFIG
def save_project_mcp_servers(name: str, mcp_servers: str):
project_name = validate_project_name(name)
content = mcp_servers if isinstance(mcp_servers, str) else DEFAULT_MCP_SERVERS_CONFIG
files.write_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE), content)
def get_active_projects_list():
return _get_projects_list(get_projects_parent_folder())

View file

@ -3,7 +3,7 @@
## Purpose
- Own the `projects.py` helper module.
- This module owns project metadata, workspace creation, Git status, and project-scoped settings.
- This module owns project metadata, workspace creation, Git status, and project-scoped settings including per-project MCP server config.
- Keep this file-level DOX profile synchronized with `projects.py` because this directory is intentionally flat.
## Ownership
@ -20,6 +20,7 @@
- `get_projects_parent_folder()`
- `get_project_folder(name: str)`
- `get_project_meta(name: str, *sub_dirs)`
- `validate_project_name(name: str | None) -> str`
- `delete_project(name: str)`
- `create_project(name: str, data: BasicProjectData)`
- `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 @@
- `save_project_header(name: str, data: BasicProjectData)`
- `load_project_llm_data(name: str) -> dict`
- `save_project_llm_settings(name: str, llm_data: object)`
- `load_project_mcp_servers(name: str) -> str`
- `save_project_mcp_servers(name: str, mcp_servers: str)`
- `get_active_projects_list()`
- `_get_projects_list(parent_dir)`
- `activate_project(context_id: str, name: str, mark_dirty: bool=...)`
@ -47,18 +50,21 @@
- `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None`
- `_format_project_instruction_files(instruction_files: list[tuple[str, str]]) -> str`
- `_normalize_include_agents_md(value: object) -> bool`
- 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`.
- 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`.
## Runtime Contracts
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
- 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.
- Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
- Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
- Imported dependency areas include: `helpers`, `helpers.print_style`, `os`, `typing`.
## Key Concepts
- 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`.
- 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`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -153,6 +153,109 @@ def _agent_recorder(context_id: str = "ctx-mcp"):
return agent, log, tool_results, messages, updates, warnings
def test_mcp_config_preserves_dotted_tool_names(mcp_handler_module):
module, _tmp_path = mcp_handler_module
called: list[tuple[str, dict]] = []
class _FakeServer:
name = "server"
description = "Fake MCP server"
type = "stdio"
scope = "global"
def get_tools(self):
return [
{
"name": "alpha.beta",
"description": "Dotted MCP tool",
"input_schema": {},
}
]
def has_tool(self, tool_name):
return tool_name == "alpha.beta"
async def call_tool(self, tool_name, input_data):
called.append((tool_name, input_data))
return _FakeCallToolResult(content=[], isError=False)
def get_error(self):
return ""
def get_log(self):
return ""
config = module.MCPConfig(servers_list=[])
config.servers = [_FakeServer()]
assert config.has_tool("server.alpha.beta") is True
asyncio.run(config.call_tool("server.alpha.beta", {"value": 7}))
assert called == [("alpha.beta", {"value": 7})]
def test_mcp_status_marks_servers_with_errors_disconnected(mcp_handler_module):
module, _tmp_path = mcp_handler_module
class _FakeServer:
name = "broken"
description = "Broken MCP server"
type = "stdio"
scope = "global"
def get_tools(self):
return []
def get_error(self):
return "Failed to initialize"
def get_log(self):
return "stderr"
config = module.MCPConfig(servers_list=[])
config.servers = [_FakeServer()]
status = config.get_servers_status()
assert status[0]["connected"] is False
assert status[0]["error"] == "Failed to initialize"
assert status[0]["has_log"] is True
def test_mcp_client_call_tool_uses_server_tool_timeout(mcp_handler_module, monkeypatch):
module, _tmp_path = mcp_handler_module
session_timeouts = []
call_timeouts = []
monkeypatch.setattr(
module.settings,
"get_settings",
lambda: {"mcp_client_init_timeout": 10, "mcp_client_tool_timeout": 120},
raising=False,
)
class _FakeSession:
async def call_tool(self, tool_name, input_data, read_timeout_seconds=None):
call_timeouts.append(read_timeout_seconds)
return _FakeCallToolResult(content=[], isError=False)
class _FakeClient(module.MCPClientBase):
async def _create_stdio_transport(self, current_exit_stack):
raise AssertionError("transport should be bypassed by fake session")
async def _execute_with_session(self, coro_func, read_timeout_seconds=60):
session_timeouts.append(read_timeout_seconds)
return await coro_func(_FakeSession())
client = _FakeClient(SimpleNamespace(name="server", tool_timeout=7, init_timeout=0))
client.tools = [{"name": "run"}]
asyncio.run(client.call_tool("run", {"x": 1}))
assert session_timeouts == [7]
assert call_timeouts[0].total_seconds() == 7
def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module, monkeypatch):
module, _tmp_path = mcp_handler_module
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,
async def call_tool(self, name, kwargs):
return result
monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
tool = module.MCPTool(
agent=agent,
@ -209,7 +312,7 @@ def test_mcp_audio_content_is_saved_instead_of_discarded(mcp_handler_module, mon
async def call_tool(self, name, kwargs):
return result
monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
tool = module.MCPTool(
agent=agent,
@ -259,7 +362,7 @@ def test_mcp_image_resource_blob_becomes_history_image_attachment(mcp_handler_mo
async def call_tool(self, name, kwargs):
return result
monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
tool = module.MCPTool(
agent=agent,
@ -308,7 +411,7 @@ def test_mcp_resource_text_is_preserved(mcp_handler_module, monkeypatch):
async def call_tool(self, name, kwargs):
return result
monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig())
tool = module.MCPTool(
agent=agent,

View file

@ -6,6 +6,8 @@ from helpers import dirty_json, files, projects
def _prepare_project_tree(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
(tmp_path / "usr" / "projects").mkdir(parents=True, exist_ok=True)
(tmp_path / "usr" / "plugins").mkdir(parents=True, exist_ok=True)
(tmp_path / "plugins").mkdir(parents=True, exist_ok=True)
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
assert saved["include_agents_md"] is True
def test_project_mcp_servers_persist_in_project_meta(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
config = '{"mcpServers":{"demo":{"url":"https://example.com/mcp"}}}'
projects.create_project(
"demo",
{
"title": "Demo",
"mcp_servers": config,
},
)
assert projects.load_project_mcp_servers("demo") == config
assert projects.load_edit_project_data("demo")["mcp_servers"] == config
updated = '{"mcpServers":{"other":{"command":"uvx","args":["pkg"]}}}'
projects.save_project_mcp_servers("demo", updated)
assert projects.load_project_mcp_servers("demo") == updated
def test_project_mcp_servers_reject_path_names(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
for name in ("../escape", "nested/project", ".", "..", ""):
try:
projects.save_project_mcp_servers(name, '{"mcpServers":{}}')
except ValueError:
pass
else:
raise AssertionError(f"Expected invalid project name: {name!r}")
def test_project_system_prompt_includes_root_agents_md_with_path(monkeypatch, tmp_path):
_prepare_project_tree(monkeypatch, tmp_path)
projects.create_project(

View file

@ -6,6 +6,7 @@
import { store as historyStore } from "/components/modals/history/history-store.js";
import { store as contextStore } from "/components/modals/context/context-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as mcpServersStore } from "/components/settings/mcp/client/mcp-servers-store.js";
</script>
</head>
<body>
@ -48,6 +49,15 @@
<span>Attach folder</span>
</button>
<button
type="button"
class="chat-bottom-menu-item"
@click="$store.mcpServersStore.openFromComposer($store.chats.selectedContext?.project?.name || ''); $store.chatInput.closeChatMoreMenu()"
>
<span class="material-symbols-outlined" aria-hidden="true">hub</span>
<span>MCP Servers</span>
</button>
<button
type="button"
class="chat-bottom-menu-item"

View file

@ -2,7 +2,7 @@
## Purpose
- Own WebUI project creation, selection, editing, secrets, model, skill, and file-structure components.
- Own WebUI project creation, selection, editing, secrets, model, skill, MCP server, and file-structure components.
## Ownership
@ -15,7 +15,7 @@
- Keep project API payloads synchronized with backend project handlers.
- Do not expose project secrets in logs, URLs, or long-lived frontend state unnecessarily.
- Preserve scoped settings interactions with plugins, models, and skills.
- Preserve scoped settings interactions with plugins, models, skills, and MCP servers.
## Work Guidance
@ -23,7 +23,7 @@
## Verification
- Smoke-test create, select, edit, secrets, LLM, skills, and file-structure flows after changes.
- Smoke-test create, select, edit, secrets, LLM, skills, MCP servers, and file-structure flows after changes.
## Child DOX Index

View file

@ -0,0 +1,75 @@
<html>
<head>
<title>Project MCP servers</title>
<script type="module">
import { store as mcpServersStore } from "/components/settings/mcp/client/mcp-servers-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.projects && $store.projects.selectedProject && $store.mcpServersStore">
<div class="project-mcp-section">
<div>
<p class="project-mcp-description">
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.
</p>
<p class="project-mcp-count">
<span x-text="$store.mcpServersStore.countServersInConfig($store.projects.selectedProject.mcp_servers)"></span>
<span>project servers configured</span>
</p>
</div>
<button type="button" class="button" @click="$store.mcpServersStore.openProjectConfig($store.projects.selectedProject)">
<span class="icon material-symbols-outlined">hub</span>
MCP Servers
</button>
</div>
</template>
</div>
<style>
.project-mcp-section {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.project-mcp-description {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.45;
}
.project-mcp-count {
margin: 0.5rem 0 0;
color: var(--color-text);
font-size: 0.85rem;
font-weight: 600;
}
.project-mcp-count span + span {
margin-left: 0.25rem;
color: var(--color-text-muted);
font-weight: 500;
}
.project-mcp-section .button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-shrink: 0;
}
@media (max-width: 760px) {
.project-mcp-section {
flex-direction: column;
}
}
</style>
</body>
</html>

View file

@ -63,6 +63,14 @@
</x-component>
</div>
<div class="project-detail">
<div class="project-detail-header">
<span class="projects-project-card-title">MCP Servers</span>
</div>
<x-component path="projects/project-edit-mcp.html">
</x-component>
</div>
<div class="project-detail">
<div class="project-detail-header">
<span class="projects-project-card-title">File structure</span>

View file

@ -1,144 +1,626 @@
import { createStore } from "/js/AlpineStore.js";
import sleep from "/js/sleep.js";
import * as API from "/js/api.js";
import { openModal } from "/js/modals.js";
import { store as settingsStore } from "/components/settings/settings-store.js";
import {
toastFrontendError,
toastFrontendSuccess,
toastFrontendWarning,
} from "/components/notifications/notification-store.js";
const EMPTY_CONFIG = '{\n "mcpServers": {}\n}';
const STATUS_INTERVAL_MS = 3000;
function normalizeName(value) {
return String(value || "mcp_server")
.trim()
.toLowerCase()
.replace(/[^\w]/gu, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "") || "mcp_server";
}
function parseJsonConfig(value) {
const text = String(value || "").trim() || EMPTY_CONFIG;
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) return { mcpServers: parsed };
if (parsed && typeof parsed === "object") {
if (!parsed.mcpServers) parsed.mcpServers = {};
return parsed;
}
return { mcpServers: {} };
}
function stringifyConfig(config) {
return JSON.stringify(config || { mcpServers: {} }, null, 2);
}
function parseKeyValueText(text) {
const raw = String(text || "").trim();
if (!raw) return {};
if (raw.startsWith("{")) {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
}
return raw.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reduce((acc, line) => {
const idx = line.indexOf("=");
if (idx <= 0) return acc;
const key = line.slice(0, idx).trim();
if (!key) return acc;
acc[key] = line.slice(idx + 1).trim();
return acc;
}, {});
}
function formatKeyValueText(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
return Object.entries(value)
.map(([key, val]) => `${key}=${val ?? ""}`)
.join("\n");
}
function parseArgsText(text) {
const raw = String(text || "").trim();
if (!raw) return [];
if (raw.startsWith("[")) {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
}
return raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
function formatArgsText(value) {
return Array.isArray(value) ? value.join("\n") : "";
}
function deriveNameFromUrl(url) {
try {
const parsed = new URL(url);
const parts = parsed.pathname.split("/").filter(Boolean);
return normalizeName(parts.at(-1) || parsed.hostname || "remote_mcp");
} catch {
return "remote_mcp";
}
}
function createEmptyForm() {
return {
mode: "remote",
name: "",
description: "",
url: "",
type: "streamable-http",
command: "",
argsText: "",
headersText: "",
envText: "",
init_timeout: "",
tool_timeout: "",
verify: true,
disabled: false,
allow_local_execution: false,
allow_remote_network: false,
};
}
const model = {
editor: null,
servers: [],
loading: true,
applying: false,
statusCheck: false,
serverLog: "",
serverDetail: null,
activeView: "visual",
addOpen: false,
advancedOpen: false,
serverForm: createEmptyForm(),
scanLoading: false,
scanResult: null,
scope: "global",
projectName: "",
projectModel: null,
standaloneProject: false,
async initialize() {
// Initialize the JSON Viewer after the modal is rendered
const container = document.getElementById("mcp-servers-config-json");
if (container) {
const editor = ace.edit("mcp-servers-config-json");
const dark = localStorage.getItem("darkMode");
if (dark != "false") {
editor.setTheme("ace/theme/github_dark");
} else {
editor.setTheme("ace/theme/tomorrow");
}
editor.session.setMode("ace/mode/json");
const json = this.getSettingsFieldConfigJson();
editor.setValue(json);
editor.clearSelection();
this.editor = editor;
}
this.loading = true;
await this.ensureScopeLoaded();
this.setupEditor();
await this.loadStatus();
this.loading = false;
this.startStatusCheck();
},
setupEditor() {
const container = document.getElementById("mcp-servers-config-json");
if (!container) return;
const editor = ace.edit("mcp-servers-config-json");
const dark = localStorage.getItem("darkMode");
editor.setTheme(dark !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow");
editor.session.setMode("ace/mode/json");
editor.setValue(this.getScopeConfigJson());
editor.clearSelection();
this.editor = editor;
requestAnimationFrame(() => this.editor?.resize());
},
async ensureScopeLoaded() {
if (this.scope === "project") return;
if (settingsStore.settings) return;
try {
const response = await API.callJsonApi("settings_get", null);
if (response?.settings) {
settingsStore.settings = response.settings;
settingsStore.additional = response.additional || null;
}
} catch (error) {
console.error("Failed to load settings for MCP manager:", error);
void toastFrontendError("Failed to load settings for MCP manager", "MCP Servers");
}
},
async openGlobalConfig() {
this.configureGlobalScope();
await openModal("settings/mcp/client/mcp-servers.html");
},
async openProjectConfig(projectModel) {
if (!projectModel?.name) return;
this.configureProjectScope(projectModel.name, projectModel, false);
await openModal("settings/mcp/client/mcp-servers.html");
},
async openFromComposer(projectName = "") {
const normalizedProject = String(projectName || "").trim();
if (normalizedProject) {
try {
const response = await API.callJsonApi("projects", {
action: "load",
name: normalizedProject,
});
if (!response?.ok) throw new Error(response?.error || "Project load failed");
this.configureProjectScope(normalizedProject, response.data, true);
} catch (error) {
console.error("Failed to load project MCP config:", error);
void toastFrontendError("Failed to load project MCP config", "MCP Servers");
return;
}
} else {
this.configureGlobalScope();
await this.ensureScopeLoaded();
}
await openModal("settings/mcp/client/mcp-servers.html");
},
configureGlobalScope() {
this.scope = "global";
this.projectName = "";
this.projectModel = null;
this.standaloneProject = false;
},
configureProjectScope(projectName, projectModel, standaloneProject = false) {
this.scope = "project";
this.projectName = projectName;
this.projectModel = projectModel || null;
this.standaloneProject = !!standaloneProject;
},
resetScope() {
this.configureGlobalScope();
},
get scopeTitle() {
if (this.scope === "project") return `Project MCP servers`;
return "Global MCP servers";
},
get scopeSubtitle() {
if (this.scope === "project") {
return this.projectName ? `Project: ${this.projectName}` : "Project scope";
}
return "Available to every chat unless a project overrides a server.";
},
getStatusPayload() {
return this.scope === "project" && this.projectName
? { project_name: this.projectName }
: null;
},
getApplyPayload() {
const payload = { mcp_servers: this.getEditorValue() };
if (this.scope === "project" && this.projectName) payload.project_name = this.projectName;
return payload;
},
getScopeConfigJson() {
if (this.scope === "project") {
return this.projectModel?.mcp_servers || EMPTY_CONFIG;
}
return settingsStore.settings?.mcp_servers
?? settingsStore.settings?.mcpServers
?? EMPTY_CONFIG;
},
setScopeConfigJson(value) {
if (this.scope === "project") {
if (this.projectModel) this.projectModel.mcp_servers = value;
return;
}
if (settingsStore.settings) settingsStore.settings.mcp_servers = value;
},
getEditorValue() {
return this.editor?.getValue() ?? this.getScopeConfigJson();
},
setEditorValue(value) {
if (this.editor) {
this.editor.setValue(value);
this.editor.clearSelection();
this.editor.navigateFileStart();
requestAnimationFrame(() => this.editor?.resize());
}
this.setScopeConfigJson(value);
},
getConfigObject() {
return parseJsonConfig(this.getEditorValue());
},
get configuredServers() {
try {
const config = this.getConfigObject();
const servers = config.mcpServers;
if (Array.isArray(servers)) {
return servers.map((server, index) => ({
name: server?.name || `server_${index + 1}`,
config: server || {},
}));
}
if (servers && typeof servers === "object") {
return Object.entries(servers).map(([name, config]) => ({
name,
config: config || {},
}));
}
} catch {
return [];
}
return [];
},
countServersInConfig(configText) {
try {
const config = parseJsonConfig(configText || EMPTY_CONFIG);
if (Array.isArray(config.mcpServers)) return config.mcpServers.length;
return Object.keys(config.mcpServers || {}).length;
} catch {
return 0;
}
},
formatJson() {
try {
// get current content
const currentContent = this.editor.getValue();
// parse and format with 2 spaces indentation
const parsed = JSON.parse(currentContent);
const formatted = JSON.stringify(parsed, null, 2);
// update editor content
this.editor.setValue(formatted);
this.editor.clearSelection();
// move cursor to start
this.editor.navigateFileStart();
this.setEditorValue(stringifyConfig(this.getConfigObject()));
void toastFrontendSuccess("MCP JSON reformatted", "MCP Servers");
} catch (error) {
console.error("Failed to format JSON:", error);
alert("Invalid JSON: " + error.message);
void toastFrontendError(`Invalid JSON: ${error.message}`, "MCP Servers");
}
},
getEditorValue() {
return this.editor.getValue();
},
getSettingsFieldConfigJson() {
return settingsStore.settings?.mcp_servers
?? settingsStore.settings?.mcpServers
?? "{\n \"mcpServers\": {}\n}";
},
onClose() {
const val = this.getEditorValue();
if (settingsStore.settings) {
settingsStore.settings.mcp_servers = val;
setActiveView(view) {
this.activeView = view || "visual";
if (this.activeView === "raw") {
requestAnimationFrame(() => this.editor?.resize());
}
},
setFormMode(mode) {
this.serverForm.mode = mode === "local" ? "local" : "remote";
this.scanResult = null;
},
resetForm() {
this.serverForm = createEmptyForm();
this.advancedOpen = false;
this.scanResult = null;
},
buildServerFromForm() {
const form = this.serverForm;
const name = normalizeName(form.name || (form.mode === "remote" ? deriveNameFromUrl(form.url) : form.command));
if (!name) throw new Error("Name is required");
const server = {
name,
disabled: !!form.disabled,
};
if (form.description.trim()) server.description = form.description.trim();
if (form.init_timeout !== "" && form.init_timeout !== null) {
const timeout = Number(form.init_timeout);
if (Number.isFinite(timeout) && timeout > 0) server.init_timeout = timeout;
}
if (form.tool_timeout !== "" && form.tool_timeout !== null) {
const timeout = Number(form.tool_timeout);
if (Number.isFinite(timeout) && timeout > 0) server.tool_timeout = timeout;
}
if (form.mode === "remote") {
if (!form.url.trim()) throw new Error("Remote MCP server URL is required");
server.url = form.url.trim();
server.type = form.type || "streamable-http";
server.verify = form.verify !== false;
const headers = parseKeyValueText(form.headersText);
if (Object.keys(headers).length) server.headers = headers;
} else {
if (!form.command.trim()) throw new Error("Local command is required");
server.type = "stdio";
server.command = form.command.trim();
const args = parseArgsText(form.argsText);
if (args.length) server.args = args;
const env = parseKeyValueText(form.envText);
if (Object.keys(env).length) server.env = env;
}
return server;
},
async scanForm() {
let server;
try {
server = this.buildServerFromForm();
} catch (error) {
void toastFrontendError(error.message || String(error), "MCP Scanner");
return;
}
this.scanLoading = true;
this.scanResult = null;
try {
const response = await API.callJsonApi("mcp_server_scan", {
server,
inspect_runtime: true,
allow_local_execution: !!this.serverForm.allow_local_execution,
allow_remote_network: !!this.serverForm.allow_remote_network,
});
if (!response?.success) throw new Error(response?.error || "Scan failed");
this.scanResult = response;
} catch (error) {
console.error("MCP scan failed:", error);
void toastFrontendError(`MCP scan failed: ${error.message || error}`, "MCP Scanner");
} finally {
this.scanLoading = false;
}
},
addServerFromForm() {
let server;
try {
server = this.buildServerFromForm();
} catch (error) {
void toastFrontendError(error.message || String(error), "MCP Servers");
return;
}
try {
const config = this.getConfigObject();
if (Array.isArray(config.mcpServers)) {
const index = config.mcpServers.findIndex((item) => normalizeName(item?.name || "") === server.name);
if (index >= 0) config.mcpServers.splice(index, 1, server);
else config.mcpServers.push(server);
} else {
const stored = { ...server };
delete stored.name;
config.mcpServers[server.name] = stored;
}
this.setEditorValue(stringifyConfig(config));
this.addOpen = false;
this.resetForm();
void toastFrontendSuccess("MCP server added to draft config", "MCP Servers");
} catch (error) {
console.error("Failed to add MCP server:", error);
void toastFrontendError(`Failed to add MCP server: ${error.message || error}`, "MCP Servers");
}
},
editConfigServer(name) {
const entry = this.configuredServers.find((item) => item.name === name);
if (!entry) return;
const cfg = entry.config || {};
const isRemote = !!(cfg.url || cfg.serverUrl);
this.serverForm = {
...createEmptyForm(),
mode: isRemote ? "remote" : "local",
name,
description: cfg.description || "",
url: cfg.url || cfg.serverUrl || "",
type: cfg.type || "streamable-http",
command: cfg.command || "",
argsText: formatArgsText(cfg.args),
headersText: formatKeyValueText(cfg.headers),
envText: formatKeyValueText(cfg.env),
init_timeout: cfg.init_timeout || "",
tool_timeout: cfg.tool_timeout || "",
verify: cfg.verify !== false,
disabled: !!cfg.disabled,
allow_local_execution: false,
allow_remote_network: false,
};
this.addOpen = true;
this.scanResult = null;
},
removeConfigServer(name) {
try {
const config = this.getConfigObject();
if (Array.isArray(config.mcpServers)) {
config.mcpServers = config.mcpServers.filter((server) => normalizeName(server?.name || "") !== normalizeName(name));
} else {
delete config.mcpServers[name];
}
this.setEditorValue(stringifyConfig(config));
} catch (error) {
void toastFrontendError(`Failed to remove MCP server: ${error.message || error}`, "MCP Servers");
}
},
toggleConfigServer(name) {
try {
const config = this.getConfigObject();
if (Array.isArray(config.mcpServers)) {
const server = config.mcpServers.find((item) => normalizeName(item?.name || "") === normalizeName(name));
if (server) server.disabled = !server.disabled;
} else if (config.mcpServers[name]) {
config.mcpServers[name].disabled = !config.mcpServers[name].disabled;
}
this.setEditorValue(stringifyConfig(config));
} catch (error) {
void toastFrontendError(`Failed to update MCP server: ${error.message || error}`, "MCP Servers");
}
this.stopStatusCheck();
},
async startStatusCheck() {
this.statusCheck = true;
let firstLoad = true;
while (this.statusCheck) {
await this._statusCheck();
if (firstLoad) {
this.loading = false;
firstLoad = false;
await sleep(STATUS_INTERVAL_MS);
if (this.statusCheck) await this.loadStatus({ silent: true });
}
},
async loadStatus(options = {}) {
try {
const resp = await API.callJsonApi("mcp_servers_status", this.getStatusPayload());
if (resp?.success) {
this.servers = resp.status || [];
this.servers.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
} else if (!options.silent) {
void toastFrontendWarning(resp?.error || "Unable to load MCP status", "MCP Servers");
}
} catch (error) {
if (!options.silent) {
console.error("Failed to load MCP status:", error);
void toastFrontendError("Failed to load MCP status", "MCP Servers");
}
await sleep(3000);
}
},
async _statusCheck() {
const resp = await API.callJsonApi("mcp_servers_status", null);
if (resp.success) {
this.servers = resp.status;
this.servers.sort((a, b) => a.name.localeCompare(b.name));
}
},
async stopStatusCheck() {
stopStatusCheck() {
this.statusCheck = false;
},
async applyNow() {
if (this.loading) return;
this.loading = true;
if (this.applying) return;
try {
scrollModal("mcp-servers-status");
const resp = await API.callJsonApi("mcp_servers_apply", {
mcp_servers: this.getEditorValue(),
});
if (resp.success) {
this.servers = resp.status;
this.servers.sort((a, b) => a.name.localeCompare(b.name));
}
this.loading = false;
await sleep(100); // wait for ui and scroll
scrollModal("mcp-servers-status");
const formatted = stringifyConfig(this.getConfigObject());
this.setEditorValue(formatted);
} catch (error) {
void toastFrontendError(`Invalid JSON: ${error.message || error}`, "MCP Servers");
return;
}
this.applying = true;
try {
const resp = await API.callJsonApi("mcp_servers_apply", this.getApplyPayload());
if (!resp?.success) throw new Error(resp?.error || "Apply failed");
this.setScopeConfigJson(resp.mcp_servers || this.getEditorValue());
this.servers = resp.status || [];
this.servers.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
void toastFrontendSuccess("MCP servers applied", "MCP Servers");
await sleep(100);
if (globalThis.scrollModal) globalThis.scrollModal("mcp-servers-status");
} catch (error) {
console.error("Failed to apply MCP servers:", error);
void toastFrontendError(`Failed to apply MCP servers: ${error.message || error}`, "MCP Servers");
} finally {
this.applying = false;
}
this.loading = false;
},
async getServerLog(serverName) {
this.serverLog = "";
const resp = await API.callJsonApi("mcp_server_get_log", {
server_name: serverName,
});
if (resp.success) {
const payload = { server_name: serverName, ...(this.getStatusPayload() || {}) };
const resp = await API.callJsonApi("mcp_server_get_log", payload);
if (resp?.success) {
this.serverLog = resp.log;
openModal("settings/mcp/client/mcp-servers-log.html");
}
},
async onToolCountClick(serverName) {
const resp = await API.callJsonApi("mcp_server_get_detail", {
server_name: serverName,
});
if (resp.success) {
const payload = { server_name: serverName, ...(this.getStatusPayload() || {}) };
const resp = await API.callJsonApi("mcp_server_get_detail", payload);
if (resp?.success) {
this.serverDetail = resp.detail;
openModal("settings/mcp/client/mcp-server-tools.html");
}
},
statusLabel(server) {
if (!server.connected) return "Unavailable";
if (server.error) return "Needs attention";
if ((server.tool_count || 0) > 0) return "Ready";
return "Connected";
},
statusClass(server) {
if (!server.connected || server.error) return "danger";
if ((server.tool_count || 0) > 0) return "ok";
return "idle";
},
configModeLabel(config) {
if (config?.disabled) return "Disabled";
if (config?.url || config?.serverUrl) return "Remote";
return "Local";
},
configSummary(config) {
if (config?.url || config?.serverUrl) return config.url || config.serverUrl;
const args = Array.isArray(config?.args) && config.args.length ? ` ${config.args.join(" ")}` : "";
return `${config?.command || "command"}${args}`;
},
get scanWarnings() {
return this.scanResult?.warnings || [];
},
get scanRiskLabel() {
const risk = this.scanResult?.risk_level || "";
if (risk === "ok") return "No major issues found";
if (risk === "warning") return "Review warnings";
if (risk === "error") return "Action needed";
return "";
},
onClose() {
try {
this.setScopeConfigJson(this.getEditorValue());
} catch {}
this.stopStatusCheck();
if (this.editor) {
try { this.editor.destroy(); } catch {}
this.editor = null;
}
this.servers = [];
this.loading = true;
this.applying = false;
this.addOpen = false;
this.activeView = "visual";
this.resetForm();
this.resetScope();
},
};
const store = createStore("mcpServersStore", model);

View file

@ -1,7 +1,7 @@
<html>
<head>
<title>MCP Servers Configuration</title>
<title>MCP Servers</title>
<script type="module">
import { store } from "/components/settings/mcp/client/mcp-servers-store.js";
@ -11,181 +11,704 @@
<body>
<div x-data>
<template x-if="$store.mcpServersStore">
<div x-init="$store.mcpServersStore.initialize()" x-destroy="$store.mcpServersStore.onClose()">
<h3>MCP Servers Configuration JSON
<button class="btn slim" style="margin-left: 0.5em;"
onclick="openModal('settings/mcp/client/example.html')">Examples</button>
<button class="btn slim" style="margin-left: 0.5em;"
@click="$store.mcpServersStore.formatJson()">Reformat</button>
<button class="btn slim primary" :disabled="$store.mcpServersStore.loading"
style="margin-left: 0.5em;" @click="$store.mcpServersStore.applyNow()">Apply now</button>
</h3>
<div id="mcp-servers-config-json"></div>
<h3 id="mcp-servers-status">Servers status (refreshing automatically)</h3>
<div class="server-list" x-show="!$store.mcpServersStore.loading">
<template x-for="server in $store.mcpServersStore.servers" :key="server.name">
<div class="server-item">
<div class="server-main-row">
<!-- Status indicator -->
<div class="status-dot" x-data="{ connected: server.connected }">
<svg viewBox="0 0 16 16" width="12" height="12">
<circle cx="8" cy="8" r="6" x-bind:fill="server.connected
? (server.error ? '#e40138' : (server.tool_count > 0 ? '#00c340' : '#e40138'))
: 'none'" x-bind:opacity="server.connected ? 1 : 0" />
<circle cx="8" cy="8" r="6" fill="none" stroke="#e40138" stroke-width="2"
x-bind:opacity="server.connected ? 0 : 1" />
</svg>
</div>
<!-- Server name -->
<span class="server-name" x-text="server.name"></span>
<!-- Tool count (clickable if > 0, only for connected servers without errors) -->
<span class="tool-count" x-show="server.tool_count > 0"
@click="$store.mcpServersStore.onToolCountClick && $store.mcpServersStore.onToolCountClick(server.name)"
x-text="server.tool_count + ' tools'"></span>
<!-- Log button (only shown if has_log is true) -->
<span class="log-btn" x-show="server.has_log"
@click="$store.mcpServersStore.getServerLog(server.name)">Log</span>
</div>
<!-- Error message (if any) -->
<div class="server-error-row" x-show="server.error">
<span class="server-error" x-text="server.error"></span>
</div>
<div class="mcp-manager" x-create="$store.mcpServersStore.initialize()" x-destroy="$store.mcpServersStore.onClose()">
<header class="mcp-manager-header">
<div class="mcp-manager-heading">
<div class="mcp-manager-title-row">
<h2 x-text="$store.mcpServersStore.scopeTitle"></h2>
<span class="mcp-scope-pill" x-text="$store.mcpServersStore.scope === 'project' ? 'Project' : 'Global'"></span>
</div>
</template>
<div x-show="$store.mcpServersStore.servers.length === 0" class="mcp-servers-loading">
No servers
<p x-text="$store.mcpServersStore.scopeSubtitle"></p>
</div>
<div class="mcp-manager-actions">
<button type="button" class="button" title="Add MCP server" @click="$store.mcpServersStore.addOpen = !$store.mcpServersStore.addOpen">
<span class="material-symbols-outlined" aria-hidden="true">add</span>
<span>Add</span>
</button>
<button type="button" class="button" title="Examples" onclick="openModal('settings/mcp/client/example.html')">
<span class="material-symbols-outlined" aria-hidden="true">library_books</span>
</button>
<button type="button" class="button" title="Refresh status" @click="$store.mcpServersStore.loadStatus()">
<span class="material-symbols-outlined" aria-hidden="true">refresh</span>
</button>
<button type="button" class="button confirm" :disabled="$store.mcpServersStore.applying" @click="$store.mcpServersStore.applyNow()">
<span class="material-symbols-outlined" aria-hidden="true">check</span>
<span x-text="$store.mcpServersStore.applying ? 'Applying' : 'Apply'"></span>
</button>
</div>
</header>
<section class="mcp-add-panel" x-show="$store.mcpServersStore.addOpen" x-transition.opacity style="display: none;">
<div class="mcp-add-header">
<h3>Add MCP server</h3>
<button type="button" class="mcp-icon-button" title="Clear form" @click="$store.mcpServersStore.resetForm()">
<span class="material-symbols-outlined" aria-hidden="true">backspace</span>
</button>
</div>
<div class="mcp-segmented" role="tablist" aria-label="MCP server type">
<button type="button" :class="{ active: $store.mcpServersStore.serverForm.mode === 'remote' }"
@click="$store.mcpServersStore.setFormMode('remote')">Remote URL</button>
<button type="button" :class="{ active: $store.mcpServersStore.serverForm.mode === 'local' }"
@click="$store.mcpServersStore.setFormMode('local')">Local command</button>
</div>
<div class="mcp-form-grid">
<label class="mcp-field">
<span>Name</span>
<input type="text" x-model="$store.mcpServersStore.serverForm.name" placeholder="github" />
</label>
<label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
<span>Remote MCP server URL</span>
<input type="url" x-model="$store.mcpServersStore.serverForm.url" placeholder="https://example.com/mcp" />
</label>
<label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
<span>Command</span>
<input type="text" x-model="$store.mcpServersStore.serverForm.command" placeholder="uvx" />
</label>
<label class="mcp-field">
<span>Description</span>
<input type="text" x-model="$store.mcpServersStore.serverForm.description" placeholder="Optional" />
</label>
</div>
<button type="button" class="mcp-advanced-toggle" @click="$store.mcpServersStore.advancedOpen = !$store.mcpServersStore.advancedOpen">
<span class="material-symbols-outlined" :style="$store.mcpServersStore.advancedOpen ? 'transform:rotate(90deg)' : ''">chevron_right</span>
<span>Advanced settings</span>
</button>
<div class="mcp-advanced-body" x-show="$store.mcpServersStore.advancedOpen" x-transition.opacity style="display: none;">
<div class="mcp-form-grid">
<label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
<span>Transport</span>
<select x-model="$store.mcpServersStore.serverForm.type">
<option value="streamable-http">Streamable HTTP</option>
<option value="sse">SSE</option>
</select>
</label>
<label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
<span>Arguments</span>
<textarea x-model="$store.mcpServersStore.serverForm.argsText" rows="4" placeholder="--yes&#10;@modelcontextprotocol/server-filesystem"></textarea>
</label>
<label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
<span>Headers</span>
<textarea x-model="$store.mcpServersStore.serverForm.headersText" rows="4" placeholder="Authorization=Bearer ..."></textarea>
</label>
<label class="mcp-field" x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
<span>Environment</span>
<textarea x-model="$store.mcpServersStore.serverForm.envText" rows="4" placeholder="TOKEN=..."></textarea>
</label>
<label class="mcp-field">
<span>Startup timeout</span>
<input type="number" min="0" x-model="$store.mcpServersStore.serverForm.init_timeout" />
</label>
<label class="mcp-field">
<span>Tool timeout</span>
<input type="number" min="0" x-model="$store.mcpServersStore.serverForm.tool_timeout" />
</label>
</div>
<div class="mcp-toggle-row">
<label>
<input type="checkbox" x-model="$store.mcpServersStore.serverForm.disabled" />
<span>Disabled</span>
</label>
<label x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
<input type="checkbox" x-model="$store.mcpServersStore.serverForm.verify" />
<span>Verify SSL</span>
</label>
<label x-show="$store.mcpServersStore.serverForm.mode === 'remote'">
<input type="checkbox" x-model="$store.mcpServersStore.serverForm.allow_remote_network" />
<span>Trust remote inspection</span>
</label>
<label x-show="$store.mcpServersStore.serverForm.mode === 'local'" style="display: none;">
<input type="checkbox" x-model="$store.mcpServersStore.serverForm.allow_local_execution" />
<span>Trust local inspection</span>
</label>
</div>
</div>
<div class="mcp-scan-result" x-show="$store.mcpServersStore.scanResult">
<div class="mcp-scan-heading">
<span class="material-symbols-outlined" aria-hidden="true"
x-text="$store.mcpServersStore.scanResult?.risk_level === 'ok' ? 'verified' : ($store.mcpServersStore.scanResult?.risk_level === 'error' ? 'error' : 'warning')"></span>
<span x-text="$store.mcpServersStore.scanRiskLabel"></span>
</div>
<div class="mcp-scan-warnings">
<template x-for="warning in $store.mcpServersStore.scanWarnings" :key="warning.title + warning.message">
<div class="mcp-scan-warning" :class="warning.level">
<strong x-text="warning.title"></strong>
<span x-text="warning.message"></span>
</div>
</template>
</div>
</div>
<div class="mcp-add-actions">
<button type="button" class="button" :disabled="$store.mcpServersStore.scanLoading" @click="$store.mcpServersStore.scanForm()">
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.mcpServersStore.scanLoading ? 'progress_activity' : 'radar'"></span>
<span x-text="$store.mcpServersStore.scanLoading ? 'Scanning' : 'Scan'"></span>
</button>
<button type="button" class="button confirm" @click="$store.mcpServersStore.addServerFromForm()">
<span class="material-symbols-outlined" aria-hidden="true">add_circle</span>
<span>Add to config</span>
</button>
</div>
</section>
<div class="mcp-view-tabs">
<button type="button" :class="{ active: $store.mcpServersStore.activeView === 'visual' }"
@click="$store.mcpServersStore.setActiveView('visual')">
<span class="material-symbols-outlined" aria-hidden="true">dashboard_customize</span>
<span>Manager</span>
</button>
<button type="button" :class="{ active: $store.mcpServersStore.activeView === 'raw' }"
@click="$store.mcpServersStore.setActiveView('raw')">
<span class="material-symbols-outlined" aria-hidden="true">data_object</span>
<span>Raw JSON</span>
</button>
</div>
<div x-show="$store.mcpServersStore.loading" class="mcp-servers-loading">
Loading servers status...
</div>
<section x-show="$store.mcpServersStore.activeView === 'visual'" class="mcp-config-list">
<div class="mcp-section-title">
<h3>Configured servers</h3>
<span x-text="$store.mcpServersStore.configuredServers.length + ' total'"></span>
</div>
<template x-if="$store.mcpServersStore.configuredServers.length === 0">
<div class="mcp-empty">No MCP servers configured.</div>
</template>
<template x-for="entry in $store.mcpServersStore.configuredServers" :key="entry.name">
<article class="mcp-config-card" :class="{ disabled: entry.config.disabled }">
<div class="mcp-config-card-main">
<div>
<div class="mcp-config-name" x-text="entry.name"></div>
<div class="mcp-config-summary" x-text="$store.mcpServersStore.configSummary(entry.config)"></div>
</div>
<span class="mcp-config-mode" x-text="$store.mcpServersStore.configModeLabel(entry.config)"></span>
</div>
<div class="mcp-config-actions">
<button type="button" class="mcp-icon-button" title="Edit" @click="$store.mcpServersStore.editConfigServer(entry.name)">
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
</button>
<button type="button" class="mcp-icon-button" title="Enable or disable" @click="$store.mcpServersStore.toggleConfigServer(entry.name)">
<span class="material-symbols-outlined" aria-hidden="true" x-text="entry.config.disabled ? 'toggle_off' : 'toggle_on'"></span>
</button>
<button type="button" class="mcp-icon-button danger" title="Remove" @click="$store.mcpServersStore.removeConfigServer(entry.name)">
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
</button>
</div>
</article>
</template>
</section>
<section x-show="$store.mcpServersStore.activeView === 'raw'" class="mcp-raw-panel" style="display: none;">
<div class="mcp-raw-toolbar">
<h3>MCP Servers Configuration JSON</h3>
<button type="button" class="button" @click="$store.mcpServersStore.formatJson()">
<span class="material-symbols-outlined" aria-hidden="true">format_indent_increase</span>
<span>Reformat</span>
</button>
</div>
<div id="mcp-servers-config-json"></div>
</section>
<section class="mcp-status-section" id="mcp-servers-status">
<div class="mcp-section-title">
<h3>Servers status</h3>
<span x-text="$store.mcpServersStore.loading ? 'Loading' : ($store.mcpServersStore.servers.length + ' visible')"></span>
</div>
<div x-show="$store.mcpServersStore.loading" class="mcp-empty">
Loading MCP server status...
</div>
<div class="mcp-status-list" x-show="!$store.mcpServersStore.loading">
<template x-for="server in $store.mcpServersStore.servers" :key="server.scope + ':' + server.name">
<article class="mcp-status-row" :class="$store.mcpServersStore.statusClass(server)">
<div class="mcp-status-main">
<span class="mcp-status-dot"></span>
<div>
<div class="mcp-status-name">
<span x-text="server.name"></span>
<span class="mcp-status-scope" x-text="server.scope || 'global'"></span>
</div>
<div class="mcp-status-meta">
<span x-text="$store.mcpServersStore.statusLabel(server)"></span>
<span x-show="server.type" x-text="server.type"></span>
</div>
</div>
</div>
<div class="mcp-status-actions">
<button type="button" class="button" x-show="server.tool_count > 0"
@click="$store.mcpServersStore.onToolCountClick(server.name)"
x-text="server.tool_count + ' tools'"></button>
<button type="button" class="button" x-show="server.has_log"
@click="$store.mcpServersStore.getServerLog(server.name)">Log</button>
</div>
<div class="mcp-status-error" x-show="server.error" x-text="server.error"></div>
</article>
</template>
<div x-show="$store.mcpServersStore.servers.length === 0" class="mcp-empty">
No servers.
</div>
</div>
</section>
</div>
</template>
</div>
<style>
.modal-inner .modal-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-inner .modal-scroll::-webkit-scrollbar {
display: none;
}
.mcp-servers-loading {
width: 100%;
text-align: center;
margin-top: 2rem;
margin-bottom: 2rem;
}
#mcp-servers-config-json {
width: 100%;
height: 40em;
}
.server-list {
margin-top: 0.5em;
margin-bottom: 1em;
}
.server-item {
.mcp-manager {
display: flex;
flex-direction: column;
padding: 0.5em 0.7em;
margin-bottom: 0.4em;
min-height: 2.2em;
/* Ensure consistent height even without errors */
border: 1px solid rgba(192, 192, 192, 0.161);
/* Silver with 30% opacity */
border-radius: 4px;
gap: 1rem;
color: var(--color-text);
font-family: var(--font-family-main);
}
.server-list {
margin-top: 0.5em;
margin-bottom: 1em;
display: flex;
flex-direction: column;
gap: 0.2em;
.mcp-manager button,
.mcp-manager input,
.mcp-manager textarea,
.mcp-manager select {
font-family: inherit;
}
.server-main-row {
.mcp-manager-header,
.mcp-add-header,
.mcp-section-title,
.mcp-raw-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.mcp-manager-heading {
min-width: 0;
}
.mcp-manager-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.8em;
width: 100%;
gap: 0.5rem;
}
.status-dot {
display: flex;
align-items: center;
justify-content: center;
.mcp-manager h2,
.mcp-manager h3,
.mcp-manager p {
margin: 0;
}
.server-name {
font-weight: 600;
min-width: 12em;
}
.tool-count {
color: var(--c-fg2);
font-size: 0.9em;
user-select: none;
}
.tool-count {
cursor: default;
}
.tool-count:hover {
opacity: 0.8;
cursor: pointer;
}
.config-status {
color: #e40138;
font-size: 0.85em;
opacity: 0.8;
}
.log-btn {
margin-left: auto;
font-size: 0.9em;
cursor: pointer;
text-decoration: none;
opacity: 0.85;
}
.log-btn:hover {
opacity: 1;
}
.server-error-row {
margin-left: 1.8em;
margin-top: 0.1em;
font-size: 0.8em;
color: #F44336;
opacity: 0.85;
.mcp-manager h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.no-servers {
padding: 0.5em;
color: var(--c-fg2);
font-style: italic;
.mcp-manager h3 {
font-size: 1rem;
line-height: 1.2;
}
.mcp-manager p,
.mcp-config-summary,
.mcp-status-meta,
.mcp-empty {
color: var(--color-text-muted);
font-size: 0.86rem;
line-height: 1.4;
}
.mcp-scope-pill,
.mcp-status-scope,
.mcp-config-mode,
.mcp-section-title > span {
display: inline-flex;
align-items: center;
min-height: 1.45rem;
padding: 0.15rem 0.45rem;
border: 1px solid var(--color-border);
border-radius: 999px;
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 600;
}
.mcp-manager-actions,
.mcp-add-actions,
.mcp-config-actions,
.mcp-status-actions,
.mcp-toggle-row,
.mcp-raw-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.mcp-manager .button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 2rem;
padding: 0.35rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-panel);
color: var(--color-text);
cursor: pointer;
}
.mcp-manager .button.confirm {
border-color: color-mix(in srgb, var(--color-highlight) 65%, var(--color-border));
background: var(--color-highlight);
color: #fff;
}
.mcp-manager .button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.mcp-manager .material-symbols-outlined {
font-size: 1.05rem;
line-height: 1;
}
.mcp-add-panel,
.mcp-config-card,
.mcp-status-row,
.mcp-scan-result,
.mcp-empty {
border: 1px solid var(--color-border);
border-radius: 8px;
background: color-mix(in srgb, var(--color-panel) 88%, transparent);
}
.mcp-add-panel {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem;
}
.mcp-segmented,
.mcp-view-tabs {
display: inline-flex;
width: fit-content;
padding: 0.18rem;
border: 1px solid var(--color-border);
border-radius: 7px;
background: var(--color-input);
}
.mcp-segmented button,
.mcp-view-tabs button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 1.9rem;
padding: 0.3rem 0.65rem;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.mcp-segmented button.active,
.mcp-view-tabs button.active {
background: var(--color-panel);
color: var(--color-text);
}
.mcp-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.mcp-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.mcp-field span {
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 600;
}
.mcp-field input,
.mcp-field textarea,
.mcp-field select {
width: 100%;
border: 1px solid var(--color-border);
border-radius: 7px;
background: var(--color-input);
color: var(--color-text);
padding: 0.55rem 0.65rem;
font-size: 0.88rem;
outline: none;
}
.mcp-field textarea {
resize: vertical;
min-height: 5.5rem;
font-family: var(--font-family-code);
font-size: 0.8rem;
}
.mcp-field input:focus,
.mcp-field textarea:focus,
.mcp-field select:focus {
border-color: var(--color-highlight);
}
.mcp-advanced-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
width: fit-content;
padding: 0.2rem 0;
border: 0;
background: transparent;
color: var(--color-text);
cursor: pointer;
font-weight: 600;
}
.mcp-advanced-toggle .material-symbols-outlined {
transition: transform 0.12s ease;
}
.mcp-advanced-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mcp-toggle-row label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.mcp-scan-result {
padding: 0.75rem;
}
.mcp-scan-heading {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
}
.mcp-scan-warnings {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.65rem;
}
.mcp-scan-warning {
display: flex;
flex-direction: column;
gap: 0.12rem;
padding: 0.55rem 0.65rem;
border-left: 3px solid var(--color-border);
background: var(--color-input);
border-radius: 5px;
font-size: 0.82rem;
}
.mcp-scan-warning.warning {
border-left-color: var(--color-warning-text);
}
.mcp-scan-warning.error {
border-left-color: var(--color-error-text);
}
.mcp-config-list,
.mcp-status-section,
.mcp-raw-panel {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.mcp-config-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
}
.mcp-config-card.disabled {
opacity: 0.62;
}
.mcp-config-card-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.mcp-config-name,
.mcp-status-name {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
font-weight: 700;
}
.mcp-config-summary {
margin-top: 0.2rem;
overflow-wrap: anywhere;
}
.mcp-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-panel);
color: var(--color-text);
cursor: pointer;
}
.mcp-icon-button.danger {
color: var(--color-error-text);
}
.mcp-raw-toolbar {
align-items: center;
}
#mcp-servers-config-json {
width: 100%;
height: 28rem;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.mcp-status-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mcp-status-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.6rem 1rem;
padding: 0.75rem;
}
.mcp-status-main {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.mcp-status-dot {
width: 0.72rem;
height: 0.72rem;
border-radius: 50%;
border: 2px solid var(--color-border);
flex-shrink: 0;
}
.mcp-status-row.ok .mcp-status-dot {
border-color: #22c55e;
background: #22c55e;
}
.mcp-status-row.idle .mcp-status-dot {
border-color: var(--color-warning-text);
}
.mcp-status-row.danger .mcp-status-dot {
border-color: var(--color-error-text);
background: var(--color-error-text);
}
.mcp-status-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.7rem;
margin-top: 0.18rem;
}
.mcp-status-error {
grid-column: 1 / -1;
color: var(--color-error-text);
font-size: 0.8rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.mcp-empty {
padding: 0.9rem;
text-align: center;
}
@media (max-width: 760px) {
.mcp-manager-header,
.mcp-config-card,
.mcp-status-row {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.mcp-form-grid {
grid-template-columns: 1fr;
}
.mcp-manager-actions,
.mcp-status-actions {
justify-content: flex-start;
}
.mcp-view-tabs {
width: 100%;
}
.mcp-view-tabs button {
flex: 1;
justify-content: center;
}
}
</style>
</body>
</html>
</html>