v0.1.4-p2
parent
89ed5f7107
commit
dab172cb2a
|
|
@ -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:
|
||||
|
|
|
|||
241
backend/main.py
241
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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Record<string, boolean>>({});
|
||||
const [mcpTestByIndex, setMcpTestByIndex] = useState<Record<number, MCPTestState>>({});
|
||||
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<MCPServerDraft>) => {
|
||||
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<number, MCPTestState> = {};
|
||||
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<string, string> => {
|
||||
|
|
@ -2969,62 +2944,17 @@ export function BotDashboardModule({
|
|||
return headers;
|
||||
};
|
||||
|
||||
const testSingleMcpServer = async (row: MCPServerDraft, index: number): Promise<boolean> => {
|
||||
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<MCPTestResponse>(`${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<string, MCPServerConfig> = {};
|
||||
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({
|
|||
<div className="ops-config-collapsed-meta">{summary}</div>
|
||||
</div>
|
||||
<div className="ops-config-card-actions">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void testSingleMcpServer(row, idx)}
|
||||
disabled={mcpTestByIndex[idx]?.status === 'testing' || isSavingMcp}
|
||||
>
|
||||
{mcpTestByIndex[idx]?.status === 'testing' ? <RefreshCw size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
<span style={{ marginLeft: 6 }}>{t.mcpTest}</span>
|
||||
</button>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
disabled={isSavingMcp || !canRemoveMcpServer(row)}
|
||||
|
|
@ -6602,11 +6514,6 @@ export function BotDashboardModule({
|
|||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{mcpTestByIndex[idx]?.status !== 'idle' ? (
|
||||
<div className="field-label" style={{ color: mcpTestByIndex[idx]?.status === 'pass' ? 'var(--ok)' : mcpTestByIndex[idx]?.status === 'fail' ? 'var(--err)' : 'var(--muted)' }}>
|
||||
{mcpTestByIndex[idx]?.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue