v0.1.4-p2

main
mula.liu 2026-03-15 17:28:58 +08:00
parent 89ed5f7107
commit dab172cb2a
5 changed files with 3 additions and 502 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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',

View File

@ -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',

View File

@ -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>
);
})