mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Add shared skills tree and rename MCP NMS tools to *Nms*.
Canonical playbooks live under skills/; netx-mcp 0.3.0 exposes queryNmsAlarms et al. with nms_ne_id aliases. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5e7d14f253
commit
13ce2482ab
14 changed files with 463 additions and 145 deletions
18
docs/MCP.md
18
docs/MCP.md
|
|
@ -113,7 +113,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。
|
||||
2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。
|
||||
3. **Health** → **Sync Tools**(应看到 **13** 个工具)。
|
||||
3. **Health** → **Sync Tools**(应看到 **14** 个工具)。
|
||||
4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。
|
||||
|
||||
更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库:
|
||||
|
|
@ -121,20 +121,24 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
可选:oclaw 专用字段展开版 [`mcp_install_payload.json`](../mcp_install_payload.json)(与 `mcp.json` 等价)。
|
||||
|
||||
**拓扑画布 MCP**(独立安装/绑定)见 [`MCP_TOPOLOGY.md`](./MCP_TOPOLOGY.md)(`server_id=netx-topology`,13 个工具)。
|
||||
**Skills(真源)**:仓库根 [`skills/`](../skills/README.md) — `netx-nms` / `netx-common` / `netx-topology`。MCP 包不再带 skills 镜像;Cursor 直接指 `netx/skills/`。dsh-netxops 发版前可 `sync-skills-from-netx.ps1`。oclaw 旧 playbook 不再维护。
|
||||
|
||||
**拓扑画布 MCP**(独立安装/绑定)见 [`MCP_TOPOLOGY.md`](./MCP_TOPOLOGY.md)(`server_id=netx-topology`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 暴露的工具
|
||||
|
||||
模型面使用通用 **Nms** 命名(当前适配器仍为 zte-ume,HTTP 仍 `/v1/ume/*`)。**0.3.0** 起不再暴露 `*Ume*` 工具名。
|
||||
|
||||
| 类别 | 工具名 |
|
||||
|------|--------|
|
||||
| UME 告警 | `queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics` |
|
||||
| UME 网元 | `queryUmeNeInventory`, `getUmeNe` |
|
||||
| UME 原始/SQL | `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` |
|
||||
| 托管网元 CLI | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` |
|
||||
| NMS 告警 | `queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics` |
|
||||
| NMS 网元 | `queryNmsNeInventory`, `getNmsNe` |
|
||||
| NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` |
|
||||
| common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`, `findTopologyPaths` |
|
||||
|
||||
拓扑 Fabric / 画布工具已拆到 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**(含原 `queryTopologyEdges`)。oclaw 中名称带前缀:`mcp__netx__<toolName>`。
|
||||
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
**安装、更新、各宿主配置、排错** → 仓库主文档 **[docs/MCP.md](../../docs/MCP.md)**(请优先阅读)。
|
||||
|
||||
**Skills(唯一真源在仓库根 [`skills/`](../../skills/README.md))** — 本包不镜像 skills。
|
||||
|
||||
## 速查:安装
|
||||
|
||||
```powershell
|
||||
|
|
@ -36,14 +38,21 @@ python -m netx_mcp
|
|||
|
||||
[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。
|
||||
|
||||
## 工具(13)
|
||||
## 工具(14)
|
||||
|
||||
UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme`
|
||||
**NMS**(模型面通用名;当前适配器 zte-ume,REST 仍 `/v1/ume/*`):
|
||||
`queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics`, `queryNmsNeInventory`, `getNmsNe`, `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms`
|
||||
|
||||
托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`
|
||||
**common**:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`, `findTopologyPaths`
|
||||
|
||||
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。
|
||||
|
||||
拓扑画布 / Fabric → 请单独安装 [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
|
||||
|
||||
## Breaking (0.3.0)
|
||||
|
||||
模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。
|
||||
|
||||
## 兼容
|
||||
|
||||
全量 `netx-ops` 开发安装下 `python -m netx_api.mcp` 仍会转到本包;新环境请只装 **netx-mcp**。
|
||||
|
|
|
|||
5
packages/netx-mcp/SKILLS.md
Normal file
5
packages/netx-mcp/SKILLS.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Reminder: skills live at repo root
|
||||
|
||||
Canonical playbooks: [`../../skills/`](../../skills/README.md)
|
||||
|
||||
This package does **not** ship a `skills/` mirror. Point Cursor (or other hosts) at the repo `skills/` tree after clone.
|
||||
|
|
@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "netx-mcp"
|
||||
version = "0.2.0"
|
||||
description = "stdio MCP server for netx REST API (UME alarms, managed NE CLI)"
|
||||
version = "0.3.0"
|
||||
description = "stdio MCP server for netx REST API (NMS alarms, managed NE CLI)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
"""MCP tool schemas and HTTP-backed handlers (UME + managed NE)."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -251,6 +255,14 @@ def _list_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
return http_json("GET", "/v1/managed-ne", params=params)
|
||||
|
||||
|
||||
def _first_str(*vals: Any) -> str:
|
||||
for v in vals:
|
||||
s = str(v or "").strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
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 ""
|
||||
|
|
@ -262,11 +274,11 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"error_code": "ne_id_required",
|
||||
"hint": (
|
||||
"Pass managed NE id from listManagedNe/listCliTargets (source=managed). "
|
||||
"For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe."
|
||||
"For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe."
|
||||
),
|
||||
"example": {"ne_id": "<managed-ne-uuid-from-listManagedNe>"},
|
||||
}
|
||||
# UME ne_id is typically a UUID; managed NE may differ. Soft-guide when callers mix them.
|
||||
# NMS ne_id is typically a UUID; managed NE may differ. Soft-guide when callers mix them.
|
||||
out = http_json("GET", f"/v1/managed-ne/{ne_id}", params=None)
|
||||
if isinstance(out, dict) and out.get("ok") is False:
|
||||
detail = str(out.get("detail") or out.get("error") or "")
|
||||
|
|
@ -275,8 +287,8 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
out = dict(out)
|
||||
out["hint"] = (
|
||||
"Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or "
|
||||
"listCliTargets(source=managed) first. If this is a UME ne_id, use "
|
||||
"execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe."
|
||||
"listCliTargets(source=managed) first. If this is an NMS ne_id, use "
|
||||
"execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe."
|
||||
)
|
||||
return out
|
||||
|
||||
|
|
@ -284,7 +296,9 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
targets_raw = args.get("targets")
|
||||
ne_ids_raw = args.get("ne_ids")
|
||||
ume_ne_ids_raw = args.get("ume_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()]
|
||||
|
|
@ -294,7 +308,7 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
multi = bool(
|
||||
(isinstance(targets_raw, list) and targets_raw)
|
||||
or (isinstance(ne_ids_raw, list) and ne_ids_raw)
|
||||
or (isinstance(ume_ne_ids_raw, list) and ume_ne_ids_raw)
|
||||
or (isinstance(nms_ne_ids_raw, list) and nms_ne_ids_raw)
|
||||
)
|
||||
if multi:
|
||||
body: dict[str, Any] = {}
|
||||
|
|
@ -306,8 +320,10 @@ 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()
|
||||
if str(t.get("ume_ne_id") or "").strip():
|
||||
row["ume_ne_id"] = str(t.get("ume_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"))
|
||||
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()]
|
||||
|
|
@ -316,8 +332,8 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["targets"] = cleaned_targets
|
||||
if isinstance(ne_ids_raw, list) and ne_ids_raw:
|
||||
body["ne_ids"] = [str(x).strip() for x in ne_ids_raw if str(x).strip()]
|
||||
if isinstance(ume_ne_ids_raw, list) and ume_ne_ids_raw:
|
||||
body["ume_ne_ids"] = [str(x).strip() for x in ume_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 shared_commands:
|
||||
if len(shared_commands) > exec_max_commands():
|
||||
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
|
||||
|
|
@ -337,15 +353,16 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
return {"ok": True, "data": data}
|
||||
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
ume_ne_id = str(args.get("ume_ne_id") or "").strip()
|
||||
if bool(ne_id) == bool(ume_ne_id):
|
||||
nms_ne_id = _first_str(args.get("nms_ne_id"), args.get("ume_ne_id"))
|
||||
if bool(ne_id) == bool(nms_ne_id):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "exactly_one_of_ne_id_or_ume_ne_id_required",
|
||||
"error_code": "exactly_one_of_ne_id_or_ume_ne_id_required",
|
||||
"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 ume_ne_id. For many NEs pass ne_ids / ume_ne_ids "
|
||||
"with shared commands, or targets[] with per-NE commands — one call, concurrent on server."
|
||||
"For one NE pass ne_id OR nms_ne_id (alias ume_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."
|
||||
),
|
||||
}
|
||||
if not shared_commands:
|
||||
|
|
@ -355,8 +372,8 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body = {"commands": shared_commands}
|
||||
if ne_id:
|
||||
body["ne_id"] = ne_id
|
||||
if ume_ne_id:
|
||||
body["ume_ne_id"] = ume_ne_id
|
||||
if nms_ne_id:
|
||||
body["ume_ne_id"] = nms_ne_id
|
||||
# Default 60s matches netx API default; slow show commands often exceed 30s.
|
||||
rts = args.get("read_timeout_sec")
|
||||
body["read_timeout_sec"] = int(rts) if rts is not None else 60
|
||||
|
|
@ -373,22 +390,24 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
|
|||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
if str(args.get("source") or "").strip():
|
||||
params["source"] = str(args.get("source")).strip()
|
||||
source = str(args.get("source") or "").strip()
|
||||
if source:
|
||||
# API still uses source=ume; accept model-facing "nms".
|
||||
params["source"] = "ume" if source.lower() == "nms" else source
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
return http_json("GET", "/v1/cli/targets", params=params)
|
||||
|
||||
|
||||
def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
|
||||
from_uid = str(args.get("from_ume_ne_id") or "").strip()
|
||||
from_uid = _first_str(args.get("from_nms_ne_id"), args.get("from_ume_ne_id"))
|
||||
from_mid = str(args.get("from_managed_ne_id") or "").strip()
|
||||
to_uid = str(args.get("to_ume_ne_id") or "").strip()
|
||||
to_uid = _first_str(args.get("to_nms_ne_id"), args.get("to_ume_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_ume_ne_id_or_from_managed_ne_id_required"}
|
||||
return {"ok": False, "error": "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required"}
|
||||
if bool(to_uid) == bool(to_mid):
|
||||
return {"ok": False, "error": "exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required"}
|
||||
return {"ok": False, "error": "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required"}
|
||||
detail = str(args.get("detail") or "summary").strip().lower() or "summary"
|
||||
if detail not in {"summary", "full"}:
|
||||
detail = "summary"
|
||||
|
|
@ -411,9 +430,9 @@ def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "queryUmeAlarms",
|
||||
"name": "queryNmsAlarms",
|
||||
"description": (
|
||||
"Query UME current alarms (each row includes host_name). "
|
||||
"Query NMS current alarms (each row includes host_name). "
|
||||
"Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. "
|
||||
"Prefer host_name for display; ne_id is for filters only. "
|
||||
"Field keyword examples (native_probable_cause): LOS, Fiber Break, bandwidth, CRC, "
|
||||
|
|
@ -445,14 +464,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
"name": "aggregateUmeAlarms",
|
||||
"name": "aggregateNmsAlarms",
|
||||
"description": (
|
||||
"Aggregate UME current alarms (by_severity + top by_ne). "
|
||||
"Aggregate NMS current alarms (by_severity + top by_ne). "
|
||||
"Optional severity filter (e.g. critical) for risk Top-N. "
|
||||
"by_ne is capped by top_ne (default 50) and excludes missing host_name by default "
|
||||
"(see by_ne_missing). meta.last_seen_min/max show data freshness. "
|
||||
"If group_by is set (e.g. alarm_host_name), automatically routes to the same "
|
||||
"behavior as aggregateUmeAlarmsRaw — preferred for custom dimensions. "
|
||||
"behavior as aggregateNmsAlarmsRaw — preferred for custom dimensions. "
|
||||
"Snapshot volumes are large (tens of thousands); always filter severity/keyword/time "
|
||||
"before paging — do not dump unfiltered lists."
|
||||
),
|
||||
|
|
@ -482,7 +501,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"enum": UME_RAW_GROUP_FIELDS,
|
||||
"description": (
|
||||
"Optional. When set, routes to dynamic raw aggregation "
|
||||
"(same as aggregateUmeAlarmsRaw). Prefer alarm_host_name for NE Top."
|
||||
"(same as aggregateNmsAlarmsRaw). Prefer alarm_host_name for NE Top."
|
||||
),
|
||||
},
|
||||
"group_by2": {
|
||||
|
|
@ -507,16 +526,16 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
"name": "runUmeDiagnostics",
|
||||
"name": "runNmsDiagnostics",
|
||||
"description": (
|
||||
"UME alarm diagnostics: severity, top_event_types, top_alarm_codes (UME alarmCode), "
|
||||
"NMS alarm diagnostics: severity, top_event_types, top_alarm_codes (vendor alarmCode), "
|
||||
"top_ne (excludes missing host), protocol buckets, and meta.last_seen_min/max freshness."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "queryUmeNeInventory",
|
||||
"description": "Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).",
|
||||
"name": "queryNmsNeInventory",
|
||||
"description": "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -529,8 +548,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
"name": "getUmeNe",
|
||||
"description": "Get single UME NE detail by ne_id (UUID).",
|
||||
"name": "getNmsNe",
|
||||
"description": "Get single NMS NE detail by ne_id (UUID).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"ne_id": {"type": "string"}},
|
||||
|
|
@ -539,10 +558,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
"name": "queryUmeAlarmsRaw",
|
||||
"name": "queryNmsAlarmsRaw",
|
||||
"description": (
|
||||
"Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. "
|
||||
"Use field_preset=evidence for WA citations. Same keyword vocabulary as queryUmeAlarms "
|
||||
"Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. "
|
||||
"Use field_preset=evidence for WA citations. Same keyword vocabulary as queryNmsAlarms "
|
||||
"(LOS / Fiber Break / bandwidth / CRC / BN EMS / dying gasp / License / optical power). "
|
||||
"For area asks: keyword then keep hosts starting with AREA- (e.g. PAD-, ACH-, BPP-)."
|
||||
),
|
||||
|
|
@ -555,7 +574,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"event_type": {"type": "string"},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": "Substring filter; see queryUmeAlarms keyword examples.",
|
||||
"description": "Substring filter; see queryNmsAlarms keyword examples.",
|
||||
},
|
||||
"time_from": {"type": "string"},
|
||||
"time_to": {"type": "string"},
|
||||
|
|
@ -574,9 +593,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
"name": "aggregateUmeAlarmsRaw",
|
||||
"name": "aggregateNmsAlarmsRaw",
|
||||
"description": (
|
||||
"Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name. "
|
||||
"Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name. "
|
||||
"When grouping by host fields, (host_name missing) is omitted by default "
|
||||
"(see by_ne_missing)."
|
||||
),
|
||||
|
|
@ -604,16 +623,16 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
"name": "listUmeAlarmFields",
|
||||
"description": "List available fields for UME raw alarm queries.",
|
||||
"name": "listNmsAlarmFields",
|
||||
"description": "List available fields for NMS raw alarm queries.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "sqlQueryUme",
|
||||
"name": "sqlQueryNms",
|
||||
"description": (
|
||||
"Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. "
|
||||
"Requires netx scope sql:query. If insufficient_scope, use aggregateUmeAlarms / "
|
||||
"queryUmeAlarmsRaw / ume_alarm_xlsx_report instead."
|
||||
"Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne; zte-ume adapter); "
|
||||
"server enforces limits. Requires netx scope sql:query. If insufficient_scope, use "
|
||||
"aggregateNmsAlarms / queryNmsAlarmsRaw instead."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
|
|
@ -646,14 +665,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"name": "getManagedNe",
|
||||
"description": (
|
||||
"Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). "
|
||||
"Do NOT pass UME inventory UUID here — use getUmeNe or execManagedNe(ume_ne_id=...) instead."
|
||||
"Do NOT pass NMS inventory UUID here — use getNmsNe or execManagedNe(nms_ne_id=...) instead."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {
|
||||
"type": "string",
|
||||
"description": "Managed NE id (not UME host_name / not UME ne_id unless they coincide).",
|
||||
"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."},
|
||||
|
|
@ -667,11 +686,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"description": (
|
||||
f"Run read-only CLI via netx (show/display/ping/traceroute; "
|
||||
f"max {exec_max_commands()} commands per NE, NETX_NE_EXEC_MAX_COMMANDS). "
|
||||
"Single NE: ne_id OR ume_ne_id + commands. "
|
||||
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
|
||||
"Many NEs (batch-first, server concurrency default 4, max 20): "
|
||||
"(1) same CLI on all → ne_ids[]/ume_ne_ids[] + shared commands; "
|
||||
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
|
||||
"(2) different CLI per NE (vendor/role) → ONE targets=["
|
||||
"{ume_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. "
|
||||
"{nms_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. "
|
||||
"Do NOT loop one-NE execManagedNe for multi-NE work. "
|
||||
"Default read_timeout_sec=60; on timeout raise to 90–120 — do not blind-retry. "
|
||||
"Large batches (≈4+ NEs) may auto-run async in oclaw: returns job_id immediately; "
|
||||
|
|
@ -681,18 +700,25 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"ume_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"},
|
||||
"maxItems": 20,
|
||||
"description": "Managed NE ids for concurrent batch (shared commands).",
|
||||
},
|
||||
"nms_ne_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 20,
|
||||
"description": "NMS inventory ne_ids for concurrent batch (shared commands).",
|
||||
},
|
||||
"ume_ne_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 20,
|
||||
"description": "UME inventory ne_ids for concurrent batch (shared commands).",
|
||||
"description": "Legacy alias of nms_ne_ids.",
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
|
|
@ -701,7 +727,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"ume_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"},
|
||||
|
|
@ -713,7 +740,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
"description": (
|
||||
"Preferred for mixed-vendor / per-NE command sets: "
|
||||
"each item is one NE (ne_id OR ume_ne_id) with its own commands[]. "
|
||||
"each item is one NE (ne_id OR nms_ne_id) with its own commands[]. "
|
||||
"Per-target commands override top-level shared commands. "
|
||||
"Still one concurrent batch — not N single-NE calls."
|
||||
),
|
||||
|
|
@ -724,7 +751,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"minItems": 1,
|
||||
"maxItems": exec_max_commands(),
|
||||
"description": (
|
||||
"Commands for single NE, or shared commands for ne_ids/ume_ne_ids. "
|
||||
"Commands for single NE, or shared commands for ne_ids/nms_ne_ids. "
|
||||
"Optional fallback for targets that omit per-target commands."
|
||||
),
|
||||
},
|
||||
|
|
@ -757,14 +784,19 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
{
|
||||
"name": "listCliTargets",
|
||||
"description": (
|
||||
"List CLI-capable targets (managed NE and/or UME inventory). "
|
||||
"Call once per session with keyword/source, cache ume_ne_id/ne_id, "
|
||||
"List CLI-capable targets (managed NE and/or NMS inventory). "
|
||||
"Call once per session with keyword/source, cache nms_ne_id/ne_id, "
|
||||
"then execManagedNe — do not re-list before every command."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string", "enum": ["managed", "ume", "all"], "default": "all"},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["managed", "nms", "ume", "all"],
|
||||
"default": "all",
|
||||
"description": "nms preferred; ume is a legacy alias mapped to the same inventory.",
|
||||
},
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
|
||||
|
|
@ -777,18 +809,38 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"name": "findTopologyPaths",
|
||||
"description": (
|
||||
"Find up to max_paths simple paths between two fabric nodes for troubleshooting. "
|
||||
"For each endpoint provide exactly one of ume_ne_id (from UME 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). "
|
||||
"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 "
|
||||
"shortest paths first with compact label + node/edge summary (detail=summary default). "
|
||||
"Use after critical alarms to correlate neighboring NEs before CLI login."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_ume_ne_id": {"type": "string", "description": "Source UME ne_id (from alarm ne_id); mutually exclusive with from_managed_ne_id"},
|
||||
"from_managed_ne_id": {"type": "string", "description": "Source managed NE id; mutually exclusive with from_ume_ne_id"},
|
||||
"to_ume_ne_id": {"type": "string", "description": "Target UME ne_id; mutually exclusive with to_managed_ne_id"},
|
||||
"to_managed_ne_id": {"type": "string", "description": "Target managed NE id; mutually exclusive with to_ume_ne_id"},
|
||||
"from_nms_ne_id": {
|
||||
"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",
|
||||
},
|
||||
"to_nms_ne_id": {
|
||||
"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",
|
||||
},
|
||||
"max_paths": {"type": "integer", "minimum": 1, "maximum": 10, "default": 3},
|
||||
"max_hops": {"type": "integer", "minimum": 1, "maximum": 12, "default": 6},
|
||||
"layer": {"type": "string", "default": "physical"},
|
||||
|
|
@ -806,15 +858,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
]
|
||||
|
||||
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
||||
"queryUmeAlarms": _query_ume_alarms,
|
||||
"aggregateUmeAlarms": _aggregate_ume_alarms,
|
||||
"runUmeDiagnostics": _run_ume_diagnostics,
|
||||
"queryUmeNeInventory": _query_ume_ne_inventory,
|
||||
"getUmeNe": _get_ume_ne,
|
||||
"queryUmeAlarmsRaw": _query_ume_alarms_raw,
|
||||
"aggregateUmeAlarmsRaw": _aggregate_ume_alarms_raw,
|
||||
"listUmeAlarmFields": _list_ume_alarm_fields,
|
||||
"sqlQueryUme": _sql_query_ume,
|
||||
"queryNmsAlarms": _query_ume_alarms,
|
||||
"aggregateNmsAlarms": _aggregate_ume_alarms,
|
||||
"runNmsDiagnostics": _run_ume_diagnostics,
|
||||
"queryNmsNeInventory": _query_ume_ne_inventory,
|
||||
"getNmsNe": _get_ume_ne,
|
||||
"queryNmsAlarmsRaw": _query_ume_alarms_raw,
|
||||
"aggregateNmsAlarmsRaw": _aggregate_ume_alarms_raw,
|
||||
"listNmsAlarmFields": _list_ume_alarm_fields,
|
||||
"sqlQueryNms": _sql_query_ume,
|
||||
"listManagedNe": _list_managed_ne,
|
||||
"getManagedNe": _get_managed_ne,
|
||||
"execManagedNe": _exec_managed_ne,
|
||||
|
|
@ -824,15 +876,15 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|||
|
||||
# Minimum scope required to advertise / invoke each tool (matches netx API RBAC).
|
||||
TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
||||
"queryUmeAlarms": "alarms:read",
|
||||
"aggregateUmeAlarms": "alarms:read",
|
||||
"runUmeDiagnostics": "alarms:read",
|
||||
"queryUmeNeInventory": "ne:read",
|
||||
"getUmeNe": "ne:read",
|
||||
"queryUmeAlarmsRaw": "alarms:read",
|
||||
"aggregateUmeAlarmsRaw": "alarms:read",
|
||||
"listUmeAlarmFields": "alarms:read",
|
||||
"sqlQueryUme": "sql:query",
|
||||
"queryNmsAlarms": "alarms:read",
|
||||
"aggregateNmsAlarms": "alarms:read",
|
||||
"runNmsDiagnostics": "alarms:read",
|
||||
"queryNmsNeInventory": "ne:read",
|
||||
"getNmsNe": "ne:read",
|
||||
"queryNmsAlarmsRaw": "alarms:read",
|
||||
"aggregateNmsAlarmsRaw": "alarms:read",
|
||||
"listNmsAlarmFields": "alarms:read",
|
||||
"sqlQueryNms": "sql:query",
|
||||
"listManagedNe": "ne:read",
|
||||
"getManagedNe": "ne:read",
|
||||
"execManagedNe": "ne:exec",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ def run_stdio_loop() -> None:
|
|||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-mcp", "version": "0.2.1", "mode": "http"},
|
||||
"serverInfo": {"name": "netx-mcp", "version": "0.3.0", "mode": "http"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
|
@ -111,7 +111,7 @@ def run_stdio_loop() -> None:
|
|||
(
|
||||
f"insufficient_scope:{need}. "
|
||||
"Ask a netx admin to grant this scope on the API token; "
|
||||
"for sql:query prefer aggregateUmeAlarms/queryUmeAlarmsRaw/ume_alarm_xlsx_report instead."
|
||||
"for sql:query prefer aggregateNmsAlarms/queryNmsAlarmsRaw instead."
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -16,20 +16,22 @@ from netx_mcp.server import _fetch_scopes
|
|||
def test_http_mcp_tool_list_has_expected_tools() -> None:
|
||||
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
||||
assert len(names) == 14
|
||||
assert "queryUmeAlarms" in names
|
||||
assert "queryUmeAlarmsRaw" in names
|
||||
assert "queryNmsAlarms" in names
|
||||
assert "queryNmsAlarmsRaw" in names
|
||||
assert "execManagedNe" in names
|
||||
assert "listCliTargets" in names
|
||||
assert "findTopologyPaths" in names
|
||||
assert "queryUmeAlarms" not in names
|
||||
assert "queryTopologyEdges" not in names
|
||||
exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe")
|
||||
assert exec_tool["inputSchema"]["properties"]["commands"]["maxItems"] >= 5
|
||||
assert "nms_ne_id" in exec_tool["inputSchema"]["properties"]
|
||||
|
||||
|
||||
def test_call_query_ume_alarms_forwards_http() -> None:
|
||||
def test_call_query_nms_alarms_forwards_http() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"total": 0, "items": []}}
|
||||
out = call_http_tool("queryUmeAlarms", {"severity": "critical", "page": 1, "page_size": 10})
|
||||
out = call_http_tool("queryNmsAlarms", {"severity": "critical", "page": 1, "page_size": 10})
|
||||
mock_http.assert_called_once()
|
||||
assert mock_http.call_args[0][0] == "GET"
|
||||
assert mock_http.call_args[0][1] == "/v1/ume/alarms"
|
||||
|
|
@ -40,13 +42,13 @@ def test_call_query_ume_alarms_forwards_http() -> None:
|
|||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_aggregate_ume_alarms_forwards_top_ne() -> None:
|
||||
def test_call_aggregate_nms_alarms_forwards_top_ne() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {"total": 10, "by_severity": [], "by_ne": [], "by_ne_total": 3, "top_ne": 20},
|
||||
}
|
||||
out = call_http_tool("aggregateUmeAlarms", {"top_ne": 20, "severity": "critical"})
|
||||
out = call_http_tool("aggregateNmsAlarms", {"top_ne": 20, "severity": "critical"})
|
||||
mock_http.assert_called_once_with(
|
||||
"GET",
|
||||
"/v1/ume/alarms/aggregate",
|
||||
|
|
@ -57,11 +59,11 @@ def test_call_aggregate_ume_alarms_forwards_top_ne() -> None:
|
|||
assert payload["data"]["top_ne"] == 20
|
||||
|
||||
|
||||
def test_call_aggregate_ume_alarms_group_by_routes_to_raw() -> None:
|
||||
def test_call_aggregate_nms_alarms_group_by_routes_to_raw() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"buckets": []}}
|
||||
out = call_http_tool(
|
||||
"aggregateUmeAlarms",
|
||||
"aggregateNmsAlarms",
|
||||
{"group_by": "alarm_host_name", "severity": "critical", "limit": 20},
|
||||
)
|
||||
mock_http.assert_called_once()
|
||||
|
|
@ -75,8 +77,8 @@ def test_call_aggregate_ume_alarms_group_by_routes_to_raw() -> None:
|
|||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_aggregate_ume_alarms_schema_accepts_group_by() -> None:
|
||||
tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "aggregateUmeAlarms")
|
||||
def test_aggregate_nms_alarms_schema_accepts_group_by() -> None:
|
||||
tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "aggregateNmsAlarms")
|
||||
props = tool["inputSchema"]["properties"]
|
||||
assert "group_by" in props
|
||||
assert "group_by2" in props
|
||||
|
|
@ -88,7 +90,7 @@ def test_call_find_topology_paths_defaults_summary_detail() -> None:
|
|||
mock_post.return_value = {"ok": True, "data": {"path_count": 1, "detail": "summary", "paths": []}}
|
||||
out = call_http_tool(
|
||||
"findTopologyPaths",
|
||||
{"from_ume_ne_id": "a", "to_ume_ne_id": "b"},
|
||||
{"from_nms_ne_id": "a", "to_nms_ne_id": "b"},
|
||||
)
|
||||
mock_post.assert_called_once()
|
||||
body = mock_post.call_args[0][1]
|
||||
|
|
@ -99,12 +101,24 @@ 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"}}
|
||||
out = call_http_tool(
|
||||
"execManagedNe",
|
||||
{"ume_ne_id": "u1", "commands": ["show version"]},
|
||||
{"nms_ne_id": "u1", "commands": ["show version"]},
|
||||
)
|
||||
mock_post.assert_called_once()
|
||||
body = mock_post.call_args[0][1]
|
||||
|
|
@ -132,8 +146,8 @@ def test_get_managed_ne_accepts_managed_ne_id_alias() -> None:
|
|||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_get_ume_ne_requires_id() -> None:
|
||||
out = call_http_tool("getUmeNe", {})
|
||||
def test_call_get_nms_ne_requires_id() -> None:
|
||||
out = call_http_tool("getNmsNe", {})
|
||||
assert out.get("isError") is True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["error"] == "ne_id_required"
|
||||
|
|
@ -185,6 +199,7 @@ def test_exec_managed_ne_schema_documents_batch() -> None:
|
|||
tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe")
|
||||
props = tool["inputSchema"]["properties"]
|
||||
assert "ne_ids" in props
|
||||
assert "nms_ne_ids" in props
|
||||
assert "ume_ne_ids" in props
|
||||
assert "targets" in props
|
||||
assert "concurrency" in props
|
||||
|
|
@ -228,7 +243,7 @@ def test_fetch_scopes_returns_none_on_http_failure() -> None:
|
|||
def test_tools_for_scopes_filters_by_granted() -> None:
|
||||
names = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
|
||||
assert "listManagedNe" in names
|
||||
assert "queryUmeAlarms" not in names
|
||||
assert "queryNmsAlarms" not in names
|
||||
assert tools_for_scopes(None) == list(HTTP_MCP_TOOLS)
|
||||
|
||||
|
||||
|
|
@ -248,22 +263,35 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
errors="replace",
|
||||
env=env,
|
||||
)
|
||||
assert proc.stdin and proc.stdout
|
||||
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
|
||||
proc.stdin.write(init_req)
|
||||
proc.stdin.flush()
|
||||
init_line = proc.stdout.readline()
|
||||
init_resp = json.loads(init_line)
|
||||
assert init_resp["result"]["serverInfo"]["mode"] == "http"
|
||||
assert proc.stdin is not None and proc.stdout is not None
|
||||
try:
|
||||
init = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test", "version": "0"},
|
||||
},
|
||||
}
|
||||
proc.stdin.write(json.dumps(init) + "\n")
|
||||
proc.stdin.flush()
|
||||
line = proc.stdout.readline()
|
||||
assert line
|
||||
msg = json.loads(line)
|
||||
assert msg.get("id") == 1
|
||||
assert "result" in msg
|
||||
|
||||
list_req = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n"
|
||||
proc.stdin.write(list_req)
|
||||
proc.stdin.flush()
|
||||
list_line = proc.stdout.readline()
|
||||
list_resp = json.loads(list_line)
|
||||
assert "error" not in list_resp, list_resp
|
||||
tools = list_resp["result"]["tools"]
|
||||
assert len(tools) == 14
|
||||
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + "\n")
|
||||
proc.stdin.flush()
|
||||
line2 = proc.stdout.readline()
|
||||
assert line2
|
||||
listed = json.loads(line2)
|
||||
tools = listed["result"]["tools"]
|
||||
names = {t["name"] for t in tools}
|
||||
assert "queryNmsAlarms" in names
|
||||
assert "execManagedNe" in names
|
||||
finally:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
|
|
|
|||
5
packages/netx-topology-mcp/SKILLS.md
Normal file
5
packages/netx-topology-mcp/SKILLS.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Reminder: skills live at repo root
|
||||
|
||||
Canonical topology playbook: [`../../skills/topology/netx-topology/`](../../skills/topology/netx-topology/SKILL.md)
|
||||
|
||||
This package does **not** ship a `skills/` mirror.
|
||||
18
skills/README.md
Normal file
18
skills/README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# netx skills(唯一真源)
|
||||
|
||||
**一组一个 skill。** 只在这里改正文。
|
||||
|
||||
| Group | Skill bundle | 工具(MCP 裸名 / DSH = `netx__`+stem) |
|
||||
|-------|--------------|----------------------------------------|
|
||||
| `nms` | `nms/netx-nms/` | `queryNmsAlarms` … `sqlQueryNms` |
|
||||
| `common` | `common/netx-common/` | managed CLI + `findTopologyPaths` |
|
||||
| `topology` | `topology/netx-topology/` | 画布 / Fabric / dual_unit / 布图 |
|
||||
|
||||
## DSH 怎么用
|
||||
|
||||
1. 运行时优先:`NETX_SKILLS_ROOT` → 旁路 `../netx/skills` → 包内 `presets/netxops/skills`
|
||||
2. 发 npm 前:`powershell -File netxops/scripts/sync-skills-from-netx.ps1`
|
||||
3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill**
|
||||
4. 其它预设可强制挂:`dsh-netxops/tools-nms|common|topology`
|
||||
|
||||
MCP / Cursor:skill 根直接指本目录。MCP 包内不镜像 skills。
|
||||
91
skills/common/netx-common/SKILL.md
Normal file
91
skills/common/netx-common/SKILL.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
name: netx-common
|
||||
description: >-
|
||||
netx common playbook (MCP + DSH): managed-NE CLI (SSH/Telnet batch-first) plus
|
||||
native findTopologyPaths. Not NMS REST alarms/inventory. Trigger: execManagedNe,
|
||||
show/display, optical, capacity A<>B, path between sites.
|
||||
---
|
||||
|
||||
# netx-common (managed CLI + paths)
|
||||
|
||||
## Naming (MCP + DSH)
|
||||
|
||||
Canonical: **`netx/skills/common/netx-common/`**. Mirrored into MCP / dsh-netxops — edit once.
|
||||
|
||||
| Host | How tools appear |
|
||||
|------|------------------|
|
||||
| **MCP** (`netx-mcp`) | Bare names: `execManagedNe`, `findTopologyPaths`, … |
|
||||
| **DSH** (`dsh-netxops`) | Prefixed: `netx__execManagedNe`, … |
|
||||
|
||||
NMS alarms/inventory → **netx-nms**. Canvas / dual_unit → **netx-topology**.
|
||||
|
||||
## Tools
|
||||
|
||||
| Purpose | Tool |
|
||||
|---------|------|
|
||||
| List managed NEs | `listManagedNe` |
|
||||
| Managed detail | `getManagedNe` |
|
||||
| Read-only CLI (batch-first) | `execManagedNe` |
|
||||
| CLI target index | `listCliTargets` |
|
||||
| Fabric paths | `findTopologyPaths` |
|
||||
|
||||
Prefer `nms_ne_id` / `nms_ne_ids`; legacy `ume_*` accepted. `listCliTargets(source=nms)` preferred (`ume` alias).
|
||||
|
||||
## CLI order
|
||||
|
||||
1. `listManagedNe` (`connect_status=pass`) or `listCliTargets` (**once** per session, cache ids)
|
||||
2. Multi-NE → **one** `execManagedNe` with `ne_ids` / `nms_ne_ids` / `targets`
|
||||
3. Paths → `findTopologyPaths` (`nms_ne_id` **or** `managed_ne_id` per end)
|
||||
|
||||
### Batch examples
|
||||
|
||||
**Same commands, many NEs**
|
||||
|
||||
```json
|
||||
{
|
||||
"nms_ne_ids": ["uuid-a", "uuid-b", "uuid-c"],
|
||||
"commands": ["show version"],
|
||||
"read_timeout_sec": 60,
|
||||
"concurrency": 4
|
||||
}
|
||||
```
|
||||
|
||||
**Different commands per NE (still one call)**
|
||||
|
||||
```json
|
||||
{
|
||||
"targets": [
|
||||
{"nms_ne_id": "uuid-zte", "commands": ["show opticalinfo brief"]},
|
||||
{"nms_ne_id": "uuid-hw", "commands": ["display optical-module brief"]},
|
||||
{"nms_ne_id": "uuid-cisco", "commands": ["show interface transceiver"]}
|
||||
],
|
||||
"read_timeout_sec": 90
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:** N× single-NE `execManagedNe` in one turn (stdio serial).
|
||||
|
||||
## Field recipes
|
||||
|
||||
### Capacity / optical A<>B
|
||||
|
||||
1. Resolve nicknames → `host_name`
|
||||
2. `findTopologyPaths` and/or LLDP → both ports
|
||||
3. Optics CLI on **both** ends; summarize RX/TX / thresholds
|
||||
4. Do not answer with only NMS bandwidth/optical-power-threshold alarm tallies unless asked
|
||||
|
||||
### ZTE optical CLI
|
||||
|
||||
Try in order; one failure → switch spelling (do not blind-retry):
|
||||
|
||||
| Prefer | Fallback |
|
||||
|--------|----------|
|
||||
| `show opticalinfo brief` | Field-confirmed on many ZXR10 |
|
||||
| `show optical brief` | Some EN platforms |
|
||||
| `show opticalinfo brief \| begin <if>` | After port known |
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never pass NMS alarm UUIDs as managed `ne_id` for `getManagedNe`
|
||||
- Allowlist prefixes: `show ` / `display ` / `ping ` / `traceroute` …
|
||||
- Prefer `host_name` for users; keep ids for tool params
|
||||
70
skills/nms/netx-nms/SKILL.md
Normal file
70
skills/nms/netx-nms/SKILL.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
name: netx-nms
|
||||
description: >-
|
||||
netx NMS playbook (MCP + DSH): vendor NMS adapter (zte-ume) for alarm
|
||||
query/aggregate/diagnostics, NE inventory, raw fields, and read-only SQL.
|
||||
Trigger: NMS alarms, host_name, Critical Top, LOS, BN EMS, netx ops.
|
||||
---
|
||||
|
||||
# netx-nms (vendor NMS adapter)
|
||||
|
||||
## Naming (MCP + DSH)
|
||||
|
||||
Canonical playbooks live in **`netx/skills/`** (this file is mirrored into MCP packages / dsh-netxops).
|
||||
Edit the netx repo copy; run sync scripts — do not maintain divergent forks.
|
||||
|
||||
| Host | How tools appear |
|
||||
|------|------------------|
|
||||
| **MCP** (`netx-mcp`) | Bare names: `queryNmsAlarms`, … |
|
||||
| **DSH** (`dsh-netxops`) | Prefixed: `netx__queryNmsAlarms`, … (same camelCase stem) |
|
||||
|
||||
REST still `/v1/ume/*` for provider `zte-ume`.
|
||||
Prefer `nms_ne_id` / `nms_ne_ids`; legacy `ume_*` aliases still work.
|
||||
|
||||
Path lookup → **common** skill `netx-common` (`findTopologyPaths`).
|
||||
Canvas → **netx-topology**(含 dual_unit / 布图;DSH 可把部分布局工具放在实验 tool group)。
|
||||
|
||||
## Tool names
|
||||
|
||||
| Purpose | Tool |
|
||||
|---------|------|
|
||||
| Alarm list | `queryNmsAlarms` |
|
||||
| Alarm aggregate | `aggregateNmsAlarms` |
|
||||
| Diagnostics | `runNmsDiagnostics` |
|
||||
| NE inventory | `queryNmsNeInventory` |
|
||||
| NE detail | `getNmsNe` |
|
||||
| Field list | `listNmsAlarmFields` |
|
||||
| Raw rows | `queryNmsAlarmsRaw` |
|
||||
| Dynamic aggregate | `aggregateNmsAlarmsRaw` |
|
||||
| SQL | `sqlQueryNms` |
|
||||
| Managed CLI (other skill) | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` |
|
||||
|
||||
## Tool order
|
||||
|
||||
1. **Freshness first**: `runNmsDiagnostics` or `aggregateNmsAlarms` → `meta.last_seen_min` / `last_seen_max`.
|
||||
2. Overview: `aggregateNmsAlarms` + `runNmsDiagnostics`; samples via `queryNmsAlarms` (one page).
|
||||
3. Evidence: `listNmsAlarmFields` → `queryNmsAlarmsRaw` (`field_preset=evidence`).
|
||||
4. Custom aggregate: `aggregateNmsAlarmsRaw`.
|
||||
5. SQL: `sqlQueryNms` (SELECT only; `statement_timeout_ms`).
|
||||
6. Paths: `findTopologyPaths` via **netx-common**.
|
||||
7. Device CLI: **netx-common** — multi-NE = **one** `execManagedNe` batch.
|
||||
|
||||
## Short-intent recipes
|
||||
|
||||
| User says | Recipe |
|
||||
|-----------|--------|
|
||||
| fiber cut / LOS / 断纤 | `queryNmsAlarmsRaw(keyword=LOS)` and/or `Fiber Break` → **host_name** list |
|
||||
| offline / BN EMS | keyword=`BN EMS` |
|
||||
| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` |
|
||||
| CRC in area | Raw `keyword=CRC` + `AREA-` hostname prefix |
|
||||
| optical power **threshold** | keyword=`optical power` + area — **not** fiber-cut |
|
||||
| one hostname | host-scoped `queryNmsAlarms` / Raw only |
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Prefer non-SQL; filter severity → keyword/host → time → ne_id.
|
||||
- Lists ≤2 pages; `page_size` default 50.
|
||||
- Display **host_name** only; never bare UUID to users.
|
||||
- `getManagedNe` needs managed id; NMS UUID → `getNmsNe` / `execManagedNe(nms_ne_id=...)`.
|
||||
|
||||
See [reference.md](reference.md).
|
||||
20
skills/nms/netx-nms/reference.md
Normal file
20
skills/nms/netx-nms/reference.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# netx-nms quick reference
|
||||
|
||||
## Freshness
|
||||
|
||||
- `runNmsDiagnostics` / `aggregateNmsAlarms` → `meta.last_seen_min` / `last_seen_max`
|
||||
- Snapshot: windows inside min~max — do not default to `now()-30m`
|
||||
|
||||
## Tools
|
||||
|
||||
| Intent | Call |
|
||||
|--------|------|
|
||||
| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` |
|
||||
| Fiber / LOS | Raw `keyword=LOS` / `Fiber Break` |
|
||||
| Offline / BN EMS | Raw keyword=`BN EMS` |
|
||||
| Single host | `queryNmsAlarms(host_name=…)` |
|
||||
| Inventory | `queryNmsNeInventory(keyword=…)` / `getNmsNe` |
|
||||
| Paths | `findTopologyPaths(from_nms_ne_id, to_nms_ne_id)` — **common** skill |
|
||||
| SQL | `sqlQueryNms` SELECT; `statement_timeout_ms=8000` |
|
||||
|
||||
DSH: prefix every tool with `netx__`.
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
---
|
||||
name: netx-topology
|
||||
description: >-
|
||||
用 netx-topology MCP 查邻接、分类打标;先 dual_unit 一次抽最大核心眼,再换其它算法下沉/布图(不污染 Fabric)。
|
||||
触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。先读本 skill 再调工具。
|
||||
用 netx-topology MCP / DSH topology 组查邻接、分类打标;先 dual_unit 一次抽最大核心眼,
|
||||
再换其它算法下沉/布图(不污染 Fabric)。触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。
|
||||
---
|
||||
|
||||
# netx 拓扑(通用)
|
||||
|
||||
只用 **`netx-topology`** MCP(包 `netx-topology-mcp`)。安装与 scopes:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。
|
||||
## Hosts(MCP + DSH)
|
||||
|
||||
| Host | Tools |
|
||||
|------|--------|
|
||||
| **MCP** `netx-topology-mcp` | 裸名(`sinkTopologyDualUnits` …);安装见 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md) |
|
||||
| **DSH** `dsh-netxops` | `netx__` + 同 stem。整组工具 + skill **`netx-topology`**(能力组 **topology**,默认关)。缺布局引擎能力时用 MCP。 |
|
||||
|
||||
Canonical: `netx/skills/topology/netx-topology/`(唯一正文)。
|
||||
|
||||
**原则**:
|
||||
1. **第一步用 dual_units**:从**核心**出发,选**覆盖网元最多**的那一只眼(`prefer_top_eye` + max cover)。
|
||||
|
|
@ -16,7 +23,7 @@ description: >-
|
|||
4. **眼图已定型 → 门控精修**:对该 sink 只用下表「允许」动作;禁止全局拆眼工具。
|
||||
5. 布眼目标:少交叉、眼心空旷、少重叠(椭圆弧带;长链在眼外)。对照人工金标时还要看:**紧凑度、正交边、贴边清开**(见「算法天花板」)。
|
||||
|
||||
**禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**。不造 Fabric 边。
|
||||
**禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**(或 DSH 已实现的同名工具)。不造 Fabric 边。
|
||||
**脱敏(硬)**:勿把客户网元名、站点/区域名、具体交叉数、具体 view_id 写进本 skill。角色只用通用词:门户 / 枢纽 / 汇聚 / 接入 / 末梢。**禁止**在 skill 正文写站点缩写或设备角色缩写当专名举例。
|
||||
|
||||
---
|
||||
|
|
@ -12,19 +12,19 @@ import pytest
|
|||
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||
|
||||
|
||||
def test_http_mcp_tool_list_has_thirteen_tools() -> None:
|
||||
def test_http_mcp_tool_list_has_fourteen_tools() -> None:
|
||||
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
||||
assert len(names) == 14
|
||||
assert "queryUmeAlarms" in names
|
||||
assert "queryUmeAlarmsRaw" in names
|
||||
assert "queryNmsAlarms" in names
|
||||
assert "queryNmsAlarmsRaw" in names
|
||||
assert "execManagedNe" in names
|
||||
assert "listCliTargets" in names
|
||||
|
||||
|
||||
def test_call_query_ume_alarms_forwards_http() -> None:
|
||||
def test_call_query_nms_alarms_forwards_http() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"total": 0, "items": []}}
|
||||
out = call_http_tool("queryUmeAlarms", {"severity": "critical", "page": 1, "page_size": 10})
|
||||
out = call_http_tool("queryNmsAlarms", {"severity": "critical", "page": 1, "page_size": 10})
|
||||
mock_http.assert_called_once()
|
||||
assert mock_http.call_args[0][0] == "GET"
|
||||
assert mock_http.call_args[0][1] == "/v1/ume/alarms"
|
||||
|
|
@ -35,8 +35,8 @@ def test_call_query_ume_alarms_forwards_http() -> None:
|
|||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_get_ume_ne_requires_id() -> None:
|
||||
out = call_http_tool("getUmeNe", {})
|
||||
def test_call_get_nms_ne_requires_id() -> None:
|
||||
out = call_http_tool("getNmsNe", {})
|
||||
assert out.get("isError") is True
|
||||
text = out["content"][0]["text"]
|
||||
payload = json.loads(text)
|
||||
|
|
@ -61,6 +61,11 @@ def test_call_exec_managed_ne_posts_body() -> None:
|
|||
|
||||
|
||||
def test_stdio_initialize_and_tools_list() -> None:
|
||||
import os
|
||||
|
||||
env = os.environ.copy()
|
||||
env["NETX_API_URL"] = "http://127.0.0.1:1"
|
||||
env.pop("NETX_API_TOKEN", None)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "netx_mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
|
|
@ -69,6 +74,7 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
)
|
||||
assert proc.stdin and proc.stdout
|
||||
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
|
||||
|
|
@ -85,7 +91,10 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
list_resp = json.loads(list_line)
|
||||
assert "error" not in list_resp, list_resp
|
||||
tools = list_resp["result"]["tools"]
|
||||
names = {t["name"] for t in tools}
|
||||
assert len(tools) == 14
|
||||
assert "queryNmsAlarms" in names
|
||||
assert "findTopologyPaths" in names
|
||||
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue