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:
oliver 2026-09-06 01:46:21 +08:00
parent 5e7d14f253
commit 13ce2482ab
14 changed files with 463 additions and 145 deletions

View file

@ -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>`。
---

View file

@ -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**。

View 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.

View file

@ -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" }

View file

@ -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",

View file

@ -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

View file

@ -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)

View 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
View 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。

View 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

View 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).

View 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__`.

View file

@ -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 正文写站点缩写或设备角色缩写当专名举例。
---

View file

@ -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)