mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 11:30:44 +08:00
MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools mirroring the netxops bizMonitor group. - Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP contracts; surface partial batch failures as tool errors. - CLI hard gate: single-line show/display only; exec job IDs, path containment, owner and ne:exec checks. - Cisco IOSv collection: stable prompt/echo handling over CR. - Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json; sync skills and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
|
|
@ -48,14 +48,24 @@ python -m netx_mcp
|
|||
|
||||
0.3.1:`getNmsNe` 可直接传 host_name/IP;输出统一 `nms_ne_id` / `source=nms`;参数类型错误返回工具错误而非协议崩溃。
|
||||
|
||||
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。
|
||||
参数使用 `nms_ne_id` / `nms_ne_ids`,路径端点使用 `from_nms_ne_id` / `to_nms_ne_id`;`getManagedNe` 只接受 `ne_id`。旧 `ume_*`、`managed_ne_id`、`id`、告警 `ne_name` 别名已删除,未知参数会返回工具错误。REST 适配器内部的 `ume_*` 字段不变。
|
||||
|
||||
NMS 查询(含 inventory)按当前 HTTP 权限规则需要 `alarms:read`;托管资源、CLI 目标和路径查询需要 `ne:read`;执行与轮询需要 `ne:exec`;SQL 需要 `sql:query`。所有工具返回递归裁剪结构化口令、令牌等凭据,但 CLI 原文输出仍需调用者判断是否敏感。
|
||||
|
||||
拓扑画布 / Fabric → skill **topology** / [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
|
||||
|
||||
业务监控 / 割接 → [`netx-bizmonitor-mcp`](../netx-bizmonitor-mcp)。三组统一配置见根 `mcp.all.json`,均不依赖 netxops。
|
||||
|
||||
本包的 `execManagedNe` 独立强制只读 show/display;即使后端设备开放 shell,也拒绝配置、命令拼接、重定向和多行脚本。getManagedNe.capability 描述后端能力,不能作为 MCP 脚本许可;Linux/RouterOS 原生 CLI 当前未暴露。异步 ack 返回 `poll_after_ms=5000`;单台核对 terminal/result.ok,批量还须核对 summary.failed=0 和每个 results[].ok。作业 succeeded 仍可能有单台失败,须分别报告,勿整批重跑已成功设备。失败 job 作为工具错误返回,保留原始失败证据。
|
||||
|
||||
同 IP 多条纳管记录须按厂商/驱动选 ID。connect_status 为历史连接测试状态,不替代当前执行结果;有正常业务正文才算查询通过,不能把空正文或仅命令头当作成功。后端的 Cisco IOS SSH 驱动使用 CR 换行并等待命令回显和完整提示符;不影响其它厂商或 Telnet 的默认换行。
|
||||
|
||||
## Breaking (0.3.0)
|
||||
|
||||
模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。
|
||||
|
||||
## 兼容
|
||||
## 入口
|
||||
|
||||
全量 `netx-ops` 开发安装下 `python -m netx_api.mcp` 仍会转到本包;新环境请只装 **netx-mcp**。
|
||||
只保留独立 **netx-mcp** 包入口:`python -m netx_mcp` / `netx-mcp`。旧 `netx_api.mcp`、`netx_api/mcp_server.py` 和直连数据库模式已删除;配置需迁移到本包。
|
||||
|
||||
旧 DB 模式的导入告警查询/聚合/批次/诊断、同步触发和历史告警查询未纳入本包 15 工具,相关 HTTP API 仍保留。
|
||||
|
|
|
|||
26
packages/netx-mcp/src/netx_mcp/cli_guard.py
Normal file
26
packages/netx-mcp/src/netx_mcp/cli_guard.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""MCP 独立只读边界:后端开放 shell 策略也不能扩大模型的执行权限。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_PREFIX = re.compile(r"(?i)^(show|display)[ \t]+\S")
|
||||
_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f\u2028\u2029]")
|
||||
_SHELL = re.compile(r"[;&`$<>]")
|
||||
_DESTRUCTIVE = re.compile(
|
||||
r"(?i)\b(reboot|reload|shutdown|erase|format|delete|commit|rollback|redirect|append|tee)\b"
|
||||
r"|\b(?:configure\s+terminal|conf\s+t|write\s+memory|system-view|undo\s+)"
|
||||
)
|
||||
_FILTER = re.compile(r"(?i)^(include|exclude|begin|section|count|match|grep|one-line|no-more)(?:[ \t]|$)")
|
||||
|
||||
|
||||
def validate_readonly_cli(command: str) -> None:
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError("MCP CLI requires a nonempty show/display command")
|
||||
if len(command) > 500 or _CONTROL.search(command) or _SHELL.search(command):
|
||||
raise ValueError("MCP CLI rejects control characters, shell operators and commands longer than 500 characters")
|
||||
cmd = command.strip()
|
||||
if not _PREFIX.match(cmd) or _DESTRUCTIVE.search(cmd):
|
||||
raise ValueError("MCP CLI allows read-only show/display only; open device policies do not override this boundary")
|
||||
parts = cmd.split("|")
|
||||
if any(not _FILTER.match(part.strip()) for part in parts[1:]):
|
||||
raise ValueError("MCP CLI pipes allow output filters only")
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""MCP tool schemas and HTTP-backed handlers (NMS adapter + managed NE).
|
||||
|
||||
Model-facing tool names use generic Nms*; REST paths remain /v1/ume/* for the
|
||||
current zte-ume provider. Params prefer nms_ne_id with ume_ne_id aliases.
|
||||
current zte-ume provider. Model-facing params use nms_ne_id exclusively.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from .cli_guard import validate_readonly_cli
|
||||
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
|
||||
|
||||
_EXEC_MAX_COMMANDS_CAP = 50
|
||||
|
|
@ -47,17 +48,16 @@ def _truthy_async_flag(raw: Any) -> bool | None:
|
|||
|
||||
def _count_exec_ne_targets(args: dict[str, Any]) -> int:
|
||||
n = 0
|
||||
for key in ("ne_ids", "nms_ne_ids", "ume_ne_ids"):
|
||||
for key in ("ne_ids", "nms_ne_ids"):
|
||||
val = args.get(key)
|
||||
if isinstance(val, list):
|
||||
n = max(n, len([x for x in val if str(x or "").strip()]))
|
||||
n += len([x for x in val if str(x or "").strip()])
|
||||
targets = args.get("targets")
|
||||
if isinstance(targets, list):
|
||||
n = max(n, len([t for t in targets if isinstance(t, dict)]))
|
||||
n += len([t for t in targets if isinstance(t, dict)])
|
||||
if n == 0 and (
|
||||
str(args.get("ne_id") or "").strip()
|
||||
or str(args.get("nms_ne_id") or "").strip()
|
||||
or str(args.get("ume_ne_id") or "").strip()
|
||||
):
|
||||
return 1
|
||||
return int(n)
|
||||
|
|
@ -243,11 +243,8 @@ def _query_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if str(args.get("is_cleared") or "").strip():
|
||||
params["is_cleared"] = str(args.get("is_cleared")).strip()
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
ne_name = str(args.get("ne_name") or "").strip()
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
elif ne_name:
|
||||
params["keyword"] = ne_name
|
||||
if str(args.get("ne_id") or "").strip():
|
||||
params["ne_id"] = str(args.get("ne_id")).strip()
|
||||
if str(args.get("host_name") or "").strip():
|
||||
|
|
@ -264,7 +261,7 @@ def _aggregate_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if group_by:
|
||||
return _aggregate_ume_alarms_raw(args)
|
||||
# Default top 50 named NEs — missing host buckets reported separately.
|
||||
params: dict[str, Any] = {"top_ne": _int_arg(args, "top_ne", 50, 0, 500)}
|
||||
params: dict[str, Any] = {"top_ne": _int_arg(args, "top_ne", 50, 0, 5000)}
|
||||
if "exclude_missing_host" in args:
|
||||
params["exclude_missing_host"] = bool(args.get("exclude_missing_host"))
|
||||
if _severity(args):
|
||||
|
|
@ -357,6 +354,8 @@ def _query_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
||||
if not str(args.get("group_by") or "").strip():
|
||||
raise ToolArgError("group_by is required; use listNmsAlarmFields to choose a field")
|
||||
params: dict[str, Any] = {}
|
||||
for k in (
|
||||
"group_by",
|
||||
|
|
@ -442,9 +441,7 @@ def _first_str(*vals: Any) -> str:
|
|||
|
||||
|
||||
def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
ne_id = str(
|
||||
args.get("ne_id") or args.get("managed_ne_id") or args.get("id") or ""
|
||||
).strip()
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
if not ne_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -473,12 +470,46 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
return out
|
||||
|
||||
|
||||
def _exec_ack(out: dict[str, Any]) -> dict[str, Any]:
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else out
|
||||
if isinstance(data, dict) and data.get("job_id"):
|
||||
data = dict(data)
|
||||
data["poll_after_ms"] = 5000
|
||||
data["hint"] = (
|
||||
"Execution started; it has not succeeded yet. Continue independent work, then poll "
|
||||
"getNeExecJob with this job_id after about 5 seconds. Do not resubmit commands while "
|
||||
"the job is running. Inspect terminal, result.ok, summary.failed and every target result before reporting success."
|
||||
)
|
||||
return data if isinstance(data, dict) else {"ok": True, "data": data}
|
||||
|
||||
|
||||
def _exec_result_failed(data: Any) -> bool:
|
||||
"""Batch ok means the request ran; individual targets may still all fail."""
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
if data.get("ok") is False:
|
||||
return True
|
||||
summary = data.get("summary")
|
||||
if isinstance(summary, dict) and isinstance(summary.get("failed"), int) and summary["failed"] > 0:
|
||||
return True
|
||||
results = data.get("results")
|
||||
return isinstance(results, list) and any(isinstance(row, dict) and row.get("ok") is False for row in results)
|
||||
|
||||
|
||||
def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
supplied_commands = list(args.get("commands") or [])
|
||||
for target in args.get("targets") or []:
|
||||
supplied_commands.extend(target.get("commands") or [])
|
||||
for command in supplied_commands:
|
||||
try:
|
||||
validate_readonly_cli(command)
|
||||
except ValueError as exc:
|
||||
raise ToolArgError(str(exc)) from None
|
||||
targets_raw = args.get("targets")
|
||||
ne_ids_raw = args.get("ne_ids")
|
||||
nms_ne_ids_raw = args.get("nms_ne_ids")
|
||||
if not isinstance(nms_ne_ids_raw, list) or not nms_ne_ids_raw:
|
||||
nms_ne_ids_raw = args.get("ume_ne_ids")
|
||||
shared_cmds_raw = args.get("commands")
|
||||
shared_commands = (
|
||||
[str(c).strip() for c in shared_cmds_raw if str(c).strip()]
|
||||
|
|
@ -492,6 +523,13 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
want_async = _should_run_exec_async(args)
|
||||
|
||||
if multi and (args.get("ne_id") or args.get("nms_ne_id")):
|
||||
raise ToolArgError("Do not mix single NE identifiers with batch targets")
|
||||
if multi and _count_exec_ne_targets(args) == 0:
|
||||
raise ToolArgError("targets_required")
|
||||
if _count_exec_ne_targets(args) > 20:
|
||||
raise ToolArgError("too_many_targets (max 20 across targets/ne_ids/nms_ne_ids)")
|
||||
|
||||
if multi:
|
||||
body: dict[str, Any] = {}
|
||||
if isinstance(targets_raw, list) and targets_raw:
|
||||
|
|
@ -502,13 +540,20 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
row: dict[str, Any] = {}
|
||||
if str(t.get("ne_id") or "").strip():
|
||||
row["ne_id"] = str(t.get("ne_id")).strip()
|
||||
# Wire still uses ume_ne_id on REST; accept nms_ne_id as preferred model param.
|
||||
nms_id = _first_str(t.get("nms_ne_id"), t.get("ume_ne_id"))
|
||||
# Wire still uses ume_ne_id on REST.
|
||||
nms_id = _first_str(t.get("nms_ne_id"))
|
||||
if bool(row.get("ne_id")) == bool(nms_id):
|
||||
raise ToolArgError("Each target requires exactly one of ne_id or nms_ne_id")
|
||||
if nms_id:
|
||||
row["ume_ne_id"] = nms_id
|
||||
cmds = t.get("commands")
|
||||
if isinstance(cmds, list) and cmds:
|
||||
row["commands"] = [str(c).strip() for c in cmds if str(c).strip()]
|
||||
effective_commands = row.get("commands", shared_commands)
|
||||
if not effective_commands:
|
||||
raise ToolArgError("commands_required for every target")
|
||||
if len(effective_commands) > exec_max_commands():
|
||||
raise ToolArgError("too_many_commands for target")
|
||||
if row:
|
||||
cleaned_targets.append(row)
|
||||
body["targets"] = cleaned_targets
|
||||
|
|
@ -516,6 +561,8 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["ne_ids"] = [str(x).strip() for x in ne_ids_raw if str(x).strip()]
|
||||
if isinstance(nms_ne_ids_raw, list) and nms_ne_ids_raw:
|
||||
body["ume_ne_ids"] = [str(x).strip() for x in nms_ne_ids_raw if str(x).strip()]
|
||||
if (body.get("ne_ids") or body.get("ume_ne_ids")) and not shared_commands:
|
||||
raise ToolArgError("commands_required for ne_ids/nms_ne_ids")
|
||||
if shared_commands:
|
||||
if len(shared_commands) > exec_max_commands():
|
||||
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
|
||||
|
|
@ -525,28 +572,25 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["concurrency"] = _int_arg(args, "concurrency", 4, 1, 8)
|
||||
if want_async:
|
||||
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else out
|
||||
return data if isinstance(data, dict) else {"ok": True, "data": data}
|
||||
return _exec_ack(out)
|
||||
# Wall clock: many NEs × per-cmd timeout; keep below oclaw MCP override.
|
||||
out = http_post_json("/v1/managed-ne/exec-batch", body, timeout=600.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") or {}
|
||||
if isinstance(data, dict) and data.get("ok") is False:
|
||||
if _exec_result_failed(data):
|
||||
return {"ok": False, "data": data, "error": str(data.get("error") or "exec_batch_failed")}
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
nms_ne_id = _first_str(args.get("nms_ne_id"), args.get("ume_ne_id"))
|
||||
nms_ne_id = _first_str(args.get("nms_ne_id"))
|
||||
if bool(ne_id) == bool(nms_ne_id):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "exactly_one_of_ne_id_or_nms_ne_id_required",
|
||||
"error_code": "exactly_one_of_ne_id_or_nms_ne_id_required",
|
||||
"hint": (
|
||||
"For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass "
|
||||
"For one NE pass ne_id OR nms_ne_id. For many NEs pass "
|
||||
"ne_ids / nms_ne_ids with shared commands, or targets[] with per-NE commands — "
|
||||
"one call, concurrent on server."
|
||||
),
|
||||
|
|
@ -564,10 +608,7 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["read_timeout_sec"] = _int_arg(args, "read_timeout_sec", 60, 10, 120)
|
||||
if want_async:
|
||||
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else out
|
||||
return data if isinstance(data, dict) else {"ok": True, "data": data}
|
||||
return _exec_ack(out)
|
||||
out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
|
|
@ -586,7 +627,16 @@ def _get_ne_exec_job(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"error_code": "job_id_required",
|
||||
"hint": "Pass job_id from execManagedNe async ack.",
|
||||
}
|
||||
return http_json("GET", f"/v1/managed-ne/exec-jobs/{quote_ne_id(job_id)}", params=None)
|
||||
out = http_json("GET", f"/v1/managed-ne/exec-jobs/{quote_ne_id(job_id)}", params=None)
|
||||
data = out.get("data")
|
||||
if out.get("ok") and isinstance(data, dict):
|
||||
if data.get("terminal") and (data.get("status") in {"failed", "timeout"}
|
||||
or _exec_result_failed(data.get("result"))):
|
||||
return {"ok": False, "error": "ne_exec_job_failed", "data": data}
|
||||
if not data.get("terminal") and data.get("status") == "running":
|
||||
data["poll_after_ms"] = 5000
|
||||
data["hint"] = "Still running. Continue independent work; poll this job_id after about 5 seconds. Do not resubmit."
|
||||
return out
|
||||
|
||||
|
||||
def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -610,9 +660,9 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
|
||||
from_uid = _first_str(args.get("from_nms_ne_id"), args.get("from_ume_ne_id"))
|
||||
from_uid = _first_str(args.get("from_nms_ne_id"))
|
||||
from_mid = str(args.get("from_managed_ne_id") or "").strip()
|
||||
to_uid = _first_str(args.get("to_nms_ne_id"), args.get("to_ume_ne_id"))
|
||||
to_uid = _first_str(args.get("to_nms_ne_id"))
|
||||
to_mid = str(args.get("to_managed_ne_id") or "").strip()
|
||||
if bool(from_uid) == bool(from_mid):
|
||||
return {"ok": False, "error": "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required"}
|
||||
|
|
@ -664,7 +714,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"is_cleared": {"type": "string", "description": "\"False\" = active only; \"True\" = cleared."},
|
||||
"ne_id": {"type": "string", "description": "Filter only; do not show UUID to users"},
|
||||
"host_name": {"type": "string", "description": "Filter by NE host_name"},
|
||||
"ne_name": {"type": "string", "description": "Legacy alias mapped to keyword"},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
|
|
@ -703,9 +752,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"top_ne": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 500,
|
||||
"maximum": 5000,
|
||||
"default": 50,
|
||||
"description": "Max NE buckets to return (0 = all, capped at API 5000). Ignored when group_by is set.",
|
||||
"description": "Max NE buckets to return (0 = all; positive limit up to 5000). Ignored when group_by is set.",
|
||||
},
|
||||
"exclude_missing_host": {
|
||||
"type": "boolean",
|
||||
|
|
@ -883,8 +932,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"name": "getManagedNe",
|
||||
"description": (
|
||||
"Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). "
|
||||
"Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) "
|
||||
"— read it before complex execManagedNe. "
|
||||
"Response includes BACKEND capability (device_family, exec_policy_effective, recommended_mode, hints). "
|
||||
"This MCP independently permits show/display only: backend shell/script capability does not "
|
||||
"authorize scripts here. Native Linux/RouterOS CLI is not exposed by this MCP. "
|
||||
"Do NOT pass NMS inventory UUID here — use getNmsNe or execManagedNe(nms_ne_id=...) instead."
|
||||
),
|
||||
"inputSchema": {
|
||||
|
|
@ -894,23 +944,21 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "string",
|
||||
"description": "Managed NE id (not NMS host_name / not NMS ne_id unless they coincide).",
|
||||
},
|
||||
"managed_ne_id": {"type": "string", "description": "Alias for ne_id."},
|
||||
"id": {"type": "string", "description": "Alias for ne_id."},
|
||||
},
|
||||
"required": [],
|
||||
"required": ["ne_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "execManagedNe",
|
||||
"description": (
|
||||
f"Run CLI via netx (default read-only: show/display/ping/traceroute; "
|
||||
f"Run single-line read-only show/display CLI via netx ("
|
||||
f"max {exec_max_commands()} commands per NE, NETX_NE_EXEC_MAX_COMMANDS). "
|
||||
"Managed NE exec_policy=linux_shell|unrestricted allows shell/script on Linux or "
|
||||
"MikroTik (routeros/switchos) hosts "
|
||||
"(pipes/&&/;/quotes/heredoc / RouterOS multiline OK; check getManagedNe / listManagedNe). "
|
||||
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
|
||||
"Many NEs (batch-first, server concurrency default 4, max 20): "
|
||||
"MCP independently allows single-line read-only show/display only, even when the "
|
||||
"device has an open exec_policy. Shell operators, configuration commands and multiline "
|
||||
"scripts are rejected before HTTP; only read-only output-filter pipes are allowed. "
|
||||
"Single NE: ne_id OR nms_ne_id + commands. "
|
||||
"Many NEs (batch-first, max 20 targets, concurrency default 4, max 8): "
|
||||
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
|
||||
"(2) different CLI per NE (vendor/role) → ONE targets=["
|
||||
"{nms_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. "
|
||||
|
|
@ -918,14 +966,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"Default read_timeout_sec=60; on timeout raise to 90–120 — do not blind-retry. "
|
||||
"Long or multi-NE work: async=true (or auto when ≥4 NEs) returns job_id immediately; "
|
||||
"poll getNeExecJob. Pass async=false to force sync. "
|
||||
"Read getManagedNe.capability before choosing show_only vs script_on_device."
|
||||
"Use getManagedNe.capability to identify the vendor and backend policy; open backend "
|
||||
"policies never expand this MCP's show/display restriction. For batches inspect "
|
||||
"summary.failed and every result: job succeeded/result.ok alone does not mean all targets passed."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"nms_ne_id": {"type": "string", "description": "NMS inventory id (preferred)."},
|
||||
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
|
||||
"ne_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
|
|
@ -938,12 +987,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"maxItems": 20,
|
||||
"description": "NMS inventory ne_ids for concurrent batch (shared commands).",
|
||||
},
|
||||
"ume_ne_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 20,
|
||||
"description": "Legacy alias of nms_ne_ids.",
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
|
|
@ -952,7 +995,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"nms_ne_id": {"type": "string"},
|
||||
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
|
|
@ -1010,7 +1052,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"description": (
|
||||
"Poll a background execManagedNe job (job_id from async ack). "
|
||||
"When terminal=true, result holds the exec/exec-batch payload. "
|
||||
"Do not busy-wait; end the turn and poll later if still running."
|
||||
"Use poll_after_ms (about 5 seconds); continue independent work while waiting. "
|
||||
"Terminal failed/timeout is a tool error and retains the original result for diagnosis."
|
||||
" Batch jobs can finish succeeded with individual failures: inspect result.summary.failed "
|
||||
"and each result.results[].ok before reporting success."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
|
|
@ -1033,9 +1078,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["managed", "nms", "ume", "all"],
|
||||
"enum": ["managed", "nms", "all"],
|
||||
"default": "all",
|
||||
"description": "nms preferred; ume is a legacy alias mapped to the same inventory.",
|
||||
"description": "nms selects the configured NMS inventory; managed selects managed NEs.",
|
||||
},
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
|
|
@ -1050,8 +1095,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"description": (
|
||||
"Find up to max_paths simple paths between two fabric nodes for troubleshooting "
|
||||
"(parallel links between the same NEs count as one hop; see edges[].parallel_links). "
|
||||
"For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id; alias "
|
||||
"from_ume_ne_id) or managed_ne_id — resolved to fabric node internally. Returns "
|
||||
"For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id) or "
|
||||
"managed_ne_id — resolved to fabric node internally. Returns "
|
||||
"shortest paths first with compact label + node/edge summary (detail=summary default). "
|
||||
"Use after critical alarms to correlate neighboring NEs before CLI login."
|
||||
),
|
||||
|
|
@ -1062,10 +1107,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "string",
|
||||
"description": "Source NMS ne_id (from alarm ne_id); mutually exclusive with from_managed_ne_id",
|
||||
},
|
||||
"from_ume_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Legacy alias of from_nms_ne_id",
|
||||
},
|
||||
"from_managed_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Source managed NE id; mutually exclusive with from_nms_ne_id",
|
||||
|
|
@ -1074,10 +1115,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "string",
|
||||
"description": "Target NMS ne_id; mutually exclusive with to_managed_ne_id",
|
||||
},
|
||||
"to_ume_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Legacy alias of to_nms_ne_id",
|
||||
},
|
||||
"to_managed_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Target managed NE id; mutually exclusive with to_nms_ne_id",
|
||||
|
|
@ -1121,8 +1158,8 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
|||
"queryNmsAlarms": "alarms:read",
|
||||
"aggregateNmsAlarms": "alarms:read",
|
||||
"runNmsDiagnostics": "alarms:read",
|
||||
"queryNmsNeInventory": "ne:read",
|
||||
"getNmsNe": "ne:read",
|
||||
"queryNmsNeInventory": "alarms:read",
|
||||
"getNmsNe": "alarms:read",
|
||||
"queryNmsAlarmsRaw": "alarms:read",
|
||||
"aggregateNmsAlarmsRaw": "alarms:read",
|
||||
"listNmsAlarmFields": "alarms:read",
|
||||
|
|
@ -1137,7 +1174,7 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
|||
|
||||
|
||||
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
|
||||
"""Filter MCP tool list by granted scopes. Empty/None => return all (offline / unauthenticated listing)."""
|
||||
"""Filter by granted scopes. None advertises all offline; an empty grant advertises none."""
|
||||
if scopes is None:
|
||||
return list(HTTP_MCP_TOOLS)
|
||||
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
|
||||
|
|
@ -1152,12 +1189,56 @@ def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> li
|
|||
return out
|
||||
|
||||
|
||||
def _validate_arguments(args: Any, schema: dict[str, Any], path: str = "arguments") -> None:
|
||||
"""Enforce advertised shapes even when a host skips JSON Schema validation."""
|
||||
kind = schema.get("type")
|
||||
expected = {"object": dict, "array": list, "string": str, "boolean": bool, "integer": int}
|
||||
if kind in expected and (not isinstance(args, expected[kind]) or (kind == "integer" and isinstance(args, bool))):
|
||||
raise ToolArgError(f"{path} must be {kind}")
|
||||
if "enum" in schema and args not in schema["enum"]:
|
||||
raise ToolArgError(f"{path} must be one of {schema['enum']}")
|
||||
if kind == "object":
|
||||
props = schema.get("properties", {})
|
||||
unknown = set(args) - set(props)
|
||||
if schema.get("additionalProperties") is False and unknown:
|
||||
raise ToolArgError(f"{path}: unsupported parameter(s): {', '.join(sorted(unknown))}")
|
||||
for key, value in args.items():
|
||||
if key in props:
|
||||
_validate_arguments(value, props[key], f"{path}.{key}")
|
||||
elif kind == "array":
|
||||
# Command limits are read at invocation time by the handler (env may change).
|
||||
if not path.endswith(".commands") and len(args) > schema.get("maxItems", float("inf")):
|
||||
raise ToolArgError(f"{path} has too many items (max {schema['maxItems']})")
|
||||
if len(args) < schema.get("minItems", 0):
|
||||
raise ToolArgError(f"{path} requires at least {schema['minItems']} item(s)")
|
||||
for i, value in enumerate(args):
|
||||
_validate_arguments(value, schema.get("items", {}), f"{path}[{i}]")
|
||||
|
||||
|
||||
_SECRET_KEYS = frozenset({
|
||||
"password", "hop_password", "password_enc", "hop_password_enc", "password_ciphertext",
|
||||
"hop_password_ciphertext", "credential_secret", "secret_key", "token", "access_token",
|
||||
"refresh_token", "authorization", "private_key",
|
||||
})
|
||||
|
||||
|
||||
def _redact_secrets(value: Any) -> Any:
|
||||
"""Keep capability/has_password flags, but never expose structured credentials to a model."""
|
||||
if isinstance(value, dict):
|
||||
return {key: _redact_secrets(item) for key, item in value.items() if str(key).lower() not in _SECRET_KEYS}
|
||||
if isinstance(value, list):
|
||||
return [_redact_secrets(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
fn = _HANDLERS.get(str(name or "").strip())
|
||||
if not fn:
|
||||
raise ValueError(f"unknown tool: {name}")
|
||||
try:
|
||||
schema = next(tool["inputSchema"] for tool in HTTP_MCP_TOOLS if tool["name"] == name)
|
||||
_validate_arguments(args, schema)
|
||||
result = fn(dict(args or {}))
|
||||
except ToolArgError as exc:
|
||||
result = {"ok": False, "error": "invalid_argument", "error_code": "invalid_argument", "detail": str(exc)}
|
||||
return mcp_from_handler_result(result)
|
||||
return mcp_from_handler_result(_redact_secrets(result))
|
||||
|
|
|
|||
77
packages/netx-mcp/tests/test_cli_safety.py
Normal file
77
packages/netx-mcp/tests/test_cli_safety.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""无设备/网络副作用地验证 MCP 执行边界与终态失败语义。"""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from netx_mcp.http_tools import call_http_tool
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
"reboot", "configure terminal", "show version; reboot", "show version && reboot",
|
||||
"show version | reboot", "show version | redirect flash:config", "show $(reboot)",
|
||||
"show `reboot`", "display version\nreboot", "show version\u2028reboot",
|
||||
"show version > config", "show version\x00", "/system reboot",
|
||||
])
|
||||
@pytest.mark.parametrize("batch", [False, True])
|
||||
def test_unsafe_cli_never_reaches_http(command, batch):
|
||||
args = {"targets": [{"ne_id": "test-only", "commands": [command]}]} if batch else {
|
||||
"ne_id": "test-only", "commands": [command], "async": True}
|
||||
with patch("netx_mcp.http_tools.http_post_json") as post:
|
||||
result = call_http_tool("execManagedNe", args)
|
||||
assert result.get("isError") is True
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["show version", "display version", "show bgp summary | one-line"])
|
||||
def test_readonly_cli_still_executes(command):
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post:
|
||||
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": [command]})
|
||||
assert not result.get("isError")
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec"
|
||||
assert post.call_args.args[1]["commands"] == [command]
|
||||
|
||||
|
||||
def test_async_ack_explains_polling_without_forcing_turn_end():
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {
|
||||
"ok": True, "job_id": "test-job", "status": "running", "hint": "end the turn"}}):
|
||||
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": ["show version"], "async": True})
|
||||
data = json.loads(result["content"][0]["text"])
|
||||
assert data["poll_after_ms"] == 5000 and data["job_id"] == "test-job"
|
||||
assert "end the turn" not in data["hint"]
|
||||
|
||||
|
||||
def test_failed_exec_job_is_tool_error_and_preserves_evidence():
|
||||
terminal = {"ok": True, "terminal": True, "status": "failed", "result": {
|
||||
"ok": False, "detail": "SSH connection failed", "commands": ["display version"]}}
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
result = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
||||
assert result.get("isError") is True
|
||||
data = json.loads(result["content"][0]["text"])
|
||||
assert data["data"]["result"]["detail"] == "SSH connection failed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failed", [1, 2])
|
||||
@pytest.mark.parametrize("async_job", [False, True])
|
||||
def test_batch_target_failures_are_tool_errors_even_when_backend_ok(failed, async_job):
|
||||
result = {"ok": True, "summary": {"total": 2, "ok": 2-failed, "failed": failed},
|
||||
"results": [{"ok": failed == 1, "output": "version"}, {"ok": False, "detail": "SSH banner timeout"}]}
|
||||
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": result}
|
||||
if async_job:
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
||||
assert json.loads(out["content"][0]["text"])["data"] == terminal
|
||||
else:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": result}):
|
||||
out = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "commands": ["show version"], "async": False})
|
||||
assert json.loads(out["content"][0]["text"])["data"] == result
|
||||
assert out["isError"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_ok", [False, True])
|
||||
def test_batch_checks_individual_results_without_summary(target_ok):
|
||||
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": {
|
||||
"ok": True, "results": [{"ok": target_ok, "output": "version"}]}}
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
||||
assert bool(out.get("isError")) is not target_ok
|
||||
|
|
@ -103,18 +103,6 @@ def test_call_find_topology_paths_defaults_summary_detail() -> None:
|
|||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_find_topology_paths_accepts_legacy_ume_params() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
|
||||
mock_post.return_value = {"ok": True, "data": {"path_count": 0, "paths": []}}
|
||||
call_http_tool(
|
||||
"findTopologyPaths",
|
||||
{"from_ume_ne_id": "a", "to_ume_ne_id": "b"},
|
||||
)
|
||||
body = mock_post.call_args[0][1]
|
||||
assert body["from_ume_ne_id"] == "a"
|
||||
assert body["to_ume_ne_id"] == "b"
|
||||
|
||||
|
||||
def test_call_exec_managed_ne_defaults_read_timeout() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
|
||||
mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "hi"}}
|
||||
|
|
@ -138,16 +126,6 @@ def test_get_managed_ne_requires_id_with_hint() -> None:
|
|||
assert "hint" in payload
|
||||
|
||||
|
||||
def test_get_managed_ne_accepts_managed_ne_id_alias() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"ne_id": "m1"}}
|
||||
out = call_http_tool("getManagedNe", {"managed_ne_id": "m1"})
|
||||
mock_http.assert_called_once()
|
||||
assert mock_http.call_args[0][1].endswith("/m1")
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_get_nms_ne_requires_id() -> None:
|
||||
out = call_http_tool("getNmsNe", {})
|
||||
assert out.get("isError") is True
|
||||
|
|
@ -202,7 +180,7 @@ def test_exec_managed_ne_schema_documents_batch() -> None:
|
|||
props = tool["inputSchema"]["properties"]
|
||||
assert "ne_ids" in props
|
||||
assert "nms_ne_ids" in props
|
||||
assert "ume_ne_ids" in props
|
||||
assert "ume_ne_ids" not in props
|
||||
assert "targets" in props
|
||||
assert "concurrency" in props
|
||||
assert "Many NEs" in tool["description"] or "ne_ids" in tool["description"]
|
||||
|
|
@ -394,3 +372,194 @@ def test_managed_ne_list_drops_disabled_hop_noise() -> None:
|
|||
out = call_http_tool("listManagedNe", {"connect_status": "pass"})
|
||||
row = json.loads(out["content"][0]["text"])["data"]["items"][0]
|
||||
assert row == {"id": "m1", "hop_enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "args"), [
|
||||
("queryNmsAlarms", {"ne_name": "old"}),
|
||||
("getManagedNe", {"managed_ne_id": "old"}),
|
||||
("getManagedNe", {"id": "old"}),
|
||||
("execManagedNe", {"ume_ne_id": "old", "commands": ["show version"]}),
|
||||
("execManagedNe", {"ume_ne_ids": ["old"], "commands": ["show version"]}),
|
||||
("execManagedNe", {"targets": [{"ume_ne_id": "old", "commands": ["show version"]}]}),
|
||||
("findTopologyPaths", {"from_ume_ne_id": "old", "to_nms_ne_id": "b"}),
|
||||
("findTopologyPaths", {"from_nms_ne_id": "a", "to_ume_ne_id": "old"}),
|
||||
("listCliTargets", {"source": "ume"}),
|
||||
])
|
||||
def test_obsolete_aliases_are_rejected_before_http(name: str, args: dict) -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as get, patch("netx_mcp.http_tools.http_post_json") as post:
|
||||
out = call_http_tool(name, args)
|
||||
get.assert_not_called()
|
||||
post.assert_not_called()
|
||||
assert out["isError"] is True
|
||||
assert json.loads(out["content"][0]["text"])["error"] == "invalid_argument"
|
||||
|
||||
|
||||
def test_all_fifteen_tools_match_backend_scope_policy() -> None:
|
||||
from netx_api.auth_scopes import required_scope_for_request
|
||||
from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE
|
||||
|
||||
routes = {
|
||||
"queryNmsAlarms": ("GET", "/v1/ume/alarms"),
|
||||
"aggregateNmsAlarms": ("GET", "/v1/ume/alarms/aggregate"),
|
||||
"runNmsDiagnostics": ("GET", "/v1/ume/diagnostics"),
|
||||
"queryNmsNeInventory": ("GET", "/v1/ume/inventory/ne"),
|
||||
"getNmsNe": ("GET", "/v1/ume/inventory/ne/id"),
|
||||
"queryNmsAlarmsRaw": ("GET", "/v1/ume/alarms/raw"),
|
||||
"aggregateNmsAlarmsRaw": ("GET", "/v1/ume/alarms/aggregate/raw"),
|
||||
"listNmsAlarmFields": ("GET", "/v1/ume/alarms/fields"),
|
||||
"sqlQueryNms": ("POST", "/v1/sql/ume_query"),
|
||||
"listManagedNe": ("GET", "/v1/managed-ne"),
|
||||
"getManagedNe": ("GET", "/v1/managed-ne/id"),
|
||||
"execManagedNe": ("POST", "/v1/managed-ne/exec"),
|
||||
"getNeExecJob": ("GET", "/v1/managed-ne/exec-jobs/id"),
|
||||
"listCliTargets": ("GET", "/v1/cli/targets"),
|
||||
"findTopologyPaths": ("POST", "/v1/topology/fabric/paths"),
|
||||
}
|
||||
assert set(routes) == {t["name"] for t in HTTP_MCP_TOOLS} == set(TOOL_REQUIRED_SCOPE)
|
||||
for name, (method, path) in routes.items():
|
||||
assert TOOL_REQUIRED_SCOPE[name] == required_scope_for_request(method, path)
|
||||
assert name in {t["name"] for t in tools_for_scopes([TOOL_REQUIRED_SCOPE[name]])}
|
||||
assert tools_for_scopes([]) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "args", "path", "expected"), [
|
||||
("aggregateNmsAlarms", {"top_ne": 3000}, "/v1/ume/alarms/aggregate", {"top_ne": 3000}),
|
||||
("runNmsDiagnostics", {}, "/v1/ume/diagnostics", None),
|
||||
("queryNmsNeInventory", {"keyword": "edge", "page": 9, "page_size": 900}, "/v1/ume/inventory/ne", {"keyword": "edge", "page": 9, "page_size": 500}),
|
||||
("listNmsAlarmFields", {}, "/v1/ume/alarms/fields", None),
|
||||
("getNeExecJob", {"job_id": "job /?"}, "/v1/managed-ne/exec-jobs/job%20%2F%3F", None),
|
||||
("listCliTargets", {"source": "nms", "keyword": "edge"}, "/v1/cli/targets", {"source": "ume", "keyword": "edge", "page": 1, "page_size": 50}),
|
||||
])
|
||||
def test_ops_read_routes_and_backend_limits(name: str, args: dict, path: str, expected: dict | None) -> None:
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"items": []}}) as get:
|
||||
out = call_http_tool(name, args)
|
||||
get.assert_called_once_with("GET", path, params=expected)
|
||||
assert not out.get("isError")
|
||||
|
||||
|
||||
def test_raw_aggregation_filters_and_compaction() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"buckets": [{"key": "edge", "count": 4}], "meta": {"group_by_allowed": ["large"], "time_filter_field": "last_seen_at"}}}) as get:
|
||||
out = call_http_tool("aggregateNmsAlarmsRaw", {"group_by": "alarm_host_name", "severity": "CRITICAL", "exclude_missing_host": False, "limit": 9999})
|
||||
assert get.call_args.kwargs["params"] == {"group_by": "alarm_host_name", "severity": "critical", "exclude_missing_host": False, "limit": 2000}
|
||||
data = json.loads(out["content"][0]["text"])["data"]
|
||||
assert data["buckets"][0]["count"] == 4
|
||||
assert data["meta"] == {"time_filter_field": "last_seen_at"}
|
||||
|
||||
|
||||
def test_sql_limits_are_forwarded_and_denial_is_tool_error() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": False, "error": "netx_http_403", "detail": "insufficient_scope:sql:query"}) as post:
|
||||
out = call_http_tool("sqlQueryNms", {"sql": " SELECT ne_id FROM ume_inventory_ne ", "limit": 9999, "statement_timeout_ms": 99999})
|
||||
post.assert_called_once_with("/v1/sql/ume_query", {"sql": "SELECT ne_id FROM ume_inventory_ne", "limit": 2000, "statement_timeout_ms": 30000}, timeout=60.0)
|
||||
assert out["isError"] is True
|
||||
assert "insufficient_scope" in out["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("listManagedNe", {"keyword": "edge"}),
|
||||
("getManagedNe", {"ne_id": "m1"}),
|
||||
("listCliTargets", {"source": "managed"}),
|
||||
("getNmsNe", {"ne_id": "u1"}),
|
||||
])
|
||||
def test_structured_credentials_are_removed_but_capabilities_remain(name: str, args: dict) -> None:
|
||||
row = {"id": "m1", "password": "secret", "has_password": True, "capability": {"recommended_mode": "show_only", "token": "secret"}, "hop_password_enc": "secret"}
|
||||
data = {"items": [row]} if name.startswith("list") else row
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": data}):
|
||||
out = call_http_tool(name, args)
|
||||
text = out["content"][0]["text"]
|
||||
assert "secret" not in text
|
||||
assert "show_only" in text
|
||||
assert "has_password" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [
|
||||
{"targets": [{"ne_id": "m1", "nms_ne_id": "u1", "commands": ["show version"]}]},
|
||||
{"targets": [{"ne_id": "m1"}]},
|
||||
{"targets": [{"ne_id": "m1", "commands": ["show version"] * 6}]},
|
||||
{"ne_ids": ["m1"]},
|
||||
{"ne_ids": ["m1"] * 11, "nms_ne_ids": ["u1"] * 10, "commands": ["show version"]},
|
||||
{"ne_id": "m1", "ne_ids": ["m2"], "commands": ["show version"]},
|
||||
{"targets": ["m1"], "commands": ["show version"]},
|
||||
])
|
||||
def test_invalid_exec_batches_do_not_reach_http(args: dict, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "5")
|
||||
with patch("netx_mcp.http_tools.http_post_json") as post:
|
||||
out = call_http_tool("execManagedNe", args)
|
||||
post.assert_not_called()
|
||||
assert out["isError"] is True
|
||||
|
||||
|
||||
def test_async_auto_counts_all_target_forms_and_poll_preserves_terminal_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "4")
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "job_id": "j1", "terminal": False}}) as post:
|
||||
ack = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "concurrency": 20})
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec-jobs"
|
||||
assert post.call_args.args[1]["concurrency"] == 8
|
||||
assert json.loads(ack["content"][0]["text"])["job_id"] == "j1"
|
||||
terminal = {"ok": True, "terminal": True, "result": {"ok": True, "results": [{"ok": False, "error": "timeout"}]}}
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
out = call_http_tool("getNeExecJob", {"job_id": "j1"})
|
||||
assert json.loads(out["content"][0]["text"])["data"] == terminal
|
||||
assert out["isError"] is True
|
||||
|
||||
|
||||
def test_explicit_sync_overrides_auto_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "1")
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post:
|
||||
call_http_tool("execManagedNe", {"nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "async": False})
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec-batch"
|
||||
|
||||
|
||||
def test_stdio_scope_denial_never_invokes_handler() -> None:
|
||||
import io
|
||||
from netx_mcp.server import run_stdio_loop
|
||||
|
||||
request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "execManagedNe", "arguments": {"ne_id": "m1", "commands": ["show version"]}}}
|
||||
output = io.StringIO()
|
||||
with patch("netx_mcp.server.sys.stdin", io.StringIO(json.dumps(request) + "\n")), patch("netx_mcp.server.sys.stdout", output), patch("netx_mcp.server._fetch_scopes", return_value=["ne:read"]), patch("netx_mcp.server.call_http_tool") as handler:
|
||||
run_stdio_loop()
|
||||
handler.assert_not_called()
|
||||
response = json.loads(output.getvalue())
|
||||
assert response["error"]["code"] == -32001
|
||||
assert "ne:exec" in response["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["GET", "POST"])
|
||||
@pytest.mark.parametrize("status", [401, 403, 404, 500])
|
||||
def test_http_errors_preserve_status_and_bound_detail(method: str, status: int) -> None:
|
||||
import httpx
|
||||
from netx_mcp.http_client import http_json, http_post_json
|
||||
|
||||
transport = httpx.MockTransport(lambda request: httpx.Response(status, text="denied" * 300))
|
||||
client = httpx.Client(transport=transport)
|
||||
with patch("netx_mcp.http_client.httpx.Client", return_value=client):
|
||||
result = http_json("GET", "/v1/ume/alarms") if method == "GET" else http_post_json("/v1/managed-ne/exec", {})
|
||||
assert result["ok"] is False
|
||||
assert result["error"] == f"netx_http_{status}"
|
||||
assert len(result["detail"]) == 800
|
||||
|
||||
|
||||
|
||||
def test_mixed_exec_targets_translate_nms_ids_and_preserve_per_target_commands() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "items": [{"ok": True, "password": "secret", "output": "version"}]}}) as post:
|
||||
out = call_http_tool("execManagedNe", {"targets": [{"ne_id": "m1"}, {"nms_ne_id": "u1", "commands": ["display version"]}], "commands": ["show version"], "async": False})
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec-batch"
|
||||
assert post.call_args.args[1]["targets"] == [{"ne_id": "m1"}, {"ume_ne_id": "u1", "commands": ["display version"]}]
|
||||
text = out["content"][0]["text"]
|
||||
assert "secret" not in text
|
||||
assert json.loads(text)["data"]["items"][0]["output"] == "version"
|
||||
|
||||
|
||||
def test_async_job_queue_failure_is_not_a_successful_ack() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": False, "error": "ne_exec_job_busy"}}):
|
||||
out = call_http_tool("execManagedNe", {"ne_id": "m1", "commands": ["show version"], "async": True})
|
||||
assert out["isError"] is True
|
||||
assert json.loads(out["content"][0]["text"])["error"] == "ne_exec_job_busy"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [{}, {"group_by": ""}])
|
||||
def test_raw_aggregation_missing_dimension_is_actionable(args: dict) -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as get:
|
||||
out = call_http_tool("aggregateNmsAlarmsRaw", args)
|
||||
get.assert_not_called()
|
||||
assert out["isError"] is True
|
||||
assert "group_by" in out["content"][0]["text"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue