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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue