netx/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py
oliver 03ef9eb39a Project discover neighbors from scanned seeds only.
Accept optional seed ids on project-neighbors; canvas discover passes scanned fabric nodes and places new peers near them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:34:20 +08:00

679 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _create_topology_folder(args: dict[str, Any]) -> dict[str, Any]:
"""Create a region folder under the topology root (API kind=region only)."""
name = str(args.get("name") or "").strip()
if not name:
return {"ok": False, "error": "name_required"}
body: dict[str, Any] = {
"name": name,
"kind": "region",
"sort_order": int(args.get("sort_order") or 0),
}
parent_id = str(args.get("parent_id") or "").strip()
if parent_id:
body["parent_id"] = parent_id
return _data(http_json("POST", "/v1/topology/folders", body=body))
_CHUNK = 500
def _filter_fields(args: dict[str, Any]) -> dict[str, str]:
out: dict[str, str] = {}
for key in ("keyword", "role", "vendor", "link_status"):
val = str(args.get(key) or "").strip()
if val:
out[key] = val
return out
def _has_filter(args: dict[str, Any]) -> bool:
return bool(_filter_fields(args))
def _merge_mutation_summaries(parts: list[dict[str, Any]]) -> dict[str, Any]:
if not parts:
return {"ok": False, "error": "empty_batch"}
if len(parts) == 1:
return parts[0]
base = dict(parts[-1])
for key in (
"matched",
"added",
"updated",
"removed",
"skipped_existing",
"skipped_missing",
"skipped_locked",
):
base[key] = sum(int(p.get(key) or 0) for p in parts)
base["truncated"] = any(bool(p.get("truncated")) for p in parts)
# Keep last next_offset / view_node_count / max_nodes from final chunk.
base["ok"] = all(bool(p.get("ok", True)) for p in parts) and not any(
str(p.get("error") or "") for p in parts
)
return base
def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Place existing fabric nodes on a view — prefer server-side filters over id lists."""
view_id = str(args.get("view_id") or "").strip()
if not view_id:
return {"ok": False, "error": "view_id_required"}
if args.get("managed_ne_ids") or args.get("ume_ne_ids"):
return {
"ok": False,
"error": "fabric_nodes_only",
"detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.",
}
filters = _filter_fields(args)
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not filters and not fabric_ids:
return {
"ok": False,
"error": "filter_or_fabric_node_ids_required",
"detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.",
}
layout = str(args.get("layout") or "grid").strip() or "grid"
if filters:
body: dict[str, Any] = {
"managed_ne_ids": [],
"ume_ne_ids": [],
"fabric_node_ids": [],
"layout": layout,
"limit": min(2000, max(1, int(args.get("limit") or 500))),
"offset": max(0, int(args.get("offset") or 0)),
"return_graph": False,
**filters,
}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body))
# Explicit ids: chunk to avoid huge payloads.
parts: list[dict[str, Any]] = []
for i in range(0, len(fabric_ids), _CHUNK):
chunk = fabric_ids[i : i + _CHUNK]
body = {
"managed_ne_ids": [],
"ume_ne_ids": [],
"fabric_node_ids": chunk,
"layout": layout,
"return_graph": False,
}
parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)))
return _merge_mutation_summaries(parts)
def _remove_topology_view_nodes(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"}
filters = _filter_fields(args)
ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not filters and not ids:
return {"ok": False, "error": "filter_or_fabric_node_ids_required"}
if filters:
body: dict[str, Any] = {"fabric_node_ids": ids, "return_graph": False, **filters}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes/remove", body=body))
parts: list[dict[str, Any]] = []
for i in range(0, len(ids), _CHUNK):
chunk = ids[i : i + _CHUNK]
parts.append(
_data(
http_json(
"POST",
f"/v1/topology/views/{view_id}/nodes/remove",
body={"fabric_node_ids": chunk, "return_graph": False},
)
)
)
return _merge_mutation_summaries(parts)
def _update_topology_view_positions(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"}
layout = str(args.get("layout") or "").strip().lower()
filters = _filter_fields(args)
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
positions = args.get("positions")
if layout in {"grid", "offset", "stack"}:
body: dict[str, Any] = {
"layout": layout,
"origin_x": float(args.get("origin_x") if args.get("origin_x") is not None else 40),
"origin_y": float(args.get("origin_y") if args.get("origin_y") is not None else 40),
"gap_x": float(args.get("gap_x") if args.get("gap_x") is not None else 180),
"gap_y": float(args.get("gap_y") if args.get("gap_y") is not None else 120),
"cols": int(args.get("cols") or 0),
"dx": float(args.get("dx") or 0),
"dy": float(args.get("dy") or 0),
"fabric_node_ids": fabric_ids,
"return_graph": False,
**filters,
}
return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body=body))
if not isinstance(positions, list) or not positions:
return {
"ok": False,
"error": "layout_or_positions_required",
"detail": "Pass layout=grid|offset|stack with optional filters, or positions[].",
}
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"}
parts: list[dict[str, Any]] = []
for i in range(0, len(cleaned), _CHUNK):
chunk = cleaned[i : i + _CHUNK]
parts.append(
_data(
http_json(
"PATCH",
f"/v1/topology/views/{view_id}/positions",
body={"positions": chunk, "return_graph": False},
)
)
)
return _merge_mutation_summaries(parts)
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"}
body: dict[str, Any] = {}
seeds = args.get("seed_fabric_node_ids") or args.get("fabric_node_ids") or []
if isinstance(seeds, list) and seeds:
body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()]
mids = args.get("managed_ne_ids") or []
if isinstance(mids, list) and mids:
body["managed_ne_ids"] = [str(x).strip() for x in mids if str(x).strip()]
return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body=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; create region with createTopologyFolder if missing.",
"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": "createTopologyFolder",
"description": (
"Create a region folder under the topology root. Returns folder id for createTopologyView. "
"Does not create a canvas — call createTopologyView next. Requires ne:write."
),
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Region display name"},
"parent_id": {
"type": "string",
"description": "Optional; must be topology root id (default: root)",
},
"sort_order": {"type": "integer", "default": 0},
},
"required": ["name"],
"additionalProperties": False,
},
},
{
"name": "createTopologyView",
"description": "Create a topology canvas under a folder (folder_id from getTopologyTree or createTopologyFolder).",
"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": (
"Bulk-place existing fabric nodes on a view. Prefer server filters "
"(keyword/role/vendor/link_status + limit/offset); API selects ids — do not pull then re-send huge id lists. "
"Returns a summary (added/truncated/next_offset). Canvas hard cap 2000. Never pass managed/UME ids."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"keyword": {"type": "string"},
"role": {"type": "string"},
"vendor": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 500},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}},
"layout": {"type": "string", "enum": ["grid", "keep"], "default": "grid"},
},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "removeTopologyViewNodes",
"description": (
"Remove placements from a view (not fabric). Prefer filters (keyword/role/vendor/link_status) "
"so the API selects matches; or pass fabric_node_ids. Returns a summary."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"keyword": {"type": "string"},
"role": {"type": "string"},
"vendor": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}},
},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "updateTopologyViewPositions",
"description": (
"Move nodes on a view. Prefer layout=grid|offset|stack with optional filters "
"(API selects matches and computes coords). Use positions[] only for small manual tweaks. Returns a summary."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"layout": {"type": "string", "enum": ["grid", "offset", "stack"]},
"keyword": {"type": "string"},
"role": {"type": "string"},
"vendor": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}},
"origin_x": {"type": "number", "default": 40},
"origin_y": {"type": "number", "default": 40},
"gap_x": {"type": "number", "default": 180},
"gap_y": {"type": "number", "default": 120},
"cols": {"type": "integer", "minimum": 0, "default": 0},
"dx": {"type": "number", "default": 0},
"dy": {"type": "number", "default": 0},
"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,
},
},
},
"required": ["view_id"],
"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. "
"Optional seed_fabric_node_ids / managed_ne_ids limit expansion to those seeds; "
"omit to expand from every node on the view."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"seed_fabric_node_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Optional fabric node ids already on the view to expand from",
},
"managed_ne_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Optional managed NE ids to resolve to on-view fabric seeds",
},
},
"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,
"createTopologyFolder": _create_topology_folder,
"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",
"createTopologyFolder": "ne:write",
"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 {})))