diff --git a/backend/main.py b/backend/main.py index ee507f0..4940397 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1036,7 +1036,7 @@ def _normalize_mcp_servers(raw: Any) -> Dict[str, Dict[str, Any]]: rows: Dict[str, Dict[str, Any]] = {} for server_name, server_cfg in raw.items(): name = str(server_name or "").strip() - if not name or not _MCP_SERVER_NAME_RE.match(name): + if not name or not _MCP_SERVER_NAME_RE.fullmatch(name): continue if not isinstance(server_cfg, dict): continue @@ -1074,6 +1074,44 @@ def _normalize_mcp_servers(raw: Any) -> Dict[str, Dict[str, Any]]: return rows +def _merge_mcp_servers_preserving_extras( + current_raw: Any, + normalized: Dict[str, Dict[str, Any]], +) -> Dict[str, Dict[str, Any]]: + """Preserve unknown per-server fields already present in config.json. + + Dashboard only edits a subset of MCP fields (type/url/headers/toolTimeout). + Some MCP providers may rely on additional keys; dropping them can break startup. + """ + current_map = current_raw if isinstance(current_raw, dict) else {} + merged: Dict[str, Dict[str, Any]] = {} + for name, normalized_cfg in normalized.items(): + base = current_map.get(name) + base_cfg = dict(base) if isinstance(base, dict) else {} + next_cfg = dict(base_cfg) + next_cfg.update(normalized_cfg) + merged[name] = next_cfg + return merged + + +def _sanitize_mcp_servers_in_config_data(config_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Normalize tools.mcpServers and drop hidden invalid entries safely. + + Returns the sanitized mcpServers map written into config_data["tools"]["mcpServers"]. + """ + if not isinstance(config_data, dict): + return {} + tools_cfg = config_data.get("tools") + if not isinstance(tools_cfg, dict): + tools_cfg = {} + current_raw = tools_cfg.get("mcpServers") + normalized = _normalize_mcp_servers(current_raw) + merged = _merge_mcp_servers_preserving_extras(current_raw, normalized) + tools_cfg["mcpServers"] = merged + config_data["tools"] = tools_cfg + return merged + + def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict[str, Any]: transport_type = str(cfg.get("type") or "streamableHttp").strip() if transport_type not in {"streamableHttp", "sse"}: @@ -1397,7 +1435,9 @@ def _probe_mcp_server_for_start(cfg: Dict[str, Any], image_tag: str) -> Dict[str def _preflight_mcp_servers_for_start(bot_id: str, image_tag: str) -> List[str]: config_data = _read_bot_config(bot_id) - tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {} + if not isinstance(config_data, dict): + return [] + tools_cfg = config_data.get("tools") if not isinstance(tools_cfg, dict): return [] mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers")) @@ -2401,6 +2441,16 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)): if not bot: raise HTTPException(status_code=404, detail="Bot not found") _sync_workspace_channels(session, bot_id) + mcp_preflight_failures = _preflight_mcp_servers_for_start(bot_id, image_tag=bot.image_tag) + if mcp_preflight_failures: + bot.docker_status = "STOPPED" + session.add(bot) + session.commit() + _invalidate_bot_detail_cache(bot_id) + raise HTTPException( + status_code=400, + detail="MCP precheck failed: " + " | ".join(mcp_preflight_failures[:5]), + ) runtime_snapshot = _read_bot_runtime_snapshot(bot) env_params = _read_env_store(bot_id) @@ -2566,15 +2616,18 @@ def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, sessi tools_cfg = config_data.get("tools") if not isinstance(tools_cfg, dict): tools_cfg = {} - mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {}) - tools_cfg["mcpServers"] = mcp_servers + normalized_mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {}) + current_mcp_servers = tools_cfg.get("mcpServers") + merged_mcp_servers = _merge_mcp_servers_preserving_extras(current_mcp_servers, normalized_mcp_servers) + tools_cfg["mcpServers"] = merged_mcp_servers config_data["tools"] = tools_cfg + sanitized_after_save = _sanitize_mcp_servers_in_config_data(config_data) _write_bot_config(bot_id, config_data) _invalidate_bot_detail_cache(bot_id) return { "status": "updated", "bot_id": bot_id, - "mcp_servers": mcp_servers, + "mcp_servers": _normalize_mcp_servers(sanitized_after_save), "locked_servers": [], "restart_required": True, }