From fe29b4369517af6762b4a502f348414fcaa04dc8 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 11 Aug 2026 02:48:47 +0800 Subject: [PATCH] Remove MCP wire suppression; keep specialist binding only. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop Admin 线侧策略 UI/APIs and gut tool_wire_policy to schema+size prep so MCP tools are gated solely by expert bindings. --- docs/ENVIRONMENT_VARIABLES.md | 45 +- docs/MCP_LOCAL_SERVER.md | 56 +- interfaces/admin/routes.py | 292 +------ interfaces/admin/static/app.js | 695 +--------------- runtime/tools/exposure_plan.py | 22 +- svc/llm/tool_wire_policy.py | 768 ++---------------- .../test_admin_mcp_tool_wire_role_mode_api.py | 137 ---- tests/test_tool_wire_policy.py | 213 ----- tests/test_tool_wire_policy_passthrough.py | 50 ++ tests/test_tool_wire_policy_role_scoped.py | 60 -- 10 files changed, 127 insertions(+), 2211 deletions(-) delete mode 100644 tests/test_admin_mcp_tool_wire_role_mode_api.py delete mode 100644 tests/test_tool_wire_policy.py create mode 100644 tests/test_tool_wire_policy_passthrough.py delete mode 100644 tests/test_tool_wire_policy_role_scoped.py diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 78b4debb..af3c3f6c 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -239,50 +239,7 @@ - 作用:追加给 filesystem MCP 的根目录 - 生效:`oclaw/tools/mcp/filesystem_argv.py` -- `AIA_MCP_WIRE_USAGE_POLICY` - - 默认:空(按 base_url 继承) - - 作用:是否启用 MCP 工具线侧分层策略 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_PENALTY_DISABLE` - - 默认:`0` - - 作用:禁用线侧陈旧惩罚 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_TOP_N_FULL` - - 默认:`20` - - 作用:全量上送工具数量 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_STALE_HOURS` - - 默认:`3` - - 作用:陈旧判定小时阈值 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_PENALTY_MINUTES` - - 默认:`30` - - 作用:惩罚窗口分钟数 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_MEDIUM_RANK_START` - - 默认:`21` - - 作用:中等层起始排名 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_MEDIUM_RANK_END` - - 默认:`50` - - 作用:中等层结束排名 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_MEDIUM_DESC_CHARS` - - 默认:`520` - - 作用:中等层描述截断长度 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` - -- `AIA_MCP_WIRE_MINIMAL_DESC_CAP` - - 默认:`80` - - 作用:最小层描述长度 - - 生效:`oclaw/platform/llm/tool_wire_policy.py` +> 历史 `AIA_MCP_WIRE_*`(线侧分层/惩罚)环境变量已废弃;MCP 工具可见性仅由专家绑定控制。 ## LLM 工具载荷与模型兼容 diff --git a/docs/MCP_LOCAL_SERVER.md b/docs/MCP_LOCAL_SERVER.md index c5d708c2..d034ec2e 100644 --- a/docs/MCP_LOCAL_SERVER.md +++ b/docs/MCP_LOCAL_SERVER.md @@ -187,59 +187,15 @@ for raw in sys.stdin: - 在 **【4】已安装 MCP 服务** 使用 **Export JSON (download)**,可下载当前库中**全部**已安装 MCP 的可重装 JSON(`servers` 包 + `exported_at`)。 - 每次 **安装、重装、卸载且删除库记录、Delete** 成功后,会刷新 **`oclaw/_local/mcp_registry_migrated.json`**(与导出内容同结构,便于换库/换机后把文件粘回 **Install from JSON** 或 `python scripts/seed_mcp_registry.py path/to/file.json` 注意 seed 会跑 npm/pypi 安装步骤,与 `dry_run` 等字段一致)。该文件建议加入 `.gitignore`(如未忽略),避免本机差异被误提交;密钥仍放在 `oclaw/_local/mcp_local.env` 等环境变量,不在此 JSON 中。 -### 3.3 MCP 工具线侧策略(上送压缩与惩罚) +### 3.3 MCP 工具可见性(仅绑定) -在管理台 **Plugins(插件)** 页中的 **「线侧策略」** 折叠区块(**【6】全局参数**、**【7】已安装工具**)可配置发往 LLM 的 OpenAI 格式 `tools[]` 的**分层压缩**、**全局闲置惩罚**,以及**按完整工具名** `mcp__{server_id}__{tool_name}` 的策略(与模型 `base_url` 解耦时,将 **wire_policy** 设为 `always`)。**【7】** 中每个工具的等级为**数字输入框**(任意整数 1–9998;留空表示未配置;0 与留空语义不同,见下表)。 +线侧「打压」策略(分层压缩 / 闲置惩罚 / 9999 永禁 / role_mode)**已移除**。 -**【7】表格筛选与专家列(管理台)** +发给 LLM 的 MCP 工具范围只由 **专家 ↔ MCP server 绑定** 决定: -- 表头**第二行**为各列筛选输入(子串匹配,不区分大小写):`server`、`tool`、`wire_name`、**专家**、`count`(上下界)、`last_ts`、惩罚/解封说明、策略**等级**(上下界)。分页条数按**筛选后**结果计算。 -- **专家**列由本页当前 **MCP 专家绑定草稿**(`mapping`)与后端返回的 `available_specialists` 合并推导:某 `server_id` 出现在哪些专家的绑定列表中,即显示为逗号分隔的专家 id;可按专家子串筛选。勾选「仅已勾选」时只显示当前勾选的行(勾选集合在筛选、翻页间保留)。 -- 同页 **【8】专家 MCP 绑定看板(自动)**:按当前草稿与**已安装 MCP** 各服务的 `tools` 列表,汇总每个专家绑定的 **MCP 个数** 与 **tool 条数**(各已绑定 server 的 `tools` 长度之和);专家集合随 `available_specialists` 与 `mapping` 中的键自动扩展,无需写死。 -- **【9】MCP 专家绑定(编辑)** 为原绑定编辑区(勾选、反向视图、保存);与【8】看板联动,改绑定后看板即时刷新(无需单独保存看板)。 - -**持久化(SQLite `app_setting`)** - -| 键 | 含义 | -| --- | --- | -| `mcp_tool_wire_admin_config` | JSON:全局参数 + `wire_policy`(`inherit` / `always` / `never`)、`penalty_disable` 等 | -| `mcp_tool_wire_tool_policies` | JSON:`{ "mcp__sid__tool": 等级 }` | -| `mcp_tool_wire_penalty_state` | JSON:各工具惩罚状态机(`phase`、`omit_until`、`wave_ts`、`kind`),由运行时维护,一般无需手改 | - -**`wire_policy`** - -- `inherit`:与原先一致,默认在 DashScope 兼容 URL 上启用线侧策略;其它环境变量 `OPS_MCP_WIRE_*` 仍可作为默认值来源。 -- `always`:**不依赖 URL**,始终启用分层与惩罚逻辑(适合非 DashScope 网关也要控 payload)。 -- `never`:关闭分层/惩罚逻辑;**等级 `9999` 永久封禁仍会过滤该工具**(不上送)。 - -**按工具等级(`mcp_tool_wire_tool_policies`)** - -| 配置 | 库中是否存在键 | 行为 | -| --- | --- | --- | -| **未配置**(管理台留空 / `GET` 中 `policy_level` 为 `null`、`policy_in_db` 为 `false`) | 否 | **自动走全局**:参与用量排名与分层压缩;适用**全局**闲置小时与罚时长;可被 **Top N 全量**豁免全局闲置惩罚。新安装 MCP 在 **Sync Tools** 后出现新 `wire_name`,默认即为此状态,无需手工登记。 | -| **显式 `0`** | 是 | **不参与**全局闲置 omission;仍参与用量分层。与「未配置」不同。 | -| **显式 `1`~`9998`** | 是 | 与 Top N **无关**:距上次成功调用超过 **N×10 分钟** 视为闲置,进入罚时 **N×10 分钟** 的上送 omission;罚满后需再次闲置达到阈值才会再罚(状态与 `last_ts` / `kind` 对齐)。 | -| **显式 `9999`** | 是 | **永久**从线侧 `tools[]` 中移除(彻底封禁)。 | - -**生效优先级(同一工具上的概念顺序,便于排障)** - -1. **`9999` 永久封禁**(若已写入 `mcp_tool_wire_tool_policies`):在组装 `tools[]` 的较早阶段即剔除,不进入后续分层与动态惩罚状态机。 -2. **显式 `1`~`9998`**:走按工具闲置/罚分钟逻辑,**不享受** Top N 对「全局惩罚」的豁免。 -3. **显式 `0`**:跳过全局闲置 omission,仍走压缩档位。 -4. **未配置**:走全局线侧逻辑(含全局闲置与 Top N 豁免等),由 `prepare_openai_tools_for_llm_api` 与 `mcp_tool_wire_admin_config` / 环境变量共同决定。 - -`wire_policy=never` 时关闭分层与动态惩罚,但 **`9999` 仍会过滤** 对应工具。 - -全局闲置小时、罚时长(分钟)、Top N 全量、medium 档位等,在 **【6】** 中可调;未写入 `app_setting` 的项继续沿用环境变量(见仓库根 `data/mcp_local.env.example` 中 `OPS_MCP_WIRE_*`)。 - -**Admin HTTP API**(需 `admin:tenant:write`,与 MCP 安装类接口一致) - -- `GET /admin/api/mcp/tool-wire` — 返回合并后的 `config`、当前 `policies`、`penalty_state`,以及已安装 MCP 工具列表(每条含 `policy_level`:`null` 表示未在库中配置,`policy_in_db` 标明是否持久化过)及惩罚/解封说明。 -- `POST /admin/api/mcp/tool-wire/config` — 保存全局参数(部分字段可增量合并)。 -- `POST /admin/api/mcp/tool-wire/policies` — body:`{ "policies": { "mcp__...": 整数等级 }, "clears": ["mcp__...", ...](可选) }`。先按 `clears` 从已存策略中**删除键**(用于管理台留空后恢复「未配置」),再合并 `policies`。 -- `POST /admin/api/mcp/tool-wire/policies/batch` — body:`{ "level": 等级, "wire_names": ["mcp__...", ...] }`,批量写入策略。 - -实现代码:`oclaw/platform/llm/tool_wire_policy.py`;在发 Chat Completions 前由 `prepare_openai_tools_for_llm_api` 应用。 +- 管理台 Plugins:**【6】专家 MCP 绑定看板**、**【7】MCP 专家绑定(编辑)** +- 持久化键:`mcp_specialist_server_binding`(及粗粒度兜底 `mcp_allowed_specialists` / `AIA_MCP_SPECIALISTS`) +- 运行时:`materialize_mcp_tools_for_specialist`;上送前仅做 schema complete 与可选 JSON 体积压缩(`prepare_openai_tools_for_llm_api`) --- diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 6c481f87..a57f5d44 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -584,12 +584,10 @@ def build_admin_router() -> APIRouter: ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") - from svc.llm.tool_wire_policy import load_role_mode_for_role, load_tool_policies_dict_for_role from runtime.tools.exposure_plan import build_internal_tool_specs, build_llm_tools_plan roles = _ordered_roles() items: list[dict[str, Any]] = [] - total_perm_ban = 0 total_wired = 0 total_internal = 0 for role in roles: @@ -602,15 +600,12 @@ def build_admin_router() -> APIRouter: include_mcp=True, preview_internal=True, ) - policies = load_tool_policies_dict_for_role(store, role=role) - perm_ban = len([k for k, v in policies.items() if str(k).startswith("mcp__") and int(v) == 9999]) - total_perm_ban += perm_ban total_wired += len(llm_plan.tools_wired) total_internal += len(internal_specs) items.append( { "role": role, - "role_mode": load_role_mode_for_role(store, role=role), + "role_mode": "unrestricted", "internal_count": len(internal_specs), "internal_public_count": int(internal_diag.get("public_count") or 0), "internal_expert_count": int(internal_diag.get("expert_count") or 0), @@ -619,9 +614,9 @@ def build_admin_router() -> APIRouter: "removed_total": len(llm_plan.removed_names), "removed_mcp_total": len(llm_plan.removed_mcp_names), "changed_total": len(llm_plan.changed_names), - "policy_perm_ban_9999": perm_ban, + "policy_perm_ban_9999": 0, "mcp_enabled": bool(llm_plan.mcp_enabled), - "wire_policy_effective": bool(llm_plan.wire_policy_effective), + "wire_policy_effective": False, } ) @@ -632,7 +627,7 @@ def build_admin_router() -> APIRouter: "summary": { "total_internal_tools": total_internal, "total_wired_tools": total_wired, - "total_perm_ban_9999": total_perm_ban, + "total_perm_ban_9999": 0, }, "items": items, } @@ -644,7 +639,7 @@ def build_admin_router() -> APIRouter: max_json_bytes: int | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: - """Preview the final tools injected to LLM for a role (internal + MCP + wire policy).""" + """Preview the final tools injected to LLM for a role (internal + MCP binding).""" store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") @@ -3101,283 +3096,6 @@ def build_admin_router() -> APIRouter: "calls": store.list_mcp_tool_call_logs(server_id=server_id, limit=limit), } - @router.get("/admin/api/mcp/tool-wire") - def api_mcp_tool_wire_get( - role: str | None = Query(default=None), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - from svc.llm.tool_wire_policy import build_tool_wire_snapshot - - return build_tool_wire_snapshot(store, role=str(role or "").strip().lower() or None) - - @router.post("/admin/api/mcp/tool-wire/config") - def api_mcp_tool_wire_config_save( - payload: dict[str, Any] | None = Body(default=None), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - from svc.llm.tool_wire_policy import SETTINGS_KEY_ADMIN_CONFIG, load_merged_admin_config - - payload = payload or {} - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG) - cur: dict[str, Any] = {} - if raw: - try: - cur = json.loads(raw) if isinstance(raw, str) else {} - except Exception: - cur = {} - if not isinstance(cur, dict): - cur = {} - if "wire_policy" in payload: - wp = str(payload.get("wire_policy") or "").strip().lower() - if wp in ("inherit", "always", "never"): - cur["wire_policy"] = wp - if "top_n_full" in payload: - cur["top_n_full"] = max(3, min(80, int(payload.get("top_n_full") or 20))) - if "stale_hours" in payload: - cur["stale_hours"] = max(0.25, min(720.0, float(payload.get("stale_hours") or 3))) - if "penalty_minutes" in payload: - cur["penalty_minutes"] = max(1.0, min(24 * 60, float(payload.get("penalty_minutes") or 30))) - if "medium_rank_start" in payload: - cur["medium_rank_start"] = int(payload.get("medium_rank_start") or 21) - if "medium_rank_end" in payload: - cur["medium_rank_end"] = int(payload.get("medium_rank_end") or 50) - if "medium_desc_chars" in payload: - cur["medium_desc_chars"] = max(80, min(4000, int(payload.get("medium_desc_chars") or 520))) - if "minimal_desc_cap" in payload: - cur["minimal_desc_cap"] = max(0, min(2000, int(payload.get("minimal_desc_cap") or 80))) - if "penalty_disable" in payload: - cur["penalty_disable"] = bool(payload.get("penalty_disable")) - store.set_setting(SETTINGS_KEY_ADMIN_CONFIG, json.dumps(cur, ensure_ascii=False)) - store.add_admin_audit_log( - actor_tenant_id=ctx["tenant_id"], - actor_user_id=ctx["user_id"], - action="mcp_tool_wire_config_update", - target_type="app_setting", - target_id=SETTINGS_KEY_ADMIN_CONFIG, - status="ok", - detail={"keys": list(cur.keys())}, - ) - return {"ok": True, "config": load_merged_admin_config(store)} - - @router.post("/admin/api/mcp/tool-wire/role-mode") - def api_mcp_tool_wire_role_mode_save( - payload: dict[str, Any] | None = Body(default=None), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - from svc.llm.tool_wire_policy import SETTINGS_KEY_ROLE_MODE_BY_ROLE - - payload = payload or {} - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - role = str(payload.get("role") or "").strip().lower() - if not role: - raise HTTPException(status_code=400, detail="role_required") - valid_roles = set(_ordered_mcp_roles()) - if role not in valid_roles: - raise HTTPException(status_code=400, detail="invalid_role") - mode = str(payload.get("mode") or "").strip().lower() - if mode not in {"restricted", "unrestricted", "forbidden"}: - raise HTTPException(status_code=400, detail="invalid_mode") - raw = str(store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "").strip() or "{}" - try: - obj = json.loads(raw) - except Exception: - obj = {} - if not isinstance(obj, dict): - obj = {} - obj[role] = mode - store.set_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE, json.dumps(obj, ensure_ascii=False)) - store.add_admin_audit_log( - actor_tenant_id=ctx["tenant_id"], - actor_user_id=ctx["user_id"], - action="mcp_tool_wire_role_mode_update", - target_type="app_setting", - target_id=f"{SETTINGS_KEY_ROLE_MODE_BY_ROLE}:{role}", - status="ok", - detail={"role": role, "mode": mode}, - ) - return {"ok": True, "role": role, "mode": mode} - - @router.post("/admin/api/mcp/tool-wire/penalty/reset") - def api_mcp_tool_wire_penalty_reset( - role: str | None = Query(default=None), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - from svc.llm.tool_wire_policy import SETTINGS_KEY_PENALTY_STATE, SETTINGS_KEY_PENALTY_STATE_BY_ROLE - - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - r = str(role or "").strip().lower() - if not r: - store.set_setting(SETTINGS_KEY_PENALTY_STATE, "{}") - target_id = SETTINGS_KEY_PENALTY_STATE - else: - # Reset only one role's penalty bucket. - raw = str(store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "").strip() or "{}" - try: - obj = json.loads(raw) - except Exception: - obj = {} - if not isinstance(obj, dict): - obj = {} - obj[r] = {} - store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(obj, ensure_ascii=False)) - target_id = f"{SETTINGS_KEY_PENALTY_STATE_BY_ROLE}:{r}" - store.add_admin_audit_log( - actor_tenant_id=ctx["tenant_id"], - actor_user_id=ctx["user_id"], - action="mcp_tool_wire_penalty_reset", - target_type="app_setting", - target_id=target_id, - status="ok", - detail={"reset": True, "role": r}, - ) - return {"ok": True, "penalty_state": {}, "role": r} - - @router.post("/admin/api/mcp/tool-wire/policies") - def api_mcp_tool_wire_policies_save( - payload: dict[str, Any] | None = Body(default=None), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - from svc.llm.tool_wire_policy import ( - SETTINGS_KEY_TOOL_POLICIES, - SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, - load_tool_policies_dict_for_role, - ) - - payload = payload or {} - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - role = str(payload.get("role") or "").strip().lower() - pol_in = payload.get("policies") - if not isinstance(pol_in, dict): - raise HTTPException(status_code=400, detail="policies must be an object") - merged = dict(load_tool_policies_dict_for_role(store, role=role or None)) - - def _coerce_lv(v: Any) -> int | None: - try: - n = int(v) - except (TypeError, ValueError): - return None - if n == 9999: - return 9999 - if n <= 0: - return 0 - return min(n, 9998) - - clears = payload.get("clears") - if isinstance(clears, list): - for w in clears: - wn = str(w or "").strip() - if wn.startswith("mcp__"): - merged.pop(wn, None) - for k, v in pol_in.items(): - wn = str(k or "").strip() - if not wn.startswith("mcp__"): - continue - co = _coerce_lv(v) - if co is None: - continue - merged[wn] = co - if not role: - store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False)) - target_id = SETTINGS_KEY_TOOL_POLICIES - else: - raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}" - try: - outer = json.loads(raw) - except Exception: - outer = {} - if not isinstance(outer, dict): - outer = {} - outer[role] = merged - store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False)) - target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}" - store.add_admin_audit_log( - actor_tenant_id=ctx["tenant_id"], - actor_user_id=ctx["user_id"], - action="mcp_tool_wire_policies_update", - target_type="app_setting", - target_id=target_id, - status="ok", - detail={"count": len(merged), "role": role}, - ) - return {"ok": True, "policies": merged, "role": role} - - @router.post("/admin/api/mcp/tool-wire/policies/batch") - def api_mcp_tool_wire_policies_batch( - payload: dict[str, Any] | None = Body(default=None), - authorization: str | None = Header(default=None), - ) -> dict[str, Any]: - from svc.llm.tool_wire_policy import ( - SETTINGS_KEY_TOOL_POLICIES, - SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, - load_tool_policies_dict_for_role, - ) - - payload = payload or {} - store = get_assistant_store() - ctx = _resolve_auth(store, authorization) - _require_permission(ctx, "admin:tenant:write") - try: - lv = int(payload.get("level")) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="level must be int") - if lv != 9999 and (lv < 0 or lv > 9998): - raise HTTPException(status_code=400, detail="invalid level") - names = payload.get("wire_names") - if not isinstance(names, list) or not names: - raise HTTPException(status_code=400, detail="wire_names must be non-empty array") - role = str(payload.get("role") or "").strip().lower() - if role: - valid_roles = set(_ordered_mcp_roles()) - if role not in valid_roles: - raise HTTPException(status_code=400, detail="invalid_role") - merged = dict(load_tool_policies_dict_for_role(store, role=role or None)) - for wn in names: - s = str(wn or "").strip() - if not s.startswith("mcp__"): - continue - if lv == 9999: - merged[s] = 9999 - elif lv <= 0: - merged[s] = 0 - else: - merged[s] = min(lv, 9998) - if not role: - store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False)) - target_id = SETTINGS_KEY_TOOL_POLICIES - else: - raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}" - try: - outer = json.loads(raw) - except Exception: - outer = {} - if not isinstance(outer, dict): - outer = {} - outer[role] = merged - store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False)) - target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}" - store.add_admin_audit_log( - actor_tenant_id=ctx["tenant_id"], - actor_user_id=ctx["user_id"], - action="mcp_tool_wire_policies_batch", - target_type="app_setting", - target_id=target_id, - status="ok", - detail={"level": lv, "n": len(names), "role": role}, - ) - return {"ok": True, "policies": merged, "role": role} - @router.get("/admin/api/mcp/market/search") def api_mcp_market_search( q: str = Query(default=""), diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 3246121a..5067b60b 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -4873,17 +4873,6 @@ function pluginsPagerBar(totalHolder, pageRef, onRepaint) { return { wrap, sync }; } -/** 与后端 `load_tool_policies_dict` 一致:0;1–9998;≥9999 → 9999 */ -function normalizeWirePolicyLevel(raw) { - const s = String(raw ?? "").trim(); - if (s === "") return 0; - const n = Number(s); - if (!Number.isFinite(n)) return 0; - if (n >= 9999) return 9999; - if (n <= 0) return 0; - return Math.min(Math.trunc(n), 9998); -} - async function renderPlugins() { const p = await apiGet("/admin/api/plugins"); let toolPolicy = { @@ -6181,580 +6170,6 @@ async function renderPlugins() { repaintUsageCalls(); const usageSummaryPager = pluginsPagerBar(usageSummaryTotalHolder, usageSummaryPageRef, repaintUsageSummary); const usageCallsPager = pluginsPagerBar(usageCallsTotalHolder, usageCallsPageRef, repaintUsageCalls); - let toolWire = { tools: [], config: {}, policies: {}, penalty_state: {}, role: "" }; - try { - toolWire = await apiGet("/admin/api/mcp/tool-wire"); - } catch (_) {} - const twc = toolWire.config || {}; - const wireTools = Array.isArray(toolWire.tools) ? toolWire.tools : []; - const wireRoleMode = String(toolWire.role_mode || "restricted"); - const wireRoleSelect = el("select", { class: "input" }, [ - el("option", { value: "", text: "global(默认)" }), - el("option", { value: "manager", text: "manager(全能者)" }), - ...availableSpecialists - .filter((x) => String(x) !== "manager") - .map((sp) => el("option", { value: String(sp), text: String(sp) })), - ]); - wireRoleSelect.value = String(toolWire.role || ""); - const wireCfgStatus = el("div", { class: "muted", text: "" }); - const wirePolStatus = el("div", { class: "muted", text: "" }); - const wireRoleModeSelect = el("select", { class: "input" }, [ - el("option", { value: "restricted", text: "受限(启用惩罚机制)" }), - el("option", { value: "unrestricted", text: "不受限(惩罚无效)" }), - el("option", { value: "forbidden", text: "禁止(MCP 全禁)" }), - ]); - wireRoleModeSelect.value = wireRoleMode; - const saveWireRoleModeBtn = el("button", { - class: "btn", - text: "保存 role 模式", - onclick: async () => { - const role = String(wireRoleSelect.value || "").trim(); - if (!role) { - wireCfgStatus.textContent = "[role-mode] 请选择具体 role(非 global)"; - return; - } - const r = await apiPost("/admin/api/mcp/tool-wire/role-mode", { - role, - mode: String(wireRoleModeSelect.value || "restricted"), - }); - wireCfgStatus.textContent = `[role-mode] ` + JSON.stringify(r); - markPrewarmReminder("tool_wire_role_mode_changed"); - router(); - }, - }); - const applyWireRoleSelectorState = () => { - const isGlobal = !String(wireRoleSelect.value || "").trim(); - saveWireRoleModeBtn.disabled = isGlobal; - wireRoleModeSelect.disabled = isGlobal; - if (isGlobal) { - wireCfgStatus.textContent = "[role-mode] global 不支持 role 模式设置,请选择具体 role。"; - } - }; - const inpWirePolicy = el("select", { class: "input" }, [ - el("option", { value: "inherit", text: "inherit(随 URL;DashScope 默认开)" }), - el("option", { value: "always", text: "always(不按 URL,始终启用分层)" }), - el("option", { value: "never", text: "never(关分层;9999 仍过滤)" }), - ]); - inpWirePolicy.value = String(twc.wire_policy || "inherit"); - const inpTopN = el("input", { class: "input", type: "number", min: "3", max: "80", value: String(twc.top_n_full ?? 20) }); - const inpStaleH = el("input", { class: "input", type: "number", step: "0.25", value: String(twc.stale_hours ?? 3) }); - const inpPenMin = el("input", { class: "input", type: "number", value: String(twc.penalty_minutes ?? 30) }); - const inpMedS = el("input", { class: "input", type: "number", value: String(twc.medium_rank_start ?? 21) }); - const inpMedE = el("input", { class: "input", type: "number", value: String(twc.medium_rank_end ?? 50) }); - const inpMedDesc = el("input", { class: "input", type: "number", value: String(twc.medium_desc_chars ?? 520) }); - const inpMinCap = el("input", { class: "input", type: "number", value: String(twc.minimal_desc_cap ?? 80) }); - const inpPenaltyEnabled = el("input", { type: "checkbox" }); - inpPenaltyEnabled.checked = !Boolean(twc.penalty_disable); - const saveWireCfgBtn = el("button", { - class: "btn", - text: "保存全局参数", - onclick: async () => { - const r = await apiPost("/admin/api/mcp/tool-wire/config", { - wire_policy: inpWirePolicy.value, - top_n_full: Number(inpTopN.value), - stale_hours: Number(inpStaleH.value), - penalty_minutes: Number(inpPenMin.value), - medium_rank_start: Number(inpMedS.value), - medium_rank_end: Number(inpMedE.value), - medium_desc_chars: Number(inpMedDesc.value), - minimal_desc_cap: Number(inpMinCap.value), - penalty_disable: !Boolean(inpPenaltyEnabled.checked), - }); - wireCfgStatus.textContent = JSON.stringify(r); - markPrewarmReminder("tool_wire_config_changed"); - router(); - }, - }); - const resetPenaltyStateBtn = el("button", { - class: "btn", - text: "重置惩罚状态", - onclick: async () => { - if (!window.confirm("确认重置当前 MCP 工具惩罚状态?该操作会立即清空 penalty state。")) return; - const qs = wireRoleSelect.value ? ("?role=" + encodeURIComponent(wireRoleSelect.value)) : ""; - const r = await apiPost("/admin/api/mcp/tool-wire/penalty/reset" + qs, {}); - wireCfgStatus.textContent = JSON.stringify(r); - markPrewarmReminder("tool_wire_penalty_reset"); - router(); - }, - }); - const draftPolicies = {}; - wireTools.forEach((t) => { - if (t.policy_in_db) draftPolicies[t.wire_name] = normalizeWirePolicyLevel(t.policy_level); - else draftPolicies[t.wire_name] = null; - }); - const wireToolBody = el("tbody"); - const wireToolsPageRef = { value: 1 }; - const wireToolsTotalHolder = { value: wireTools.length }; - const wireCheckedSet = new Set(); - const wireSubMatch = (a, pat) => { - const p = String(pat || "").trim(); - if (!p) return true; - return String(a ?? "").toLowerCase().includes(p.toLowerCase()); - }; - const wireFOnlyChecked = el("input", { type: "checkbox", title: "仅显示已勾选行" }); - const wireFServer = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); - const wireFTool = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); - const wireFWire = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); - const wireFExpert = el("input", { class: "input", placeholder: "专家含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); - const wireFCountMin = el("input", { class: "input", type: "number", placeholder: "≥", style: "width:100%;box-sizing:border-box;" }); - const wireFCountMax = el("input", { class: "input", type: "number", placeholder: "≤", style: "width:100%;box-sizing:border-box;" }); - const wireFLastTs = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); - const wireFPenalty = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); - const wireFLevelMin = el("input", { class: "input", type: "number", placeholder: "等级≥", style: "width:100%;box-sizing:border-box;" }); - const wireFLevelMax = el("input", { class: "input", type: "number", placeholder: "等级≤", style: "width:100%;box-sizing:border-box;" }); - const wireFilterCountLabel = el("span", { class: "muted", text: "" }); - const wireRoleModeHint = el("div", { class: "muted", text: "" }); - const wireRowMatchesFilters = (t) => { - if (wireFOnlyChecked.checked && !wireCheckedSet.has(t.wire_name)) return false; - if (!wireSubMatch(t.server_id, wireFServer.value)) return false; - if (!wireSubMatch(t.mcp_tool_name, wireFTool.value)) return false; - if (!wireSubMatch(t.wire_name, wireFWire.value)) return false; - const ex = specialistsBoundToServer(t.server_id).join(", "); - if (!wireSubMatch(ex, wireFExpert.value)) return false; - const cnt = Number(t.count || 0); - if (String(wireFCountMin.value).trim() && cnt < Number(wireFCountMin.value)) return false; - if (String(wireFCountMax.value).trim() && cnt > Number(wireFCountMax.value)) return false; - const ph = (t.penalty && t.penalty.unblock_hint) || ""; - if (!wireSubMatch(ph, wireFPenalty.value)) return false; - if (!wireSubMatch(t.last_ts || "", wireFLastTs.value)) return false; - const lvRaw = draftPolicies[t.wire_name]; - const lvNum = lvRaw === null || lvRaw === undefined ? null : Number(lvRaw); - const minS = String(wireFLevelMin.value).trim(); - if (minS) { - const m = Number(minS); - if (Number.isFinite(m)) { - if (lvNum === null || lvNum === undefined) { - if (m > 0) return false; - } else if (lvNum < m) return false; - } - } - const maxS = String(wireFLevelMax.value).trim(); - if (maxS && lvNum !== null && lvNum !== undefined) { - const m = Number(maxS); - if (Number.isFinite(m) && lvNum > m) return false; - } - return true; - }; - const roleModeBadge = (modeRaw) => { - const mode = String(modeRaw || "restricted"); - if (mode === "unrestricted") { - return el("span", { class: "badge badge--ok", text: "unrestricted" }); - } - if (mode === "forbidden") { - return el("span", { class: "badge badge--bad", text: "forbidden" }); - } - return el("span", { class: "badge badge--mode-restricted", text: "restricted" }); - }; - const getWireToolsFiltered = () => wireTools.filter((x) => wireRowMatchesFilters(x)); - const paintWireToolRows = () => { - wireToolBody.innerHTML = ""; - if (!wireTools.length) { - wireToolBody.appendChild(el("tr", {}, [el("td", { text: "暂无已缓存工具(对各 MCP 点 Sync Tools)", colspan: "9" })])); - return; - } - const list = getWireToolsFiltered(); - if (!list.length) { - wireToolBody.appendChild(el("tr", {}, [el("td", { text: "无匹配行(请调整筛选)", colspan: "9" })])); - return; - } - const start = (wireToolsPageRef.value - 1) * PLUGINS_PAGE_SIZE; - list.slice(start, start + PLUGINS_PAGE_SIZE).forEach((t) => { - const rowCb = el("input", { type: "checkbox" }); - rowCb.checked = wireCheckedSet.has(t.wire_name); - rowCb.addEventListener("change", () => { - if (rowCb.checked) wireCheckedSet.add(t.wire_name); - else wireCheckedSet.delete(t.wire_name); - }); - const lv0 = draftPolicies[t.wire_name]; - const lvlSel = el( - "select", - { - class: "input", - title: "按 role:默认(继承全局惩罚)/ 不惩罚 / 永禁", - style: "width:140px;max-width:100%;", - "data-wire-level": "1", - }, - [ - el("option", { value: "", text: "默认(继承)" }), - el("option", { value: "0", text: "不惩罚(0)" }), - el("option", { value: "9999", text: "永禁(9999)" }), - ], - ); - lvlSel.value = lv0 === null || lv0 === undefined ? "" : String(normalizeWirePolicyLevel(lv0)); - const syncLevelFromSelect = () => { - const v = String(lvlSel.value || "").trim(); - if (!v) { - draftPolicies[t.wire_name] = null; - lvlSel.value = ""; - return; - } - const n = normalizeWirePolicyLevel(v); - draftPolicies[t.wire_name] = n; - lvlSel.value = String(n); - }; - lvlSel.addEventListener("change", syncLevelFromSelect); - const ph = (t.penalty && t.penalty.unblock_hint) || "-"; - const exCell = specialistsBoundToServer(t.server_id).join(", ") || "—"; - wireToolBody.appendChild( - el( - "tr", - { "data-wire-name": t.wire_name }, - [ - el("td", {}, [rowCb]), - tdCell(t.server_id, 20), - tdCell(t.mcp_tool_name, 22), - tdCell(t.wire_name, 32), - tdCell(exCell, 20), - tdCell(String(t.count || 0), 8), - tdCell(String(t.last_ts || "-"), 22), - el("td", {}, [roleModeBadge(wireRoleModeSelect.value || "restricted")]), - el("td", { - text: ph, - title: ph, - style: "max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;", - }), - el("td", {}, [lvlSel]), - ], - ), - ); - }); - }; - const applyWireRoleModeUiState = () => { - const mode = String(wireRoleModeSelect.value || "restricted"); - const disabled = mode !== "restricted"; - if (mode === "unrestricted") { - wireRoleModeHint.textContent = "当前 role 为不受限:单工具惩罚策略不生效。"; - } else if (mode === "forbidden") { - wireRoleModeHint.textContent = "当前 role 为禁止:MCP 全禁,单工具惩罚策略不生效。"; - } else { - wireRoleModeHint.textContent = ""; - } - const staticControls = [ - wireFOnlyChecked, - wireFServer, - wireFTool, - wireFWire, - wireFExpert, - wireFCountMin, - wireFCountMax, - wireFLastTs, - wireFPenalty, - wireFLevelMin, - wireFLevelMax, - clearWireFiltersBtn, - bulkLvlInput, - applyBulkWireBtn, - saveWirePolBtn, - ]; - staticControls.forEach((node) => { - if (node) node.disabled = disabled; - }); - Array.from(wireToolBody.querySelectorAll("input,select,button")).forEach((el0) => { - el0.disabled = disabled; - }); - }; - const wireToolsPager = pluginsPagerBar(wireToolsTotalHolder, wireToolsPageRef, paintWireToolRows); - const refreshWireToolsFiltered = () => { - const list = getWireToolsFiltered(); - wireToolsTotalHolder.value = list.length; - wireFilterCountLabel.textContent = `筛选 ${list.length} / 共 ${wireTools.length} 条`; - const maxPage = Math.max(1, Math.ceil(list.length / PLUGINS_PAGE_SIZE) || 1); - if (wireToolsPageRef.value > maxPage) wireToolsPageRef.value = maxPage; - if (wireToolsPageRef.value < 1) wireToolsPageRef.value = 1; - paintWireToolRows(); - wireToolsPager.sync(); - applyWireRoleModeUiState(); - }; - const onWireFilterChange = () => { - wireToolsPageRef.value = 1; - refreshWireToolsFiltered(); - }; - [ - wireFServer, - wireFTool, - wireFWire, - wireFExpert, - wireFCountMin, - wireFCountMax, - wireFLastTs, - wireFPenalty, - wireFLevelMin, - wireFLevelMax, - ].forEach((inp) => inp.addEventListener("input", onWireFilterChange)); - wireFOnlyChecked.addEventListener("change", onWireFilterChange); - wireRoleModeSelect.addEventListener("change", applyWireRoleModeUiState); - wireRoleSelect.addEventListener("change", applyWireRoleSelectorState); - const bulkLvlInput = el( - "select", - { class: "input", title: "批量写入", style: "width:140px;" }, - [ - el("option", { value: "0", text: "不惩罚(0)" }), - el("option", { value: "9999", text: "永禁(9999)" }), - el("option", { value: "", text: "清空(继承)" }), - ], - ); - bulkLvlInput.value = "0"; - const clearWireFiltersBtn = el("button", { - class: "btn", - text: "清除筛选", - onclick: () => { - wireFServer.value = ""; - wireFTool.value = ""; - wireFWire.value = ""; - wireFExpert.value = ""; - wireFCountMin.value = ""; - wireFCountMax.value = ""; - wireFLastTs.value = ""; - wireFPenalty.value = ""; - wireFLevelMin.value = ""; - wireFLevelMax.value = ""; - wireFOnlyChecked.checked = false; - onWireFilterChange(); - }, - }); - const applyBulkWireBtn = el("button", { - class: "btn", - text: "批量应用到选中", - onclick: async () => { - const bulkRaw = String(bulkLvlInput.value ?? "").trim(); - const lv = bulkRaw === "" ? null : normalizeWirePolicyLevel(bulkRaw); - const selectedWireNames = []; - Array.from(wireToolBody.querySelectorAll("tr")).forEach((tr) => { - const cb = tr.querySelector("input[type=checkbox]"); - if (!cb || !cb.checked) return; - const wn = tr.getAttribute("data-wire-name"); - const sel = tr.querySelector("[data-wire-level]"); - if (wn && sel) { - selectedWireNames.push(String(wn)); - draftPolicies[wn] = lv; - sel.value = lv === null ? "" : String(lv); - } - }); - if (selectedWireNames.length) { - try { - if (lv === null) { - await apiPost("/admin/api/mcp/tool-wire/policies", { - role: String(wireRoleSelect.value || ""), - policies: {}, - clears: selectedWireNames, - }); - } else { - await apiPost("/admin/api/mcp/tool-wire/policies/batch", { - role: String(wireRoleSelect.value || ""), - level: Number(lv), - wire_names: selectedWireNames, - }); - } - markPrewarmReminder("tool_wire_policies_batch_changed"); - } catch (err) { - wirePolStatus.textContent = `[批量] 后端批量写入失败: ${String((err && err.message) || err)}`; - return; - } - } - wirePolStatus.textContent = "[批量] 已写入后端并更新本地视图"; - }, - }); - const saveWirePolBtn = el("button", { - class: "btn btn--primary", - text: "保存工具策略", - onclick: async () => { - Array.from(wireToolBody.querySelectorAll("tr")).forEach((tr) => { - const wn = tr.getAttribute("data-wire-name"); - const sel = tr.querySelector("[data-wire-level]"); - if (wn && sel) { - const raw = String(sel.value ?? "").trim(); - draftPolicies[wn] = raw === "" ? null : normalizeWirePolicyLevel(raw); - } - }); - const pol = {}; - const clears = []; - wireTools.forEach((t) => { - const wn = t.wire_name; - const v = draftPolicies[wn]; - if (v === null || v === undefined) clears.push(wn); - else pol[wn] = v; - }); - const r = await apiPost("/admin/api/mcp/tool-wire/policies", { - role: String(wireRoleSelect.value || ""), - policies: pol, - clears, - }); - wirePolStatus.textContent = JSON.stringify(r); - markPrewarmReminder("tool_wire_policies_changed"); - router(); - }, - }); - refreshWireToolsFiltered(); - applyWireRoleSelectorState(); - applyWireRoleModeUiState(); - const foldToolPolicy = pluginsFold(`【1】工具策略与已注册插件(${pluginCatalog.length})`, [ - el("div", { class: "muted", text: "Tool policy(并发 / 轮次 / MCP·插件开关)与 Python 工具插件表" }), - el("div", { class: "row" }, [ - el("label", { text: "Turn max tool workers (1-32)" }), - turnMaxWorkersInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "Turn max tool rounds (1-300)" }), - turnMaxRoundsInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "Turn max context messages (10-400)" }), - turnMaxCtxInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "SSE queue maxsize (200-50000)" }), - sseQueueMaxsizeInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "Tool log max chars (20000-2000000)" }), - toolLogMaxCharsInput, - ]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [toolContextTruncateCb, document.createTextNode(" Compress tool result in agent context (50 chars + hint)")])]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [chatShowTtftDebugCb, document.createTextNode(" Show TTFT debug timings in chat status")])]), - el("div", { class: "row" }, [ - el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }), - toolLlmMessageMaxCharsInput, - ]), - el("div", { class: "muted", text: "Set 0 to disable truncation. If some gateways return 400 for oversized tool messages, set back to 24000." }), - el("div", { class: "row" }, [ - el("label", { text: "MCP filesystem extra roots (| separated)" }), - mcpFilesystemExtraRootsInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "MCP env allowlist (comma separated)" }), - mcpEnvAllowlistInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "oclaw retryable error codes (comma separated)" }), - oclawRetryableErrorCodesInput, - ]), - el("div", { class: "row" }, [el("label", { class: "kv" }, [oclawRetryCodesStrictModeCb, document.createTextNode(" Strict mode: reject unknown retry codes")])]), - el("div", { class: "row" }, [ - el("label", { text: "WeCom longconn workers (1-8)" }), - wecomLongconnWorkersInput, - ]), - el("div", { class: "row" }, [ - el("label", { text: "WeCom inbound queue maxsize (20-5000)" }), - wecomLongconnInboundQueueInput, - ]), - el("div", { class: "row" }, [saveToolPolicyBtn]), - toolPolicyStatus, - el("div", { class: "table-wrap" }, [ - el("table", { class: "table" }, [ - el("thead", {}, [el("tr", {}, [el("th", { text: t("table.name") }), el("th", { text: t("table.version") }), el("th", { text: t("table.entryPoint") }), el("th", { text: t("table.enabled") })])]), - pluginTbody, - ]), - ]), - pluginPager.wrap, - ]); - const foldMcpMarket = pluginsFold("【2】MCP 市场 / 依赖 / Trending / 检索结果", [ - el("div", { class: "muted", text: failureText ? `Failure summary: ${failureText}` : "Failure summary: -" }), - el("div", { class: "muted", text: "本地依赖检查" }), - depWrap, - el("div", { class: "row" }, [marketQ, marketBtn, marketRefreshBtn]), - el("div", { class: "muted", text: "Trending" }), - trendingWrap, - el("div", { class: "muted", text: "Market search" }), - marketWrap, - ]); - const foldMcpInstall = pluginsFold("【3】MCP 安装(表单 / JSON / 运维)", [ - el("div", { class: "row" }, [sourceType, sourceRef, version]), - el("div", { class: "row" }, [entryCmd, entryArgs, installBtn]), - el("div", { class: "muted", text: "CLI direct install (paste one command)" }), - el("div", { class: "row" }, [cliInstallInput, cliInstallBtn]), - el("div", { class: "muted", text: "常用命令行安装示例(可先本机验证,再填上方表单)" }), - el("pre", { - class: "pre", - text: -`# npm 包(本地安装) -npm install mcp-fetch-server - -# 直接运行(推荐) -npx -y mcp-fetch-server - -# 全局安装后运行 -npm install -g mcp-fetch-server -mcp-fetch-server - -# Python 包示例 -pip install mcp-server-time -python -m mcp_server_time - -# Python(Git URL / VCS)示例:必须显式指定 entry module -pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m code_sandbox_mcp`, - }), - el("div", { class: "muted", text: "JSON install (single object or array)" }), - 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, - ]), - installStatus, - preflightFixWrap, - ]); - const mcpExportJsonBtn = el("button", { - class: "btn", - text: "Export JSON (download)", - title: "Download uninstall/reinstallable snapshot (same shape as “Install from JSON”)", - onclick: async () => { - let r; - try { - r = await apiGet("/admin/api/mcp/export"); - } catch (err) { - installStatus.textContent = "[export] " + String((err && err.message) || err); - return; - } - if (!r || r.ok !== true || !r.document) { - installStatus.textContent = "[export] failed: " + JSON.stringify(r); - return; - } - const text = JSON.stringify(r.document, null, 2) + "\n"; - const blob = new Blob([text], { type: "application/json" }); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = "mcp_registry_migrated.json"; - a.click(); - URL.revokeObjectURL(a.href); - installStatus.textContent = - "[export] downloaded mcp_registry_migrated.json" + (r.local_path ? " ; on server: " + r.local_path : ""); - }, - }); - const foldMcpInstalled = pluginsFold(`【4】已安装 MCP 服务(${mcpServerList.length})`, [ - el("div", { class: "row", style: "align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:8px;" }, [ - mcpExportJsonBtn, - el("div", { - class: "muted", - text: "新安装/重装/卸载(删记录)成功后自动写入: src/_local/mcp_registry_migrated.json,便于换机迁移。", - }), - ]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [el("tr", {}, [ - el("th", { text: "server_id" }), - el("th", { text: "source" }), - el("th", { text: "ref" }), - 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" }), - ])]), - mcpInstalledTbody, - ]), - ]), - mcpInstalledPager.wrap, - ]); const foldMcpUsage = pluginsFold( `【5】MCP 用量(summary ${usageSummaryList.length} / calls ${usageCallsList.length})`, [ @@ -6789,109 +6204,7 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m usageCallsPager.wrap, ], ); - const foldWireGlobal = pluginsFold("【6】线侧策略 — 全局参数", [ - el("div", { - class: "muted", - text: "按 mcp__server__tool;wire_policy=always 不依赖 base_url。", - }), - el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ - el("label", { text: "wire_policy" }), - inpWirePolicy, - el("label", { text: "Top N 全量" }), - inpTopN, - el("label", { text: "全局闲置(h)" }), - inpStaleH, - el("label", { text: "罚时长(min)" }), - inpPenMin, - ]), - el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ - el("label", { text: "medium rank" }), - inpMedS, - inpMedE, - el("label", { text: "medium 描述上限" }), - inpMedDesc, - el("label", { text: "minimal 描述" }), - inpMinCap, - el("label", {}, [inpPenaltyEnabled, el("span", { text: "启用惩罚机制", style: "margin-left:6px;" })]), - ]), - el("div", { class: "row" }, [ - el("label", { text: "role" }), - wireRoleSelect, - wireRoleModeSelect, - saveWireRoleModeBtn, - saveWireCfgBtn, - resetPenaltyStateBtn, - wireCfgStatus, - ]), - ]); - const wireLevelHint = el("div", { - class: "muted", - style: "font-size:12px;line-height:1.45;margin-bottom:6px;", - text: - "留空=未配置(运行时走全局闲置惩罚与线侧分层/压缩)。0=该 role 下此工具不参与闲置惩罚。任意整数 1–9998:闲置与罚均为 N×10 分钟;≥9999 视为 9999 永久不上送。新安装 MCP 在 Sync Tools 后出现新行,默认留空即自动走全局,直至你在本页保存。", - }); - const foldWireTools = pluginsFold(`【7】线侧策略 — 已安装工具(${wireTools.length})`, [ - wireLevelHint, - wireRoleModeHint, - el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ - wireFilterCountLabel, - clearWireFiltersBtn, - el("span", { class: "muted", text: "批量等级" }), - bulkLvlInput, - applyBulkWireBtn, - saveWirePolBtn, - wirePolStatus, - ]), - el("div", { class: "table-wrap" }, [ - el("table", { class: "table table--compact" }, [ - el("thead", {}, [ - el("tr", {}, [ - el("th", { text: "选" }), - el("th", { text: "server" }), - el("th", { text: "tool" }), - el("th", { text: "wire_name" }), - el("th", { text: "专家(绑定推导)" }), - el("th", { text: "count" }), - el("th", { text: "last_ts" }), - el("th", { text: "effective_mode" }), - el("th", { text: "惩罚/解封" }), - el("th", { text: "策略" }), - ]), - el("tr", {}, [ - el("th", {}, [wireFOnlyChecked]), - el("th", {}, [wireFServer]), - el("th", {}, [wireFTool]), - el("th", {}, [wireFWire]), - el("th", {}, [wireFExpert]), - el("th", {}, [ - el("div", { style: "display:flex;flex-direction:column;gap:4px;" }, [wireFCountMin, wireFCountMax]), - ]), - el("th", {}, [wireFLastTs]), - el("th", { text: "-" }), - el("th", {}, [wireFPenalty]), - el("th", {}, [ - el("div", { style: "display:flex;flex-direction:column;gap:4px;" }, [wireFLevelMin, wireFLevelMax]), - ]), - ]), - ]), - wireToolBody, - ]), - ]), - wireToolsPager.wrap, - ]); - wireRoleSelect.addEventListener("change", async () => { - try { - const qs = wireRoleSelect.value ? ("?role=" + encodeURIComponent(wireRoleSelect.value)) : ""; - toolWire = await apiGet("/admin/api/mcp/tool-wire" + qs); - wireCfgStatus.textContent = `[role] switched to ${String(toolWire.role || "global")}`; - wireRoleModeSelect.value = String(toolWire.role_mode || "restricted"); - // reload current page to rebuild wireTools + drafts cleanly - router(); - } catch (err) { - wireCfgStatus.textContent = `[role] load failed: ${String((err && err.message) || err)}`; - } - }); - const foldExpertBindingDash = pluginsFold("【8】专家 MCP 绑定看板(自动)", [ + const foldExpertBindingDash = pluginsFold("【6】专家 MCP 绑定看板(自动)", [ el("div", { class: "muted", text: "按当前绑定草稿与已安装 MCP 的 tools 列表汇总;专家列表来自 SPECIALISTS 与绑定 mapping 键,随扩展自动增减。", @@ -6907,7 +6220,7 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m ]), ]), ]); - const foldMcpBinding = pluginsFold("【9】MCP 专家绑定(编辑)", [ + const foldMcpBinding = pluginsFold("【7】MCP 专家绑定(编辑)", [ el("div", { class: "muted", text: "Bind MCP servers to specialists (many-to-many)." }), el("div", { class: "row" }, [el("label", { text: "Specialist" }), specialistSelect, selectAllBindingBtn, clearBindingBtn, saveBindingBtn]), bindingListWrap, @@ -6932,8 +6245,6 @@ pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m el("div", { id: "plugins-install" }, [foldMcpInstall]), el("div", { id: "plugins-instances" }, [foldMcpInstalled]), foldMcpUsage, - foldWireGlobal, - foldWireTools, foldExpertBindingDash, el("div", { id: "plugins-binding" }, [foldMcpBinding]), ]), @@ -9078,7 +8389,7 @@ async function renderSkills() { }); const llmToolsBox = el("details", { style: "margin:10px 0 14px 0;" }, [ el("summary", { text: "LLM tools preview (after wire policy)", style: "cursor:pointer;user-select:none;" }), - el("div", { class: "muted", style: "margin:8px 0;line-height:1.5;", text: "Preview the final tools injected to the model for a role (internal + MCP + role_mode + permanent bans + wire policy tiers/penalty)." }), + el("div", { class: "muted", style: "margin:8px 0;line-height:1.5;", text: "Preview the final tools injected to the model for a role (internal + MCP + role_mode + MCP specialist binding)." }), llmToolsStatus, el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center;" }, [ el("label", { text: "Role" }), diff --git a/runtime/tools/exposure_plan.py b/runtime/tools/exposure_plan.py index 5d7ef7fa..08bd46ae 100644 --- a/runtime/tools/exposure_plan.py +++ b/runtime/tools/exposure_plan.py @@ -5,13 +5,7 @@ from dataclasses import dataclass from typing import Any from svc.llm.tool_schema import default_max_openai_tools_json_bytes -from svc.llm.tool_wire_policy import ( - load_merged_admin_config, - load_role_mode_for_role, - load_tool_policies_dict_for_role, - prepare_openai_tools_for_llm_api, - wire_graduation_effective, -) +from svc.llm.tool_wire_policy import prepare_openai_tools_for_llm_api from runtime.tools.base import ToolSpec from runtime.tools.catalog import _is_truthy from runtime.tools.expert_registry import materialize_tools_for_expert, preview_expert_tools @@ -174,11 +168,7 @@ def build_llm_tools_plan( else: raw_openai_tools = list(raw_openai_tools_override) - admin_cfg = load_merged_admin_config(store) - role_mode = load_role_mode_for_role(store, role=r) - policies = load_tool_policies_dict_for_role(store, role=r) - wire_effective = wire_graduation_effective(bu, admin_cfg) and role_mode == "restricted" - + # Wire suppression removed: only schema complete + optional JSON size shrink. wired_openai_tools = prepare_openai_tools_for_llm_api( raw_openai_tools, base_url=bu, @@ -195,7 +185,7 @@ def build_llm_tools_plan( added = sorted([n for n in wired_set if n and n not in raw_set]) removed_mcp = [n for n in removed if n.startswith("mcp__")] - # changed = same tool name but payload differs + # changed = same tool name but payload differs (e.g. shrink shortened descriptions) def _map_by_name(tools: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: m: dict[str, dict[str, Any]] = {} for ent in tools or []: @@ -221,9 +211,9 @@ def build_llm_tools_plan( base_url=bu, max_json_bytes=cap, mcp_enabled=mcp_enabled, - role_mode=str(role_mode or "restricted"), - wire_policy_effective=bool(wire_effective), - policy_keys=len(policies), + role_mode="unrestricted", + wire_policy_effective=False, + policy_keys=0, public_risk_gate_allow_high=bool(diag_internal.get("public_risk_gate_allow_high")), public_blocked_high_risk_tools=list(diag_internal.get("public_blocked_high_risk_tools") or []), skipped_public=list(diag_internal.get("skipped_public") or []), diff --git a/svc/llm/tool_wire_policy.py b/svc/llm/tool_wire_policy.py index d4f5b691..4f4f3a39 100644 --- a/svc/llm/tool_wire_policy.py +++ b/svc/llm/tool_wire_policy.py @@ -1,401 +1,76 @@ -"""Graduated OpenAI ``tools[]`` shaping + usage-aware omission / per-tool admin policies. +"""OpenAI ``tools[]`` wire prep (schema complete + optional size shrink). -Uses ``tool_log`` aggregates, ``app_setting`` for admin config / per-tool levels / penalty state. -Wire payload differs from local :class:`~src.tools.base.ToolSpec`; validation still uses full schemas. +MCP suppression / penalty / tier / role_mode wire policy was removed. +Server visibility is controlled only by specialist↔MCP binding. """ from __future__ import annotations -import json -import logging -import os -from datetime import datetime, timedelta, timezone from typing import Any -from svc.llm.tool_schema import MIN_OPENAI_FUNCTION_PARAMETERS, complete_openai_tools_wire_parameters -from svc.persistence.assistant_store import get_assistant_store - -logger = logging.getLogger(__name__) +from svc.llm.tool_schema import complete_openai_tools_wire_parameters +# Legacy setting keys kept so old admin/DB rows do not break readers. SETTINGS_KEY_PENALTY_STATE = "mcp_tool_wire_penalty_state" SETTINGS_KEY_TOOL_POLICIES = "mcp_tool_wire_tool_policies" SETTINGS_KEY_ADMIN_CONFIG = "mcp_tool_wire_admin_config" - -# Role-scoped (per expert) overrides. Shape: -# - policies: { "": { "mcp__server__tool": } } -# - penalty: { "": { "mcp__server__tool": } } SETTINGS_KEY_PENALTY_STATE_BY_ROLE = "mcp_tool_wire_penalty_state_by_role" SETTINGS_KEY_TOOL_POLICIES_BY_ROLE = "mcp_tool_wire_tool_policies_by_role" SETTINGS_KEY_ROLE_MODE_BY_ROLE = "mcp_tool_wire_role_mode_by_role" -_MIN_PARAMETERS: dict[str, Any] = dict(MIN_OPENAI_FUNCTION_PARAMETERS) + +def wire_policy_enabled(_base_url: str | None) -> bool: + return False -def _env_prefixed(name_suffix: str, default: str = "") -> str: - raw = str(os.getenv(f"AIA_{name_suffix}") or "").strip() - if raw: - return raw - raw = str(os.getenv(f"OPS_{name_suffix}") or "").strip() - if raw: - return raw - return str(default).strip() +def wire_graduation_effective(_base_url: str | None, _admin: dict[str, Any] | None = None) -> bool: + return False -def wire_policy_enabled(base_url: str | None) -> bool: - raw = _env_prefixed("MCP_WIRE_USAGE_POLICY", "").lower() - if raw in ("1", "true", "yes", "on"): - return True - if raw in ("0", "false", "no", "off"): - return False - u = (base_url or "").lower() - return "dashscope.aliyuncs.com" in u - - -def penalty_disabled() -> bool: - return _env_prefixed("MCP_WIRE_PENALTY_DISABLE", "").lower() in ("1", "true", "yes", "on") - - -def _admin_defaults_from_env() -> dict[str, Any]: +def load_merged_admin_config(_store: Any) -> dict[str, Any]: return { - "wire_policy": "inherit", - "top_n_full": int(_env_prefixed("MCP_WIRE_TOP_N_FULL", "20") or "20"), - "stale_hours": float(_env_prefixed("MCP_WIRE_STALE_HOURS", "3") or "3"), - "penalty_minutes": float(_env_prefixed("MCP_WIRE_PENALTY_MINUTES", "30") or "30"), - "medium_rank_start": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_START", "21") or "21"), - "medium_rank_end": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_END", "50") or "50"), - "medium_desc_chars": int(_env_prefixed("MCP_WIRE_MEDIUM_DESC_CHARS", "520") or "520"), - "minimal_desc_cap": int(_env_prefixed("MCP_WIRE_MINIMAL_DESC_CAP", "80") or "80"), - "penalty_disable": False, + "wire_policy": "never", + "top_n_full": 20, + "stale_hours": 3.0, + "penalty_minutes": 30.0, + "medium_rank_start": 21, + "medium_rank_end": 50, + "medium_desc_chars": 520, + "minimal_desc_cap": 80, + "penalty_disable": True, } -def load_merged_admin_config(store: Any) -> dict[str, Any]: - out = _admin_defaults_from_env() - try: - raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG) - if not raw: - return out - d = json.loads(raw) if isinstance(raw, str) else raw - if isinstance(d, dict): - for k, v in d.items(): - if k in out or k == "wire_policy": - out[k] = v - except Exception: - pass - pd = out.get("penalty_disable") - if isinstance(pd, str): - out["penalty_disable"] = pd.strip().lower() in ("1", "true", "yes", "on") - return out +def load_tool_policies_dict(_store: Any) -> dict[str, int]: + return {} -def load_tool_policies_dict(store: Any) -> dict[str, int]: - try: - raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES) or "{}" - d = json.loads(raw) if isinstance(raw, str) else raw - except Exception: - return {} - if not isinstance(d, dict): - return {} - out: dict[str, int] = {} - for k, v in d.items(): - nm = str(k or "").strip() - if not nm.startswith("mcp__"): - continue - try: - n = int(v) - except (TypeError, ValueError): - continue - if n == 9999: - out[nm] = 9999 - elif n <= 0: - out[nm] = 0 - else: - out[nm] = min(n, 9998) - return out +def load_tool_policies_dict_for_role(_store: Any, *, role: str | None) -> dict[str, int]: + del role + return {} -def load_tool_policies_dict_for_role(store: Any, *, role: str | None) -> dict[str, int]: - """Role-scoped policies; falls back to global if absent/unparseable.""" - r = str(role or "").strip().lower() - if not r: - return load_tool_policies_dict(store) - try: - raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "{}" - outer = json.loads(raw) if isinstance(raw, str) else raw - except Exception: - outer = {} - if not isinstance(outer, dict): - return load_tool_policies_dict(store) - inner = outer.get(r) - if not isinstance(inner, dict): - return load_tool_policies_dict(store) - # Reuse same coercion rules as global loader. - out: dict[str, int] = {} - for k, v in inner.items(): - nm = str(k or "").strip() - if not nm.startswith("mcp__"): - continue - try: - n = int(v) - except (TypeError, ValueError): - continue - if n == 9999: - out[nm] = 9999 - elif n <= 0: - out[nm] = 0 - else: - out[nm] = min(n, 9998) - return out +def load_penalty_state_for_role(_store: Any, *, role: str | None) -> dict[str, Any]: + del role + return {} -def load_penalty_state_for_role(store: Any, *, role: str | None) -> dict[str, Any]: - """Role-scoped penalty state; falls back to global if absent/unparseable.""" - r = str(role or "").strip().lower() - if not r: - raw_pen = store.get_setting(SETTINGS_KEY_PENALTY_STATE) or "{}" - try: - pen = json.loads(raw_pen) if isinstance(raw_pen, str) else {} - except Exception: - pen = {} - if not isinstance(pen, dict): - pen = {} - return migrate_legacy_penalty_store(pen) - try: - raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}" - outer = json.loads(raw) if isinstance(raw, str) else raw - except Exception: - outer = {} - if not isinstance(outer, dict): - return load_penalty_state_for_role(store, role=None) - inner = outer.get(r) - if not isinstance(inner, dict): - return load_penalty_state_for_role(store, role=None) - return migrate_legacy_penalty_store(inner) +def load_role_mode_for_role(_store: Any, *, role: str | None) -> str: + del role + return "unrestricted" -def _persist_penalty_state_for_role(store: Any, *, role: str | None, penalty_state: dict[str, Any]) -> None: - r = str(role or "").strip().lower() - clean: dict[str, Any] = {} - for k, v in (penalty_state or {}).items(): - if v is None: - continue - if isinstance(v, dict): - clean[str(k)] = v - if not r: - store.set_setting(SETTINGS_KEY_PENALTY_STATE, json.dumps(clean, ensure_ascii=False)) - return - try: - raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}" - outer = json.loads(raw) if isinstance(raw, str) else raw - except Exception: - outer = {} - if not isinstance(outer, dict): - outer = {} - outer[r] = clean - store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(outer, ensure_ascii=False)) +def filter_permanent_ban_mcp_tools(tools: list[dict[str, Any]], _policies: dict[str, int] | None = None) -> list[dict[str, Any]]: + return list(tools or []) -def wire_graduation_effective(base_url: str | None, admin: dict[str, Any]) -> bool: - mode = str(admin.get("wire_policy") or "inherit").strip().lower() - if mode in ("always", "on", "true", "1", "yes"): - return True - if mode in ("never", "off", "false", "0", "no"): - return False - return wire_policy_enabled(base_url) +def migrate_legacy_penalty_store(_store: Any) -> None: + return None -def penalty_effective_disabled(admin: dict[str, Any]) -> bool: - if bool(admin.get("penalty_disable")): - return True - return penalty_disabled() - - -def migrate_legacy_penalty_store(raw: dict[str, Any]) -> dict[str, Any]: - """Older rows used ``omit_until`` without ``phase``; add ``kind`` for global tier.""" - out: dict[str, Any] = {} - for k, v in (raw or {}).items(): - if not isinstance(v, dict): - continue - v2 = dict(v) - if str(v2.get("phase") or "") in ("active", "done"): - if "kind" not in v2: - v2["kind"] = "d" - out[str(k)] = v2 - continue - ou_s = str(v2.get("omit_until") or "") - ws = str(v2.get("wave_ts") or "") - ou = _parse_iso(ou_s) - if ou_s and ou: - if _utc_now() < ou: - v2 = {"phase": "active", "omit_until": ou_s, "wave_ts": ws, "kind": v2.get("kind") or "d"} - else: - v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"} - elif ws: - v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"} - else: - continue - out[str(k)] = v2 - return out - - -def _parse_iso(ts: str) -> datetime | None: - s = str(ts or "").strip() - if not s: - return None - try: - if s.endswith("Z"): - s = s[:-1] + "+00:00" - return datetime.fromisoformat(s) - except Exception: - return None - - -def _utc_now() -> datetime: - return datetime.now(timezone.utc) - - -def _wire_json_size(tools: list[dict[str, Any]]) -> int: - try: - return len(json.dumps(tools, ensure_ascii=False, default=str)) - except Exception: - return 0 - - -def _strip_nested_descriptions(obj: Any) -> Any: - if isinstance(obj, dict): - out: dict[str, Any] = {} - for k, v in obj.items(): - if str(k) == "description": - continue - out[str(k)] = _strip_nested_descriptions(v) - return out - if isinstance(obj, list): - return [_strip_nested_descriptions(x) for x in obj] - return obj - - -def _tier_medium(fn: dict[str, Any], *, cfg: dict[str, Any]) -> dict[str, Any]: - mid_desc_len = int(cfg.get("medium_desc_chars") or 520) - mid_desc_len = max(80, min(mid_desc_len, 4000)) - desc = str(fn.get("description") or "")[:mid_desc_len] - params = fn.get("parameters") - if isinstance(params, dict): - params_out = dict(_strip_nested_descriptions(params)) - else: - params_out = dict(_MIN_PARAMETERS) - return {"name": fn.get("name"), "description": desc, "parameters": params_out} - - -def _tier_minimal(fn: dict[str, Any], *, desc_cap: int) -> dict[str, Any]: - desc = str(fn.get("description") or "")[: max(0, desc_cap)] - return {"name": fn.get("name"), "description": desc, "parameters": dict(_MIN_PARAMETERS)} - - -def _clone_tool_entry(t: dict[str, Any]) -> dict[str, Any]: - try: - return json.loads(json.dumps(t, ensure_ascii=False, default=str)) - except Exception: - return dict(t) - - -def _is_builtin_tool_name(name: str) -> bool: - return not str(name).startswith("mcp__") - - -def _normalize_role_mode(v: Any) -> str: - s = str(v or "").strip().lower() - if s in {"unrestricted", "forbidden", "restricted"}: - return s - return "restricted" - - -def load_role_mode_for_role(store: Any, *, role: str | None) -> str: - r = str(role or "").strip().lower() - if not r: - return "restricted" - try: - raw = store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "{}" - obj = json.loads(raw) if isinstance(raw, str) else raw - except Exception: - obj = {} - if not isinstance(obj, dict): - return "restricted" - return _normalize_role_mode(obj.get(r)) - - -def _is_stale_hours(last_ts_iso: str | None, stale_hours: float) -> bool: - if not last_ts_iso: - return False - dt = _parse_iso(last_ts_iso) - if dt is None: - return False - return (_utc_now() - dt).total_seconds() > stale_hours * 3600.0 - - -def _idle_minutes_exceeded(last_ts_iso: str | None, idle_minutes: float) -> bool: - if not last_ts_iso: - return False - dt = _parse_iso(last_ts_iso) - if dt is None: - return False - return (_utc_now() - dt).total_seconds() > idle_minutes * 60.0 - - -def _wave_penalty_omit( - name: str, - last_ts: str | None, - idle_hit: bool, - penalty_minutes: float, - st_nm: Any, - out_penalty_updates: dict[str, Any], - *, - kind: str, -) -> bool: - pw = last_ts or "" - mins = max(1.0, min(float(penalty_minutes), 24 * 60)) - if not idle_hit: - return False - prev = out_penalty_updates.get(name) - if isinstance(prev, dict) and str(prev.get("kind") or "") != str(kind): - out_penalty_updates.pop(name, None) - st_nm = None - else: - st_nm = prev - - omit = False - if isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "active": - ou = _parse_iso(str(st_nm.get("omit_until") or "")) - if ou and _utc_now() < ou: - omit = True - else: - out_penalty_updates[name] = {"phase": "done", "wave_ts": pw, "kind": str(kind)} - elif isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "done" and str(st_nm.get("wave_ts") or "") == pw: - pass - else: - until = (_utc_now() + timedelta(minutes=mins)).isoformat() - out_penalty_updates[name] = {"phase": "active", "omit_until": until, "wave_ts": pw, "kind": str(kind)} - omit = True - return omit - - -def filter_permanent_ban_mcp_tools(tools: list[dict[str, Any]], policies: dict[str, int]) -> list[dict[str, Any]]: - out: list[dict[str, Any]] = [] - for entry in tools: - if not isinstance(entry, dict) or str(entry.get("type") or "") != "function": - out.append(entry) - continue - fn = entry.get("function") - if not isinstance(fn, dict): - out.append(entry) - continue - name = str(fn.get("name") or "").strip() - if not name or _is_builtin_tool_name(name): - out.append(entry) - continue - if policies.get(name) == 9999: - continue - out.append(entry) - return out +def penalty_row_status(_row: dict[str, Any] | None, *, now: Any = None) -> str: + del now + return "ok" def prepare_openai_tools_for_llm_api( @@ -406,364 +81,32 @@ def prepare_openai_tools_for_llm_api( store: Any | None = None, role: str | None = None, ) -> list[dict[str, Any]]: - """Build wire ``tools[]``: optional graduated policy + byte cap.""" - - def _fallback_shrink(raw: list[dict[str, Any]], cap: int) -> list[dict[str, Any]]: - from svc.llm.tool_schema import shrink_openai_tools_payload_for_api - - return shrink_openai_tools_payload_for_api(raw, max_json_bytes=cap) - + """Complete tool schemas and optionally shrink payload size. No MCP suppression.""" + del base_url, store, role if not tools: return tools - - tools = complete_openai_tools_wire_parameters(tools) - - def _finalize(result: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Re-apply completion after tier/shrink/squeeze (those paths emit fresh ``parameters`` dicts).""" - return complete_openai_tools_wire_parameters(result) - - policies: dict[str, int] = {} - admin: dict[str, Any] = _admin_defaults_from_env() - role_mode = "restricted" - try: - if store is None: - from svc.config.paths import db_path - from svc.persistence.sqlite_store import SqliteStore - - store = get_assistant_store() - # Role-scoped policies if configured; otherwise fall back to global. - policies = load_tool_policies_dict_for_role(store, role=str(role or "").strip().lower() or None) - admin = load_merged_admin_config(store) - role_mode = load_role_mode_for_role(store, role=str(role or "").strip().lower() or None) - except Exception as exc: - logger.warning("tool_wire_policy: load admin/policies (%s)", exc) - - def _filter_mcp_out(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: - out: list[dict[str, Any]] = [] - for entry in raw: - if not isinstance(entry, dict) or str(entry.get("type") or "") != "function": - out.append(entry) - continue - fn = entry.get("function") - if not isinstance(fn, dict): - out.append(entry) - continue - nm = str(fn.get("name") or "").strip() - if nm.startswith("mcp__"): - continue - out.append(entry) - return out - - tools_pass1 = filter_permanent_ban_mcp_tools(tools, policies) - - if role_mode == "forbidden": - out = _filter_mcp_out(tools) - if max_json_bytes is not None and max_json_bytes > 0: - return _finalize(_fallback_shrink(out, max_json_bytes)) - return _finalize(out) - if role_mode == "unrestricted": - if max_json_bytes is not None and max_json_bytes > 0: - return _finalize(_fallback_shrink(tools_pass1, max_json_bytes)) - return _finalize(tools_pass1) - - if not wire_graduation_effective(base_url, admin): - if max_json_bytes is not None and max_json_bytes > 0: - return _finalize(_fallback_shrink(tools_pass1, max_json_bytes)) - return _finalize(tools_pass1) - - top_n = int(admin.get("top_n_full") or 20) - top_n = max(3, min(top_n, 80)) - stale_h = float(admin.get("stale_hours") or 3) - stale_h = max(0.25, min(stale_h, 720.0)) - penalty_min_global = float(admin.get("penalty_minutes") or 30) - penalty_min_global = max(1.0, min(penalty_min_global, 24 * 60)) - medium_floor = int(admin.get("medium_rank_start") or 21) - medium_ceiling = int(admin.get("medium_rank_end") or 50) - if medium_floor > medium_ceiling: - medium_floor, medium_ceiling = medium_ceiling, medium_floor - - usage_map: dict[str, dict[str, Any]] = {} - penalty_state: dict[str, Any] = {} - try: - usage_map = store.list_mcp_tool_aggregate_usage() - penalty_state = load_penalty_state_for_role(store, role=str(role or "").strip().lower() or None) - except Exception as exc: - logger.warning("tool_wire_policy: usage store unavailable (%s); fallback shrink only", exc) - if max_json_bytes is not None and max_json_bytes > 0: - return _finalize(_fallback_shrink(tools_pass1, max_json_bytes)) - return _finalize(tools_pass1) - - ranked = sorted( - usage_map.items(), - key=lambda kv: (-int(kv[1].get("count") or 0), str(kv[1].get("last_ts") or "")), - ) - rank_by_name = {nm: idx + 1 for idx, (nm, _) in enumerate(ranked)} - top_full = {nm for nm, _ in ranked[:top_n]} - - out_penalty_updates: dict[str, Any] = dict(penalty_state) - omit_set: set[str] = set() - - for nm in list(out_penalty_updates.keys()): - meta = usage_map.get(nm) - cur_ts = str(meta.get("last_ts") or "") if meta else "" - st = out_penalty_updates.get(nm) - if not isinstance(st, dict): - continue - w_ts = str(st.get("wave_ts") or "") - if w_ts and cur_ts and w_ts != cur_ts: - out_penalty_updates.pop(nm, None) - - pen_glob = penalty_effective_disabled(admin) - built: list[dict[str, Any]] = [] - for entry in tools_pass1: - if not isinstance(entry, dict) or str(entry.get("type") or "") != "function": - built.append(entry) - continue - fn = entry.get("function") - if not isinstance(fn, dict): - built.append(entry) - continue - name = str(fn.get("name") or "").strip() - if not name: - built.append(entry) - continue - - if _is_builtin_tool_name(name): - built.append(_clone_tool_entry(entry)) - continue - - usage = usage_map.get(name) or {} - last_ts = str(usage.get("last_ts") or "") or None - rk = rank_by_name.get(name) - - policy_lv = policies.get(name) - st_nm = out_penalty_updates.get(name) - - # policy_lv semantics (punishment only): - # - None: inherit global graduation + idle penalty. - # - 0: exempt this tool from idle penalty (but still participates in graduation tiers). - # - 1..9998: per-tool idle/penalty tuning (N * 10 minutes) + still participates in graduation tiers. - # - 9999: permanent ban (filtered earlier in filter_permanent_ban_mcp_tools). - omit = False - if policy_lv == 0: - omit = False - elif policy_lv is not None and policy_lv >= 1: - idle_m = float(policy_lv) * 10.0 - pen_m = float(policy_lv) * 10.0 - idle_hit = _idle_minutes_exceeded(last_ts, idle_m) - kind = f"L{int(policy_lv)}" - if not pen_glob: - omit = _wave_penalty_omit( - name, last_ts, idle_hit, pen_m, st_nm, out_penalty_updates, kind=kind - ) - else: - idle_hit = _is_stale_hours(last_ts, stale_h) - exempt = name in top_full - if not pen_glob and idle_hit and not exempt: - omit = _wave_penalty_omit( - name, - last_ts, - idle_hit, - penalty_min_global, - st_nm, - out_penalty_updates, - kind="d", - ) - - if omit: - omit_set.add(name) - continue - - tier = "minimal" - if name in top_full: - tier = "full" - elif rk is not None and medium_floor <= rk <= medium_ceiling: - tier = "medium" - else: - tier = "minimal" - - if tier == "full": - built.append(_clone_tool_entry(entry)) - elif tier == "medium": - mf = _tier_medium(fn, cfg=admin) - built.append({"type": "function", "function": mf}) - else: - cap0 = int(admin.get("minimal_desc_cap") or 80) - cap0 = max(0, min(cap0, 2000)) - mf = _tier_minimal(fn, desc_cap=cap0) - built.append({"type": "function", "function": mf}) - - try: - _persist_penalty_state_for_role( - store, - role=str(role or "").strip().lower() or None, - penalty_state=out_penalty_updates, - ) - except Exception as exc: - logger.warning("tool_wire_policy: persist penalty state failed: %s", exc) - - if omit_set: - logger.info( - "tool_wire_policy: omitted %d MCP tools during penalty window: %s", - len(omit_set), - sorted(omit_set)[:12], - ) - - if max_json_bytes is not None and max_json_bytes > 0 and _wire_json_size(built) > max_json_bytes: - return _finalize(_squeeze_to_budget(built, max_json_bytes, admin=admin)) - - return _finalize(built) - - -def _squeeze_to_budget( - built: list[dict[str, Any]], max_json_bytes: int, *, admin: dict[str, Any] -) -> list[dict[str, Any]]: - work = json.loads(json.dumps(built, ensure_ascii=False, default=str)) - - def sz() -> int: - return _wire_json_size(work) - - if sz() <= max_json_bytes: - return work - for i, ent in enumerate(work): - fn = ent.get("function") if isinstance(ent.get("function"), dict) else {} - ps = fn.get("parameters") - if isinstance(ps, dict) and len(json.dumps(ps, ensure_ascii=False)) > 400: - nm = str(fn.get("name") or "") - if nm.startswith("mcp__"): - cap = int(admin.get("minimal_desc_cap") or 80) - work[i] = { - "type": "function", - "function": _tier_minimal(fn, desc_cap=max(0, cap)), - } - if sz() <= max_json_bytes: - return work - - for cap in (60, 40, 24, 12, 8, 4, 0): - for i, ent in enumerate(work): - fn = ent.get("function") if isinstance(ent.get("function"), dict) else {} - nm = str(fn.get("name") or "") - if nm.startswith("mcp__"): - work[i] = {"type": "function", "function": _tier_minimal(fn, desc_cap=cap)} - if sz() <= max_json_bytes: - return work - - idxs = [i for i, e in enumerate(work) if str((e.get("function") or {}).get("name") or "").startswith("mcp__")] - for i in reversed(idxs): - if sz() <= max_json_bytes: - break - work.pop(i) - - if sz() > max_json_bytes: + out = complete_openai_tools_wire_parameters(list(tools)) + if max_json_bytes is not None and int(max_json_bytes) > 0: from svc.llm.tool_schema import shrink_openai_tools_payload_for_api - work = shrink_openai_tools_payload_for_api(work, max_json_bytes=max_json_bytes) - return work - - -def penalty_row_status( - *, - wire_name: str, - policy_level: int | None, - penalty: dict[str, Any] | None, - last_ts: str | None, -) -> dict[str, Any]: - """Human-readable row for Admin UI.""" - if policy_level is None: - return { - "phase": "inherit", - "omit_until": None, - "unblock_hint": "未配置:走全局线侧与闲置惩罚(与显式写入 0 不同)", - "kind": None, - } - if policy_level == 9999: - return { - "phase": "permanent_ban", - "omit_until": None, - "unblock_hint": "永久不上送(需将策略改为非 9999)", - "kind": None, - } - if policy_level == 0: - return { - "phase": "exempt", - "omit_until": None, - "unblock_hint": "策略 0:不参与闲置惩罚(仍可能受线侧分层/压缩影响)", - "kind": None, - } - st = penalty.get(wire_name) if isinstance(penalty, dict) else None - if not isinstance(st, dict): - return {"phase": "none", "omit_until": None, "unblock_hint": "-", "kind": None} - ph = str(st.get("phase") or "") - ou = str(st.get("omit_until") or "") - kd = str(st.get("kind") or "") - if ph == "active" and ou: - dt = _parse_iso(ou) - hint = f"惩罚中,预计解封(omit_until): {ou}" - if dt and _utc_now() >= dt: - hint = "惩罚窗口应已结束;下次请求会写入 done" - return {"phase": "active", "omit_until": ou, "unblock_hint": hint, "kind": kd} - if ph == "done": - return { - "phase": "done", - "omit_until": None, - "unblock_hint": "本轮已服完刑;再次闲置达到阈值后会重新惩罚", - "kind": kd, - } - return {"phase": ph or "unknown", "omit_until": ou or None, "unblock_hint": "-", "kind": kd or None} + out = shrink_openai_tools_payload_for_api(out, max_json_bytes=int(max_json_bytes)) + out = complete_openai_tools_wire_parameters(out) + return out def build_tool_wire_snapshot(store: Any, *, role: str | None = None) -> dict[str, Any]: - """Aggregate MCP install list + usage + policies for Admin GET.""" - from runtime.tools.mcp.registry import McpRegistry - - admin = load_merged_admin_config(store) - role_norm = str(role or "").strip().lower() or None - role_mode = load_role_mode_for_role(store, role=role_norm) - policies = load_tool_policies_dict_for_role(store, role=role_norm) - pen = load_penalty_state_for_role(store, role=role_norm) - usage = store.list_mcp_tool_aggregate_usage() - servers = McpRegistry(store).list_servers(enabled_only=False) - tools_out: list[dict[str, Any]] = [] - for s in servers: - sid = str(s.get("server_id") or "").strip() - if not sid: - continue - for t in store.list_mcp_server_tools(server_id=sid): - short = str(t.get("tool_name") or "").strip() - if not short: - continue - wire = f"mcp__{sid}__{short}" - agg = usage.get(wire) or {} - in_db = wire in policies - lv = int(policies[wire]) if in_db else None - st = penalty_row_status( - wire_name=wire, - policy_level=lv, - penalty=pen, - last_ts=str(agg.get("last_ts") or "") or None, - ) - tools_out.append( - { - "server_id": sid, - "mcp_tool_name": short, - "wire_name": wire, - "policy_level": lv, - "policy_in_db": in_db, - "count": int(agg.get("count") or 0), - "last_ts": str(agg.get("last_ts") or ""), - "penalty": st, - "raw_penalty": pen.get(wire) if isinstance(pen.get(wire), dict) else None, - } - ) - tools_out.sort(key=lambda x: (x["server_id"], x["wire_name"])) + """Compat stub for removed Admin wire UI.""" + del store return { "ok": True, - "role": role_norm or "", - "role_mode": role_mode, - "config": admin, - "policies": policies, - "penalty_state": pen, - "tools": tools_out, + "removed": True, + "role": str(role or "").strip().lower(), + "role_mode": "unrestricted", + "config": load_merged_admin_config(None), + "policies": {}, + "penalty_state": {}, + "tools": [], + "message": "MCP wire suppression removed; use specialist MCP binding only.", } @@ -784,5 +127,6 @@ __all__ = [ "migrate_legacy_penalty_store", "penalty_row_status", "prepare_openai_tools_for_llm_api", + "wire_graduation_effective", "wire_policy_enabled", ] diff --git a/tests/test_admin_mcp_tool_wire_role_mode_api.py b/tests/test_admin_mcp_tool_wire_role_mode_api.py deleted file mode 100644 index 063df652..00000000 --- a/tests/test_admin_mcp_tool_wire_role_mode_api.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import tempfile -import unittest -from pathlib import Path - -from fastapi.testclient import TestClient - -from interfaces.http.fastapi_app import create_app -from svc.persistence.sqlite_store import SqliteStore - - -class AdminMcpToolWireRoleModeApiTests(unittest.TestCase): - def setUp(self) -> None: - self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) - self.db = Path(self._tmp.name) / "ops.sqlite" - os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db) - os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass" - self.store = SqliteStore(str(self.db)) - t = self.store.create_tenant("Team") - self.tenant_id = str(t["id"]) - self.store.create_user_account( - tenant_id=self.tenant_id, - username="administrator", - display_name="Admin", - role="owner", - password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(), - is_active=True, - ) - self.client = TestClient(create_app()) - self.client.post("/admin/api/auth/bootstrap", json={}) - r = self.client.post( - "/admin/api/auth/login", - json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"}, - ) - self.token = str((r.json() or {}).get("token") or "") - - def tearDown(self) -> None: - self._tmp.cleanup() - - def _h(self) -> dict[str, str]: - return {"authorization": f"Bearer {self.token}"} - - def test_role_mode_save_and_snapshot(self) -> None: - s = self.client.post( - "/admin/api/mcp/tool-wire/role-mode", - json={"role": "ops", "mode": "forbidden"}, - headers=self._h(), - ) - self.assertEqual(s.status_code, 200, s.text) - body = s.json() or {} - self.assertTrue(body.get("ok")) - self.assertEqual(body.get("role"), "ops") - self.assertEqual(body.get("mode"), "forbidden") - - g = self.client.get("/admin/api/mcp/tool-wire?role=ops", headers=self._h()) - self.assertEqual(g.status_code, 200, g.text) - gb = g.json() or {} - self.assertTrue(gb.get("ok")) - self.assertEqual(gb.get("role"), "ops") - self.assertEqual(gb.get("role_mode"), "forbidden") - - def test_role_mode_rejects_unknown_role(self) -> None: - s = self.client.post( - "/admin/api/mcp/tool-wire/role-mode", - json={"role": "unknown_role_x", "mode": "restricted"}, - headers=self._h(), - ) - self.assertEqual(s.status_code, 400, s.text) - self.assertIn("invalid_role", s.text) - - def test_policies_batch_supports_role_scope(self) -> None: - r = self.client.post( - "/admin/api/mcp/tool-wire/policies/batch", - json={"role": "ops", "level": 9999, "wire_names": ["mcp__echo__ping"]}, - headers=self._h(), - ) - self.assertEqual(r.status_code, 200, r.text) - body = r.json() or {} - self.assertTrue(body.get("ok")) - self.assertEqual(body.get("role"), "ops") - raw = str(self.store.get_setting("mcp_tool_wire_tool_policies_by_role") or "").strip() or "{}" - obj = json.loads(raw) - self.assertEqual(int(((obj.get("ops") or {}).get("mcp__echo__ping") or 0)), 9999) - - def test_e2e_role_mode_then_batch_then_refresh_consistent(self) -> None: - # 1) switch role mode for ops - s = self.client.post( - "/admin/api/mcp/tool-wire/role-mode", - json={"role": "ops", "mode": "restricted"}, - headers=self._h(), - ) - self.assertEqual(s.status_code, 200, s.text) - self.assertTrue((s.json() or {}).get("ok")) - - # 2) apply role-scoped batch policy for ops - b = self.client.post( - "/admin/api/mcp/tool-wire/policies/batch", - json={"role": "ops", "level": 0, "wire_names": ["mcp__echo__ping"]}, - headers=self._h(), - ) - self.assertEqual(b.status_code, 200, b.text) - bb = b.json() or {} - self.assertTrue(bb.get("ok")) - self.assertEqual(bb.get("role"), "ops") - - # 3) query snapshot for ops (equivalent to refresh + reload) - g1 = self.client.get("/admin/api/mcp/tool-wire?role=ops", headers=self._h()) - self.assertEqual(g1.status_code, 200, g1.text) - body1 = g1.json() or {} - self.assertTrue(body1.get("ok")) - self.assertEqual(body1.get("role"), "ops") - self.assertEqual(body1.get("role_mode"), "restricted") - self.assertEqual(int((body1.get("policies") or {}).get("mcp__echo__ping") or 0), 0) - - # 4) switch another role and ensure isolation - s2 = self.client.post( - "/admin/api/mcp/tool-wire/role-mode", - json={"role": "generalist", "mode": "forbidden"}, - headers=self._h(), - ) - self.assertEqual(s2.status_code, 200, s2.text) - - g2 = self.client.get("/admin/api/mcp/tool-wire?role=generalist", headers=self._h()) - self.assertEqual(g2.status_code, 200, g2.text) - body2 = g2.json() or {} - self.assertEqual(body2.get("role_mode"), "forbidden") - # no accidental carry-over from ops role policies - self.assertNotIn("mcp__echo__ping", dict(body2.get("policies") or {})) - - -if __name__ == "__main__": - unittest.main() - diff --git a/tests/test_tool_wire_policy.py b/tests/test_tool_wire_policy.py deleted file mode 100644 index 1c598236..00000000 --- a/tests/test_tool_wire_policy.py +++ /dev/null @@ -1,213 +0,0 @@ -from __future__ import annotations - -import json -import unittest -from datetime import datetime, timedelta, timezone -from unittest.mock import patch - -from svc.llm.tool_wire_policy import ( - SETTINGS_KEY_PENALTY_STATE, - migrate_legacy_penalty_store, - prepare_openai_tools_for_llm_api, -) - - -class FakeToolStore: - """Minimal store stub for ``prepare_openai_tools_for_llm_api``.""" - - def __init__(self, usage_map: dict, penalty_state: dict | None = None) -> None: - self.usage_map = dict(usage_map) - self._penalty_json = json.dumps(penalty_state or {}) - self.last_penalty_saved: str | None = None - - def list_mcp_tool_aggregate_usage(self) -> dict: - return dict(self.usage_map) - - def get_setting(self, key: str) -> str | None: - if key == SETTINGS_KEY_PENALTY_STATE: - return self._penalty_json - return None - - def set_setting(self, key: str, val: str) -> None: - if key == SETTINGS_KEY_PENALTY_STATE: - self._penalty_json = val - self.last_penalty_saved = val - - -def _fn(name: str, *, desc: str = "hello") -> dict: - props: dict = {"fld": {"type": "string", "description": "inner"}} - return { - "type": "function", - "function": { - "name": name, - "description": desc, - "parameters": {"type": "object", "properties": props}, - }, - } - - -def _names(tools: list[dict]) -> list[str]: - out: list[str] = [] - for t in tools: - fn = t.get("function") - if isinstance(fn, dict) and fn.get("name"): - out.append(str(fn["name"])) - return out - - -def _iso_hours_ago(h: float) -> str: - n = datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc) - return (n - timedelta(hours=h)).isoformat() - - -class ToolWirePolicyTests(unittest.TestCase): - def test_builtin_tools_always_full(self) -> None: - ts = _iso_hours_ago(1.0) - usage = { - "mcp__rank__1": {"count": 400, "last_ts": ts}, - "mcp__rank__2": {"count": 300, "last_ts": ts}, - "mcp__rank__3": {"count": 200, "last_ts": ts}, - "mcp__srv__x": {"count": 1, "last_ts": ts}, - } - tools = [_fn("read_file"), _fn("mcp__rank__1"), _fn("mcp__rank__2"), _fn("mcp__rank__3"), _fn("mcp__srv__x")] - st = FakeToolStore(usage) - with patch("svc.llm.tool_wire_policy._utc_now", return_value=datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)): - with patch.dict("os.environ", {"OPS_MCP_WIRE_TOP_N_FULL": "3"}, clear=False): - out = prepare_openai_tools_for_llm_api( - tools, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - max_json_bytes=None, - store=st, - ) - by_name = {str(t["function"]["name"]): t for t in out if t.get("type") == "function"} - self.assertIn("inner", json.dumps(by_name["read_file"])) - self.assertNotIn("inner", json.dumps(by_name["mcp__srv__x"])) - - def test_graduated_minimal_below_top_n(self) -> None: - ts = _iso_hours_ago(0.5) - usage = { - "mcp__p__a": {"count": 100, "last_ts": ts}, - "mcp__p__b": {"count": 90, "last_ts": ts}, - "mcp__p__c": {"count": 80, "last_ts": ts}, - "mcp__p__d": {"count": 70, "last_ts": ts}, - } - tools = [_fn("mcp__p__a"), _fn("mcp__p__b"), _fn("mcp__p__c"), _fn("mcp__p__d")] - st = FakeToolStore(usage) - fixed = datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc) - with patch("svc.llm.tool_wire_policy._utc_now", return_value=fixed): - with patch.dict( - "os.environ", - { - "OPS_MCP_WIRE_TOP_N_FULL": "3", - "OPS_MCP_WIRE_MEDIUM_RANK_START": "999", - "OPS_MCP_WIRE_MEDIUM_RANK_END": "999", - }, - clear=False, - ): - out = prepare_openai_tools_for_llm_api( - tools, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - max_json_bytes=None, - store=st, - ) - by_name = {str(t["function"]["name"]): t for t in out} - self.assertIn("properties", by_name["mcp__p__a"]["function"]["parameters"]) - self.assertIn("properties", by_name["mcp__p__b"]["function"]["parameters"]) - self.assertIn("properties", by_name["mcp__p__c"]["function"]["parameters"]) - self.assertEqual(by_name["mcp__p__d"]["function"]["parameters"].get("additionalProperties"), True) - - def test_never_logged_mcp_not_omitted(self) -> None: - tools = [_fn("mcp__ghost__z")] - st = FakeToolStore({}) - out = prepare_openai_tools_for_llm_api( - tools, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - max_json_bytes=None, - store=st, - ) - self.assertEqual(_names(out), ["mcp__ghost__z"]) - - def test_stale_penalty_then_release(self) -> None: - t0 = datetime(2026, 4, 19, 12, 0, 0, tzinfo=timezone.utc) - stale_ts = (t0 - timedelta(hours=4)).isoformat() - usage = { - "mcp__st__fill1": {"count": 900, "last_ts": stale_ts}, - "mcp__st__fill2": {"count": 800, "last_ts": stale_ts}, - "mcp__st__fill3": {"count": 700, "last_ts": stale_ts}, - "mcp__st__old": {"count": 5, "last_ts": stale_ts}, - } - tools = [_fn("mcp__st__fill1"), _fn("mcp__st__fill2"), _fn("mcp__st__fill3"), _fn("mcp__st__old")] - st = FakeToolStore(usage) - - with patch.dict( - "os.environ", - {"OPS_MCP_WIRE_STALE_HOURS": "3", "OPS_MCP_WIRE_TOP_N_FULL": "3"}, - clear=False, - ): - with patch("svc.llm.tool_wire_policy._utc_now", return_value=t0): - out1 = prepare_openai_tools_for_llm_api( - tools, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - max_json_bytes=None, - store=st, - ) - self.assertEqual(_names(out1), ["mcp__st__fill1", "mcp__st__fill2", "mcp__st__fill3"]) - - pen = json.loads(st.last_penalty_saved or "{}") - self.assertEqual(pen["mcp__st__old"]["phase"], "active") - - t_late = t0 + timedelta(minutes=31) - with patch("svc.llm.tool_wire_policy._utc_now", return_value=t_late): - out2 = prepare_openai_tools_for_llm_api( - tools, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - max_json_bytes=None, - store=st, - ) - want = ["mcp__st__fill1", "mcp__st__fill2", "mcp__st__fill3", "mcp__st__old"] - self.assertEqual(_names(out2), want) - pen2 = json.loads(st.last_penalty_saved or "{}") - self.assertEqual(pen2["mcp__st__old"]["phase"], "done") - - def test_wave_ts_change_clears_penalty(self) -> None: - t0 = datetime(2026, 4, 19, 15, 0, 0, tzinfo=timezone.utc) - old_ts = (t0 - timedelta(hours=5)).isoformat() - usage1 = {"mcp__w__x": {"count": 1, "last_ts": old_ts}} - penalty1 = {"mcp__w__x": {"phase": "done", "wave_ts": old_ts}} - tools = [_fn("mcp__w__x")] - st = FakeToolStore(usage1, penalty1) - - new_ts = (t0 - timedelta(minutes=30)).isoformat() - usage2 = {"mcp__w__x": {"count": 2, "last_ts": new_ts}} - st.usage_map = usage2 - - with patch.dict( - "os.environ", - {"OPS_MCP_WIRE_STALE_HOURS": "3", "OPS_MCP_WIRE_TOP_N_FULL": "3"}, - clear=False, - ): - with patch("svc.llm.tool_wire_policy._utc_now", return_value=t0): - out = prepare_openai_tools_for_llm_api( - tools, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - max_json_bytes=None, - store=st, - ) - pen = json.loads(st.last_penalty_saved or "{}") - self.assertNotIn("mcp__w__x", pen) - self.assertEqual(_names(out), ["mcp__w__x"]) - - def test_migrate_legacy_penalty_store(self) -> None: - future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() - past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() - raw = { - "legacy_active": {"omit_until": future, "wave_ts": "x"}, - "legacy_done": {"omit_until": past, "wave_ts": "y"}, - } - mig = migrate_legacy_penalty_store(raw) - self.assertEqual(mig["legacy_active"]["phase"], "active") - self.assertEqual(mig["legacy_done"]["phase"], "done") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_tool_wire_policy_passthrough.py b/tests/test_tool_wire_policy_passthrough.py new file mode 100644 index 00000000..50fa4f00 --- /dev/null +++ b/tests/test_tool_wire_policy_passthrough.py @@ -0,0 +1,50 @@ +"""Wire suppression removed: prepare_openai_tools keeps MCP tools.""" + +from __future__ import annotations + +from svc.llm.tool_wire_policy import ( + load_role_mode_for_role, + prepare_openai_tools_for_llm_api, + wire_graduation_effective, + wire_policy_enabled, +) + + +def test_wire_policy_disabled_by_default() -> None: + assert wire_policy_enabled("https://api.openai.com") is False + assert wire_graduation_effective("https://api.openai.com", {}) is False + assert load_role_mode_for_role(None, role="ops") == "unrestricted" + + +def test_prepare_openai_tools_keeps_mcp_tools() -> None: + tools = [ + { + "type": "function", + "function": { + "name": "mcp__demo__ping", + "description": "ping", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "read", + "parameters": {"type": "object", "properties": {}}, + }, + }, + ] + out = prepare_openai_tools_for_llm_api( + tools, + base_url="https://example", + max_json_bytes=None, + store=None, + role="ops", + ) + names = [ + str(((e.get("function") or {}).get("name") if isinstance(e, dict) else "") or "") + for e in out + ] + assert "mcp__demo__ping" in names + assert "read_file" in names diff --git a/tests/test_tool_wire_policy_role_scoped.py b/tests/test_tool_wire_policy_role_scoped.py deleted file mode 100644 index 262112b9..00000000 --- a/tests/test_tool_wire_policy_role_scoped.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from svc.persistence.sqlite_store import SqliteStore -from svc.llm.tool_wire_policy import prepare_openai_tools_for_llm_api - - -def test_manager_role_respects_permanent_ban_by_default(tmp_path: Path) -> None: - store = SqliteStore(str(tmp_path / "ops.sqlite")) - store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}') - tools = [ - {"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}, - {"type": "function", "function": {"name": "system_time", "description": "t", "parameters": {"type": "object"}}}, - ] - out_mgr = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="manager") - out_gen = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="generalist") - mgr_names = {t["function"]["name"] for t in out_mgr if isinstance(t, dict) and t.get("type") == "function"} - gen_names = {t["function"]["name"] for t in out_gen if isinstance(t, dict) and t.get("type") == "function"} - assert "mcp__echo__ping" not in mgr_names - assert "mcp__echo__ping" not in gen_names - - -def test_role_scoped_policies_override_global(tmp_path: Path) -> None: - store = SqliteStore(str(tmp_path / "ops.sqlite")) - # Global ban - store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}') - # Role override allows it (policy 0) - store.set_setting("mcp_tool_wire_tool_policies_by_role", '{"ops":{"mcp__echo__ping":0}}') - tools = [{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}] - out_ops = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="ops") - out_gen = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="generalist") - ops_names = {t["function"]["name"] for t in out_ops if isinstance(t, dict) and t.get("type") == "function"} - gen_names = {t["function"]["name"] for t in out_gen if isinstance(t, dict) and t.get("type") == "function"} - assert "mcp__echo__ping" in ops_names - assert "mcp__echo__ping" not in gen_names - - -def test_role_mode_unrestricted_disables_penalty_but_keeps_ban(tmp_path: Path) -> None: - store = SqliteStore(str(tmp_path / "ops.sqlite")) - store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}') - store.set_setting("mcp_tool_wire_role_mode_by_role", '{"manager":"unrestricted"}') - tools = [{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}] - out_mgr = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="manager") - names = {t["function"]["name"] for t in out_mgr if isinstance(t, dict) and t.get("type") == "function"} - assert "mcp__echo__ping" not in names - - -def test_role_mode_forbidden_disables_all_mcp(tmp_path: Path) -> None: - store = SqliteStore(str(tmp_path / "ops.sqlite")) - store.set_setting("mcp_tool_wire_role_mode_by_role", '{"ops":"forbidden"}') - tools = [ - {"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}, - {"type": "function", "function": {"name": "system_time", "description": "t", "parameters": {"type": "object"}}}, - ] - out_ops = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="ops") - names = {t["function"]["name"] for t in out_ops if isinstance(t, dict) and t.get("type") == "function"} - assert "mcp__echo__ping" not in names - assert "system_time" in names -