diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py index c426914..8584674 100644 --- a/backend/core/docker_manager.py +++ b/backend/core/docker_manager.py @@ -4,7 +4,6 @@ import threading import time import codecs import base64 -import uuid from typing import Any, Callable, Dict, List, Optional, Tuple import json @@ -26,87 +25,6 @@ class BotDockerManager: self.active_monitors = {} self._last_delivery_error: Dict[str, str] = {} - @staticmethod - def _build_http_probe_payload_b64( - url: str, - method: str = "GET", - headers: Optional[Dict[str, str]] = None, - body_json: Optional[Dict[str, Any]] = None, - timeout_seconds: int = 10, - ) -> str: - safe_method = str(method or "GET").strip().upper() - if safe_method not in {"GET", "POST"}: - safe_method = "GET" - timeout = max(1, min(int(timeout_seconds or 10), 30)) - payload = { - "url": str(url or "").strip(), - "method": safe_method, - "headers": headers or {}, - "body_json": body_json if isinstance(body_json, dict) else None, - "timeout": timeout, - } - return base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") - - @staticmethod - def _http_probe_python_script() -> str: - return ( - "import base64, json, os, urllib.request, urllib.error\n" - "cfg = json.loads(base64.b64decode(os.environ['DASHBOARD_HTTP_PROBE_B64']).decode('utf-8'))\n" - "url = str(cfg.get('url') or '').strip()\n" - "method = str(cfg.get('method') or 'GET').upper()\n" - "headers = cfg.get('headers') or {}\n" - "timeout = int(cfg.get('timeout') or 10)\n" - "data = None\n" - "if method == 'POST':\n" - " body = cfg.get('body_json')\n" - " if not isinstance(body, dict):\n" - " body = {}\n" - " data = json.dumps(body, ensure_ascii=False).encode('utf-8')\n" - " if 'Content-Type' not in headers:\n" - " headers['Content-Type'] = 'application/json'\n" - "req = urllib.request.Request(url, data=data, headers=headers, method=method)\n" - "result = {'ok': False, 'status_code': None, 'content_type': '', 'body_preview': '', 'message': ''}\n" - "try:\n" - " with urllib.request.urlopen(req, timeout=timeout) as resp:\n" - " body = resp.read(1024).decode('utf-8', 'ignore')\n" - " result.update({'ok': True, 'status_code': int(getattr(resp, 'status', 200) or 200), 'content_type': str(resp.headers.get('content-type') or ''), 'body_preview': body[:512], 'message': 'ok'})\n" - "except urllib.error.HTTPError as e:\n" - " body = ''\n" - " try:\n" - " body = e.read(1024).decode('utf-8', 'ignore')\n" - " except Exception:\n" - " body = ''\n" - " result.update({'ok': False, 'status_code': int(e.code or 0), 'content_type': str((e.headers or {}).get('content-type') or ''), 'body_preview': body[:512], 'message': f'HTTPError: {e.code}'})\n" - "except Exception as e:\n" - " result.update({'ok': False, 'status_code': None, 'content_type': '', 'body_preview': '', 'message': f'{type(e).__name__}: {e}'})\n" - "print(json.dumps(result, ensure_ascii=False))\n" - ) - - def _run_http_probe_exec(self, container, payload_b64: str) -> Dict[str, Any]: - py_script = self._http_probe_python_script() - py_bins = ["python3", "python"] - last_error = "" - for py_bin in py_bins: - try: - exec_result = container.exec_run( - [py_bin, "-c", py_script], - environment={"DASHBOARD_HTTP_PROBE_B64": payload_b64}, - ) - except Exception as e: - last_error = f"exec {py_bin} failed: {e}" - continue - output = exec_result.output.decode("utf-8", errors="ignore") if isinstance(exec_result.output, (bytes, bytearray)) else str(exec_result.output) - if exec_result.exit_code != 0: - last_error = f"exec {py_bin} exit={exec_result.exit_code}: {output[:300]}" - continue - try: - parsed = json.loads(output.strip() or "{}") - if isinstance(parsed, dict): - return parsed - except Exception: - last_error = f"exec {py_bin} returned non-json: {output[:300]}" - return {"ok": False, "message": last_error or "Failed to run probe in container"} - @staticmethod def _normalize_resource_limits( cpu_cores: Optional[float], @@ -293,77 +211,6 @@ class BotDockerManager: print(f"[DockerManager] Error stopping bot {bot_id}: {e}") return False - def probe_http_from_container( - self, - bot_id: str, - url: str, - method: str = "GET", - headers: Optional[Dict[str, str]] = None, - body_json: Optional[Dict[str, Any]] = None, - timeout_seconds: int = 10, - ) -> Dict[str, Any]: - if not self.client: - return {"ok": False, "message": "Docker client is not available"} - try: - container = self.client.containers.get(f"worker_{bot_id}") - container.reload() - if container.status != "running": - return {"ok": False, "message": f"Bot container is {container.status}"} - except docker.errors.NotFound: - return {"ok": False, "message": "Bot container not found"} - except Exception as e: - return {"ok": False, "message": f"Failed to inspect bot container: {e}"} - payload_b64 = self._build_http_probe_payload_b64( - url=url, - method=method, - headers=headers, - body_json=body_json, - timeout_seconds=timeout_seconds, - ) - return self._run_http_probe_exec(container, payload_b64) - - def probe_http_via_temporary_container( - self, - image_tag: str, - url: str, - method: str = "GET", - headers: Optional[Dict[str, str]] = None, - body_json: Optional[Dict[str, Any]] = None, - timeout_seconds: int = 10, - ) -> Dict[str, Any]: - if not self.client: - return {"ok": False, "message": "Docker client is not available"} - image = str(image_tag or self.base_image).strip() or self.base_image - payload_b64 = self._build_http_probe_payload_b64( - url=url, - method=method, - headers=headers, - body_json=body_json, - timeout_seconds=timeout_seconds, - ) - container = None - try: - container = self.client.containers.run( - image=image, - name=f"dashboard_probe_{uuid.uuid4().hex[:10]}", - command=["sh", "-c", "sleep 45"], - detach=True, - tty=False, - stdin_open=False, - network_mode="bridge", - ) - return self._run_http_probe_exec(container, payload_b64) - except docker.errors.ImageNotFound: - return {"ok": False, "message": f"Probe image not found: {image}"} - except Exception as e: - return {"ok": False, "message": f"Failed to run temporary probe container: {e}"} - finally: - if container is not None: - try: - container.remove(force=True) - except Exception: - pass - def send_command(self, bot_id: str, command: str, media: Optional[List[str]] = None) -> bool: """Send a command to dashboard channel with robust container-local delivery.""" if not self.client: diff --git a/backend/main.py b/backend/main.py index d5d2684..f1a388e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -163,13 +163,6 @@ class BotMcpConfigUpdateRequest(BaseModel): mcp_servers: Optional[Dict[str, Any]] = None -class BotMcpConfigTestRequest(BaseModel): - type: Optional[str] = None - url: Optional[str] = None - headers: Optional[Dict[str, str]] = None - tool_timeout: Optional[int] = None - - class BotEnvParamsUpdateRequest(BaseModel): env_params: Optional[Dict[str, str]] = None @@ -1112,215 +1105,6 @@ def _sanitize_mcp_servers_in_config_data(config_data: Dict[str, Any]) -> Dict[st 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"}: - transport_type = "streamableHttp" - url = str(cfg.get("url") or "").strip() - headers_raw = cfg.get("headers") - headers: Dict[str, str] = {} - if isinstance(headers_raw, dict): - for k, v in headers_raw.items(): - key = str(k or "").strip() - if key: - headers[key] = str(v or "").strip() - timeout_raw = cfg.get("toolTimeout", 10) - try: - timeout_s = max(1, min(int(timeout_raw), 30)) - except Exception: - timeout_s = 10 - - if not url: - return { - "ok": False, - "transport": transport_type, - "status_code": None, - "message": "MCP url is required", - "probe_from": "validation", - } - - def _with_body_preview(message: str, preview: Any) -> str: - text = str(message or "").strip() - body = " ".join(str(preview or "").strip().split()) - if not body: - return text - body = body[:240] - return f"{text}: {body}" if text else body - - if bot_id: - if transport_type == "sse": - probe_headers = dict(headers) - probe_headers.setdefault("Accept", "text/event-stream") - probe = docker_manager.probe_http_from_container( - bot_id=bot_id, - url=url, - method="GET", - headers=probe_headers, - body_json=None, - timeout_seconds=timeout_s, - ) - status_code = probe.get("status_code") - content_type = str(probe.get("content_type") or "") - message = str(probe.get("message") or "").strip() - body_preview = probe.get("body_preview") - if status_code in {401, 403}: - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP SSE endpoint", "content_type": content_type, "probe_from": "bot-container"} - if status_code == 404: - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint not found", "content_type": content_type, "probe_from": "bot-container"} - if isinstance(status_code, int) and status_code >= 500: - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("MCP SSE endpoint server error", body_preview), "content_type": content_type, "probe_from": "bot-container"} - if not probe.get("ok"): - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview(message or "Failed to connect MCP SSE endpoint from bot container", body_preview), "content_type": content_type, "probe_from": "bot-container"} - if "text/event-stream" not in content_type.lower(): - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("Endpoint reachable, but content-type is not text/event-stream", body_preview), "content_type": content_type, "probe_from": "bot-container"} - return {"ok": True, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint is reachable", "content_type": content_type, "probe_from": "bot-container"} - - probe_headers = dict(headers) - probe_headers.setdefault("Accept", "application/json, text/event-stream") - probe = docker_manager.probe_http_from_container( - bot_id=bot_id, - url=url, - method="GET", - headers=probe_headers, - body_json=None, - timeout_seconds=timeout_s, - ) - status_code = probe.get("status_code") - message = str(probe.get("message") or "").strip() - body_preview = probe.get("body_preview") - if status_code in {401, 403}: - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP endpoint", "probe_from": "bot-container"} - if status_code == 404: - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint not found", "probe_from": "bot-container"} - if isinstance(status_code, int) and status_code >= 500: - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("MCP endpoint server error", body_preview), "probe_from": "bot-container"} - if probe.get("ok") and status_code in {200, 201, 202, 204, 400, 401, 403, 405, 406, 415, 422}: - reachability_msg = "MCP endpoint is reachable" if status_code in {200, 201, 202, 204} else "MCP endpoint is reachable (HTTP endpoint responded as expected)" - return {"ok": True, "transport": transport_type, "status_code": status_code, "message": reachability_msg, "probe_from": "bot-container"} - return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview(message or "Unexpected response from MCP endpoint", body_preview), "probe_from": "bot-container"} - - try: - with httpx.Client(timeout=httpx.Timeout(timeout_s), follow_redirects=True) as client: - if transport_type == "sse": - req_headers = dict(headers) - req_headers.setdefault("Accept", "text/event-stream") - resp = client.get(url, headers=req_headers) - content_type = str(resp.headers.get("content-type") or "") - body_preview = resp.text[:512] - if resp.status_code in {401, 403}: - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": "Auth failed for MCP SSE endpoint", - "content_type": content_type, - "probe_from": "backend-host", - } - if resp.status_code == 404: - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": "MCP SSE endpoint not found", - "content_type": content_type, - "probe_from": "backend-host", - } - if resp.status_code >= 500: - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": _with_body_preview("MCP SSE endpoint server error", body_preview), - "content_type": content_type, - "probe_from": "backend-host", - } - if "text/event-stream" not in content_type.lower(): - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": _with_body_preview("Endpoint reachable, but content-type is not text/event-stream", body_preview), - "content_type": content_type, - "probe_from": "backend-host", - } - return { - "ok": True, - "transport": transport_type, - "status_code": resp.status_code, - "message": "MCP SSE endpoint is reachable", - "content_type": content_type, - "probe_from": "backend-host", - } - - req_headers = dict(headers) - req_headers.setdefault("Accept", "application/json, text/event-stream") - resp = client.get(url, headers=req_headers) - body_preview = resp.text[:512] - if resp.status_code in {401, 403}: - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": "Auth failed for MCP endpoint", - "probe_from": "backend-host", - } - if resp.status_code == 404: - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": "MCP endpoint not found", - "probe_from": "backend-host", - } - if resp.status_code >= 500: - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": _with_body_preview("MCP endpoint server error", body_preview), - "probe_from": "backend-host", - } - if resp.status_code in {200, 201, 202, 204}: - return { - "ok": True, - "transport": transport_type, - "status_code": resp.status_code, - "message": "MCP endpoint is reachable", - "probe_from": "backend-host", - } - if resp.status_code in {400, 401, 403, 405, 406, 415, 422}: - return { - "ok": True, - "transport": transport_type, - "status_code": resp.status_code, - "message": "MCP endpoint is reachable (HTTP endpoint responded as expected)", - "probe_from": "backend-host", - } - return { - "ok": False, - "transport": transport_type, - "status_code": resp.status_code, - "message": _with_body_preview("Unexpected response from MCP endpoint", body_preview), - "probe_from": "backend-host", - } - except httpx.TimeoutException: - return { - "ok": False, - "transport": transport_type, - "status_code": None, - "message": "MCP endpoint timeout", - "probe_from": "backend-host", - } - except Exception as exc: - return { - "ok": False, - "transport": transport_type, - "status_code": None, - "message": f"MCP probe failed: {type(exc).__name__}: {exc}", - "probe_from": "backend-host", - } - - def _parse_env_params(raw: Any) -> Dict[str, str]: return _normalize_env_params(raw) @@ -2491,31 +2275,6 @@ def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, sessi } -@app.post("/api/bots/{bot_id}/mcp-config/test") -def test_bot_mcp_config(bot_id: str, payload: BotMcpConfigTestRequest, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - cfg = { - "type": str(payload.type or "streamableHttp").strip(), - "url": str(payload.url or "").strip(), - "headers": payload.headers or {}, - "toolTimeout": payload.tool_timeout if payload.tool_timeout is not None else 10, - } - result = _probe_mcp_server(cfg, bot_id=bot_id) - if not result.get("ok"): - logger.error( - "mcp probe failed bot_id=%s transport=%s url=%s probe_from=%s status_code=%s message=%s", - bot_id, - result.get("transport"), - cfg.get("url"), - result.get("probe_from"), - result.get("status_code"), - result.get("message"), - ) - return result - - @app.get("/api/bots/{bot_id}/env-params") def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index faad7d2..56ad013 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -195,12 +195,6 @@ export const dashboardEn = { mcpBotIdPlaceholder: 'e.g. mula_bot_b02', mcpBotSecretPlaceholder: 'Secret for this bot identity', mcpToolTimeout: 'Tool Timeout (seconds)', - mcpTest: 'Test Connectivity', - mcpTesting: 'Testing connectivity...', - mcpTestPass: 'Connectivity test passed.', - mcpTestFail: 'Connectivity test failed.', - mcpTestNeedUrl: 'Please provide MCP URL first.', - mcpTestBlockSave: 'MCP connectivity test failed. Save is blocked.', mcpDraftRequired: 'MCP server name and URL are required.', mcpDraftAdded: 'Added to the MCP list. Save config to apply.', addMcpServer: 'Add MCP Server', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 92823cc..beca947 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -195,12 +195,6 @@ export const dashboardZhCn = { mcpBotIdPlaceholder: '如 mula_bot_b02', mcpBotSecretPlaceholder: '输入该 Bot 对应的密钥', mcpToolTimeout: 'Tool Timeout(秒)', - mcpTest: '测试连通性', - mcpTesting: '连通性测试中...', - mcpTestPass: '连通性测试通过。', - mcpTestFail: '连通性测试失败。', - mcpTestNeedUrl: '请先填写 MCP URL。', - mcpTestBlockSave: '存在未通过的 MCP 连通性测试,已阻止保存。', mcpDraftRequired: '请先填写 MCP 服务名称和 URL。', mcpDraftAdded: '已加入 MCP 列表,记得保存配置。', addMcpServer: '新增 MCP Server', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 54a822e..32bcb9d 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -134,19 +134,6 @@ interface MCPConfigResponse { status?: string; } -interface MCPTestResponse { - ok: boolean; - transport?: string; - status_code?: number | null; - message?: string; - probe_from?: string; -} - -interface MCPTestState { - status: 'idle' | 'testing' | 'pass' | 'fail'; - message: string; -} - interface MCPServerDraft { name: string; type: 'streamableHttp' | 'sse'; @@ -1051,7 +1038,6 @@ export function BotDashboardModule({ originName: '', }); const [expandedMcpByKey, setExpandedMcpByKey] = useState>({}); - const [mcpTestByIndex, setMcpTestByIndex] = useState>({}); const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftValue, setEnvDraftValue] = useState(''); const [envDraftVisible, setEnvDraftVisible] = useState(false); @@ -2419,7 +2405,6 @@ export function BotDashboardModule({ }); return next; }); - setMcpTestByIndex({}); return drafts; }; @@ -2913,7 +2898,6 @@ export function BotDashboardModule({ const updateMcpServer = (index: number, patch: Partial) => { setMcpServers((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row))); - setMcpTestByIndex((prev) => ({ ...prev, [index]: { status: 'idle', message: '' } })); }; const canRemoveMcpServer = (row?: MCPServerDraft | null) => { @@ -2937,16 +2921,7 @@ export function BotDashboardModule({ }); return next; }); - setMcpTestByIndex((prev) => { - const next: Record = {}; - Object.entries(prev).forEach(([key, val]) => { - const idx = Number(key); - if (idx < index) next[idx] = val; - if (idx > index) next[idx - 1] = val; - }); - return next; - }); - await saveBotMcpConfig(nextRows, { skipConnectivityTest: true }); + await saveBotMcpConfig(nextRows); }; const buildMcpHeaders = (row: MCPServerDraft): Record => { @@ -2969,62 +2944,17 @@ export function BotDashboardModule({ return headers; }; - const testSingleMcpServer = async (row: MCPServerDraft, index: number): Promise => { - if (!selectedBot) return false; - const url = String(row.url || '').trim(); - if (!url) { - setMcpTestByIndex((prev) => ({ - ...prev, - [index]: { status: 'fail', message: t.mcpTestNeedUrl }, - })); - return false; - } - const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60)); - setMcpTestByIndex((prev) => ({ - ...prev, - [index]: { status: 'testing', message: t.mcpTesting }, - })); - try { - const res = await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config/test`, { - type: row.type === 'sse' ? 'sse' : 'streamableHttp', - url, - headers: buildMcpHeaders(row), - tool_timeout: timeout, - }); - const ok = Boolean(res.data?.ok); - const baseMsg = String(res.data?.message || '').trim() || (ok ? t.mcpTestPass : t.mcpTestFail); - const probeFrom = String(res.data?.probe_from || '').trim(); - const msg = probeFrom ? `${baseMsg} (${probeFrom})` : baseMsg; - setMcpTestByIndex((prev) => ({ - ...prev, - [index]: { status: ok ? 'pass' : 'fail', message: msg }, - })); - return ok; - } catch (error: any) { - const msg = error?.response?.data?.detail || t.mcpTestFail; - setMcpTestByIndex((prev) => ({ - ...prev, - [index]: { status: 'fail', message: String(msg) }, - })); - return false; - } - }; - const saveBotMcpConfig = async ( rows: MCPServerDraft[] = mcpServers, - options?: { closeDraft?: boolean; expandedKey?: string; skipConnectivityTest?: boolean }, + options?: { closeDraft?: boolean; expandedKey?: string }, ) => { if (!selectedBot) return; const mcp_servers: Record = {}; - const testQueue: Array<{ index: number; row: MCPServerDraft }> = []; - for (const [index, row] of rows.entries()) { + for (const row of rows) { const name = String(row.name || '').trim(); const url = String(row.url || '').trim(); if (!name || !url) continue; const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60)); - if (!row.locked) { - testQueue.push({ index, row }); - } mcp_servers[name] = { type: row.type === 'sse' ? 'sse' : 'streamableHttp', url, @@ -3034,16 +2964,6 @@ export function BotDashboardModule({ } setIsSavingMcp(true); try { - if (!options?.skipConnectivityTest) { - for (const item of testQueue) { - const ok = await testSingleMcpServer(item.row, item.index); - if (!ok) { - notify(t.mcpTestBlockSave, { tone: 'error' }); - setIsSavingMcp(false); - return; - } - } - } await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/mcp-config`, { mcp_servers }); if (options?.expandedKey) { setExpandedMcpByKey({ [options.expandedKey]: true }); @@ -6496,14 +6416,6 @@ export function BotDashboardModule({
{summary}
- ) : null} - {mcpTestByIndex[idx]?.status !== 'idle' ? ( -
- {mcpTestByIndex[idx]?.message} -
- ) : null}
); })