mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
增加 Admin MCP 更新检测与按需更新能力。
新增 check-updates 接口并在已安装列表展示更新状态,支持一键检查更新与仅更新过期项,减少 MCP 上游变更导致的被动排障成本。 Made-with: Cursor
This commit is contained in:
parent
1d46356d9e
commit
6a71892c72
3 changed files with 416 additions and 0 deletions
|
|
@ -5,7 +5,10 @@ import hashlib
|
|||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from urllib import parse as urllib_parse
|
||||
from urllib import request as urllib_request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -155,6 +158,106 @@ def _mcp_health_and_sync_one(store: SqliteStore, row: dict[str, Any]) -> dict[st
|
|||
rt.stop()
|
||||
|
||||
|
||||
def _http_get_json(url: str, *, timeout: float = 8.0) -> dict[str, Any]:
|
||||
req = urllib_request.Request(
|
||||
url,
|
||||
headers={
|
||||
"accept": "application/json",
|
||||
"user-agent": "oclaw-admin-mcp-check/1.0",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="ignore")
|
||||
data = json.loads(raw) if raw else {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _github_repo_from_source_ref(source_ref: str) -> str:
|
||||
s = str(source_ref or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
m = re.search(r"github\.com[:/]+([^/]+)/([^/#]+)", s, re.I)
|
||||
if m:
|
||||
owner = str(m.group(1) or "").strip()
|
||||
repo = str(m.group(2) or "").strip().removesuffix(".git")
|
||||
return f"{owner}/{repo}" if owner and repo else ""
|
||||
if re.fullmatch(r"[^/\s]+/[^/\s]+", s):
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_mcp_latest_version(source_type: str, source_ref: str) -> tuple[str, str]:
|
||||
st = str(source_type or "").strip().lower()
|
||||
sr = str(source_ref or "").strip()
|
||||
if st == "npm":
|
||||
pkg = urllib_parse.quote(sr, safe="@/")
|
||||
data = _http_get_json(f"https://registry.npmjs.org/{pkg}")
|
||||
tags = data.get("dist-tags") if isinstance(data.get("dist-tags"), dict) else {}
|
||||
latest = str(tags.get("latest") or "").strip()
|
||||
return latest, "npm:dist-tags.latest"
|
||||
if st == "pypi":
|
||||
pkg = urllib_parse.quote(sr, safe="")
|
||||
data = _http_get_json(f"https://pypi.org/pypi/{pkg}/json")
|
||||
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
||||
latest = str(info.get("version") or "").strip()
|
||||
return latest, "pypi:info.version"
|
||||
if st == "github":
|
||||
repo = _github_repo_from_source_ref(sr)
|
||||
if not repo:
|
||||
return "", "github:repo_parse_failed"
|
||||
try:
|
||||
data = _http_get_json(f"https://api.github.com/repos/{repo}/releases/latest")
|
||||
latest = str(data.get("tag_name") or data.get("name") or "").strip()
|
||||
if latest:
|
||||
return latest, "github:releases.latest"
|
||||
except Exception:
|
||||
pass
|
||||
data2 = _http_get_json(f"https://api.github.com/repos/{repo}/tags?per_page=1")
|
||||
if isinstance(data2, list): # defensive; _http_get_json returns dict for objects
|
||||
return "", "github:tags_empty"
|
||||
# GitHub tags endpoint returns list; if parse failed to dict, try raw fetch.
|
||||
req = urllib_request.Request(
|
||||
f"https://api.github.com/repos/{repo}/tags?per_page=1",
|
||||
headers={"accept": "application/json", "user-agent": "oclaw-admin-mcp-check/1.0"},
|
||||
method="GET",
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=8.0) as resp:
|
||||
arr_raw = resp.read().decode("utf-8", errors="ignore")
|
||||
arr = json.loads(arr_raw) if arr_raw else []
|
||||
if isinstance(arr, list) and arr:
|
||||
top = arr[0] if isinstance(arr[0], dict) else {}
|
||||
return str(top.get("name") or "").strip(), "github:tags[0]"
|
||||
return "", "github:no_release_or_tag"
|
||||
return "", "unsupported_source_type"
|
||||
|
||||
|
||||
def _check_mcp_update_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
sid = str(row.get("server_id") or "").strip()
|
||||
source_type = str(row.get("source_type") or "").strip().lower()
|
||||
source_ref = str(row.get("source_ref") or "").strip()
|
||||
current_version = str(row.get("version") or "").strip()
|
||||
out = {
|
||||
"server_id": sid,
|
||||
"source_type": source_type,
|
||||
"source_ref": source_ref,
|
||||
"current_version": current_version,
|
||||
"latest_version": "",
|
||||
"has_update": False,
|
||||
"check_error": "",
|
||||
"check_source": "",
|
||||
}
|
||||
try:
|
||||
latest, src = _detect_mcp_latest_version(source_type, source_ref)
|
||||
out["latest_version"] = latest
|
||||
out["check_source"] = src
|
||||
if latest and current_version and current_version not in {"latest", "*"} and current_version != latest:
|
||||
out["has_update"] = True
|
||||
except Exception as exc:
|
||||
out["check_error"] = f"{type(exc).__name__}: {exc}"
|
||||
return out
|
||||
|
||||
|
||||
def _enrich_wecom_channel_account(store: SqliteStore, tenant_id: str, user_id: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||
aid = str(item.get("account_id") or "")
|
||||
cfg = item.get("config")
|
||||
|
|
@ -2772,6 +2875,127 @@ def build_admin_router() -> APIRouter:
|
|||
"mcp_migrated_saved": str(mcp_migrated_json_path()),
|
||||
}
|
||||
|
||||
@router.post("/admin/api/mcp/update")
|
||||
def api_mcp_update(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
server_id = str(payload.get("server_id") or "").strip()
|
||||
enabled_only = bool(payload.get("enabled_only", True))
|
||||
dry_run = bool(payload.get("dry_run", False))
|
||||
sync_tools = bool(payload.get("sync_tools", True))
|
||||
version_override = str(payload.get("version") or "").strip()
|
||||
update_to_latest = bool(payload.get("update_to_latest", True))
|
||||
|
||||
rows = store.list_mcp_servers(enabled_only=enabled_only if not server_id else False)
|
||||
if server_id:
|
||||
rows = [x for x in rows if str(x.get("server_id") or "").strip() == server_id]
|
||||
if not rows:
|
||||
return {"ok": False, "error": "server_not_found"}
|
||||
|
||||
apply_gateway_mcp_env_to_os()
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
sid = str(row.get("server_id") or "").strip()
|
||||
req_version = version_override
|
||||
if not req_version:
|
||||
req_version = "" if update_to_latest else str(row.get("version") or "").strip()
|
||||
manifest = McpServerManifest(
|
||||
server_id=sid,
|
||||
source_type=str(row.get("source_type") or ""),
|
||||
source_ref=str(row.get("source_ref") or ""),
|
||||
version=req_version,
|
||||
entry_command=str(row.get("entry_command") or ""),
|
||||
entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()],
|
||||
env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {},
|
||||
permissions=[str(x) for x in (row.get("required_permissions") or [])],
|
||||
risk_level=str(row.get("risk_level") or "high"),
|
||||
enabled=bool(row.get("enabled")),
|
||||
timeout_s=float(row.get("timeout_s") or 30.0),
|
||||
)
|
||||
install = install_mcp_server(manifest, dry_run=dry_run)
|
||||
store.upsert_mcp_server(
|
||||
server_id=manifest.server_id,
|
||||
source_type=manifest.source_type,
|
||||
source_ref=manifest.source_ref,
|
||||
version=manifest.version,
|
||||
entry_command=manifest.entry_command,
|
||||
entry_args=manifest.entry_args,
|
||||
env_schema=manifest.env_schema,
|
||||
required_permissions=manifest.permissions,
|
||||
risk_level=manifest.risk_level,
|
||||
timeout_s=manifest.timeout_s,
|
||||
enabled=manifest.enabled if install.ok else False,
|
||||
)
|
||||
item: dict[str, Any] = {
|
||||
"server_id": manifest.server_id,
|
||||
"ok": bool(install.ok),
|
||||
"error_code": str(install.error_code or ""),
|
||||
"error": str(install.error or ""),
|
||||
"install_command": str(install.install_command or ""),
|
||||
"requested_version": req_version,
|
||||
"tools_synced": 0,
|
||||
}
|
||||
if install.ok and sync_tools and (not dry_run):
|
||||
latest_row = next(
|
||||
(x for x in store.list_mcp_servers(enabled_only=False) if str(x.get("server_id") or "") == sid),
|
||||
None,
|
||||
)
|
||||
if isinstance(latest_row, dict):
|
||||
hs = _mcp_health_and_sync_one(store, latest_row)
|
||||
if isinstance(hs, dict):
|
||||
item["tools_synced"] = int(hs.get("tools_synced") or 0)
|
||||
if not bool(hs.get("ok")):
|
||||
item["ok"] = False
|
||||
item["error_code"] = str(hs.get("error_code") or "mcp_sync_failed")
|
||||
item["error"] = str(hs.get("error") or "sync_failed")
|
||||
store.add_mcp_installation_log(
|
||||
server_id=manifest.server_id,
|
||||
status="ok" if item["ok"] else "error",
|
||||
error_code=str(item["error_code"] or None),
|
||||
detail={
|
||||
"error": item["error"],
|
||||
"update": True,
|
||||
"requested_version": req_version,
|
||||
"tools_synced": int(item.get("tools_synced") or 0),
|
||||
},
|
||||
install_command=install.install_command,
|
||||
)
|
||||
out.append(item)
|
||||
|
||||
ok_count = len([x for x in out if bool(x.get("ok"))])
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=ctx["tenant_id"],
|
||||
actor_user_id=ctx["user_id"],
|
||||
action="mcp_update" if server_id else "mcp_update_batch",
|
||||
target_type="mcp_server",
|
||||
target_id=server_id or "batch",
|
||||
status="ok" if ok_count == len(out) else "error",
|
||||
detail={
|
||||
"enabled_only": enabled_only,
|
||||
"dry_run": dry_run,
|
||||
"sync_tools": sync_tools,
|
||||
"update_to_latest": update_to_latest,
|
||||
"requested_version": version_override,
|
||||
"ok_count": ok_count,
|
||||
"error_count": len(out) - ok_count,
|
||||
},
|
||||
)
|
||||
if ok_count > 0:
|
||||
persist_mcp_migrated_file(store)
|
||||
return {
|
||||
"ok": ok_count == len(out),
|
||||
"total": len(out),
|
||||
"ok_count": ok_count,
|
||||
"error_count": len(out) - ok_count,
|
||||
"items": out,
|
||||
"mcp_migrated_saved": str(mcp_migrated_json_path()),
|
||||
}
|
||||
|
||||
@router.post("/admin/api/mcp/uninstall")
|
||||
def api_mcp_uninstall(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
|
|
@ -3033,6 +3257,27 @@ def build_admin_router() -> APIRouter:
|
|||
ok_count = len([x for x in out if bool(x.get("ok"))])
|
||||
return {"ok": True, "total": len(out), "ok_count": ok_count, "error_count": len(out) - ok_count, "items": out}
|
||||
|
||||
@router.post("/admin/api/mcp/check-updates")
|
||||
def api_mcp_check_updates(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = _resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
enabled_only = bool(payload.get("enabled_only", True))
|
||||
rows = store.list_mcp_servers(enabled_only=enabled_only)
|
||||
items = [_check_mcp_update_row(row) for row in rows]
|
||||
update_count = len([x for x in items if bool(x.get("has_update"))])
|
||||
return {
|
||||
"ok": True,
|
||||
"total": len(items),
|
||||
"update_count": update_count,
|
||||
"up_to_date_count": len(items) - update_count,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@router.post("/admin/api/mcp/repair-weak")
|
||||
def api_mcp_repair_weak(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
|
|
|
|||
|
|
@ -4269,6 +4269,7 @@ async function renderPlugins() {
|
|||
let depStatus = { items: [] };
|
||||
let mcpBinding = { available_specialists: ["generalist"], servers: [], mapping: {} };
|
||||
let mcpUsage = { summary: [], calls: [] };
|
||||
let mcpUpdateState = { byServer: {} };
|
||||
let mcpActionMenuEl = null;
|
||||
const closeMcpActionMenu = () => {
|
||||
if (mcpActionMenuEl && mcpActionMenuEl.parentNode) {
|
||||
|
|
@ -4344,6 +4345,53 @@ async function renderPlugins() {
|
|||
router();
|
||||
},
|
||||
});
|
||||
const checkUpdatesBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "Check Updates",
|
||||
onclick: async () => {
|
||||
const r = await apiPost("/admin/api/mcp/check-updates", { enabled_only: true });
|
||||
const items = Array.isArray(r.items) ? r.items : [];
|
||||
const next = {};
|
||||
items.forEach((x) => {
|
||||
const sid = String(x.server_id || "").trim();
|
||||
if (!sid) return;
|
||||
next[sid] = x;
|
||||
});
|
||||
mcpUpdateState.byServer = next;
|
||||
const updates = items.filter((x) => !!x.has_update).map((x) => String(x.server_id || "-"));
|
||||
installStatus.textContent =
|
||||
`[check-updates] total=${Number(r.total || 0)} updates=${Number(r.update_count || 0)} ` +
|
||||
(updates.length ? `(${updates.join(", ")})` : "(none)");
|
||||
repaintMcpInstalled();
|
||||
},
|
||||
});
|
||||
const updateOutdatedBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "Update Outdated Only",
|
||||
onclick: async () => {
|
||||
const byServer = mcpUpdateState.byServer && typeof mcpUpdateState.byServer === "object" ? mcpUpdateState.byServer : {};
|
||||
const outdated = Object.keys(byServer).filter((sid) => !!(byServer[sid] && byServer[sid].has_update));
|
||||
if (!outdated.length) {
|
||||
installStatus.textContent = "[update-outdated] no outdated MCPs. Click Check Updates first.";
|
||||
return;
|
||||
}
|
||||
const results = [];
|
||||
for (const sid of outdated) {
|
||||
const r = await apiPost("/admin/api/mcp/update", {
|
||||
server_id: sid,
|
||||
update_to_latest: true,
|
||||
sync_tools: true,
|
||||
});
|
||||
results.push({ server_id: sid, ok: !!r.ok, raw: r });
|
||||
}
|
||||
const okCount = results.filter((x) => x.ok).length;
|
||||
installStatus.textContent =
|
||||
`[update-outdated] total=${results.length} ok=${okCount} err=${results.length - okCount} ` +
|
||||
results.map((x) => `${x.server_id}:${x.ok ? "ok" : "err"}`).join(" | ");
|
||||
markPrewarmReminder("mcp_updated");
|
||||
router();
|
||||
},
|
||||
});
|
||||
const e2eCheckBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "E2E Check",
|
||||
|
|
@ -4358,6 +4406,27 @@ async function renderPlugins() {
|
|||
router();
|
||||
},
|
||||
});
|
||||
const updateAllBtn = el("button", {
|
||||
class: "btn",
|
||||
text: "Update Installed",
|
||||
onclick: async () => {
|
||||
const r = await apiPost("/admin/api/mcp/update", {
|
||||
enabled_only: true,
|
||||
update_to_latest: true,
|
||||
sync_tools: true,
|
||||
});
|
||||
const items = Array.isArray(r.items) ? r.items : [];
|
||||
const parts = items.map((x) => {
|
||||
const sid = String(x.server_id || "-");
|
||||
if (x.ok) return `${sid}:ok(sync=${Number(x.tools_synced || 0)})`;
|
||||
return `${sid}:err(${String(x.error_code || "unknown")})`;
|
||||
});
|
||||
installStatus.textContent =
|
||||
`[update-all] total=${Number(r.total || 0)} ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} ${parts.join(" | ")}`;
|
||||
markPrewarmReminder("mcp_updated");
|
||||
router();
|
||||
},
|
||||
});
|
||||
const repairWeakIncludeDisabledCb = el("input", { type: "checkbox" });
|
||||
repairWeakIncludeDisabledCb.checked = false;
|
||||
const repairWeakBtn = el("button", {
|
||||
|
|
@ -5071,6 +5140,21 @@ async function renderPlugins() {
|
|||
router();
|
||||
},
|
||||
});
|
||||
const updateBtn = el("button", {
|
||||
class: "chat-sess-menu-item",
|
||||
text: "Update",
|
||||
onclick: async () => {
|
||||
closeMcpActionMenu();
|
||||
const r = await apiPost("/admin/api/mcp/update", {
|
||||
server_id: sid,
|
||||
update_to_latest: true,
|
||||
sync_tools: true,
|
||||
});
|
||||
installStatus.textContent = `[update:${sid}] ` + JSON.stringify(r);
|
||||
markPrewarmReminder("mcp_updated");
|
||||
router();
|
||||
},
|
||||
});
|
||||
const uninstallBtn = el("button", {
|
||||
class: "chat-sess-menu-item",
|
||||
text: "Uninstall",
|
||||
|
|
@ -5109,6 +5193,7 @@ async function renderPlugins() {
|
|||
toggleBtn,
|
||||
healthBtn,
|
||||
syncBtn,
|
||||
updateBtn,
|
||||
reinstallBtn,
|
||||
uninstallBtn,
|
||||
deleteBtn,
|
||||
|
|
@ -5137,6 +5222,14 @@ async function renderPlugins() {
|
|||
},
|
||||
});
|
||||
const tools = Array.isArray(x.tools) ? x.tools.map((t) => String(t.tool_name || "")).join(", ") : "";
|
||||
const upd = (mcpUpdateState.byServer && mcpUpdateState.byServer[sid]) || null;
|
||||
const updText = upd
|
||||
? (upd.check_error
|
||||
? `err:${String(upd.check_error || "").slice(0, 80)}`
|
||||
: (upd.has_update
|
||||
? `update ${String(upd.current_version || "-")} -> ${String(upd.latest_version || "-")}`
|
||||
: "up-to-date"))
|
||||
: "-";
|
||||
const healthObj = x.health && typeof x.health === "object" ? x.health : {};
|
||||
const healthStatus = String(healthObj.status || "-");
|
||||
const healthDetail = healthObj.detail && typeof healthObj.detail === "object" ? healthObj.detail : {};
|
||||
|
|
@ -5153,6 +5246,7 @@ async function renderPlugins() {
|
|||
tdCell(String(x.version || ""), 16),
|
||||
tdCell(String(x.entry_command || ""), 24),
|
||||
tdCell(tools || "-", 48),
|
||||
tdCell(updText, 28),
|
||||
tdCell(String(x.enabled ? 1 : 0), 8),
|
||||
el("td", { text: healthText, title: healthTitle }),
|
||||
el("td", { class: "table__cell-actions" }, [actionMenuBtn]),
|
||||
|
|
@ -5859,8 +5953,11 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m
|
|||
jsonInstallInput,
|
||||
el("div", { class: "row" }, [jsonInstallBtn]),
|
||||
el("div", { class: "row", style: "flex-wrap:wrap;align-items:center;gap:8px;" }, [
|
||||
checkUpdatesBtn,
|
||||
updateOutdatedBtn,
|
||||
checkAllBtn,
|
||||
e2eCheckBtn,
|
||||
updateAllBtn,
|
||||
repairWeakBtn,
|
||||
repairWeakScopeLabel,
|
||||
]),
|
||||
|
|
@ -5911,6 +6008,7 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m
|
|||
el("th", { text: "version" }),
|
||||
el("th", { text: "entry" }),
|
||||
el("th", { text: "tools" }),
|
||||
el("th", { text: "update" }),
|
||||
el("th", { text: "enabled" }),
|
||||
el("th", { text: "health" }),
|
||||
el("th", { text: "actions" }),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.interfaces.admin import routes as admin_routes
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
|
@ -160,6 +161,38 @@ class McpAdminApiTests(unittest.TestCase):
|
|||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertEqual(str(data.get("server_id") or ""), "dummy-reinstall")
|
||||
|
||||
def test_update_from_saved_manifest_single_and_batch(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="dummy-update",
|
||||
source_type="npm",
|
||||
source_ref="mcp-fetch-server",
|
||||
entry_command="python",
|
||||
entry_args=[script],
|
||||
enabled=True,
|
||||
)
|
||||
single = self.client.post(
|
||||
"/admin/api/mcp/update",
|
||||
json={"server_id": "dummy-update", "dry_run": True, "update_to_latest": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(single.status_code, 200)
|
||||
d1 = single.json()
|
||||
self.assertTrue(d1.get("ok"), d1)
|
||||
self.assertEqual(int(d1.get("total") or 0), 1)
|
||||
self.assertEqual(str((d1.get("items") or [{}])[0].get("server_id") or ""), "dummy-update")
|
||||
|
||||
batch = self.client.post(
|
||||
"/admin/api/mcp/update",
|
||||
json={"enabled_only": True, "dry_run": True, "update_to_latest": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(batch.status_code, 200)
|
||||
d2 = batch.json()
|
||||
self.assertTrue(d2.get("ok"), d2)
|
||||
self.assertGreaterEqual(int(d2.get("total") or 0), 1)
|
||||
|
||||
def test_check_all_enabled_servers(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = SqliteStore(db_path())
|
||||
|
|
@ -257,6 +290,46 @@ class McpAdminApiTests(unittest.TestCase):
|
|||
self.assertTrue(isinstance(row, dict))
|
||||
self.assertTrue(bool((row or {}).get("ok")), row)
|
||||
|
||||
def test_check_updates_reports_update_candidates(self) -> None:
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="update-s1",
|
||||
source_type="npm",
|
||||
source_ref="demo-mcp",
|
||||
version="1.0.0",
|
||||
entry_command="python",
|
||||
entry_args=["-V"],
|
||||
enabled=True,
|
||||
)
|
||||
old_checker = admin_routes._check_mcp_update_row
|
||||
|
||||
def _fake_checker(row: dict[str, object]) -> dict[str, object]:
|
||||
sid = str(row.get("server_id") or "")
|
||||
return {
|
||||
"server_id": sid,
|
||||
"source_type": "npm",
|
||||
"source_ref": "demo-mcp",
|
||||
"current_version": "1.0.0",
|
||||
"latest_version": "1.1.0",
|
||||
"has_update": True,
|
||||
"check_error": "",
|
||||
"check_source": "npm:dist-tags.latest",
|
||||
}
|
||||
|
||||
try:
|
||||
admin_routes._check_mcp_update_row = _fake_checker # type: ignore[assignment]
|
||||
resp = self.client.post("/admin/api/mcp/check-updates", json={"enabled_only": True}, headers=self._headers())
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertGreaterEqual(int(data.get("update_count") or 0), 1)
|
||||
items = data.get("items") or []
|
||||
row = next((x for x in items if str(x.get("server_id") or "") == "update-s1"), None)
|
||||
self.assertTrue(isinstance(row, dict))
|
||||
self.assertTrue(bool((row or {}).get("has_update")), row)
|
||||
finally:
|
||||
admin_routes._check_mcp_update_row = old_checker # type: ignore[assignment]
|
||||
|
||||
def test_mcp_specialists_config(self) -> None:
|
||||
get_resp = self.client.get("/admin/api/mcp/specialists", headers=self._headers())
|
||||
self.assertEqual(get_resp.status_code, 200)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue