diff --git a/docs/MCP.md b/docs/MCP.md index 1f68fae..00f1d91 100644 --- a/docs/MCP.md +++ b/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__`。 +参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__`;DSH:`netx__`。 --- diff --git a/packages/netx-mcp/README.md b/packages/netx-mcp/README.md index dbb7e42..f4db519 100644 --- a/packages/netx-mcp/README.md +++ b/packages/netx-mcp/README.md @@ -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**。 diff --git a/packages/netx-mcp/SKILLS.md b/packages/netx-mcp/SKILLS.md new file mode 100644 index 0000000..a0d0309 --- /dev/null +++ b/packages/netx-mcp/SKILLS.md @@ -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. diff --git a/packages/netx-mcp/pyproject.toml b/packages/netx-mcp/pyproject.toml index 722e871..56d3e79 100644 --- a/packages/netx-mcp/pyproject.toml +++ b/packages/netx-mcp/pyproject.toml @@ -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" } diff --git a/packages/netx-mcp/src/netx_mcp/http_tools.py b/packages/netx-mcp/src/netx_mcp/http_tools.py index 4527e5a..bed447d 100644 --- a/packages/netx-mcp/src/netx_mcp/http_tools.py +++ b/packages/netx-mcp/src/netx_mcp/http_tools.py @@ -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": ""}, } - # 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", diff --git a/packages/netx-mcp/src/netx_mcp/server.py b/packages/netx-mcp/src/netx_mcp/server.py index e4b28de..9f4b9ae 100644 --- a/packages/netx-mcp/src/netx_mcp/server.py +++ b/packages/netx-mcp/src/netx_mcp/server.py @@ -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 diff --git a/packages/netx-mcp/tests/test_mcp_http.py b/packages/netx-mcp/tests/test_mcp_http.py index 5835d50..a6693ce 100644 --- a/packages/netx-mcp/tests/test_mcp_http.py +++ b/packages/netx-mcp/tests/test_mcp_http.py @@ -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) diff --git a/packages/netx-topology-mcp/SKILLS.md b/packages/netx-topology-mcp/SKILLS.md new file mode 100644 index 0000000..4ea07b0 --- /dev/null +++ b/packages/netx-topology-mcp/SKILLS.md @@ -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. diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..3dd447c --- /dev/null +++ b/skills/README.md @@ -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。 diff --git a/skills/common/netx-common/SKILL.md b/skills/common/netx-common/SKILL.md new file mode 100644 index 0000000..18b5d16 --- /dev/null +++ b/skills/common/netx-common/SKILL.md @@ -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 ` | 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 diff --git a/skills/nms/netx-nms/SKILL.md b/skills/nms/netx-nms/SKILL.md new file mode 100644 index 0000000..5839103 --- /dev/null +++ b/skills/nms/netx-nms/SKILL.md @@ -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). diff --git a/skills/nms/netx-nms/reference.md b/skills/nms/netx-nms/reference.md new file mode 100644 index 0000000..7da47eb --- /dev/null +++ b/skills/nms/netx-nms/reference.md @@ -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__`. diff --git a/.cursor/skills/netx-topology/SKILL.md b/skills/topology/netx-topology/SKILL.md similarity index 96% rename from .cursor/skills/netx-topology/SKILL.md rename to skills/topology/netx-topology/SKILL.md index 4d8c4c6..43c6da7 100644 --- a/.cursor/skills/netx-topology/SKILL.md +++ b/skills/topology/netx-topology/SKILL.md @@ -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 正文写站点缩写或设备角色缩写当专名举例。 --- diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index e1a138d..88f5ff2 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -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)