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

Add project-scoped MCP server configuration with global/project merge semantics, a richer settings UI, and chat composer access. Introduce MCP config scanning plus project-aware status/detail/log/apply APIs while preserving the raw JSON editor. Strengthen MCP runtime handling for dotted tool names, timeouts, status accuracy, and project-aware tool execution, with focused regression coverage.
This commit is contained in:
Alessandro 2026-06-09 16:07:24 +02:00
parent 77f7aa0274
commit 521172b489
23 changed files with 2066 additions and 368 deletions

View file

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

View file

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

View file

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

View file

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

232
api/mcp_server_scan.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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