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)

View file

@ -0,0 +1,39 @@
# netx-topology-mcp
独立的 **stdio MCP**,只暴露 netx **拓扑画布 / Fabric** 能力,与告警/CLI 的 [`netx-mcp`](../netx-mcp) 分开安装,方便 Agent 按需启用。
```
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx API /v1/topology/*
```
详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)**
配套 Skill → **[`.cursor/skills/netx-topology`](../../.cursor/skills/netx-topology/SKILL.md)**(画图流水线与硬规则)
## 安装
```powershell
cd D:\project\chatgpt\netx
pip install -e ./packages/netx-topology-mcp
python -c "import netx_topology_mcp; print('ok')"
```
GitHub:
```powershell
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
```
## 配置
复制 [`mcp.json`](./mcp.json) 到 Cursor / oclaw(`server_id=netx-topology`),可与 `netx` 同时存在。
## 工具(13)
| 类别 | 工具 |
|------|------|
| 树/画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` |
| 画图 | `addTopologyViewNodes`(仅已有 `fabric_node_ids`), `removeTopologyViewNodes`, `updateTopologyViewPositions`, `projectTopologyNeighbors` |
| Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。

View file

@ -0,0 +1,14 @@
{
"mcpServers": {
"netx-topology": {
"command": "python",
"args": ["-m", "netx_topology_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
}
}
}

View file

@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netx-topology-mcp"
version = "0.1.0"
description = "stdio MCP server for netx topology canvas (views / fabric / draw)"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"httpx>=0.27.0",
]
[project.scripts]
netx-topology-mcp = "netx_topology_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -0,0 +1,3 @@
"""netx topology MCP — canvas / fabric tools for drawing topology maps."""
__version__ = "0.1.0"

View file

@ -0,0 +1,4 @@
from netx_topology_mcp.server import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,94 @@
"""HTTP client for netx REST API (topology MCP)."""
from __future__ import annotations
import json
import os
from typing import Any
import httpx
def api_base_url() -> str:
raw = (
os.getenv("NETX_API_URL")
or os.getenv("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890"
)
return str(raw or "").strip().rstrip("/")
def api_headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
if not tok:
candidates = [
os.getenv("NETX_MCP_TOKEN_FILE", "").strip(),
"data/auth/mcp_token",
os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"),
]
for raw in candidates:
if not raw:
continue
path = os.path.abspath(raw)
try:
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
tok = fh.read().strip()
if tok:
break
except Exception:
continue
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def lang_query_params() -> dict[str, str]:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def http_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
timeout: float = 60.0,
) -> dict[str, Any]:
url = f"{api_base_url()}{path}"
merged: dict[str, Any] = dict(lang_query_params())
if params:
merged.update(params)
try:
with httpx.Client(timeout=timeout, trust_env=False) as client:
resp = client.request(
method,
url,
params=merged or None,
json=body,
headers=api_headers(),
)
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if is_error:
out["isError"] = True
return out
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
if not result.get("ok"):
return mcp_text_result(result, is_error=True)
return mcp_text_result(result)

View file

@ -0,0 +1,467 @@
"""MCP tool schemas and HTTP handlers for netx topology canvas / fabric."""
from __future__ import annotations
from typing import Any, Callable
from netx_topology_mcp.http_client import http_json, mcp_from_handler_result
def _data(out: dict[str, Any]) -> dict[str, Any]:
"""Return API payload dict from http_json envelope (or error as-is)."""
if not isinstance(out, dict):
return {"ok": False, "error": "invalid_response"}
if not out.get("ok"):
return out
data = out.get("data")
if isinstance(data, dict):
merged = dict(data)
merged["ok"] = True
return merged
return {"ok": True, "data": data}
def _get_topology_tree(_args: dict[str, Any]) -> dict[str, Any]:
return _data(http_json("GET", "/v1/topology/tree"))
def _list_topology_views(_args: dict[str, Any]) -> dict[str, Any]:
return _data(http_json("GET", "/v1/topology/views"))
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"}
return _data(http_json("GET", f"/v1/topology/views/{view_id}"))
def _create_topology_view(args: dict[str, Any]) -> dict[str, Any]:
name = str(args.get("name") or "").strip()
folder_id = str(args.get("folder_id") or "").strip()
if not name:
return {"ok": False, "error": "name_required"}
if not folder_id:
return {"ok": False, "error": "folder_id_required"}
body: dict[str, Any] = {
"name": name,
"folder_id": folder_id,
"remark": str(args.get("remark") or ""),
"kind": str(args.get("kind") or "custom").strip() or "custom",
"role": str(args.get("role") or "core").strip() or "core",
"sort_order": int(args.get("sort_order") or 0),
}
filt = args.get("filter")
if isinstance(filt, dict):
body["filter"] = filt
return _data(http_json("POST", "/v1/topology/views", body=body))
def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Place existing fabric nodes on a view only — never create fabric placeholders."""
view_id = str(args.get("view_id") or "").strip()
if not view_id:
return {"ok": False, "error": "view_id_required"}
# Reject inventory-id shortcuts that would call ensure_fabric_node_* on the API.
if args.get("managed_ne_ids") or args.get("ume_ne_ids"):
return {
"ok": False,
"error": "fabric_nodes_only",
"detail": "Only fabric_node_ids are allowed; resolve inventory via search/list first.",
}
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not fabric_ids:
return {"ok": False, "error": "fabric_node_ids_required"}
body: dict[str, Any] = {
"managed_ne_ids": [],
"ume_ne_ids": [],
"fabric_node_ids": fabric_ids,
"layout": str(args.get("layout") or "grid").strip() or "grid",
}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body))
def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip()
ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not view_id:
return {"ok": False, "error": "view_id_required"}
if not ids:
return {"ok": False, "error": "fabric_node_ids_required"}
return _data(
http_json("POST", f"/v1/topology/views/{view_id}/nodes/remove", body={"fabric_node_ids": ids})
)
def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip()
positions = args.get("positions")
if not view_id:
return {"ok": False, "error": "view_id_required"}
if not isinstance(positions, list) or not positions:
return {"ok": False, "error": "positions_required"}
cleaned: list[dict[str, Any]] = []
for p in positions:
if not isinstance(p, dict):
continue
fid = str(p.get("fabric_node_id") or "").strip()
if not fid:
continue
cleaned.append(
{
"fabric_node_id": fid,
"x": float(p.get("x") or 0),
"y": float(p.get("y") or 0),
"label": str(p.get("label") or ""),
"locked": bool(p.get("locked") or False),
}
)
if not cleaned:
return {"ok": False, "error": "positions_required"}
return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body={"positions": cleaned}))
def _project_topology_neighbors(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"}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body={}))
def _get_topology_fabric_summary(_args: dict[str, Any]) -> dict[str, Any]:
return _data(http_json("GET", "/v1/topology/fabric/summary"))
def _list_topology_fabric_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()
if str(args.get("role") or "").strip():
params["role"] = str(args.get("role")).strip()
if str(args.get("link_status") or "").strip():
params["link_status"] = str(args.get("link_status")).strip()
return _data(http_json("GET", "/v1/topology/fabric/nodes", params=params))
def _search_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("q") or args.get("keyword") or "").strip()
if not q:
return {"ok": False, "error": "q_required"}
params: dict[str, Any] = {
"q": q,
"page": max(1, int(args.get("page") or 1)),
"page_size": min(200, max(1, int(args.get("page_size") or args.get("limit") or 50))),
}
return _data(http_json("GET", "/v1/topology/fabric/nodes/search", params=params))
def _query_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"}
params: dict[str, Any] = {
"node_id": node_id,
"depth": min(3, max(1, int(args.get("depth") or 1))),
"layer": str(args.get("layer") or "physical").strip() or "physical",
}
return _data(http_json("GET", "/v1/topology/fabric/neighborhood", params=params))
def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
"""List fabric edges; with node_id, also summarize unique peer NEs."""
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 100)))
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) or not out.get("ok"):
return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"}
data = out.get("data") if isinstance(out.get("data"), dict) else {}
items = data.get("items") if isinstance(data.get("items"), list) else []
result: dict[str, Any] = {"ok": True, **data}
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})
result["peer_count"] = len(peers)
result["peers"] = peer_labels
result["edge_total"] = int(data.get("total") or len(items))
result["peers_complete"] = int(data.get("total") or 0) <= len(items)
return result
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{
"name": "getTopologyTree",
"description": "Get topology folder tree (sites/regions) with nested views — start here before createTopologyView.",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "listTopologyViews",
"description": "List topology canvas views (maps).",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "getTopologyView",
"description": "Get a topology view graph (nodes + edges + positions) by view_id.",
"inputSchema": {
"type": "object",
"properties": {"view_id": {"type": "string"}},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "createTopologyView",
"description": "Create a topology canvas under a folder (folder_id from getTopologyTree).",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"folder_id": {"type": "string"},
"remark": {"type": "string"},
"kind": {"type": "string", "enum": ["physical", "custom"], "default": "custom"},
"role": {"type": "string", "default": "core"},
"sort_order": {"type": "integer", "default": 0},
"filter": {"type": "object"},
},
"required": ["name", "folder_id"],
"additionalProperties": False,
},
},
{
"name": "addTopologyViewNodes",
"description": (
"Place existing fabric nodes onto a view canvas (layout=grid|keep). "
"Only fabric_node_ids — never creates fabric placeholders from managed/UME ids."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"layout": {"type": "string", "enum": ["grid", "keep"], "default": "grid"},
},
"required": ["view_id", "fabric_node_ids"],
"additionalProperties": False,
},
},
{
"name": "removeTopologyViewNodes",
"description": "Remove fabric nodes from a view canvas (does not delete fabric inventory).",
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
},
"required": ["view_id", "fabric_node_ids"],
"additionalProperties": False,
},
},
{
"name": "updateTopologyViewPositions",
"description": "Set x/y positions for fabric nodes on a view (draw / rearrange).",
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"positions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fabric_node_id": {"type": "string"},
"x": {"type": "number"},
"y": {"type": "number"},
"label": {"type": "string"},
"locked": {"type": "boolean"},
},
"required": ["fabric_node_id"],
"additionalProperties": False,
},
"minItems": 1,
},
},
"required": ["view_id", "positions"],
"additionalProperties": False,
},
},
{
"name": "projectTopologyNeighbors",
"description": (
"Project existing LLDP fabric neighbors of nodes already on the view onto the canvas. "
"Only places nodes that already exist in fabric."
),
"inputSchema": {
"type": "object",
"properties": {"view_id": {"type": "string"}},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "getTopologyFabricSummary",
"description": "Fabric inventory summary (node/edge counts).",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "listTopologyFabricNodes",
"description": "Paged fabric nodes (keyword/role/link_status filters).",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"role": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "searchTopologyFabricNodes",
"description": "Quick search fabric nodes by name/IP/id.",
"inputSchema": {
"type": "object",
"properties": {
"q": {"type": "string"},
"keyword": {"type": "string", "description": "Alias of q"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50},
"limit": {"type": "integer", "description": "Alias of page_size"},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "queryTopologyNeighborhood",
"description": "Neighborhood around a fabric 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": "queryTopologyEdges",
"description": (
"Query fabric LLDP/manual links. Pass node_id for edges of NE A plus peer_count. "
"Raise page_size if peers_complete is false."
),
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string"},
"keyword": {"type": "string"},
"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]]] = {
"getTopologyTree": _get_topology_tree,
"listTopologyViews": _list_topology_views,
"getTopologyView": _get_topology_view,
"createTopologyView": _create_topology_view,
"addTopologyViewNodes": _add_topology_view_nodes,
"removeTopologyViewNodes": _remove_topology_view_nodes,
"updateTopologyViewPositions": _update_topology_view_positions,
"projectTopologyNeighbors": _project_topology_neighbors,
"getTopologyFabricSummary": _get_topology_fabric_summary,
"listTopologyFabricNodes": _list_topology_fabric_nodes,
"searchTopologyFabricNodes": _search_topology_fabric_nodes,
"queryTopologyNeighborhood": _query_topology_neighborhood,
"queryTopologyEdges": _query_topology_edges,
}
TOOL_REQUIRED_SCOPE: dict[str, str] = {
"getTopologyTree": "ne:read",
"listTopologyViews": "ne:read",
"getTopologyView": "ne:read",
"createTopologyView": "ne:write",
"addTopologyViewNodes": "ne:write",
"removeTopologyViewNodes": "ne:write",
"updateTopologyViewPositions": "ne:write",
"projectTopologyNeighbors": "ne:write",
"getTopologyFabricSummary": "ne:read",
"listTopologyFabricNodes": "ne:read",
"searchTopologyFabricNodes": "ne:read",
"queryTopologyNeighborhood": "ne:read",
"queryTopologyEdges": "ne:read",
}
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
if scopes is None:
return list(HTTP_MCP_TOOLS)
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
if not granted:
return []
out: list[dict[str, Any]] = []
for tool in HTTP_MCP_TOOLS:
name = str(tool.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
if need is None or need in granted:
out.append(tool)
return out
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
fn = _HANDLERS.get(str(name or "").strip())
if not fn:
raise ValueError(f"unknown tool: {name}")
return mcp_from_handler_result(fn(dict(args or {})))

View file

@ -0,0 +1,122 @@
"""netx topology stdio MCP server (HTTP client to netx REST API).
Environment:
- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``)
- ``NETX_API_TOKEN``: optional Bearer token
- ``NETX_LANG``: ``zh`` or ``en``
"""
from __future__ import annotations
import json
import sys
from typing import Any
from netx_topology_mcp.http_client import http_json
from netx_topology_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
def _ensure_utf8_stdio() -> None:
for stream in (sys.stdin, sys.stdout, sys.stderr):
if stream is None or not hasattr(stream, "reconfigure"):
continue
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def _ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
+ "\n"
)
sys.stdout.flush()
_UNSET = object()
def _fetch_scopes() -> list[str] | None:
try:
envelope = http_json("GET", "/v1/auth/me")
if not isinstance(envelope, dict) or not envelope.get("ok"):
return None
data = envelope.get("data")
if not isinstance(data, dict):
return None
scopes = data.get("scopes")
if isinstance(scopes, list):
return [str(s) for s in scopes]
user = data.get("user")
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
return [str(s) for s in user["scopes"]]
except Exception:
return None
return None
def run_stdio_loop() -> None:
cached_scopes: list[str] | None | object = _UNSET
def scopes() -> list[str] | None:
nonlocal cached_scopes
if cached_scopes is _UNSET:
cached_scopes = _fetch_scopes()
return cached_scopes # type: ignore[return-value]
for line in sys.stdin:
raw = line.strip()
if not raw:
continue
try:
req = json.loads(raw)
except Exception:
continue
rid = req.get("id")
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
try:
if method == "initialize":
_ok(
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "netx-topology-mcp", "version": "0.1.0", "mode": "http"},
},
)
continue
if method == "notifications/initialized":
continue
if method == "tools/list":
_ok(rid, {"tools": tools_for_scopes(scopes())})
continue
if method == "tools/call":
name = str(params.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
granted = scopes()
if need and granted is not None and need not in {str(s).lower() for s in granted}:
_err(rid, -32001, f"insufficient_scope:{need}")
continue
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
_ok(rid, call_http_tool(name, args))
continue
_err(rid, -32601, f"method not found: {method}")
except Exception as exc:
_err(rid, -32000, str(exc))
def main() -> None:
_ensure_utf8_stdio()
run_stdio_loop()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,146 @@
"""Tests for netx topology HTTP MCP server."""
from __future__ import annotations
import json
import subprocess
import sys
from unittest.mock import patch
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes
from netx_topology_mcp.server import _fetch_scopes
def test_tool_list_has_draw_and_query_tools() -> None:
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert len(names) == 13
assert "createTopologyView" in names
assert "addTopologyViewNodes" in names
assert "updateTopologyViewPositions" in names
assert "queryTopologyEdges" in names
assert "getTopologyTree" in names
assert "createTopologyManualEdge" not in names
assert "populateTopologyView" not in names
def test_add_nodes_rejects_managed_ume_ids() -> None:
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "managed_ne_ids": ["m1"], "fabric_node_ids": ["f1"]},
)
assert out.get("isError") is True
payload = json.loads(out["content"][0]["text"])
assert payload["error"] == "fabric_nodes_only"
def test_add_nodes_posts_fabric_ids_only() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"nodes": []}}
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "fabric_node_ids": ["f1", "f2"], "layout": "grid"},
)
body = mock_http.call_args[1]["body"]
assert body["fabric_node_ids"] == ["f1", "f2"]
assert body["managed_ne_ids"] == []
assert body["ume_ne_ids"] == []
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_create_view_requires_folder() -> None:
out = call_http_tool("createTopologyView", {"name": "map1"})
assert out.get("isError") is True
payload = json.loads(out["content"][0]["text"])
assert payload["error"] == "folder_id_required"
def test_create_view_posts_body() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"id": "v1", "name": "map1"}}
out = call_http_tool(
"createTopologyView",
{"name": "map1", "folder_id": "f1", "kind": "custom"},
)
mock_http.assert_called_once()
assert mock_http.call_args[0][0] == "POST"
assert mock_http.call_args[0][1] == "/v1/topology/views"
body = mock_http.call_args[1]["body"]
assert body["name"] == "map1"
assert body["folder_id"] == "f1"
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_query_edges_enriches_peers() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {
"total": 1,
"items": [
{
"a_node_id": "A",
"b_node_id": "B",
"a_name": "ne-a",
"b_name": "ne-b",
"a_ip": "1.1.1.1",
"b_ip": "2.2.2.2",
}
],
},
}
out = call_http_tool("queryTopologyEdges", {"node_id": "A", "page_size": 100})
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["peer_count"] == 1
assert payload["peers"][0]["node_id"] == "B"
assert payload["peers_complete"] is True
def test_tools_for_scopes_filters_write() -> None:
read_only = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
assert "queryTopologyEdges" in read_only
assert "createTopologyView" not in read_only
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
assert "createTopologyView" in write
def test_fetch_scopes_unwraps_envelope() -> None:
with patch("netx_topology_mcp.server.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"scopes": ["ne:read", "ne:write"]}}
assert _fetch_scopes() == ["ne:read", "ne:write"]
def test_stdio_initialize_and_tools_list() -> None:
import os
# Force scopes fetch to fail so tools/list returns the full catalog.
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_topology_mcp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
assert proc.stdin and proc.stdout
try:
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n")
proc.stdin.flush()
init_resp = json.loads(proc.stdout.readline())
assert init_resp["result"]["serverInfo"]["name"] == "netx-topology-mcp"
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n")
proc.stdin.flush()
list_resp = json.loads(proc.stdout.readline())
tools = list_resp["result"]["tools"]
assert len(tools) == 13
finally:
proc.terminate()
proc.wait(timeout=5)