From 6a71892c72d689a0e818965980ae978ad3a32a46 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 29 Apr 2026 10:47:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Admin=20MCP=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=B5=8B=E4=B8=8E=E6=8C=89=E9=9C=80=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=83=BD=E5=8A=9B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 check-updates 接口并在已安装列表展示更新状态,支持一键检查更新与仅更新过期项,减少 MCP 上游变更导致的被动排障成本。 Made-with: Cursor --- interfaces/admin/routes.py | 245 +++++++++++++++++++++++++++++++++ interfaces/admin/static/app.js | 98 +++++++++++++ tests/test_mcp_admin_api.py | 73 ++++++++++ 3 files changed, 416 insertions(+) diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 2cc073c8..ea52c0ce 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -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), diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index f6362df3..fa94591f 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -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" }), diff --git a/tests/test_mcp_admin_api.py b/tests/test_mcp_admin_api.py index bc05f379..8c411b0b 100644 --- a/tests/test_mcp_admin_api.py +++ b/tests/test_mcp_admin_api.py @@ -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)