mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-16 04:20:45 +08:00
Make prewarm resync MCP tools and rebuild frozen wire cache.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
25d09d78ce
commit
3785e78a72
10 changed files with 253 additions and 110 deletions
|
|
@ -111,88 +111,22 @@ def _expand_mcp_entry_args(raw: list[Any] | None) -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def _mcp_health_and_sync_one(store: SqliteStore, row: dict[str, Any]) -> dict[str, Any] | None:
|
def _mcp_health_and_sync_one(store: SqliteStore, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
"""Run MCP initialize health + tools/list + persist tools (same semantics as check-all per row)."""
|
"""Persist MCP tools/list for one server. Health is best-effort and does not gate sync."""
|
||||||
sid = str(row.get("server_id") or "").strip()
|
from runtime.tools.mcp.sync_tools import sync_mcp_server_tools
|
||||||
cmd = str(row.get("entry_command") or "").strip()
|
|
||||||
args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()]
|
return sync_mcp_server_tools(store, row)
|
||||||
if not sid:
|
|
||||||
return None
|
|
||||||
if not cmd:
|
|
||||||
item: dict[str, Any] = {
|
|
||||||
"server_id": sid,
|
|
||||||
"ok": False,
|
|
||||||
"error_code": "mcp_entry_missing",
|
|
||||||
"error": "entry_command_missing",
|
|
||||||
"health": {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"},
|
|
||||||
"tools_synced": 0,
|
|
||||||
}
|
|
||||||
store.set_mcp_server_health(server_id=sid, status="error", detail=item["health"])
|
|
||||||
return item
|
|
||||||
if _is_bailian_webparser_remote(entry_command=cmd, entry_args=args):
|
|
||||||
tools = _bailian_webparser_virtual_tools()
|
|
||||||
store.replace_mcp_server_tools(server_id=sid, tools=tools)
|
|
||||||
detail = {"synced_tools": len(tools), "compat_mode": "bailian_webparser"}
|
|
||||||
store.set_mcp_server_health(server_id=sid, status="ok", detail=detail)
|
|
||||||
return {"server_id": sid, "ok": True, "health": detail, "tools_synced": len(tools)}
|
|
||||||
rt = mcp_runtime_for_row(row, store=store)
|
|
||||||
try:
|
|
||||||
health = rt.health()
|
|
||||||
health_ok = bool(health.get("ok"))
|
|
||||||
if not health_ok:
|
|
||||||
item = {
|
|
||||||
"server_id": sid,
|
|
||||||
"ok": False,
|
|
||||||
"error_code": str(health.get("error_code") or "mcp_healthcheck_failed"),
|
|
||||||
"error": str(health.get("error") or "healthcheck_failed"),
|
|
||||||
"health": health,
|
|
||||||
"tools_synced": 0,
|
|
||||||
}
|
|
||||||
store.set_mcp_server_health(server_id=sid, status="error", detail=health)
|
|
||||||
return item
|
|
||||||
tools_res = rt.tools_list()
|
|
||||||
if not bool(tools_res.get("ok")):
|
|
||||||
item = {
|
|
||||||
"server_id": sid,
|
|
||||||
"ok": False,
|
|
||||||
"error_code": str(tools_res.get("error_code") or "mcp_tools_list_invalid"),
|
|
||||||
"error": str(tools_res.get("error") or "tools_list_failed"),
|
|
||||||
"health": health,
|
|
||||||
"tools_synced": 0,
|
|
||||||
}
|
|
||||||
store.set_mcp_server_health(server_id=sid, status="error", detail=tools_res)
|
|
||||||
return item
|
|
||||||
tools = tools_res.get("tools") if isinstance(tools_res.get("tools"), list) else []
|
|
||||||
store.replace_mcp_server_tools(server_id=sid, tools=tools if isinstance(tools, list) else [])
|
|
||||||
store.set_mcp_server_health(server_id=sid, status="ok", detail={"synced_tools": len(tools)})
|
|
||||||
return {"server_id": sid, "ok": True, "health": health, "tools_synced": len(tools)}
|
|
||||||
finally:
|
|
||||||
rt.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_bailian_webparser_remote(*, entry_command: str, entry_args: list[str]) -> bool:
|
def _is_bailian_webparser_remote(*, entry_command: str, entry_args: list[str]) -> bool:
|
||||||
cmd = str(entry_command or "").strip().lower()
|
from runtime.tools.mcp.sync_tools import is_bailian_webparser_remote
|
||||||
if cmd not in {"npx", "npx.cmd", "node"}:
|
|
||||||
return False
|
return is_bailian_webparser_remote(entry_command=entry_command, entry_args=entry_args)
|
||||||
joined = " ".join(str(x or "").strip().lower() for x in (entry_args or []))
|
|
||||||
return "mcp-remote" in joined and "/api/v1/mcps/webparser/sse" in joined
|
|
||||||
|
|
||||||
|
|
||||||
def _bailian_webparser_virtual_tools() -> list[dict[str, Any]]:
|
def _bailian_webparser_virtual_tools() -> list[dict[str, Any]]:
|
||||||
return [
|
from runtime.tools.mcp.sync_tools import bailian_webparser_virtual_tools
|
||||||
{
|
|
||||||
"tool_name": "bailian_webparser_parse",
|
return bailian_webparser_virtual_tools()
|
||||||
"description": "Parse webpage via DashScope WebParser compatibility mode. Requires `url` (http/https).",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"url": {"type": "string", "description": "Target webpage URL (required). Example: https://example.com"},
|
|
||||||
"timeout": {"type": "integer", "default": 35, "minimum": 8, "maximum": 90},
|
|
||||||
},
|
|
||||||
"required": ["url"],
|
|
||||||
"additionalProperties": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _http_get_json(url: str, *, timeout: float = 8.0) -> dict[str, Any]:
|
def _http_get_json(url: str, *, timeout: float = 8.0) -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>oliver</title>
|
<title>oliver</title>
|
||||||
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
||||||
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-21" />
|
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-22" />
|
||||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-21" />
|
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-22" />
|
||||||
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-21" />
|
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-22" />
|
||||||
<style>
|
<style>
|
||||||
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
||||||
.admin-boot-splash {
|
.admin-boot-splash {
|
||||||
|
|
@ -129,33 +129,33 @@
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-21",
|
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-22",
|
||||||
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-21",
|
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-22",
|
||||||
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-21",
|
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-22",
|
||||||
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-21",
|
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-22",
|
||||||
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-21",
|
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-22",
|
||||||
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-21",
|
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-22",
|
||||||
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-21",
|
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-21",
|
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-21",
|
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-21",
|
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-21",
|
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-21",
|
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-21",
|
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-21",
|
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-21",
|
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-21",
|
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-21",
|
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-21",
|
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-21",
|
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-21",
|
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-21",
|
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-22",
|
||||||
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-21",
|
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-22",
|
||||||
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-21",
|
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-22",
|
||||||
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-21"
|
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/admin/assets/app.js?v=20260812-21" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
<script type="module" src="/admin/assets/app.js?v=20260812-22" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@
|
||||||
* - interfaces/admin/static/index.html importmap + CSS/JS ?v=
|
* - interfaces/admin/static/index.html importmap + CSS/JS ?v=
|
||||||
* - interfaces/admin/static/app.js entry query (optional if importmap covers it)
|
* - interfaces/admin/static/app.js entry query (optional if importmap covers it)
|
||||||
*/
|
*/
|
||||||
export const ADMIN_ASSET_V = "20260812-21";
|
export const ADMIN_ASSET_V = "20260812-22";
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export default {
|
||||||
"auth.logout": "Logout",
|
"auth.logout": "Logout",
|
||||||
"auth.password": "Password",
|
"auth.password": "Password",
|
||||||
"auth.username": "Username",
|
"auth.username": "Username",
|
||||||
"auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme": "After any skill/tool/role/prompt change, run prewarm immediately; restart for complex changes. System also auto-prewarms every 10 minutes.",
|
"auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme": "After any skill/tool/role/prompt/MCP change, run prewarm immediately (syncs enabled MCP tools/list, then freezes the tool wire); restart for complex changes. System also auto-prewarms every 10 minutes.",
|
||||||
"auto.prewarm_running_in_background_refreshing_this_page_when_": "[prewarm] running in background; refreshing this page when finished…",
|
"auto.prewarm_running_in_background_refreshing_this_page_when_": "[prewarm] running in background; refreshing this page when finished…",
|
||||||
"boot.loadFailed": "Failed to load admin scripts",
|
"boot.loadFailed": "Failed to load admin scripts",
|
||||||
"boot.loadFailedHint": "Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.",
|
"boot.loadFailedHint": "Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.",
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export default {
|
||||||
"auth.logout": "退出登录",
|
"auth.logout": "退出登录",
|
||||||
"auth.password": "密码",
|
"auth.password": "密码",
|
||||||
"auth.username": "用户名",
|
"auth.username": "用户名",
|
||||||
"auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme": "任何 skill/tool/角色/提示词变更后,请立即预热;复杂变更可直接重启。系统每10分钟自动异步预热一次。",
|
"auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme": "任何 skill/tool/角色/提示词/MCP 变更后,请立即预热(会先同步已启用 MCP 的 tools/list,再冻结核线);复杂变更可直接重启。系统每10分钟自动异步预热一次。",
|
||||||
"auto.prewarm_running_in_background_refreshing_this_page_when_": "[预热] 后台执行中,完成后自动刷新本页数据(含提示词)…",
|
"auto.prewarm_running_in_background_refreshing_this_page_when_": "[预热] 后台执行中,完成后自动刷新本页数据(含提示词)…",
|
||||||
"boot.loadFailed": "无法加载管理台脚本",
|
"boot.loadFailed": "无法加载管理台脚本",
|
||||||
"boot.loadFailedHint": "请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。",
|
"boot.loadFailedHint": "请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。",
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,12 @@ def warm_tool_wire_cache(
|
||||||
global _TOOL_WIRE_FROZEN_SIGNATURE, _TOOL_WIRE_LAST_WARM_TS_MS, _TOOL_WIRE_LAST_WARM_ROLES, _TOOL_WIRE_LAST_WARM_COUNT
|
global _TOOL_WIRE_FROZEN_SIGNATURE, _TOOL_WIRE_LAST_WARM_TS_MS, _TOOL_WIRE_LAST_WARM_ROLES, _TOOL_WIRE_LAST_WARM_COUNT
|
||||||
freeze_enabled = _tool_wire_freeze_enabled(store)
|
freeze_enabled = _tool_wire_freeze_enabled(store)
|
||||||
runtime_enabled, sig = _tool_wire_settings_signature(store)
|
runtime_enabled, sig = _tool_wire_settings_signature(store)
|
||||||
|
# Force rebuild: frozen mode makes _prepare_llm_tools return the previous wire forever
|
||||||
|
# (cache key ignores MCP tools/list content). Prewarm / MCP sync must clear first or
|
||||||
|
# "health + sync + prewarm" appears to do nothing.
|
||||||
|
with _TOOL_WIRE_CACHE_LOCK:
|
||||||
|
_TOOL_WIRE_FROZEN_SIGNATURE = None
|
||||||
|
_TOOL_WIRE_CACHE.clear()
|
||||||
warmed = 0
|
warmed = 0
|
||||||
for role in roles or []:
|
for role in roles or []:
|
||||||
_ = _prepare_llm_tools(
|
_ = _prepare_llm_tools(
|
||||||
|
|
@ -222,7 +228,7 @@ def warm_tool_wire_cache(
|
||||||
_TOOL_WIRE_LAST_WARM_TS_MS = int(time.time() * 1000)
|
_TOOL_WIRE_LAST_WARM_TS_MS = int(time.time() * 1000)
|
||||||
_TOOL_WIRE_LAST_WARM_ROLES = tuple(str(x or "").strip().lower() for x in roles or [])
|
_TOOL_WIRE_LAST_WARM_ROLES = tuple(str(x or "").strip().lower() for x in roles or [])
|
||||||
_TOOL_WIRE_LAST_WARM_COUNT = int(warmed)
|
_TOOL_WIRE_LAST_WARM_COUNT = int(warmed)
|
||||||
return {"roles_warmed": int(warmed), "frozen": int(bool(freeze_enabled))}
|
return {"roles_warmed": int(warmed), "frozen": int(bool(freeze_enabled)), "cache_cleared": 1}
|
||||||
|
|
||||||
|
|
||||||
def tool_wire_freeze_status(*, store: Any | None = None) -> dict[str, Any]:
|
def tool_wire_freeze_status(*, store: Any | None = None) -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,16 @@ def run_runtime_prewarm(
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
own_store = store if store is not None else get_assistant_store()
|
own_store = store if store is not None else get_assistant_store()
|
||||||
try:
|
try:
|
||||||
|
from runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
||||||
|
from runtime.tools.mcp.sync_tools import sync_enabled_mcp_servers
|
||||||
|
|
||||||
|
try:
|
||||||
|
apply_gateway_mcp_env_to_os()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Refresh MCP tool catalogs first so wire freeze/prewarm sees current tools/list.
|
||||||
|
# Health is best-effort and does not gate sync (wire penalty/suppression was removed).
|
||||||
|
mcp_sync = sync_enabled_mcp_servers(own_store)
|
||||||
registry = default_registry(store=own_store)
|
registry = default_registry(store=own_store)
|
||||||
prompt_stats = warm_startup_prompt_prebuild(
|
prompt_stats = warm_startup_prompt_prebuild(
|
||||||
store=own_store,
|
store=own_store,
|
||||||
|
|
@ -125,6 +135,7 @@ def run_runtime_prewarm(
|
||||||
"elapsed_ms": int((time.perf_counter() - t0) * 1000),
|
"elapsed_ms": int((time.perf_counter() - t0) * 1000),
|
||||||
"started_at_ms": started_at_ms,
|
"started_at_ms": started_at_ms,
|
||||||
"finished_at_ms": int(time.time() * 1000),
|
"finished_at_ms": int(time.time() * 1000),
|
||||||
|
"mcp_sync": mcp_sync,
|
||||||
"prompt": prompt_stats,
|
"prompt": prompt_stats,
|
||||||
"tools": tool_stats,
|
"tools": tool_stats,
|
||||||
"freeze": freeze,
|
"freeze": freeze,
|
||||||
|
|
|
||||||
|
|
@ -190,8 +190,8 @@ def materialize_tool_specs(
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("expert tool load skipped: %s", exc)
|
logger.warning("expert tool load skipped: %s", exc)
|
||||||
|
|
||||||
# MCP tools are role-bound and should be materialized before model injection.
|
# MCP tools are role-bound and materialized before model injection.
|
||||||
# Fine-grained penalty/visibility is still applied by wire policy in direct_loop.
|
# Wire penalty/suppression was removed; visibility is specialist↔MCP binding only.
|
||||||
mcp_enabled = True
|
mcp_enabled = True
|
||||||
try:
|
try:
|
||||||
if store is not None:
|
if store is not None:
|
||||||
|
|
|
||||||
168
runtime/tools/mcp/sync_tools.py
Normal file
168
runtime/tools/mcp/sync_tools.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""Sync MCP tools/list into SQLite without wire-penalty / health gating.
|
||||||
|
|
||||||
|
Health is recorded when available but must not block persisting a successful tools/list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def is_bailian_webparser_remote(*, entry_command: str, entry_args: list[str]) -> bool:
|
||||||
|
cmd = str(entry_command or "").strip().lower()
|
||||||
|
if cmd not in {"npx", "npx.cmd", "node"}:
|
||||||
|
return False
|
||||||
|
joined = " ".join(str(x or "").strip().lower() for x in (entry_args or []))
|
||||||
|
return "mcp-remote" in joined and "/api/v1/mcps/webparser/sse" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def bailian_webparser_virtual_tools() -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"tool_name": "bailian_webparser_parse",
|
||||||
|
"description": "Parse webpage via DashScope WebParser compatibility mode. Requires `url` (http/https).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {"type": "string", "description": "Target webpage URL (required). Example: https://example.com"},
|
||||||
|
"timeout": {"type": "integer", "default": 35, "minimum": 8, "maximum": 90},
|
||||||
|
},
|
||||||
|
"required": ["url"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tools_list(items: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
norm: list[dict[str, Any]] = []
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
tool_name = str(it.get("tool_name") or it.get("name") or "").strip()
|
||||||
|
if not tool_name:
|
||||||
|
continue
|
||||||
|
params = it.get("parameters") if isinstance(it.get("parameters"), dict) else it.get("inputSchema")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
params = {}
|
||||||
|
norm.append(
|
||||||
|
{
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"description": str(it.get("description") or ""),
|
||||||
|
"parameters": params,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return norm
|
||||||
|
|
||||||
|
|
||||||
|
def sync_mcp_server_tools(store: Any, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Run tools/list (+ optional health) and persist tools.
|
||||||
|
|
||||||
|
Unlike the old health-gated path, a failed initialize/health no longer skips sync:
|
||||||
|
we always attempt tools/list. Health status is best-effort metadata only.
|
||||||
|
"""
|
||||||
|
from runtime.tools.mcp.env_config import mcp_runtime_for_row
|
||||||
|
|
||||||
|
sid = str(row.get("server_id") or "").strip()
|
||||||
|
cmd = str(row.get("entry_command") or "").strip()
|
||||||
|
args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()]
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
if not cmd:
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"server_id": sid,
|
||||||
|
"ok": False,
|
||||||
|
"error_code": "mcp_entry_missing",
|
||||||
|
"error": "entry_command_missing",
|
||||||
|
"health": {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"},
|
||||||
|
"tools_synced": 0,
|
||||||
|
}
|
||||||
|
store.set_mcp_server_health(server_id=sid, status="error", detail=item["health"])
|
||||||
|
return item
|
||||||
|
|
||||||
|
if is_bailian_webparser_remote(entry_command=cmd, entry_args=args):
|
||||||
|
tools = bailian_webparser_virtual_tools()
|
||||||
|
store.replace_mcp_server_tools(server_id=sid, tools=tools)
|
||||||
|
detail = {"synced_tools": len(tools), "compat_mode": "bailian_webparser"}
|
||||||
|
store.set_mcp_server_health(server_id=sid, status="ok", detail=detail)
|
||||||
|
return {"server_id": sid, "ok": True, "health": detail, "tools_synced": len(tools)}
|
||||||
|
|
||||||
|
rt = mcp_runtime_for_row(row, store=store)
|
||||||
|
health: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
health = rt.health() if hasattr(rt, "health") else {}
|
||||||
|
if not isinstance(health, dict):
|
||||||
|
health = {}
|
||||||
|
except Exception as exc:
|
||||||
|
health = {"ok": False, "error_code": "mcp_healthcheck_failed", "error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
|
||||||
|
tools_res = rt.tools_list()
|
||||||
|
if not bool(tools_res.get("ok")):
|
||||||
|
# Persist health note but keep previous tools; do not clear catalog on transient list failure.
|
||||||
|
detail = {
|
||||||
|
"error_code": str(tools_res.get("error_code") or "mcp_tools_list_invalid"),
|
||||||
|
"error": str(tools_res.get("error") or "tools_list_failed"),
|
||||||
|
"health": health,
|
||||||
|
}
|
||||||
|
store.set_mcp_server_health(server_id=sid, status="error", detail=detail)
|
||||||
|
return {
|
||||||
|
"server_id": sid,
|
||||||
|
"ok": False,
|
||||||
|
"error_code": detail["error_code"],
|
||||||
|
"error": detail["error"],
|
||||||
|
"health": health,
|
||||||
|
"tools_synced": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = tools_res.get("tools") if isinstance(tools_res.get("tools"), list) else []
|
||||||
|
norm = _normalize_tools_list(tools if isinstance(tools, list) else [])
|
||||||
|
store.replace_mcp_server_tools(server_id=sid, tools=norm)
|
||||||
|
health_ok = bool(health.get("ok")) if health else True
|
||||||
|
store.set_mcp_server_health(
|
||||||
|
server_id=sid,
|
||||||
|
status="ok" if health_ok else "degraded",
|
||||||
|
detail={
|
||||||
|
"synced_tools": len(norm),
|
||||||
|
"health_ok": health_ok,
|
||||||
|
"health": health,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"server_id": sid,
|
||||||
|
"ok": True,
|
||||||
|
"health": health,
|
||||||
|
"tools_synced": len(norm),
|
||||||
|
"health_ok": health_ok,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
rt.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sync_enabled_mcp_servers(store: Any) -> dict[str, Any]:
|
||||||
|
"""Sync tools for all enabled MCP servers (used by runtime prewarm)."""
|
||||||
|
rows = store.list_mcp_servers(enabled_only=True) if store else []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in rows or []:
|
||||||
|
item = sync_mcp_server_tools(store, row)
|
||||||
|
if item is not None:
|
||||||
|
items.append(item)
|
||||||
|
ok_count = len([x for x in items if bool(x.get("ok"))])
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"total": len(items),
|
||||||
|
"ok_count": ok_count,
|
||||||
|
"error_count": len(items) - ok_count,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"bailian_webparser_virtual_tools",
|
||||||
|
"is_bailian_webparser_remote",
|
||||||
|
"sync_enabled_mcp_servers",
|
||||||
|
"sync_mcp_server_tools",
|
||||||
|
]
|
||||||
|
|
@ -28,3 +28,27 @@ def test_tool_wire_freeze_disabled_by_setting(monkeypatch) -> None:
|
||||||
st = dl.tool_wire_freeze_status(store=store)
|
st = dl.tool_wire_freeze_status(store=store)
|
||||||
assert st["enabled"] is False
|
assert st["enabled"] is False
|
||||||
assert st["frozen"] is False
|
assert st["frozen"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_tool_wire_cache_clears_frozen_stale_entries(monkeypatch) -> None:
|
||||||
|
"""Regression: prewarm must rebuild after MCP sync; frozen mode used to return stale wire."""
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def _prep(**_kwargs):
|
||||||
|
calls["n"] += 1
|
||||||
|
return [{"type": "function", "function": {"name": f"tool_{calls['n']}"}}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(dl, "_prepare_llm_tools", _prep)
|
||||||
|
monkeypatch.setenv("AIA_TOOL_WIRE_FROZEN_ON_STARTUP", "1")
|
||||||
|
store = _DummyStore({"AIA_TOOL_WIRE_FROZEN_ON_STARTUP": "1"})
|
||||||
|
with dl._TOOL_WIRE_CACHE_LOCK:
|
||||||
|
dl._TOOL_WIRE_FROZEN_SIGNATURE = "rt=1|stale"
|
||||||
|
dl._TOOL_WIRE_CACHE.clear()
|
||||||
|
dl._TOOL_WIRE_CACHE["poison"] = (0.0, [{"type": "function", "function": {"name": "old"}}])
|
||||||
|
out = dl.warm_tool_wire_cache(store=store, tools=object(), base_url="", roles=["generalist", "ops"])
|
||||||
|
assert calls["n"] == 2
|
||||||
|
assert int(out.get("cache_cleared") or 0) == 1
|
||||||
|
assert "poison" not in dl._TOOL_WIRE_CACHE
|
||||||
|
st = dl.tool_wire_freeze_status(store=store)
|
||||||
|
assert st["frozen"] is True
|
||||||
|
assert st["last_warm_count"] == 2
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue