mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Revamp MCP server configuration
Add project-scoped MCP server configuration with global/project merge semantics, a richer settings UI, and chat composer access. Introduce MCP config scanning plus project-aware status/detail/log/apply APIs while preserving the raw JSON editor. Strengthen MCP runtime handling for dotted tool names, timeouts, status accuracy, and project-aware tool execution, with focused regression coverage.
This commit is contained in:
parent
77f7aa0274
commit
521172b489
23 changed files with 2066 additions and 368 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
232
api/mcp_server_scan.py
Normal 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"
|
||||
45
api/mcp_server_scan.py.dox.md
Normal file
45
api/mcp_server_scan.py.dox.md
Normal 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.
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
75
webui/components/projects/project-edit-mcp.html
Normal file
75
webui/components/projects/project-edit-mcp.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 @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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue