Add standalone topology MCP with skill, scopes UI, and opt-in live sync.

Splits canvas/Fabric tools into netx-topology-mcp, documents install and a companion Cursor skill, lets API keys grant ne:write explicitly, and adds optional topology live sync so operators can watch agent drawing without constant polling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-04 00:16:02 +08:00
parent a95b96f648
commit bf0dfd66d8
30 changed files with 2057 additions and 410 deletions

View file

@ -36,13 +36,13 @@ python -m netx_mcp
[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。
## 工具(14)
## 工具(13)
UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme`
托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`
拓扑 Fabric:`queryTopologyEdges`(可按 `node_id` 查 A 与多少网元互联,返回 `peer_count`)
拓扑画布 / Fabric → 请单独安装 [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
## 兼容

View file

@ -1,4 +1,4 @@
"""MCP tool schemas and HTTP-backed handlers (UME + managed NE + topology fabric)."""
"""MCP tool schemas and HTTP-backed handlers (UME + managed NE)."""
from __future__ import annotations
@ -285,61 +285,6 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
return http_json("GET", "/v1/cli/targets", 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 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():
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]] = [
{
"name": "queryUmeAlarms",
@ -524,34 +469,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"additionalProperties": False,
},
},
{
"name": "queryTopologyEdges",
"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",
"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", "missing", "stale"]},
"source": {"type": "string", "enum": ["lldp", "manual"]},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
},
"required": [],
"additionalProperties": False,
},
},
]
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
@ -568,7 +485,6 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"getManagedNe": _get_managed_ne,
"execManagedNe": _exec_managed_ne,
"listCliTargets": _list_cli_targets,
"queryTopologyEdges": _query_topology_edges,
}
# Minimum scope required to advertise / invoke each tool (matches netx API RBAC).
@ -586,7 +502,6 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
"getManagedNe": "ne:read",
"execManagedNe": "ne:exec",
"listCliTargets": "ne:read",
"queryTopologyEdges": "ne:read",
}

View file

@ -15,11 +15,12 @@ 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 len(names) == 13
assert "queryUmeAlarms" in names
assert "queryUmeAlarmsRaw" in names
assert "execManagedNe" in names
assert "listCliTargets" 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
@ -105,6 +106,11 @@ def test_tools_for_scopes_filters_by_granted() -> 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,
@ -113,6 +119,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"
@ -129,7 +136,7 @@ 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"]
assert len(tools) == 14
assert len(tools) == 13
proc.terminate()
proc.wait(timeout=5)