mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 15:23:21 +08:00
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>
679 lines
27 KiB
Python
679 lines
27 KiB
Python
"""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 {})))
|