From 90dff56e48918fc1c332fcff5b8b686e52fc14de Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 04:05:45 +0800 Subject: [PATCH] Keep only queryTopologyEdges in netx-mcp topology tools. Expose interconnect peer_count for a node and document the trimmed 14-tool surface. Co-authored-by: Cursor --- docs/MCP.md | 4 +- packages/netx-mcp/README.md | 4 +- packages/netx-mcp/src/netx_mcp/http_tools.py | 205 ++++++------------- 3 files changed, 62 insertions(+), 151 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 656b90e..583967a 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**(应看到 **12** 个工具)。 +3. **Health** → **Sync Tools**(应看到 **14** 个工具)。 4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。 更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库: @@ -131,7 +131,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx | UME 网元 | `queryUmeNeInventory`, `getUmeNe` | | UME 原始/SQL | `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` | | 托管网元 CLI | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` | -| 拓扑 Fabric | `getTopologySummary`, `queryTopologyNodes`, `queryTopologyEdges`, `getTopologyNeighborhood`, `runLldpDiscover`, `getLldpDiscoverJob`, `listTopologyViews`, `getTopologyView` | +| 拓扑 Fabric | `queryTopologyEdges`(`node_id` → A 的链路与 `peer_count` 互联网元数) | 物理拓扑仅 LLDP;分页查询,勿默认拉全图。oclaw 中名称带前缀:`mcp__netx__`。 diff --git a/packages/netx-mcp/README.md b/packages/netx-mcp/README.md index fef3473..69740a9 100644 --- a/packages/netx-mcp/README.md +++ b/packages/netx-mcp/README.md @@ -36,13 +36,13 @@ python -m netx_mcp [`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。 -## 工具(21) +## 工具(14) UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` 托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` -拓扑 Fabric:`getTopologySummary`, `queryTopologyNodes`, `queryTopologyEdges`, `getTopologyNeighborhood`, `runLldpDiscover`, `getLldpDiscoverJob`, `listTopologyViews`, `getTopologyView` +拓扑 Fabric:`queryTopologyEdges`(可按 `node_id` 查 A 与多少网元互联,返回 `peer_count`) ## 兼容 diff --git a/packages/netx-mcp/src/netx_mcp/http_tools.py b/packages/netx-mcp/src/netx_mcp/http_tools.py index cd5d096..83f8602 100644 --- a/packages/netx-mcp/src/netx_mcp/http_tools.py +++ b/packages/netx-mcp/src/netx_mcp/http_tools.py @@ -285,79 +285,59 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]: return http_json("GET", "/v1/cli/targets", params=params) -def _get_topology_summary(args: dict[str, Any]) -> dict[str, Any]: - _ = args - return http_json("GET", "/v1/topology/fabric/summary", params=None) - - -def _query_topology_nodes(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("keyword") or "").strip(): - params["keyword"] = str(args.get("keyword")).strip() - return http_json("GET", "/v1/topology/fabric/nodes", params=params) - - def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]: + """List fabric edges; with node_id, also summarize unique peer NEs (interconnect count).""" page = max(1, int(args.get("page") or 1)) page_size = min(500, max(1, int(args.get("page_size") or 50))) + node_id = str(args.get("node_id") or "").strip() params: dict[str, Any] = { "page": page, "page_size": page_size, "layer": str(args.get("layer") or "physical").strip() or "physical", } - if str(args.get("node_id") or "").strip(): - params["node_id"] = str(args.get("node_id")).strip() + if node_id: + params["node_id"] = node_id if str(args.get("status") or "").strip(): params["status"] = str(args.get("status")).strip() if str(args.get("source") or "").strip(): - params["source"] = str(args.get("source")).strip() - return http_json("GET", "/v1/topology/fabric/edges", params=params) - - -def _get_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]: - node_id = str(args.get("node_id") or "").strip() - if not node_id: - return {"ok": False, "error": "node_id_required", "error_code": "node_id_required"} - params: dict[str, Any] = { - "node_id": node_id, - "depth": max(1, min(3, int(args.get("depth") or 1))), - "layer": str(args.get("layer") or "physical").strip() or "physical", - } - return http_json("GET", "/v1/topology/fabric/neighborhood", params=params) - - -def _run_lldp_discover(args: dict[str, Any]) -> dict[str, Any]: - scope = str(args.get("scope") or "ne_ids").strip() or "ne_ids" - body: dict[str, Any] = { - "scope": scope, - "auto_add_unmatched": bool(args.get("auto_add_unmatched") or False), - "concurrency": max(1, min(32, int(args.get("concurrency") or 4))), - } - ne_ids = args.get("ne_ids") - if isinstance(ne_ids, list): - body["ne_ids"] = [str(x).strip() for x in ne_ids if str(x).strip()] - return http_post_json("/v1/topology/fabric/discover", body, timeout=60.0) - - -def _get_lldp_discover_job(args: dict[str, Any]) -> dict[str, Any]: - job_id = str(args.get("job_id") or "").strip() - if not job_id: - return {"ok": False, "error": "job_id_required", "error_code": "job_id_required"} - return http_json("GET", f"/v1/topology/fabric/discover/{job_id}", params=None) - - -def _list_topology_views(args: dict[str, Any]) -> dict[str, Any]: - _ = args - return http_json("GET", "/v1/topology/views", params=None) - - -def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]: - view_id = str(args.get("view_id") or "").strip() - if not view_id: - return {"ok": False, "error": "view_id_required", "error_code": "view_id_required"} - return http_json("GET", f"/v1/topology/views/{view_id}", params=None) + src = str(args.get("source")).strip().lower() + if src == "stale": + src = "lldp" + params["source"] = src + if str(args.get("keyword") or "").strip(): + params["keyword"] = str(args.get("keyword")).strip() + out = http_json("GET", "/v1/topology/fabric/edges", params=params) + if not isinstance(out, dict): + return out + items = out.get("items") if isinstance(out.get("items"), list) else [] + # When scoping to one NE: unique peers on this page (+ total edges from API). + if node_id and items: + peers: set[str] = set() + peer_labels: list[dict[str, str]] = [] + seen_label: set[str] = set() + for e in items: + if not isinstance(e, dict): + continue + a_id = str(e.get("a_node_id") or "") + b_id = str(e.get("b_node_id") or "") + if a_id == node_id: + peer_id, pname, pip = b_id, str(e.get("b_name") or ""), str(e.get("b_ip") or "") + elif b_id == node_id: + peer_id, pname, pip = a_id, str(e.get("a_name") or ""), str(e.get("a_ip") or "") + else: + continue + if not peer_id or peer_id in peers: + continue + peers.add(peer_id) + if peer_id not in seen_label: + seen_label.add(peer_id) + peer_labels.append({"node_id": peer_id, "name": pname, "ip": pip}) + out["peer_count"] = len(peers) + out["peers"] = peer_labels + out["edge_total"] = int(out.get("total") or len(items)) + # Incomplete if caller didn't fetch all pages. + out["peers_complete"] = int(out.get("total") or 0) <= len(items) + return out HTTP_MCP_TOOLS: list[dict[str, Any]] = [ @@ -544,96 +524,34 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, - { - "name": "getTopologySummary", - "description": "Fabric topology summary: node/edge counts (active/stale) and last LLDP discover time.", - "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, - }, - { - "name": "queryTopologyNodes", - "description": "Page fabric topology nodes (keyword optional). Never dumps full fabric.", - "inputSchema": { - "type": "object", - "properties": { - "keyword": {"type": "string"}, - "page": {"type": "integer", "minimum": 1, "default": 1}, - "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}, - }, - "required": [], - "additionalProperties": False, - }, - }, { "name": "queryTopologyEdges", - "description": "Page fabric edges (physical layer by default); filter by node_id/status/source.", + "description": ( + "Query fabric LLDP/manual links. Pass node_id to list edges of NE A and get peer_count " + "(how many distinct NEs A interconnects with). Optional keyword filters by endpoint name/IP; " + "status=active|missing. Raise page_size if peers_complete is false." + ), "inputSchema": { "type": "object", "properties": { - "node_id": {"type": "string"}, + "node_id": { + "type": "string", + "description": "Fabric node id of NE A — returns its edges + peer_count/peers summary", + }, + "keyword": { + "type": "string", + "description": "Filter edges whose endpoint name/IP contains this text", + }, "layer": {"type": "string", "default": "physical"}, - "status": {"type": "string", "enum": ["active", "stale"]}, - "source": {"type": "string", "enum": ["lldp", "manual", "stale"]}, + "status": {"type": "string", "enum": ["active", "missing", "stale"]}, + "source": {"type": "string", "enum": ["lldp", "manual"]}, "page": {"type": "integer", "minimum": 1, "default": 1}, - "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}, + "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}, }, "required": [], "additionalProperties": False, }, }, - { - "name": "getTopologyNeighborhood", - "description": "Get fabric neighborhood around a node (depth 1-3).", - "inputSchema": { - "type": "object", - "properties": { - "node_id": {"type": "string"}, - "depth": {"type": "integer", "minimum": 1, "maximum": 3, "default": 1}, - "layer": {"type": "string", "default": "physical"}, - }, - "required": ["node_id"], - "additionalProperties": False, - }, - }, - { - "name": "runLldpDiscover", - "description": "Start async LLDP discovery into fabric (scope=ne_ids|all_inventory). Poll with getLldpDiscoverJob.", - "inputSchema": { - "type": "object", - "properties": { - "scope": {"type": "string", "enum": ["ne_ids", "all_inventory"], "default": "ne_ids"}, - "ne_ids": {"type": "array", "items": {"type": "string"}}, - "auto_add_unmatched": {"type": "boolean", "default": False}, - "concurrency": {"type": "integer", "minimum": 1, "maximum": 32, "default": 4}, - }, - "required": [], - "additionalProperties": False, - }, - }, - { - "name": "getLldpDiscoverJob", - "description": "Get LLDP discover job status, counters, and per-NE items/unmatched.", - "inputSchema": { - "type": "object", - "properties": {"job_id": {"type": "string"}}, - "required": ["job_id"], - "additionalProperties": False, - }, - }, - { - "name": "listTopologyViews", - "description": "List topology views (presentation canvases over fabric).", - "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, - }, - { - "name": "getTopologyView", - "description": "Get a topology view graph (nodes with positions + fabric edges between them).", - "inputSchema": { - "type": "object", - "properties": {"view_id": {"type": "string"}}, - "required": ["view_id"], - "additionalProperties": False, - }, - }, ] _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { @@ -650,14 +568,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "getManagedNe": _get_managed_ne, "execManagedNe": _exec_managed_ne, "listCliTargets": _list_cli_targets, - "getTopologySummary": _get_topology_summary, - "queryTopologyNodes": _query_topology_nodes, "queryTopologyEdges": _query_topology_edges, - "getTopologyNeighborhood": _get_topology_neighborhood, - "runLldpDiscover": _run_lldp_discover, - "getLldpDiscoverJob": _get_lldp_discover_job, - "listTopologyViews": _list_topology_views, - "getTopologyView": _get_topology_view, }