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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 04:05:45 +08:00
parent e7f97b7cf6
commit 90dff56e48
3 changed files with 62 additions and 151 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**(应看到 **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__<toolName>`。

View file

@ -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`)
## 兼容

View file

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