netx/netx_api/mcp_remote/topology/http_tools.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

4427 lines
175 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
import sys
from typing import Any, Callable
from netx_api.mcp_remote.asgi_client import http_json, http_json_many, mcp_from_handler_result
from netx_api.mcp_remote.topology.layout_jobs import (
cancel_job,
job_public,
raise_if_cancelled,
report_progress,
start_job,
)
from netx_api.mcp_remote.topology.layout_sight import build_sight
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog, run_layout_on_graph
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)
# HTTP 200 can carry a failed mutation (for example membership_max_nodes).
merged.setdefault("ok", True)
if merged.get("ok") is False and not merged.get("error"):
merged["error"] = "netx_operation_failed"
return merged
return {"ok": True, "data": data}
_FABRIC_KEEP = (
"id",
"name",
"ip",
"vendor",
"device_type",
"level",
"role",
"link_status",
"region_folder_id",
"managed_ne_id",
"ume_ne_id",
)
def _compact_fabric_item(item: dict[str, Any]) -> dict[str, Any]:
"""Drop bulky attrs / coordinate noise for agent context."""
out = {k: item.get(k) for k in _FABRIC_KEEP if k in item}
views = item.get("views")
if isinstance(views, list):
slim: list[dict[str, Any]] = []
for v in views[:12]:
if not isinstance(v, dict):
continue
slim.append(
{
"view_id": v.get("view_id") or v.get("id") or "",
"view_name": v.get("view_name") or v.get("name") or "",
"folder_name": v.get("folder_name") or "",
"kind": v.get("kind") or "",
}
)
if slim:
out["views"] = slim
if len(views) > 12:
out["views_truncated"] = True
return out
def _compact_fabric_page(payload: dict[str, Any]) -> dict[str, Any]:
if not payload.get("ok"):
return payload
items = payload.get("items")
if not isinstance(items, list):
return payload
out = dict(payload)
out["items"] = [_compact_fabric_item(x) if isinstance(x, dict) else x for x in items]
return out
def _compact_folder_node(node: dict[str, Any], *, depth: int, max_depth: int | None) -> dict[str, Any]:
views_in = [v for v in (node.get("views") or []) if isinstance(v, dict)]
views = [
{
"id": v.get("id") or "",
"name": v.get("name") or "",
"kind": v.get("kind") or "",
"node_count": v.get("node_count"),
}
for v in views_in
]
out: dict[str, Any] = {
"id": node.get("id") or "",
"parent_id": node.get("parent_id") or "",
"kind": node.get("kind") or "",
"name": node.get("name") or "",
"ne_count": int(node.get("ne_count") or 0),
"is_system": bool(node.get("is_system")),
"views": views,
}
children_in = [c for c in (node.get("children") or []) if isinstance(c, dict)]
if max_depth is not None and depth >= max_depth:
if children_in:
out["children_truncated"] = len(children_in)
out["children"] = []
else:
out["children"] = []
return out
out["children"] = [
_compact_folder_node(c, depth=depth + 1, max_depth=max_depth) for c in children_in
]
return out
def _edge_endpoints(edge: dict[str, Any]) -> tuple[str, str]:
a = str(edge.get("a_node_id") or edge.get("a") or "").strip()
b = str(edge.get("b_node_id") or edge.get("b") or "").strip()
return a, b
def _collapse_edges_to_links(
edges: list[dict[str, Any]],
*,
include_names: bool = False,
) -> list[dict[str, Any]]:
"""Collapse parallel port-level edges to one undirected NE↔NE link + link_count.
Canvas drawing only needs existence (and optionally multiplicity), not ports/labels.
"""
buckets: dict[tuple[str, str], dict[str, Any]] = {}
for e in edges:
if not isinstance(e, dict):
continue
a, b = _edge_endpoints(e)
if not a or not b or a == b:
continue
key = (a, b) if a < b else (b, a)
row = buckets.get(key)
if row is None:
lo, hi = key
row = {"a_node_id": lo, "b_node_id": hi, "link_count": 0}
if include_names:
# Prefer names matching the canonical endpoint order.
if a == lo:
row["a_name"] = str(e.get("a_name") or "")
row["b_name"] = str(e.get("b_name") or "")
else:
row["a_name"] = str(e.get("b_name") or "")
row["b_name"] = str(e.get("a_name") or "")
buckets[key] = row
row["link_count"] = int(row["link_count"]) + 1
return sorted(buckets.values(), key=lambda r: (r["a_node_id"], r["b_node_id"]))
def _summarize_view_graph(graph: dict[str, Any], *, sample: int = 20) -> dict[str, Any]:
"""Shrink getTopologyView / projectNeighbors payloads for agents."""
view = graph.get("view") if isinstance(graph.get("view"), dict) else {}
nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)]
peers = graph.get("outside_peers") if isinstance(graph.get("outside_peers"), list) else []
sample_n = max(0, min(200, int(sample)))
links = _collapse_edges_to_links(edges)
return {
"ok": True,
"detail": "summary",
"view": {
"id": view.get("id") or graph.get("view_id") or "",
"name": view.get("name") or "",
"folder_id": view.get("folder_id") or "",
"kind": view.get("kind") or "",
"node_count": int(view.get("node_count") or len(nodes)),
},
"node_count": len(nodes),
"edge_count": len(edges),
"link_count": len(links),
"links": links,
"truncated": bool(graph.get("truncated")),
"truncate_reason": str(graph.get("truncate_reason") or ""),
"outside_peer_count": len(peers),
"sample_nodes": [
{
"fabric_node_id": n.get("fabric_node_id") or n.get("id") or "",
"name": n.get("name") or n.get("label") or "",
"ip": n.get("ip") or "",
"x": n.get("x"),
"y": n.get("y"),
}
for n in nodes[:sample_n]
],
"hint": (
"links[] = undirected NE pairs (one canvas edge each); edge_count is raw port-level. "
"Use sample>=node_count for full id/name coverage when laying out. "
"Place from links[] + names. detail=full only for every membership field."
),
}
def _get_topology_tree(args: dict[str, Any]) -> dict[str, Any]:
out = _data(http_json("GET", "/v1/topology/tree"))
if not out.get("ok"):
return out
compact = str(args.get("compact") if args.get("compact") is not None else "true").strip().lower()
if compact in {"0", "false", "no", "full"}:
return out
max_depth_raw = args.get("max_depth")
max_depth: int | None = None
if max_depth_raw is not None and str(max_depth_raw).strip() != "":
try:
max_depth = max(0, int(max_depth_raw))
except (TypeError, ValueError):
max_depth = None
root = out.get("root") if isinstance(out.get("root"), dict) else None
if root is None:
return out
return {
"ok": True,
"compact": True,
"max_depth": max_depth,
"root": _compact_folder_node(root, depth=0, max_depth=max_depth),
}
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"}
out = _data(http_json("GET", f"/v1/topology/views/{view_id}"))
if not out.get("ok"):
return out
detail = str(args.get("detail") or "summary").strip().lower()
if detail not in {"summary", "full"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
if detail == "full":
out["detail"] = "full"
return out
sample = int(args["sample"]) if args.get("sample") is not None else 20
return _summarize_view_graph(out, sample=sample)
def _collect_physical_views(
node: dict[str, Any] | None,
*,
out: list[dict[str, Any]],
max_views: int,
) -> None:
if not isinstance(node, dict) or len(out) >= max_views:
return
for v in node.get("views") or []:
if not isinstance(v, dict):
continue
if str(v.get("kind") or "").strip().lower() not in {"", "physical"}:
continue
vid = str(v.get("id") or "").strip()
if not vid:
continue
out.append(
{
"view_id": vid,
"view_name": str(v.get("name") or ""),
"folder_id": str(node.get("id") or ""),
"folder_name": str(node.get("name") or ""),
"ne_count": int(node.get("ne_count") or v.get("node_count") or 0),
}
)
if len(out) >= max_views:
return
for child in node.get("children") or []:
if isinstance(child, dict):
_collect_physical_views(child, out=out, max_views=max_views)
def _analyze_one_view(
view_id: str,
*,
with_meta: bool = False,
detail: str = "summary",
sight_limit: int = 40,
sight_cell: float = 600.0,
score_profile: str = "auto",
) -> dict[str, Any]:
graph = _data(http_json("GET", f"/v1/topology/views/{view_id}"))
if not graph.get("ok"):
return {"ok": False, "view_id": view_id, "error": graph.get("error") or "view_fetch_failed"}
nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)]
view = graph.get("view") if isinstance(graph.get("view"), dict) else {}
stats = analyze_layout_stats(
nodes, edges, with_meta=with_meta, score_profile=score_profile
)
report = dict(stats.get("report") or {})
out: dict[str, Any] = {
"ok": True,
"view_id": view_id,
"view_name": str(view.get("name") or ""),
"folder_id": str(view.get("folder_id") or ""),
**report,
# Compact one-liner for agents / folder samples
"summary": stats.get("summary") or {},
}
# detail=structure → graph gravity / layer attachment (phase 0.5, no coords needed)
# detail=hotspots|blocks|both → agent "sight" for hand-drag (phase 2)
d = (detail or "summary").strip().lower()
if d in {"structure", "gravity", "plan", "both", "all"}:
try:
hub_top_k = max(4, min(40, int(sight_limit or 12)))
except (TypeError, ValueError):
hub_top_k = 12
out["structure"] = analyze_graph_structure(
nodes, edges, hub_top_k=hub_top_k, stub_top_k=max(12, hub_top_k * 2)
)
if d in {"hotspots", "blocks", "both", "all", "sight"}:
mode = "both" if d in {"both", "all", "sight"} else d
out["sight"] = build_sight(
nodes, edges, mode=mode, limit=max(5, min(80, int(sight_limit))), cell=float(sight_cell)
)
# Prefer exact crossing count from score metrics when present.
metrics = (stats.get("metrics") or {}) if isinstance(stats, dict) else {}
if isinstance(out.get("sight"), dict) and isinstance(out["sight"].get("hotspots"), dict):
if metrics.get("edge_crossings") is not None:
out["sight"]["hotspots"]["edge_crossings"] = metrics.get("edge_crossings")
elif isinstance(report.get("crossing"), dict):
out["sight"]["hotspots"]["edge_crossings"] = report["crossing"].get(
"edge_crossings"
)
return out
def _patch_positions_chunked(view_id: str, positions: list[dict[str, Any]]) -> dict[str, Any]:
updated = 0
chunks = 0
# Larger chunks cut round-trips on 1k+ node canvases (Cursor MCP ~60s budget).
step = 250
total = len(positions)
for i in range(0, total, step):
raise_if_cancelled()
chunk = positions[i : i + step]
report_progress(
"apply",
pct=90.0 + 9.0 * (i / max(total, 1)),
message=f"PATCH {i + len(chunk)}/{total}",
step=i + len(chunk),
total_steps=total,
)
out = _data(
http_json(
"PATCH",
f"/v1/topology/views/{view_id}/positions",
body={"positions": chunk, "return_graph": False},
timeout=180.0,
)
)
if not out.get("ok"):
return {
"ok": False,
"error": out.get("error") or "positions_patch_failed",
"updated": updated,
"detail": out,
}
updated += int(out.get("updated", len(chunk)))
chunks += 1
return {"ok": True, "updated": updated, "chunks": chunks}
def _slim_layout_payload(out: dict[str, Any]) -> dict[str, Any]:
"""Trim giant layout responses so MCP JSON stays under host timeouts."""
n = int(out.get("node_count") or 0)
action = str(out.get("action") or "")
heavy = n >= 400 or action in {
"polish_crossings",
"orbit_sweep",
"clear_edge_hits",
"layout_dual_unit",
}
if not heavy:
return out
slim = dict(out)
slim.pop("guide", None)
slim.pop("tried", None)
slim.pop("params_used", None)
# Keep summary + compact crossing tops; drop bulky sub-reports.
for k in ("spacing", "sparsity", "edges", "chains", "rings", "size"):
if k in slim and isinstance(slim[k], dict):
slim[k] = {
kk: slim[k].get(kk)
for kk in ("status", "score", "edge_crossings", "space_utilization", "nn_p50")
if kk in slim[k]
} or slim[k]
crossing = slim.get("crossing")
if isinstance(crossing, dict):
slim["crossing"] = {
"status": crossing.get("status"),
"score": crossing.get("score"),
"edge_crossings": crossing.get("edge_crossings"),
"crossings_per_link": crossing.get("crossings_per_link"),
"top_nodes": (crossing.get("top_nodes") or [])[:5],
"top_edges": (crossing.get("top_edges") or [])[:5],
}
local = slim.get("local")
if isinstance(local, dict):
meta = local.get("meta")
if isinstance(meta, dict):
# Drop bulky slot_meta / origins from compose echo.
keep_meta = {
kk: meta.get(kk)
for kk in (
"slots",
"nodes",
"pad",
"merge_shared",
"order_mode",
"pack_mode",
"tip",
)
if kk in meta
}
# Keep rigid_groups keys only (ids stay in compose_session).
rg = meta.get("rigid_groups")
if isinstance(rg, list):
keep_meta["rigid_groups_n"] = len(rg)
local = {**local, "meta": keep_meta}
slim["local"] = local
slim["slim"] = True
return slim
def _truthy(v: Any) -> bool:
if isinstance(v, bool):
return v
return str(v or "").strip().lower() in {"1", "true", "yes", "on"}
def _fetch_views_parallel(view_ids: list[str]) -> dict[str, dict[str, Any]]:
"""GET many views in parallel; return map view_id → payload (_data shape)."""
uniq: list[str] = []
seen: set[str] = set()
for vid in view_ids:
s = str(vid or "").strip()
if s and s not in seen:
seen.add(s)
uniq.append(s)
if not uniq:
return {}
reqs = [
{"method": "GET", "path": f"/v1/topology/views/{sid}", "key": sid}
for sid in uniq
]
print(f"[netx-topology] parallel GET views n={len(reqs)}", file=sys.stderr, flush=True)
raw = http_json_many(reqs, max_workers=min(16, len(reqs)), timeout=120.0)
out: dict[str, dict[str, Any]] = {}
for sid, envelope in zip(uniq, raw):
out[sid] = _data(envelope)
return out
def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"""Generic layout / local polish: preview or apply onto a canvas."""
if str(args.get("catalog") or "").strip().lower() in {"1", "true", "yes"} or (
args.get("catalog") is True
):
return {"ok": True, **list_layout_catalog()}
action = str(args.get("action") or "layout").strip().lower() or "layout"
overrides0 = args.get("params") if isinstance(args.get("params"), dict) else {}
# Public surface only (legacy modules may remain in-tree but are not exposed).
_public = {
"layout",
"fix_overlaps",
"untangle",
"straighten_channels",
"layout_dual_unit",
"polish_crossings",
"clear_edge_hits",
"compact_bbox",
"pull_far_chains",
"align_reference",
"orbit_sweep",
"level_bands",
"move_nodes",
"job_status",
"job_cancel",
}
if action not in _public:
return {
"ok": False,
"error": f"unknown_action:{action}",
"hint": (
"Public actions: layout|layout_dual_unit|move_nodes|orbit_sweep|"
"level_bands|polish_crossings|clear_edge_hits|compact_bbox|"
"pull_far_chains|align_reference|"
"fix_overlaps|untangle|straighten_channels|job_status|job_cancel. "
"Main path: sinkTopologyDualUnits(max_units=1) → "
"move_nodes(park) → until_limit → clear → pull_far_chains → "
"compact_bbox → hand. Default: no template. "
"align_reference = same-network debug only, not delivery. "
"Eye sinks: no polish/fix_overlaps/round."
),
**list_layout_catalog(),
}
# Bidirectional membership move: view_id=TO, source_view_id=FROM.
if action == "move_nodes":
return _move_topology_view_nodes(args)
# Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM.
if action == "align_reference":
return _align_topology_reference(args)
# Background job poll / cancel (no view_id required).
if action in {"job_status", "job_cancel"}:
job_id = str(
overrides0.get("job_id") or args.get("job_id") or ""
).strip()
if not job_id:
return {
"ok": False,
"error": "job_id_required",
"hint": "params.job_id from polish/orbit/layout_dual_unit background start.",
}
if action == "job_cancel":
cancelled = cancel_job(job_id)
# Return public snapshot after arming cancel.
snap = job_public(job_id) or {}
return {
**cancelled,
"action": "job_cancel",
"progress": snap.get("progress"),
"elapsed_ms": snap.get("elapsed_ms"),
"heartbeat_age_ms": snap.get("heartbeat_age_ms"),
}
job = job_public(job_id)
if not job:
return {
"ok": False,
"error": "job_not_found",
"job_id": job_id,
"hint": (
"Unknown job_id. Poll from a worker sharing NETX_LAYOUT_JOB_DIR; "
"records survive MCP restarts but may have been pruned."
),
}
status = str(job.get("status") or "unknown")
out: dict[str, Any] = {
"ok": True,
"action": "job_status",
"job_id": job_id,
"status": status,
"view_id": job.get("view_id"),
"job_action": job.get("action"),
"started_at": job.get("started_at"),
"finished_at": job.get("finished_at"),
"elapsed_ms": job.get("elapsed_ms"),
"heartbeat_at": job.get("heartbeat_at"),
"heartbeat_age_ms": job.get("heartbeat_age_ms"),
"stale": bool(job.get("stale")),
"cancel_requested": bool(job.get("cancel_requested")),
"progress": job.get("progress") or {},
"meta": job.get("meta"),
}
if status in {"running", "cancelling"}:
prog = out["progress"] if isinstance(out["progress"], dict) else {}
phase = prog.get("phase") or status
pct = prog.get("pct")
pct_s = f" {pct:.0f}%" if isinstance(pct, (int, float)) else ""
if bool(out.get("stale")):
out["hint"] = (
f"WARNING stale=true (no heartbeat ≥90s). "
f"Last phase={phase}{pct_s}; elapsed_ms={out['elapsed_ms']}. "
"job_cancel to arm cooperative stop; restart MCP if it never ends."
)
else:
out["hint"] = (
f"Running phase={phase}{pct_s}; elapsed_ms={out['elapsed_ms']}; "
f"heartbeat_age_ms={out['heartbeat_age_ms']}. "
"Re-poll job_status; job_cancel to request cooperative stop."
)
return out
result = job.get("result")
if isinstance(result, dict):
out["result"] = _slim_layout_payload(result)
out["ok"] = bool(result.get("ok")) and status == "done"
if not out["ok"]:
out["error"] = result.get("error") or job.get("error")
elif status == "error":
out["ok"] = False
out["error"] = job.get("error") or "job_failed"
elif status == "cancelled":
out["ok"] = False
out["error"] = "cancelled"
return out
view_id = str(args.get("view_id") or "").strip()
if not view_id:
return {
"ok": False,
"error": "view_id_required",
"hint": "Pass view_id, or catalog=true for recipe/preset list.",
**list_layout_catalog(),
}
# Local polish always reads current target coords (ignore foreign source)
source_id = str(args.get("source_view_id") or "").strip() or view_id
if action in {
"fix_overlaps",
"untangle",
"straighten_channels",
"layout_dual_unit",
"polish_crossings",
"clear_edge_hits",
"compact_bbox",
"pull_far_chains",
"orbit_sweep",
}:
source_id = view_id
recipe = str(args.get("recipe") or "rings").strip().lower() or "rings"
preset = str(args.get("preset") or "balanced").strip().lower() or "balanced"
mode = str(args.get("mode") or "preview").strip().lower() or "preview"
if mode not in {"preview", "apply"}:
return {"ok": False, "error": "mode_invalid", "hint": "mode=preview|apply"}
tune = str(args.get("tune") or "").strip().lower() in {"1", "true", "yes"} or (
args.get("tune") is True
)
overrides = args.get("params") if isinstance(args.get("params"), dict) else None
if overrides is not None:
overrides = dict(overrides)
# compose_views / compose_orbit: load staging canvases, inject blocks.
if action in {"compose_views", "compose_orbit"}:
from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides
knobs = compose_params_from_overrides(overrides)
src_ids = list(knobs.get("source_view_ids") or [])
if not src_ids:
return {
"ok": False,
"error": "source_view_ids_required",
"hint": "params.source_view_ids=[...] staging view ids to compose.",
}
# Giant compose often exceeds Cursor MCP ~60s tool timeout — run async
# unless caller forces sync (background job / params.sync=true).
force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
(overrides or {}).get("_force_sync")
)
want_bg = _truthy((overrides or {}).get("background")) or (
mode == "apply" and len(src_ids) >= 12 and not force_sync
)
if want_bg and mode == "apply":
sync_args = dict(args)
sync_params = dict(overrides or {})
sync_params["_force_sync"] = True
sync_params.pop("background", None)
sync_args["params"] = sync_params
sync_args["mode"] = "apply"
sync_args["action"] = action
job_id = start_job(
action=action,
view_id=view_id,
tool_args=sync_args,
meta={"source_n": len(src_ids)},
)
print(
f"[netx-topology] {action} background job_id={job_id} "
f"sources={len(src_ids)}",
file=sys.stderr,
flush=True,
)
return {
"ok": True,
"action": action,
"view_id": view_id,
"mode": "apply",
"applied": False,
"status": "running",
"job_id": job_id,
"source_count": len(src_ids),
"hint": (
f"{action} durable worker job_id={job_id}. "
"Poll job_status (progress/heartbeat); survives MCP restart."
),
}
report_progress(
"fetch_sources",
pct=5.0,
message=f"loading {len(src_ids)} staging views",
step=0,
total_steps=len(src_ids),
)
raise_if_cancelled()
fetched = _fetch_views_parallel(src_ids)
report_progress(
"fetch_sources",
pct=20.0,
message=f"loaded {len(fetched)} staging views",
step=len(src_ids),
total_steps=len(src_ids),
)
compose_blocks: list[dict[str, Any]] = []
for sid in src_ids:
sg = fetched.get(sid) or {}
if not sg.get("ok"):
return {
"ok": False,
"error": "compose_source_fetch_failed",
"source_view_id": sid,
"detail": sg.get("error"),
}
pos_map: dict[str, list[float]] = {}
for n in sg.get("nodes") or []:
if not isinstance(n, dict):
continue
nid = str(n.get("fabric_node_id") or "").strip()
if not nid:
continue
try:
pos_map[nid] = [float(n.get("x") or 0), float(n.get("y") or 0)]
except (TypeError, ValueError):
continue
if pos_map:
compose_blocks.append({"key": sid, "positions": pos_map})
if len(compose_blocks) < 1:
return {"ok": False, "error": "compose_sources_empty"}
overrides = overrides or {}
overrides["source_view_ids"] = src_ids
overrides["_compose_blocks"] = compose_blocks
for k in (
"pad",
"merge_shared",
"fabric_bridges",
"bridge_boost",
"ideal_scale",
"spring_iters",
):
if k in knobs:
overrides[k] = knobs[k]
# Staging may have emptied the target — restore members before scoring.
if mode == "apply":
all_ids: list[str] = []
seen_ids: set[str] = set()
for blk in compose_blocks:
for nid in (blk.get("positions") or {}):
sid = str(nid)
if sid and sid not in seen_ids:
seen_ids.add(sid)
all_ids.append(sid)
dst0 = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0))
have = {
str(n.get("fabric_node_id") or "")
for n in (dst0.get("nodes") or [])
if isinstance(n, dict)
}
missing0 = [i for i in all_ids if i not in have]
if missing0:
add_back0 = _add_topology_view_nodes(
{
"view_id": view_id,
"fabric_node_ids": missing0,
"layout": "keep",
}
)
overrides["_restored_early"] = int(
add_back0.get("added") or len(missing0)
)
# Staging membership → portal freeze for polish / untangle.
# orbit_sweep intentionally omits auto portal freeze (default protect_rigid=off).
if action in {
"untangle",
"polish_crossings",
}:
from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides
knobs = compose_params_from_overrides(overrides)
src_ids = list(knobs.get("source_view_ids") or [])
overrides = overrides or {}
# Prefer session echo from compose (skip 77× GET).
sess = overrides.get("compose_session")
if isinstance(sess, dict):
if isinstance(sess.get("portal_ids"), list) and "portal_ids" not in overrides:
overrides["portal_ids"] = [str(x) for x in sess["portal_ids"] if str(x)]
if isinstance(sess.get("mass_groups"), list) and "mass_groups" not in overrides:
overrides["mass_groups"] = sess["mass_groups"]
if isinstance(sess.get("rigid_groups"), list) and "_rigid_membership" not in overrides:
membership = []
for g in sess["rigid_groups"]:
if not isinstance(g, dict):
continue
key = str(g.get("key") or "").strip()
ids = [str(x) for x in (g.get("node_ids") or []) if str(x)]
pivots = [str(x) for x in (g.get("pivots") or []) if str(x)]
if key and len(ids) >= 2:
membership.append(
{"key": key, "node_ids": ids, "pivots": pivots}
)
if membership:
overrides["_rigid_membership"] = membership
src_ids = [] # skip re-fetch
if src_ids and "_rigid_membership" not in overrides:
# polish with only portal_ids: skip membership fetch
if (
action == "polish_crossings"
and isinstance(overrides.get("portal_ids"), list)
and overrides.get("portal_ids")
):
overrides["source_view_ids"] = list(
knobs.get("source_view_ids") or src_ids
)
else:
fetched = _fetch_views_parallel(src_ids)
membership = []
counts: dict[str, int] = {}
for sid in src_ids:
sg = fetched.get(sid) or {}
if not sg.get("ok"):
return {
"ok": False,
"error": "rigid_source_fetch_failed",
"source_view_id": sid,
"detail": sg.get("error"),
}
ids = [
str(n.get("fabric_node_id") or "")
for n in (sg.get("nodes") or [])
if isinstance(n, dict)
and n.get("fabric_node_id")
and not str(n.get("fabric_node_id")).startswith("region:")
]
for nid in ids:
counts[nid] = counts.get(nid, 0) + 1
if ids:
membership.append({"key": sid, "node_ids": ids, "pivots": []})
shared = {nid for nid, c in counts.items() if c > 1}
for row in membership:
row["pivots"] = sorted(n for n in row["node_ids"] if n in shared)
overrides["source_view_ids"] = src_ids
overrides["_rigid_membership"] = membership
if shared and "portal_ids" not in overrides:
overrides["portal_ids"] = sorted(shared)
report_progress("load_canvas", pct=25.0, message=f"GET view {source_id[:12]}…")
raise_if_cancelled()
graph = _data(http_json("GET", f"/v1/topology/views/{source_id}", timeout=120.0))
if not graph.get("ok"):
return {
"ok": False,
"error": graph.get("error") or "source_view_fetch_failed",
"source_view_id": source_id,
}
nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)]
if len(nodes) < 2:
return {"ok": False, "error": "too_few_nodes", "node_count": len(nodes)}
report_progress(
"load_canvas",
pct=30.0,
message=f"canvas n={len(nodes)} e={len(edges)}",
nodes=len(nodes),
edges=len(edges),
)
# Giant-canvas polish/untangle often exceeds host ~60s — background apply.
force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
(overrides or {}).get("_force_sync")
)
until_limit = _truthy((overrides or {}).get("until_limit"))
heavy_bg = action in {
"polish_crossings",
"orbit_sweep",
"clear_edge_hits",
"compact_bbox",
"pull_far_chains",
"layout_dual_unit",
}
# until_limit loops many orbit sweeps — background earlier than one-shot polish.
bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600
if (
mode == "apply"
and not force_sync
and (
(heavy_bg and len(nodes) >= bg_n_thr)
or _truthy((overrides or {}).get("background"))
or (until_limit and action == "orbit_sweep")
)
):
sync_args = dict(args)
sync_params = dict(overrides or {})
sync_params["_force_sync"] = True
# Keep already-resolved membership so the worker skips re-fetch.
if overrides and overrides.get("_rigid_membership"):
sync_params["_rigid_membership"] = overrides["_rigid_membership"]
if overrides and overrides.get("portal_ids"):
sync_params["portal_ids"] = overrides["portal_ids"]
sync_params.pop("background", None)
sync_args["params"] = sync_params
sync_args["mode"] = "apply"
sync_args["action"] = action
sync_args["view_id"] = view_id
job_id = start_job(
action=action,
view_id=view_id,
tool_args=sync_args,
meta={"node_count": len(nodes), "until_limit": until_limit},
)
started = job_public(job_id)
if started and started.get("status") in {"done", "error", "cancelled"}:
return _layout_topology_view({"action": "job_status", "params": {"job_id": job_id}})
print(
f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}"
+ (" until_limit" if until_limit else ""),
file=sys.stderr,
flush=True,
)
return {
"ok": True,
"action": action,
"view_id": view_id,
"mode": "apply",
"applied": False,
"status": "running",
"job_id": job_id,
"node_count": len(nodes),
"hint": (
f"{action} durable worker job_id={job_id}. "
"Poll job_status (progress/heartbeat); survives MCP restart."
),
}
# orbit_sweep: preview suggests only; apply defaults pick=1 (unless round/until_limit).
if action == "orbit_sweep" and mode == "apply":
overrides = dict(overrides or {})
if (
not overrides.get("round")
and not until_limit
and overrides.get("pick") is None
):
if overrides.get("node_id") or overrides.get("fabric_node_id"):
overrides["pick"] = 1
try:
report_progress(
"compute",
pct=40.0,
message=f"action={action}",
action=action,
)
raise_if_cancelled()
result = run_layout_on_graph(
nodes,
edges,
action=action,
recipe=recipe,
preset=preset,
params=overrides,
tune=tune,
)
report_progress("compute", pct=75.0, message=f"{action} finished")
except ValueError as e:
return {"ok": False, "error": str(e), **list_layout_catalog()}
positions = result.pop("positions", [])
ov = int((result.get("overlap") or {}).get("footprint_pairs") or 0)
lbl = int((result.get("overlap") or {}).get("label_pairs") or 0)
report_progress(
"score",
pct=80.0,
message=f"ov={ov} lbl={lbl} positions={len(positions)}",
overlaps=ov,
label_overlaps=lbl,
positions=len(positions),
)
out = {
**result,
"view_id": view_id,
"source_view_id": source_id,
"mode": mode,
"applied": False,
}
if mode == "preview":
out["hint"] = (
"Preview only. Re-call with mode=apply to PATCH. "
"If overlaps remain: action=fix_overlaps. "
"Main path: until_limit(crossing→total) → clear_edge_hits → "
"pull_far_chains → compact_bbox → hand. "
"Eye sinks: do not polish_crossings / fix_overlaps / round."
)
return _slim_layout_payload(out)
# Dual-unit: default accepts residual crossings (max-membership CN eyes).
# Optional hard gate: params.require_zero_cross=true.
if action == "layout_dual_unit":
loc = result.get("local") or {}
require_zero = bool((overrides or {}).get("require_zero_cross"))
if require_zero and not loc.get("accepted", False):
return {
**out,
"ok": False,
"error": "dual_unit_crossings",
"hint": (
"require_zero_cross=true but unit-internal crossings≠0. "
"Omit the flag (default) to apply best-effort eye layout, "
"or shrink membership / re-pick portals."
),
"local": loc,
}
elif ov > 0 or lbl > 0:
return _slim_layout_payload(
{
**out,
"ok": False,
"error": "overlaps_remain",
"hint": (
"Refusing apply while footprints/labels overlap. "
"Call layoutTopologyView with action=fix_overlaps, mode=apply."
),
}
)
# Local polish must not silently worsen the canvas.
if action in {
"untangle",
"straighten_channels",
}:
before_x = int(
(analyze_layout_stats(nodes, edges).get("summary") or {}).get("crossings") or 0
)
after_x = int((result.get("crossing") or {}).get("edge_crossings") or 0)
slack = 0 if action == "straighten_channels" else max(40, int(before_x * 0.15))
if after_x > before_x + slack:
return {
**out,
"ok": False,
"error": "crossing_regression",
"before_crossings": before_x,
"after_crossings": after_x,
"slack": slack,
"hint": (
"Refusing apply: local polish raised crossings. "
"Keep current coords; preview first, or try orbit_sweep → "
"polish_crossings → clear_edge_hits, then surgical "
"updateTopologyViewPositions."
),
}
# compose_*: staging may have emptied the target — restore members first.
restored = 0
if action in {"compose_views", "compose_orbit"}:
dst = _data(http_json("GET", f"/v1/topology/views/{view_id}"))
if not dst.get("ok"):
return {"ok": False, "error": dst.get("error") or "target_view_fetch_failed", "view_id": view_id}
dst_ids = {
str(n.get("fabric_node_id") or "")
for n in (dst.get("nodes") or [])
if isinstance(n, dict)
}
missing = [
str(p.get("fabric_node_id") or "")
for p in positions
if str(p.get("fabric_node_id") or "") and str(p.get("fabric_node_id")) not in dst_ids
]
if missing:
add_back = _add_topology_view_nodes(
{
"view_id": view_id,
"fabric_node_ids": missing,
"layout": "keep",
}
)
if not add_back.get("ok") and add_back.get("error"):
# Some APIs return ok via summary fields only — continue if added.
if int(add_back.get("added") or 0) <= 0 and not add_back.get("ok"):
return {
**out,
"ok": False,
"error": "compose_restore_members_failed",
"missing_count": len(missing),
"detail": add_back,
}
restored = int(add_back.get("added") or len(missing))
out["restored_members"] = restored
if source_id != view_id:
dst = _data(http_json("GET", f"/v1/topology/views/{view_id}"))
if not dst.get("ok"):
return {"ok": False, "error": dst.get("error") or "target_view_fetch_failed", "view_id": view_id}
dst_ids = {
str(n.get("fabric_node_id") or "")
for n in (dst.get("nodes") or [])
if isinstance(n, dict)
}
missing = [p["fabric_node_id"] for p in positions if str(p.get("fabric_node_id")) not in dst_ids]
if missing:
return {
"ok": False,
"error": "target_missing_nodes",
"missing_count": len(missing),
"hint": "addTopologyViewNodes onto view_id first (or set source_view_id=view_id).",
"sample_missing": missing[:10],
}
raise_if_cancelled()
report_progress(
"apply",
pct=90.0,
message=f"PATCH {len(positions)} positions",
positions=len(positions),
)
patch = _patch_positions_chunked(view_id, positions)
if not patch.get("ok"):
return _slim_layout_payload(
{**out, "ok": False, "error": patch.get("error"), "patch": patch}
)
out["applied"] = True
out["updated"] = patch.get("updated")
report_progress("done", pct=100.0, message="positions applied")
out["hint"] = (
"Positions applied. Main path: analyze(structure) → "
"sinkTopologyDualUnits(max_units=1) → suggestSinkHubs/move_nodes(park) → "
"orbit_sweep(until_limit crossing→total) → clear_edge_hits → "
"pull_far_chains → compact_bbox → hand drag. "
"Eye sinks: no polish_crossings / fix_overlaps / untangle / round. "
"Plateau when stall + moved≈0. Small graphs: layout(compact|corridor|rings). "
"Large jobs: poll job_status; job_cancel for cooperative stop."
)
return _slim_layout_payload(out)
def _view_membership_ids(view_id: str) -> tuple[dict[str, Any], set[str], list[dict], list[dict]]:
"""Fetch a view; return (graph, id_set, nodes, edges)."""
graph = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0))
if not graph.get("ok"):
return graph, set(), [], []
nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)]
ids: set[str] = set()
for n in nodes:
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if fid and not fid.startswith("region:"):
ids.add(fid)
return graph, ids, nodes, edges
def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]:
"""Rank hub territories on source for non-dual move_nodes(park) batches."""
from netx_api.mcp_remote.topology.layout_ops.suggest_sink_hubs import (
pick_batch,
suggest_sink_hub_batches,
)
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
source_view_id = str(
args.get("source_view_id") or args.get("view_id") or ""
).strip()
sink_view_id = str(args.get("sink_view_id") or "").strip()
if not source_view_id:
return {
"ok": False,
"error": "source_view_id_required",
"hint": "Pass source_view_id=根图 (post-dual leftovers).",
}
try:
top_n = max(1, min(40, int(args.get("top_n") or 12)))
except (TypeError, ValueError):
top_n = 12
try:
pick = max(1, int(args.get("pick") or 1))
except (TypeError, ValueError):
pick = 1
try:
min_territory = max(0, int(args.get("min_territory") or 1))
except (TypeError, ValueError):
min_territory = 1
try:
min_move_n = max(1, int(args.get("min_move_n") or 1))
except (TypeError, ValueError):
min_move_n = 1
include_hub = str(args.get("include_hub") if args.get("include_hub") is not None else "true").lower() not in {
"0",
"false",
"no",
"off",
}
# Source dual portals: default OFF — leftover root eyes (e.g. GHP) still need
# non-dual park batches. Sink dual: default ON but only the primary eye.
use_source_dual = str(
args.get("use_source_dual_portals")
if args.get("use_source_dual_portals") is not None
else "false"
).lower() not in {"0", "false", "no", "off"}
use_sink_dual = str(
args.get("use_sink_dual_portals")
if args.get("use_sink_dual_portals") is not None
else ("true" if sink_view_id else "false")
).lower() not in {"0", "false", "no", "off"}
only_layers = args.get("only_layers") or args.get("layers")
if isinstance(only_layers, str):
only_layers = [p.strip() for p in only_layers.replace(";", ",").split(",") if p.strip()]
elif not isinstance(only_layers, list):
only_layers = ["agg", "core"]
exclude: set[str] = set()
raw_ex = args.get("exclude_portal_ids") or args.get("exclude_ids") or args.get("portal_ids")
if isinstance(raw_ex, (list, tuple, set)):
exclude |= {str(x).strip() for x in raw_ex if str(x).strip()}
elif isinstance(raw_ex, str) and raw_ex.strip():
exclude |= {
p.strip()
for p in raw_ex.replace(";", ",").split(",")
if p.strip()
}
src_graph, src_ids, src_nodes, src_edges = _view_membership_ids(source_view_id)
if not src_graph.get("ok"):
return {
"ok": False,
"error": src_graph.get("error") or "source_view_fetch_failed",
"source_view_id": source_view_id,
}
if len(src_ids) < 2:
return {
"ok": False,
"error": "source_too_few_nodes",
"source_view_id": source_view_id,
"node_count": len(src_ids),
}
sink_ids: set[str] = set()
sink_dual: dict[str, Any] | None = None
if sink_view_id:
sink_graph, sink_ids, sink_nodes, sink_edges = _view_membership_ids(sink_view_id)
if not sink_graph.get("ok"):
return {
"ok": False,
"error": sink_graph.get("error") or "sink_view_fetch_failed",
"sink_view_id": sink_view_id,
}
if use_sink_dual:
sink_struct = analyze_graph_structure(sink_nodes, sink_edges, hub_top_k=8)
sink_dual = sink_struct.get("dual_units") if isinstance(sink_struct, dict) else None
struct = analyze_graph_structure(
src_nodes, src_edges, hub_top_k=max(12, top_n * 2), stub_top_k=40
)
dual = struct.get("dual_units") if use_source_dual else None
# Merge dual portals into exclude via dual_units arg.
# Sink: only the largest unit (the fixed eye) — nested eyes must not
# blanket-exclude every AN hub still holding root stubs.
merged_dual: dict[str, Any] = {"units": []}
if isinstance(dual, dict):
merged_dual["units"].extend(list(dual.get("units") or [])[:8])
if isinstance(sink_dual, dict):
sink_units = [u for u in (sink_dual.get("units") or []) if isinstance(u, dict)]
if sink_units:
best = max(sink_units, key=lambda u: int(u.get("node_count") or 0))
merged_dual["units"].append(best)
report = suggest_sink_hub_batches(
hubs=list(struct.get("hubs") or []),
soft_blocks=struct.get("soft_blocks") if isinstance(struct.get("soft_blocks"), dict) else {},
source_ids=src_ids,
sink_ids=sink_ids,
exclude_ids=exclude,
dual_units=merged_dual if merged_dual["units"] else None,
min_territory=min_territory,
min_move_n=min_move_n,
top_n=top_n,
include_hub=include_hub,
only_layers=[str(x) for x in only_layers] if only_layers else None,
)
batch = pick_batch(report, pick=pick)
src_view = src_graph.get("view") if isinstance(src_graph.get("view"), dict) else {}
out: dict[str, Any] = {
"ok": True,
"source_view_id": source_view_id,
"source_view_name": str(src_view.get("name") or ""),
"sink_view_id": sink_view_id or None,
"pick": pick,
"batch": batch,
**report,
}
if batch:
out["move_nodes"] = {
"action": "move_nodes",
"source_view_id": source_view_id,
"view_id": sink_view_id or "<sink_view_id>",
"mode": "apply",
"params": {
"fabric_node_ids": list(batch.get("fabric_node_ids") or []),
"park": True,
"remove_from_source": True,
},
}
out["hint"] = (
f"Rank#{batch.get('rank')} hub={batch.get('hub_name')} "
f"n={batch.get('remaining_n')}. "
"Call layoutTopologyView with move_nodes payload (park=true). "
"Do not sinkTopologyDualUnits again."
)
else:
out["hint"] = (
"No hub batches left (all territories on sink or excluded). "
"Check leftovers / tiny chains on source."
)
return out
def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]:
"""Map reference canvas geometry onto target via shared fabric_node_ids."""
from netx_api.mcp_remote.topology.layout_ops.align_reference import (
align_reference_params_from_overrides,
align_to_reference,
)
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
view_id = str(args.get("view_id") or "").strip()
overrides = args.get("params") if isinstance(args.get("params"), dict) else {}
knobs = align_reference_params_from_overrides(overrides)
ref_id = (
knobs.get("reference_view_id")
or str(args.get("source_view_id") or "").strip()
or str(args.get("reference_view_id") or "").strip()
)
mode = str(args.get("mode") or "preview").strip().lower() or "preview"
if not view_id:
return {"ok": False, "error": "view_id_required", "hint": "Target canvas to rewrite."}
if not ref_id:
return {
"ok": False,
"error": "reference_view_id_required",
"hint": "Pass source_view_id or params.reference_view_id (hand golden / UME).",
}
if mode not in {"preview", "apply"}:
return {"ok": False, "error": "mode_invalid"}
tgt = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0))
if not tgt.get("ok"):
return {"ok": False, "error": tgt.get("error") or "target_fetch_failed", "view_id": view_id}
ref = _data(http_json("GET", f"/v1/topology/views/{ref_id}", timeout=120.0))
if not ref.get("ok"):
return {
"ok": False,
"error": ref.get("error") or "reference_fetch_failed",
"reference_view_id": ref_id,
}
t_nodes = [n for n in (tgt.get("nodes") or []) if isinstance(n, dict)]
t_edges = [e for e in (tgt.get("edges") or []) if isinstance(e, dict)]
r_nodes = [n for n in (ref.get("nodes") or []) if isinstance(n, dict)]
st = build_state_from_nodes_edges(t_nodes, t_edges)
ref_pos: dict[str, tuple[float, float]] = {}
for n in r_nodes:
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if not fid or n.get("x") is None or n.get("y") is None:
continue
try:
ref_pos[fid] = (float(n["x"]), float(n["y"]))
except (TypeError, ValueError):
continue
op = align_to_reference(
st,
portal_ids=knobs.get("portal_ids") or [],
reference=ref_pos,
mode=knobs.get("mode") or "similarity",
park_missing=bool(knobs.get("park_missing", True)),
freeze_portals=bool(knobs.get("freeze_portals", True)),
)
if op.params.get("error"):
return {"ok": False, **op.params, "note": op.note}
positions = [
{"fabric_node_id": nid, "x": xy[0], "y": xy[1]}
for nid, xy in op.state.positions.items()
if nid in op.moved
]
out: dict[str, Any] = {
"ok": True,
"action": "align_reference",
"view_id": view_id,
"reference_view_id": ref_id,
"mode": mode,
"applied": False,
"local": {"op": op.params, "note": op.note},
"moved_n": len(op.moved),
}
if mode == "preview":
# Score proposed coords without PATCH.
preview_nodes = []
for n in t_nodes:
fid = str(n.get("fabric_node_id") or "").strip()
row = dict(n)
if fid in op.state.positions:
row["x"], row["y"] = op.state.positions[fid]
preview_nodes.append(row)
qa = analyze_layout_stats(preview_nodes, t_edges, score_profile="auto")
out["summary"] = qa.get("summary")
out["hint"] = "Preview only. Re-call mode=apply to PATCH aligned positions."
return out
patch = _patch_positions_chunked(view_id, positions)
if not patch.get("ok"):
return {
**out,
"ok": False,
"error": patch.get("error") or "patch_failed",
"patch": patch,
}
out["applied"] = True
out["updated"] = patch.get("updated")
from_live = _analyze_one_view(view_id, detail="summary", score_profile="auto")
out["summary"] = from_live.get("summary")
out["hint"] = (
"Aligned from reference. If overlaps remain, surgical orbit_sweep; "
"do not global polish on eye sinks."
)
return out
def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]:
"""Unified layout QA: overlap / crossing / spacing / sparsity / edges + score."""
view_id = str(args.get("view_id") or "").strip()
folder_id = str(args.get("folder_id") or "").strip()
with_meta = str(args.get("with_meta") or "").strip().lower() in {"1", "true", "yes"}
detail = str(args.get("detail") or "summary").strip().lower() or "summary"
try:
sight_limit = max(5, min(80, int(args.get("sight_limit") or 40)))
except (TypeError, ValueError):
sight_limit = 40
try:
sight_cell = float(args.get("sight_cell") or 600.0)
except (TypeError, ValueError):
sight_cell = 600.0
try:
max_views = max(1, min(80, int(args.get("max_views") or 25)))
except (TypeError, ValueError):
max_views = 25
try:
min_nodes = max(0, int(args.get("min_nodes") or 5))
except (TypeError, ValueError):
min_nodes = 5
try:
max_nodes = max(1, min(2000, int(args.get("max_nodes") or 800)))
except (TypeError, ValueError):
max_nodes = 800
score_profile = str(args.get("score_profile") or "auto").strip().lower() or "auto"
if view_id:
return _analyze_one_view(
view_id,
with_meta=with_meta,
detail=detail,
sight_limit=sight_limit,
sight_cell=sight_cell,
score_profile=score_profile,
)
if not folder_id:
return {
"ok": False,
"error": "view_id_or_folder_id_required",
"hint": "Pass view_id for one canvas, or folder_id to sample physical views under it.",
}
tree = _data(http_json("GET", "/v1/topology/tree"))
if not tree.get("ok"):
return tree
folder = _find_folder_in_tree(tree, folder_id)
if folder is None:
return {"ok": False, "error": "folder_not_found", "folder_id": folder_id}
candidates: list[dict[str, Any]] = []
_collect_physical_views(folder, out=candidates, max_views=max_views * 4)
# Prefer mid-size engineer canvases; skip empties / huge worlds
filtered = [
c
for c in candidates
if min_nodes <= int(c.get("ne_count") or 0) <= max_nodes
]
filtered.sort(key=lambda c: int(c.get("ne_count") or 0))
# stride sample across size range
if len(filtered) > max_views:
step = len(filtered) / max_views
picked = [filtered[min(len(filtered) - 1, int(i * step))] for i in range(max_views)]
else:
picked = filtered
rows: list[dict[str, Any]] = []
for c in picked:
one = _analyze_one_view(
str(c["view_id"]),
with_meta=with_meta,
score_profile=score_profile,
)
if not one.get("ok"):
rows.append(
{
"view_id": c.get("view_id"),
"folder_name": c.get("folder_name"),
"error": one.get("error"),
}
)
continue
size = one.get("size") or {}
crossing = one.get("crossing") or {}
spacing = one.get("spacing") or {}
sparsity = one.get("sparsity") or {}
overlap = one.get("overlap") or {}
verdict = one.get("verdict") or {}
rows.append(
{
"view_id": one.get("view_id"),
"view_name": one.get("view_name"),
"folder_name": c.get("folder_name"),
"verdict": {
"overall": verdict.get("overall"),
"total": verdict.get("total"),
"headline": verdict.get("headline"),
},
"size": size,
"overlap": {
"status": overlap.get("status"),
"footprint_pairs": overlap.get("footprint_pairs"),
"label_pairs": overlap.get("label_pairs"),
},
"crossing": {
"status": crossing.get("status"),
"edge_crossings": crossing.get("edge_crossings"),
"crossings_per_link": crossing.get("crossings_per_link"),
"top_nodes": [
{
"name": r.get("name"),
"hits": r.get("crossing_hits"),
"id": r.get("fabric_node_id"),
}
for r in (crossing.get("top_nodes") or [])[:5]
],
"top_edges": [
{
"label": r.get("label"),
"hits": r.get("crossing_hits"),
"a": r.get("a_name"),
"b": r.get("b_name"),
}
for r in (crossing.get("top_edges") or [])[:5]
],
},
"spacing": {
"status": spacing.get("status"),
"nn_p50": spacing.get("nn_p50"),
},
"sparsity": {
"status": sparsity.get("status"),
"space_utilization": sparsity.get("space_utilization"),
"grid_occupancy": sparsity.get("grid_occupancy"),
"whitespace_index": sparsity.get("whitespace_index"),
},
}
)
ok_rows = [r for r in rows if (r.get("crossing") or {}).get("edge_crossings") is not None]
def _pct(vals: list[float], p: float) -> float | None:
if not vals:
return None
s = sorted(vals)
return round(s[min(len(s) - 1, max(0, int(round((len(s) - 1) * p))))], 4)
cpl = [
float(r["crossing"]["crossings_per_link"])
for r in ok_rows
if (r.get("crossing") or {}).get("crossings_per_link") is not None
]
cross = [
float(r["crossing"]["edge_crossings"])
for r in ok_rows
if (r.get("crossing") or {}).get("edge_crossings") is not None
]
nn = [
float(r["spacing"]["nn_p50"])
for r in ok_rows
if (r.get("spacing") or {}).get("nn_p50") is not None
]
util = [
float(r["sparsity"]["space_utilization"])
for r in ok_rows
if (r.get("sparsity") or {}).get("space_utilization") is not None
]
totals = [
float(r["verdict"]["total"])
for r in ok_rows
if (r.get("verdict") or {}).get("total") is not None
]
by_bucket: dict[str, list[dict[str, Any]]] = {"1-50": [], "51-200": [], "201-500": [], "501+": []}
for r in ok_rows:
n = int((r.get("size") or {}).get("nodes") or 0)
if n <= 50:
by_bucket["1-50"].append(r)
elif n <= 200:
by_bucket["51-200"].append(r)
elif n <= 500:
by_bucket["201-500"].append(r)
else:
by_bucket["501+"].append(r)
bucket_summary = {}
for key, items in by_bucket.items():
if not items:
bucket_summary[key] = {"views": 0}
continue
bucket_summary[key] = {
"views": len(items),
"score_total_p50": _pct(
[float(i["verdict"]["total"]) for i in items if (i.get("verdict") or {}).get("total") is not None],
0.5,
),
"crossings_per_link_p50": _pct(
[float(i["crossing"]["crossings_per_link"]) for i in items], 0.5
),
"crossings_per_link_p90": _pct(
[float(i["crossing"]["crossings_per_link"]) for i in items], 0.9
),
"nn_p50_median": _pct(
[float(i["spacing"]["nn_p50"]) for i in items if (i.get("spacing") or {}).get("nn_p50") is not None],
0.5,
),
"util_p50": _pct(
[
float(i["sparsity"]["space_utilization"])
for i in items
if (i.get("sparsity") or {}).get("space_utilization") is not None
],
0.5,
),
"edge_crossings_p50": _pct(
[float(i["crossing"]["edge_crossings"]) for i in items], 0.5
),
}
return {
"ok": True,
"mode": "folder_sample",
"folder_id": folder_id,
"folder_name": str(folder.get("name") or ""),
"sampled_views": len(ok_rows),
"candidate_views": len(candidates),
"distribution": {
"score_total_p50": _pct(totals, 0.5),
"score_total_p90": _pct(totals, 0.9),
"crossings_per_link_p50": _pct(cpl, 0.5),
"crossings_per_link_p90": _pct(cpl, 0.9),
"edge_crossings_p50": _pct(cross, 0.5),
"edge_crossings_p90": _pct(cross, 0.9),
"nn_p50_median": _pct(nn, 0.5),
"space_utilization_p50": _pct(util, 0.5),
"by_node_bucket": bucket_summary,
},
"guide": {
"how_to_read": (
"同一工具:每张图看 verdict + overlap/crossing/spacing/sparsity/chains/rings。"
"对照 distribution:同规模 bucket 的 cpl ≤ 参考 p50 为优、勿差于 p90;"
"overlap 硬零;util 勿远低于参考 p50。"
),
"acceptance_hint": (
"Agent layouts: ≤ reference p50 crossings_per_link for size bucket, never worse than p90; "
"footprint/label overlap = 0; raise util without stacking."
),
},
"views": rows,
}
_ROOT_MAP_NAMES = frozenset({"根图", "Root map"})
def _walk_folders(node: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(node, dict):
return []
out = [node]
for child in node.get("children") or []:
if isinstance(child, dict):
out.extend(_walk_folders(child))
return out
def _find_folder_in_tree(tree: dict[str, Any], folder_id: str) -> dict[str, Any] | None:
fid = str(folder_id or "").strip()
if not fid:
return None
root = tree.get("root") if isinstance(tree.get("root"), dict) else tree
if not isinstance(root, dict):
return None
for folder in _walk_folders(root):
if str(folder.get("id") or "") == fid:
return folder
return None
def _pick_view(folder: dict[str, Any]) -> dict[str, Any] | None:
views = [v for v in (folder.get("views") or []) if isinstance(v, dict)]
if not views:
return None
for v in views:
if str(v.get("kind") or "").strip().lower() == "physical":
return v
return views[0]
def _is_root_map_folder(folder: dict[str, Any]) -> bool:
name = str(folder.get("name") or "").strip()
if name in _ROOT_MAP_NAMES:
return True
return bool(folder.get("is_system")) and not str(folder.get("external_ref") or "").strip()
def resolve_draw_target(tree: dict[str, Any], folder_id: str) -> dict[str, Any]:
"""Resolve canvas folder + view_id for drawing under the new 根/根图 model."""
folder = _find_folder_in_tree(tree, folder_id)
if folder is None:
return {}
view = _pick_view(folder)
if view is not None:
return {
"canvas_folder_id": str(folder.get("id") or ""),
"view_id": str(view.get("id") or ""),
"ne_count": int(folder.get("ne_count") or 0),
}
# Nav-only「根」: views empty — draw on auto「根图」/ Root map child.
for child in folder.get("children") or []:
if not isinstance(child, dict) or not _is_root_map_folder(child):
continue
child_view = _pick_view(child)
if child_view is None:
continue
return {
"canvas_folder_id": str(child.get("id") or ""),
"view_id": str(child_view.get("id") or ""),
"ne_count": int(child.get("ne_count") or 0),
}
return {}
def _create_topology_folder(args: dict[str, Any]) -> dict[str, Any]:
"""Create a region; API auto-spawns 根图 / region canvas — return draw view_id."""
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
locale = str(args.get("locale") or "").strip()
if locale:
body["locale"] = locale
created = _data(http_json("POST", "/v1/topology/folders", body=body))
if not created.get("ok"):
return created
folder_id = str(created.get("id") or "").strip()
if not folder_id:
return created
tree = _data(http_json("GET", "/v1/topology/tree"))
if not tree.get("ok"):
created["hint"] = (
"Folder created but tree refresh failed; call getTopologyTree and use the "
"根图 / region canvas view_id."
)
return created
tip = resolve_draw_target(tree, folder_id)
if tip.get("view_id"):
created["canvas_folder_id"] = tip["canvas_folder_id"]
created["view_id"] = tip["view_id"]
created["ne_count"] = tip.get("ne_count", 0)
created["hint"] = (
"Use view_id with addTopologyViewNodes on this physical canvas. "
"Under 根图 use createTopologyFolder for sub-regions."
)
else:
created["hint"] = (
"Folder created; call getTopologyTree to locate the canvas view_id before drawing."
)
return created
_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 _ensure_view_max_nodes(view_id: str, max_nodes: int) -> dict[str, Any]:
"""Raise membership.max_nodes on a view (≤2000) without wiping other filter fields."""
cap = max(1, min(2000, int(max_nodes)))
got = _data(http_json("GET", f"/v1/topology/views/{view_id}"))
view = got.get("view") if isinstance(got.get("view"), dict) else None
if not isinstance(view, dict):
return {"ok": False, "error": "view_get_failed", "detail": got}
filt = dict(view.get("filter") or {})
mem = dict(filt.get("membership") or {})
try:
cur = int(mem.get("max_nodes") or 0)
except (TypeError, ValueError):
cur = 0
if cur >= cap:
return {"ok": True, "max_nodes": cur, "changed": False}
mem["max_nodes"] = cap
filt["membership"] = mem
patched = _data(
http_json("PATCH", f"/v1/topology/views/{view_id}", body={"filter": filt})
)
if isinstance(patched, dict) and patched.get("ok") is False:
return patched
return {"ok": True, "max_nodes": cap, "changed": True, "previous": cur}
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.",
}
if args.get("max_nodes") is not None:
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
if bump.get("ok") is False:
return bump
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)))
if not parts[-1].get("ok"):
break
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},
)
)
)
if not parts[-1].get("ok"):
break
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
row: dict[str, Any] = {
"fabric_node_id": fid,
"x": float(p.get("x") or 0),
"y": float(p.get("y") or 0),
}
# Omitted label/locked = keep server value (a UI rename or an existing lock).
# label "" clears the override; locked false explicitly unlocks.
if p.get("label") is not None:
row["label"] = str(p.get("label"))
if p.get("locked") is not None:
row["locked"] = bool(p.get("locked"))
cleaned.append(row)
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},
)
)
)
if not parts[-1].get("ok"):
break
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"}
detail = str(args.get("detail") or "summary").strip().lower()
if detail not in {"summary", "full"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
body: dict[str, Any] = {}
seeds = args.get("seed_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()]
region_folder_id = str(args.get("region_folder_id") or "").strip()
if region_folder_id:
body["region_folder_id"] = region_folder_id
if "dry_run" in args:
body["dry_run"] = _truthy(args["dry_run"])
# API still returns the full graph; large canvases easily exceed the 60s default.
out = _data(
http_json(
"POST",
f"/v1/topology/views/{view_id}/project-neighbors",
body=body,
timeout=180.0,
)
)
if not out.get("ok"):
return out
skipped = int(out.get("out_of_region_skipped") or 0)
skipped_sample = out.get("out_of_region_sample") or []
if not isinstance(skipped_sample, list):
skipped_sample = []
if detail == "full":
out["detail"] = "full"
out["region_folder_id"] = region_folder_id or None
out["out_of_region_skipped"] = skipped
out["out_of_region_sample"] = skipped_sample[:20]
if region_folder_id and skipped:
out["hint"] = (
f"Projected with region_folder_id={region_folder_id}; "
f"skipped {skipped} out-of-region neighbors (not added)."
)
return out
sample = int(args["sample"]) if args.get("sample") is not None else 20
summary = _summarize_view_graph(out, sample=sample)
summary["view_id"] = view_id
summary["projected"] = True
summary["dry_run"] = bool(body.get("dry_run"))
summary["region_folder_id"] = region_folder_id or None
summary["out_of_region_skipped"] = skipped
summary["out_of_region_sample"] = [
x if isinstance(x, dict) else {"fabric_node_id": str(x)}
for x in skipped_sample[:20]
]
if region_folder_id:
summary["hint"] = (
(summary.get("hint") or "")
+ f" region_folder_id={region_folder_id} filters peers; "
f"out_of_region_skipped={skipped}."
).strip()
return summary
def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Unified fabric inventory: mode=summary|list|search (replaces 3 tools)."""
mode = str(args.get("mode") or "").strip().lower()
q = str(args.get("q") or "").strip()
keyword = str(args.get("keyword") or "").strip()
if not mode:
if _truthy(args.get("summary")):
mode = "summary"
elif q:
mode = "search"
else:
mode = "list"
if mode not in {"summary", "search", "list"}:
return {"ok": False, "error": "mode_invalid", "hint": "mode=summary|list|search"}
if mode == "summary":
out = _data(http_json("GET", "/v1/topology/fabric/summary"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["mode"] = "summary"
return out
if mode == "search":
needle = q or keyword
if not needle:
return {
"ok": False,
"error": "q_required",
"hint": "mode=search needs q (or keyword).",
}
params: dict[str, Any] = {
"q": needle,
"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))
),
}
page = _compact_fabric_page(
_data(http_json("GET", "/v1/topology/fabric/nodes/search", params=params))
)
if isinstance(page, dict):
page = dict(page)
page["mode"] = "search"
return page
# mode=list (default): paged filter browse
page_n = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or args.get("limit") or 50)))
params = {"page": page_n, "page_size": page_size}
# list path historically used keyword=; accept q as alias
filt = keyword or q
if filt:
params["keyword"] = filt
if str(args.get("role") or "").strip():
params["role"] = str(args.get("role")).strip()
if str(args.get("level") or "").strip() or args.get("level") == 0:
params["level"] = str(args.get("level")).strip()
if str(args.get("level_major") or "").strip() or args.get("level_major") == 0:
params["level_major"] = str(args.get("level_major")).strip()
if str(args.get("link_status") or "").strip():
params["link_status"] = str(args.get("link_status")).strip()
if str(args.get("region_folder_id") or "").strip():
params["region_folder_id"] = str(args.get("region_folder_id")).strip()
page = _compact_fabric_page(
_data(http_json("GET", "/v1/topology/fabric/nodes", params=params))
)
if isinstance(page, dict):
page = dict(page)
page["mode"] = "list"
return page
def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Tag Fabric level/region for agents (match → tag; rules apply; unmatched)."""
action = str(args.get("action") or "").strip().lower()
if not action:
return {
"ok": False,
"error": "action_required",
"hint": (
"action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. "
"Typical: match → tag(level|role, dry_run) → tag; "
"then queryTopologyFabricNodes by level/region and add returned fabric_node_ids."
),
}
if action == "match":
pattern = str(args.get("pattern") or args.get("q") or "").strip()
if not pattern:
return {"ok": False, "error": "pattern_required"}
match_field = str(args.get("match_field") or "name").strip() or "name"
sample_limit = min(200, max(1, int(args.get("sample_limit") or 50)))
out = _data(
http_json(
"POST",
"/v1/topology/fabric/nodes/match",
body={
"pattern": pattern,
"match_field": match_field,
"sample_limit": sample_limit,
},
)
)
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["action"] = "match"
samples = out.get("samples")
if isinstance(samples, list):
out["samples"] = [
_compact_fabric_item(s) if isinstance(s, dict) else s for s in samples[:sample_limit]
]
ids = out.get("fabric_node_ids")
if isinstance(ids, list) and len(ids) > 200:
out["fabric_node_ids"] = ids[:200]
out["fabric_node_ids_truncated"] = True
out["hint"] = (
"Preview only. Re-call action=tag with same pattern (or fabric_node_ids) "
"and level (e.g. 1 / 1.1 / 2.1) or role preset; dry_run=true first."
)
return out
if action == "tag":
level = args.get("level") if "level" in args else Ellipsis
role = args.get("role") if "role" in args else Ellipsis
region = args.get("region_folder_id") if "region_folder_id" in args else Ellipsis
clear_region = _truthy(args.get("clear_region"))
if level is Ellipsis and role is Ellipsis and region is Ellipsis and not clear_region:
return {
"ok": False,
"error": "level_or_region_required",
"hint": (
"Pass level (0/1/1.1/2/2.1/3…) and/or role preset "
"(external|core|aggregation|access) and/or region_folder_id."
),
}
body: dict[str, Any] = {
"dry_run": _truthy(args.get("dry_run")),
}
ids = args.get("fabric_node_ids")
if isinstance(ids, list) and ids:
body["fabric_node_ids"] = [str(x).strip() for x in ids if str(x).strip()][:2000]
pattern = str(args.get("pattern") or args.get("q") or "").strip()
if pattern:
body["pattern"] = pattern
body["match_field"] = str(args.get("match_field") or "name").strip() or "name"
if not body.get("fabric_node_ids") and not body.get("pattern"):
return {
"ok": False,
"error": "ids_or_pattern_required",
"hint": "Pass fabric_node_ids[] from match, or pattern for ephemeral regex tag.",
}
if level is not Ellipsis:
body["level"] = level
elif role is not Ellipsis:
body["role"] = None if role is None or str(role).strip() == "" else str(role).strip()
if clear_region:
body["region_folder_id"] = None
elif region is not Ellipsis:
body["region_folder_id"] = None if region is None or str(region).strip() == "" else str(region).strip()
out = _data(http_json("POST", "/v1/topology/fabric/nodes/tags/bulk", body=body))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["action"] = "tag"
samples = out.get("samples")
if isinstance(samples, list):
out["samples"] = [
_compact_fabric_item(s) if isinstance(s, dict) else s for s in samples[:50]
]
if out.get("dry_run"):
out["hint"] = "Preview only. Re-call with dry_run=false to write tags."
else:
out["hint"] = (
"Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or "
"pass returned fabric_node_ids to addTopologyViewNodes to place on region canvas."
)
return out
if action == "patch":
node_id = str(args.get("fabric_node_id") or args.get("node_id") or "").strip()
if not node_id:
return {"ok": False, "error": "fabric_node_id_required"}
body: dict[str, Any] = {}
if "level" in args:
body["level"] = args.get("level")
elif "role" in args:
role = args.get("role")
body["role"] = None if role is None or str(role).strip() == "" else str(role).strip()
if _truthy(args.get("clear_region")):
body["region_folder_id"] = None
elif "region_folder_id" in args:
region = args.get("region_folder_id")
body["region_folder_id"] = (
None if region is None or str(region).strip() == "" else str(region).strip()
)
if not body:
return {"ok": False, "error": "level_or_region_required"}
out = _data(
http_json(
"PATCH",
f"/v1/topology/fabric/nodes/{node_id}/tags",
body=body,
)
)
if isinstance(out, dict) and out.get("ok") is not False:
out = _compact_fabric_item(out) if "id" in out else dict(out)
out["ok"] = True
out["action"] = "patch"
return out
if action == "unmatched":
kind = str(args.get("kind") or "any").strip() or "any"
page_n = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or args.get("limit") or 50)))
page = _compact_fabric_page(
_data(
http_json(
"GET",
"/v1/topology/classify/unmatched",
params={"kind": kind, "page": page_n, "page_size": page_size},
)
)
)
if isinstance(page, dict):
page = dict(page)
page["action"] = "unmatched"
return page
if action == "preview_rules":
out = _data(http_json("POST", "/v1/topology/classify/preview"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["action"] = "preview_rules"
out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write."
return out
if action == "apply_rules":
skip_manual = True
if "skip_manual" in args:
skip_manual = _truthy(args.get("skip_manual"))
elif _truthy(args.get("overwrite_manual")):
skip_manual = False
params = {
"skip_manual": skip_manual,
"fill_empty_only": _truthy(args.get("fill_empty_only")),
}
out = _data(http_json("POST", "/v1/topology/classify/apply", params=params))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["action"] = "apply_rules"
return out
if action == "list_rules":
out = _data(http_json("GET", "/v1/topology/classify/rules"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["action"] = "list_rules"
out["hint"] = (
"Rules CRUD stays on web or HTTP. Agent tagging prefers action=match|tag "
"(ephemeral regex) over editing rules."
)
return out
return {
"ok": False,
"error": "action_invalid",
"hint": "action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules",
}
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",
}
out = _data(http_json("GET", "/v1/topology/fabric/neighborhood", params=params))
if not out.get("ok"):
return out
nodes_in = [n for n in (out.get("nodes") or []) if isinstance(n, dict)]
edges_in = [e for e in (out.get("edges") or []) if isinstance(e, dict)]
links = _collapse_edges_to_links(edges_in)
return {
"ok": True,
"center_node_id": out.get("center_node_id") or node_id,
"depth": out.get("depth") or params["depth"],
"node_count": len(nodes_in),
"edge_count": len(edges_in),
"link_count": len(links),
"nodes": [_compact_fabric_item(n) for n in nodes_in],
"links": links,
"hint": "links[] = undirected NE pairs for drawing; ignore ports. edge_count is raw.",
}
def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
"""Adjacency for agents: NE↔NE links (+ link_count). Ports only when detail=ports."""
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()
detail = str(args.get("detail") or "adjacency").strip().lower()
if detail not in {"adjacency", "ports"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=adjacency|ports"}
want_ports = detail == "ports"
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()
params["source"] = src
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
data = _data(http_json("GET", "/v1/topology/fabric/edges", params=params))
if not data.get("ok"):
return data
items = [e for e in (data.get("items") or []) if isinstance(e, dict)]
edge_total = int(data.get("total") or len(items))
links = _collapse_edges_to_links(items, include_names=True)
peers: list[dict[str, Any]] = []
if node_id:
peer_map: dict[str, dict[str, Any]] = {}
for e in items:
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:
continue
row = peer_map.get(peer_id)
if row is None:
peer_map[peer_id] = {"node_id": peer_id, "name": pname, "ip": pip, "link_count": 1}
else:
row["link_count"] = int(row["link_count"]) + 1
peers = sorted(peer_map.values(), key=lambda p: str(p.get("name") or p.get("node_id") or ""))
result: dict[str, Any] = {
"ok": True,
"detail": "ports" if want_ports else "adjacency",
"total": edge_total,
"page": data.get("page") or page,
"page_size": data.get("page_size") or page_size,
"link_count": len(links),
"links": links,
"hint": (
"Default adjacency: one undirected NE↔NE link (+ link_count). "
"Canvas draws one edge per link. Pass detail=ports only for port-level rows."
),
}
if node_id:
result["peer_count"] = len(peers)
result["peers"] = peers
result["edge_total"] = edge_total
result["peers_complete"] = edge_total <= len(items)
if want_ports:
slim_items: list[dict[str, Any]] = []
for e in items:
row = {
k: e.get(k)
for k in (
"id",
"layer",
"a_node_id",
"b_node_id",
"a_port",
"b_port",
"a_name",
"b_name",
"source",
"status",
)
if k in e
}
slim_items.append(row)
result["items"] = slim_items
return result
def _view_node_id_pos(payload: dict[str, Any]) -> tuple[list[str], dict[str, tuple[float, float]]]:
ids: list[str] = []
pos: dict[str, tuple[float, float]] = {}
for n in payload.get("nodes") or []:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or "").strip()
if not fid or fid.startswith("region:"):
continue
ids.append(fid)
if n.get("x") is not None and n.get("y") is not None:
try:
pos[fid] = (float(n["x"]), float(n["y"]))
except (TypeError, ValueError):
pass
return ids, pos
def _fabric_bridges_into(
probe_ids: list[str],
peer_ids: set[str],
*,
probe_cap: int = 16,
) -> list[tuple[str, str]]:
"""Fabric NE↔NE pairs from probes into peer_ids (cross-canvas bridges).
View GET edges miss these when one endpoint already left the source canvas.
"""
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
peers = {p for p in peer_ids if p}
for nid in probe_ids[: max(1, int(probe_cap))]:
sid = str(nid or "").strip()
if not sid:
continue
nb = _query_topology_neighborhood({"node_id": sid, "depth": 1})
if not nb.get("ok"):
continue
for link in nb.get("links") or []:
if not isinstance(link, dict):
continue
a = str(link.get("a_node_id") or "").strip()
b = str(link.get("b_node_id") or "").strip()
if not a or not b or a == b:
continue
other = b if a == sid else a if b == sid else ""
if not other or other not in peers:
continue
key = (a, b) if a < b else (b, a)
if key in seen:
continue
seen.add(key)
out.append(key)
return out
def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
"""Move a dual_unit batch from source (root) canvas onto sink (sub-region).
One call = one batch by default. Set until_empty=true to loop until the
source has no transferable nodes (or max_batches). Default layout_batch
runs layout_dual_unit per unit before parking (old staging flow). Global
polish still via layoutTopologyView on the sink.
"""
from netx_api.mcp_remote.topology.layout_ops.dual_units import find_dual_portal_units
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
batch_node_ids,
layout_and_pack_batch,
leftover_batch_ids,
merge_view_links,
park_positions,
positions_to_patch,
select_dual_unit_batch,
units_as_batch_rows,
)
source_view_id = str(args.get("source_view_id") or "").strip()
sink_view_id = str(args.get("sink_view_id") or "").strip()
if not source_view_id or not sink_view_id:
return {
"ok": False,
"error": "source_view_id_and_sink_view_id_required",
"hint": "Pass root physical view_id + child-region physical view_id.",
}
if source_view_id == sink_view_id:
return {"ok": False, "error": "source_and_sink_must_differ"}
max_units = max(1, min(20, int(args.get("max_units") or 3)))
min_nodes = max(2, min(200, int(args.get("min_nodes") or 8)))
# CN-eye units often cover 150–300 NEs; old default 80 rejected them.
max_nodes = max(min_nodes, min(400, int(args.get("max_nodes") or 300)))
max_batch_nodes = max(max_nodes, min(800, int(args.get("max_batch_nodes") or 400)))
until_empty = bool(args.get("until_empty"))
include_leftovers = bool(
args.get("include_leftovers")
if args.get("include_leftovers") is not None
else True
)
dry_run = bool(args.get("dry_run"))
max_batches = max(1, min(200, int(args.get("max_batches") or (1 if not until_empty else 50))))
pad = float(args.get("pad") or 280.0)
detect_max = max(20, min(300, int(args.get("detect_max_units") or 120)))
# Default on: per-unit layout_dual_unit before park (staging-style).
layout_batch = bool(
True if args.get("layout_batch") is None else args.get("layout_batch")
)
unit_gap = float(args.get("unit_gap") or 220.0)
# Level-aware drain: sink_layers = whitelist this phase; keep_layers = anchors.
# Default: drain access(+other), leave core/agg on root.
raw_keep = args.get("keep_layers")
if raw_keep is None:
keep_layers = {"core", "agg"}
elif isinstance(raw_keep, str):
keep_layers = {p.strip().lower() for p in raw_keep.split(",") if p.strip()}
elif isinstance(raw_keep, (list, tuple)):
keep_layers = {str(p).strip().lower() for p in raw_keep if str(p).strip()}
else:
keep_layers = {"core", "agg"}
if isinstance(raw_keep, (list, tuple)) and len(raw_keep) == 0:
keep_layers = set()
raw_sink = args.get("sink_layers")
if raw_sink is None:
# Default phase: access corridors (+ unclassified).
sink_layers: set[str] | None = {"access", "other"}
elif isinstance(raw_sink, str):
sink_layers = {p.strip().lower() for p in raw_sink.split(",") if p.strip()} or None
elif isinstance(raw_sink, (list, tuple)):
if len(raw_sink) == 0:
sink_layers = None # any non-keep
else:
sink_layers = {str(p).strip().lower() for p in raw_sink if str(p).strip()}
else:
sink_layers = {"access", "other"}
# by_level=true: auto-pick deepest remaining layer on source (access→agg→core).
by_level = bool(args.get("by_level"))
# Default false: CN/core eyes hang off keep portals; prefer_pure would skip them.
prefer_pure = bool(
False if args.get("prefer_pure") is None else args.get("prefer_pure")
)
prefer_core_eye = bool(
True if args.get("prefer_core_eye") is None else args.get("prefer_core_eye")
)
prefer_top_eye = args.get("prefer_top_eye")
if prefer_top_eye is None:
prefer_top_eye = prefer_core_eye
else:
prefer_top_eye = bool(prefer_top_eye)
_LEVEL_DEPTH = ("access", "other", "agg", "core", "external")
batches: list[dict[str, Any]] = []
source_remaining = -1
sink_count = -1
dry_removed: set[str] = set()
dry_sink_extra: set[str] = set()
for bi in range(max_batches):
report_progress(
"sink_batch",
pct=min(95.0, 5.0 + 90.0 * bi / max(1, max_batches)),
message=f"batch {bi + 1}/{max_batches}",
batch=bi + 1,
)
raise_if_cancelled()
src = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
if not src.get("ok"):
return {
"ok": False,
"error": src.get("error") or "source_view_fetch_failed",
"batches": batches,
}
snk = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0))
if not snk.get("ok"):
return {
"ok": False,
"error": snk.get("error") or "sink_view_fetch_failed",
"batches": batches,
}
src_ids, src_pos = _view_node_id_pos(src)
snk_ids, snk_pos = _view_node_id_pos(snk)
if dry_run and dry_removed:
src_ids = [i for i in src_ids if i not in dry_removed]
snk_ids = list(dict.fromkeys(list(snk_ids) + sorted(dry_sink_extra)))
source_remaining = len(src_ids)
sink_count = len(snk_ids)
if source_remaining <= 0:
break
nodes = [
n
for n in (src.get("nodes") or [])
if isinstance(n, dict)
and str(n.get("fabric_node_id") or "") not in dry_removed
]
edges = [
e
for e in (src.get("edges") or [])
if isinstance(e, dict)
and str(e.get("a_node_id") or e.get("source") or "") not in dry_removed
and str(e.get("b_node_id") or e.get("target") or "") not in dry_removed
]
st = build_state_from_nodes_edges(nodes, edges)
layer_of = {
nid: str(ly or "").strip().lower() or "other"
for nid, ly in (st.layers or {}).items()
}
# Phase layers: explicit sink_layers, or by_level → deepest remaining.
phase_layers = set(sink_layers) if sink_layers is not None else None
if by_level:
picked_ly = None
for ly in _LEVEL_DEPTH:
if ly in keep_layers:
continue
if any(layer_of.get(i) == ly for i in src_ids):
picked_ly = ly
break
if picked_ly:
phase_layers = {picked_ly}
if picked_ly == "access":
phase_layers.add("other")
keep_ids = {
nid
for nid, ly in layer_of.items()
if ly in keep_layers or (phase_layers is not None and ly not in phase_layers)
}
sink_ids = {
nid
for nid in src_ids
if nid not in keep_ids
and (phase_layers is None or layer_of.get(nid, "other") in phase_layers)
}
units = find_dual_portal_units(st, max_units=detect_max)
picked = select_dual_unit_batch(
units,
max_units=max_units,
min_nodes=min_nodes,
max_nodes=max_nodes,
max_batch_nodes=max_batch_nodes,
exclude_ids=set(snk_ids),
keep_ids=keep_ids,
sink_ids=sink_ids,
prefer_pure=prefer_pure,
prefer_core_eye=prefer_core_eye,
prefer_top_eye=prefer_top_eye,
layers=layer_of,
)
move_ids = batch_node_ids(picked, keep_ids=keep_ids, sink_ids=sink_ids)
mode = "dual_units"
if not move_ids:
# Relax size band once before leftovers.
picked = select_dual_unit_batch(
units,
max_units=max_units,
min_nodes=2,
max_nodes=max(max_nodes, 300),
max_batch_nodes=max_batch_nodes,
exclude_ids=set(snk_ids),
keep_ids=keep_ids,
sink_ids=sink_ids,
prefer_pure=prefer_pure,
prefer_core_eye=prefer_core_eye,
prefer_top_eye=prefer_top_eye,
layers=layer_of,
)
move_ids = batch_node_ids(picked, keep_ids=keep_ids, sink_ids=sink_ids)
if not move_ids and include_leftovers:
mode = "leftovers"
move_ids = leftover_batch_ids(
src_ids,
max_batch_nodes=max_batch_nodes,
exclude_ids=set(snk_ids),
keep_ids=keep_ids,
sink_ids=sink_ids,
)
# leftovers may already be on sink; still remove from source
if not move_ids:
move_ids = leftover_batch_ids(
src_ids,
max_batch_nodes=max_batch_nodes,
exclude_ids=set(),
keep_ids=keep_ids,
sink_ids=sink_ids,
)
if not move_ids:
break
need_cap = sink_count + len([i for i in move_ids if i not in set(snk_ids)])
if need_cap > 2000:
return {
"ok": False,
"error": "sink_capacity_exceeded",
"sink_nodes": sink_count,
"batch_nodes": len(move_ids),
"hint": "Create another child region sink or lower max_batch_nodes.",
"batches": batches,
"source_remaining": source_remaining,
}
batch_row: dict[str, Any] = {
"batch_index": bi + 1,
"mode": mode,
"unit_count": len(picked),
"units": units_as_batch_rows(picked, st.names),
"node_ids": move_ids,
"node_count": len(move_ids),
"kept_on_source": sorted(
{
nid
for u in picked
for nid in u.member_ids()
if nid in keep_ids
}
),
"keep_layers": sorted(keep_layers),
"sink_layers": sorted(phase_layers) if phase_layers is not None else None,
"by_level": by_level,
"sink_eligible": len(sink_ids),
"source_before": source_remaining,
"sink_before": sink_count,
}
if dry_run:
batches.append(batch_row)
dry_removed.update(move_ids)
dry_sink_extra.update(move_ids)
source_remaining = max(0, source_remaining - len(move_ids))
sink_count = len(set(snk_ids) | dry_sink_extra)
if not until_empty:
break
continue
bump = _ensure_view_max_nodes(sink_view_id, min(2000, max(need_cap + 20, 200)))
if isinstance(bump, dict) and bump.get("ok") is False:
return {**bump, "batches": batches}
add_ids = [i for i in move_ids if i not in set(snk_ids)]
add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(move_ids)}
if add_ids:
add_out = _add_topology_view_nodes(
{
"view_id": sink_view_id,
"fabric_node_ids": add_ids,
"layout": "keep",
"max_nodes": min(2000, max(need_cap + 20, 200)),
}
)
if add_out.get("ok") is False:
return {
"ok": False,
"error": add_out.get("error") or "sink_add_failed",
"detail": add_out,
"batches": batches,
"batch": batch_row,
}
layout_reports: list[dict[str, Any]] = []
attach_meta: dict[str, Any] = {}
pos_patch: list[dict[str, Any]] = []
# View edges + fabric bridges into already-sunk peers (cross-canvas tips).
attach_links = merge_view_links(src, snk)
probe_ids: list[str] = []
if mode == "dual_units" and picked:
for u in picked:
for p in (u.portal_a, u.portal_b):
if p and p not in probe_ids:
probe_ids.append(p)
for mid in move_ids:
if mid not in probe_ids:
probe_ids.append(mid)
fabric_bridges = _fabric_bridges_into(
probe_ids, set(snk_ids), probe_cap=16
)
if fabric_bridges:
have = set(attach_links)
for br in fabric_bridges:
if br not in have:
attach_links.append(br)
have.add(br)
if layout_batch and mode == "dual_units" and picked:
report_progress(
"sink_layout",
pct=min(95.0, 20.0 + 70.0 * bi / max(1, max_batches)),
message=f"layout_dual_unit + orbit_attach x{len(picked)}",
batch=bi + 1,
)
world, layout_reports, attach_meta = layout_and_pack_batch(
st,
picked,
sink_pos=snk_pos,
pad=pad,
unit_gap=unit_gap,
links=attach_links,
)
# Never patch keep-layer anchors (they stay on source only).
move_set = set(move_ids)
if world:
world = {nid: xy for nid, xy in world.items() if nid in move_set}
pos_patch = positions_to_patch(world) if world else []
# Fill any members missing from unit layout (shared skip / fail).
have = {str(p.get("fabric_node_id") or "") for p in pos_patch}
missing = [i for i in move_ids if i not in have]
if missing:
pos_patch.extend(
park_positions(
src_pos,
missing,
sink_pos=snk_pos,
pad=pad,
links=attach_links,
)
)
else:
pos_patch = park_positions(
src_pos,
move_ids,
sink_pos=snk_pos,
pad=pad,
links=attach_links,
)
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch:
patch_out = _patch_positions_chunked(sink_view_id, pos_patch)
if not patch_out.get("ok"):
return {
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"source_preserved": True,
"added": int(add_out.get("added", 0)),
"batches": batches,
"batch": batch_row,
"hint": "Sink membership may already be added; source nodes were kept. Inspect both views before retrying.",
}
rm_out = _remove_topology_view_nodes(
{"view_id": source_view_id, "fabric_node_ids": move_ids}
)
if rm_out.get("ok") is False:
return {
"ok": False,
"error": rm_out.get("error") or "source_remove_failed",
"detail": rm_out,
"hint": "Nodes may already be on sink; fix source membership manually.",
"batches": batches,
"batch": batch_row,
}
# Refresh counts
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
snk2 = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0))
if not src2.get("ok") or not snk2.get("ok"):
return {
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"detail": {"source": src2, "sink": snk2},
"batches": batches,
"batch": batch_row,
"hint": "Writes completed but verification failed. Read both views before retrying this batch.",
}
src_ids2, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
snk_ids2, _ = _view_node_id_pos(snk2 if snk2.get("ok") else {})
source_remaining = len(src_ids2) if src2.get("ok") else max(0, source_remaining - len(move_ids))
sink_count = len(snk_ids2) if snk2.get("ok") else sink_count + len(add_ids)
batch_row.update(
{
"added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed", 0)),
"source_after": source_remaining,
"sink_after": sink_count,
"max_nodes_bump": bump,
"layout_batch": layout_batch and mode == "dual_units",
"unit_layouts": layout_reports,
"units_accepted": sum(
1 for r in layout_reports if r.get("accepted")
),
"orbit_attach": {
**(attach_meta or {}),
"fabric_bridge_n": len(fabric_bridges),
},
}
)
batches.append(batch_row)
if not until_empty or source_remaining <= 0:
break
done = source_remaining <= 0
return {
"ok": True,
"action": "sinkTopologyDualUnits",
"source_view_id": source_view_id,
"sink_view_id": sink_view_id,
"dry_run": dry_run,
"until_empty": until_empty,
"batches_run": len(batches),
"batches": batches,
"source_remaining": source_remaining,
"sink_nodes": sink_count,
"done": done,
"layout_batch": layout_batch,
"hint": (
"Source empty — sink membership complete. Batches already had "
"layout_dual_unit when layout_batch=true; polish with "
"polish_crossings(straighten=false)/clear_edge_hits — "
"never fix_overlaps+straighten (destroys dual-unit eyes)."
if done
else (
"Call again for the NEXT batch only after eye-safe polish on sink "
"(no straighten). Do NOT set until_empty — one batch → tune → next."
)
),
}
def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Move (or copy) explicit fabric ids from source_view_id onto view_id.
Bidirectional: swap view_id / source_view_id to send nodes back.
Caller supplies fabric_node_ids — no auto CN/dual-unit selection.
"""
dest_view_id = str(args.get("view_id") or "").strip()
source_view_id = str(args.get("source_view_id") or "").strip()
if not dest_view_id or not source_view_id:
return {
"ok": False,
"error": "view_id_and_source_view_id_required",
"hint": (
"action=move_nodes: source_view_id=FROM, view_id=TO; "
"params.fabric_node_ids=[...]. Swap ids to reverse."
),
}
if source_view_id == dest_view_id:
return {"ok": False, "error": "source_and_dest_must_differ"}
overrides = args.get("params") if isinstance(args.get("params"), dict) else {}
raw_ids = overrides.get("fabric_node_ids")
if raw_ids is None:
raw_ids = args.get("fabric_node_ids")
if not isinstance(raw_ids, list) or not raw_ids:
return {
"ok": False,
"error": "fabric_node_ids_required",
"hint": "Pass params.fabric_node_ids with the exact NE ids to move.",
}
want: list[str] = []
seen: set[str] = set()
for x in raw_ids:
sid = str(x or "").strip()
if not sid or sid.startswith("region:") or sid in seen:
continue
seen.add(sid)
want.append(sid)
if not want:
return {"ok": False, "error": "fabric_node_ids_empty"}
mode = str(args.get("mode") or "preview").strip().lower() or "preview"
if mode not in {"preview", "apply"}:
return {"ok": False, "error": "mode_invalid", "hint": "mode=preview|apply"}
dry_run = bool(overrides.get("dry_run") if "dry_run" in overrides else args.get("dry_run"))
if mode != "apply":
dry_run = True
copy_positions = bool(
True
if overrides.get("copy_positions") is None and args.get("copy_positions") is None
else (
overrides.get("copy_positions")
if "copy_positions" in overrides
else args.get("copy_positions")
)
)
park = bool(
overrides.get("park") if "park" in overrides else args.get("park")
)
remove_from_source = bool(
True
if overrides.get("remove_from_source") is None
and args.get("remove_from_source") is None
else (
overrides.get("remove_from_source")
if "remove_from_source" in overrides
else args.get("remove_from_source")
)
)
pad = float(overrides.get("pad") or args.get("pad") or 280.0)
offset_x = float(overrides.get("offset_x") or args.get("offset_x") or 0.0)
offset_y = float(overrides.get("offset_y") or args.get("offset_y") or 0.0)
src = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
if not src.get("ok"):
return {"ok": False, "error": src.get("error") or "source_view_fetch_failed"}
dst = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0))
if not dst.get("ok"):
return {"ok": False, "error": dst.get("error") or "dest_view_fetch_failed"}
src_ids, src_pos = _view_node_id_pos(src)
dst_ids, dst_pos = _view_node_id_pos(dst)
src_set = set(src_ids)
move_ids = [i for i in want if i in src_set]
missing = [i for i in want if i not in src_set]
already = [i for i in move_ids if i in set(dst_ids)]
add_ids = [i for i in move_ids if i not in set(dst_ids)]
need_cap = len(dst_ids) + len(add_ids)
if need_cap > 2000:
return {
"ok": False,
"error": "dest_capacity_exceeded",
"dest_nodes": len(dst_ids),
"would_add": len(add_ids),
"hint": "Dest soft max is 2000; move a smaller batch.",
}
names: dict[str, str] = {}
for n in src.get("nodes") or []:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or "").strip()
if fid in seen:
names[fid] = str(n.get("name") or "")
base: dict[str, Any] = {
"ok": True,
"action": "move_nodes",
"source_view_id": source_view_id,
"view_id": dest_view_id,
"requested": len(want),
"move_ids": move_ids,
"move_count": len(move_ids),
"names": [{"fabric_node_id": i, "name": names.get(i) or ""} for i in move_ids],
"missing_on_source": missing,
"already_on_dest": already,
"would_add": len(add_ids),
"copy_positions": copy_positions,
"park": park,
"remove_from_source": remove_from_source,
"source_before": len(src_ids),
"dest_before": len(dst_ids),
"mode": "preview" if dry_run else "apply",
}
if not move_ids:
return {
**base,
"ok": False,
"error": "none_of_ids_on_source",
"hint": "Those fabric_node_ids are not on source_view_id.",
}
if dry_run:
return {
**base,
"dry_run": True,
"hint": (
"Preview only. Re-call with mode=apply to add onto view_id "
+ ("and remove from source." if remove_from_source else "(copy; source kept).")
),
}
bump = _ensure_view_max_nodes(dest_view_id, min(2000, max(need_cap + 20, 200)))
if isinstance(bump, dict) and bump.get("ok") is False:
return {**base, **bump}
add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(already)}
if add_ids:
add_out = _add_topology_view_nodes(
{
"view_id": dest_view_id,
"fabric_node_ids": add_ids,
"layout": "keep",
"max_nodes": min(2000, max(need_cap + 20, 200)),
}
)
if add_out.get("ok") is False:
return {
"ok": False,
"error": add_out.get("error") or "dest_add_failed",
"detail": add_out,
**{k: base[k] for k in ("source_view_id", "view_id", "move_ids")},
}
from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
merge_view_links,
park_positions,
)
pos_patch: list[dict[str, Any]] = []
if park:
attach_links = merge_view_links(src, dst)
pos_patch = park_positions(
src_pos,
move_ids,
sink_pos=dst_pos,
pad=pad,
links=attach_links,
)
elif copy_positions:
pos_patch = [
{
"fabric_node_id": nid,
"x": float(src_pos[nid][0] + offset_x),
"y": float(src_pos[nid][1] + offset_y),
}
for nid in move_ids
if nid in src_pos
]
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch:
patch_out = _patch_positions_chunked(dest_view_id, pos_patch)
if not patch_out.get("ok"):
return {
**base,
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"added": int(add_out.get("added", 0)),
"source_preserved": True,
"hint": "Destination membership may already be added; source nodes were kept. Inspect both views before retrying.",
}
rm_out: dict[str, Any] = {"ok": True, "removed": 0}
if remove_from_source:
rm_out = _remove_topology_view_nodes(
{"view_id": source_view_id, "fabric_node_ids": move_ids}
)
if rm_out.get("ok") is False:
return {
"ok": False,
"error": rm_out.get("error") or "source_remove_failed",
"detail": rm_out,
"hint": "Nodes may already be on dest; fix source membership manually.",
"added": int(add_out.get("added") or len(add_ids)),
**{k: base[k] for k in ("source_view_id", "view_id", "move_ids")},
}
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
dst2 = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0))
if not src2.get("ok") or not dst2.get("ok"):
return {
**base,
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"detail": {"source": src2, "destination": dst2},
"hint": "Writes completed but verification failed. Read both views before retrying the move.",
}
src_after, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return {
**base,
"dry_run": False,
"added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed", 0)),
"source_after": len(src_after) if src2.get("ok") else None,
"dest_after": len(dst_after) if dst2.get("ok") else None,
"max_nodes_bump": bump,
"hint": (
"Moved onto view_id"
+ ("; removed from source_view_id." if remove_from_source else "; source kept (copy).")
+ " Swap view_id/source_view_id to send them back."
),
}
def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Copy fabric placements (+ coords) from one canvas onto another.
Source membership is unchanged. For test sandboxes: clone a known-good
canvas onto a fresh child region without re-adding ids by hand.
"""
source_view_id = str(args.get("source_view_id") or "").strip()
target_view_id = str(args.get("target_view_id") or "").strip()
if not source_view_id or not target_view_id:
return {
"ok": False,
"error": "source_view_id_and_target_view_id_required",
}
if source_view_id == target_view_id:
return {"ok": False, "error": "source_and_target_must_differ"}
copy_positions = bool(
True if args.get("copy_positions") is None else args.get("copy_positions")
)
clear_target = bool(args.get("clear_target"))
dry_run = bool(args.get("dry_run"))
offset_x = float(args.get("offset_x") or 0.0)
offset_y = float(args.get("offset_y") or 0.0)
limit = args.get("limit")
try:
limit_n = int(limit) if limit is not None else 0
except (TypeError, ValueError):
limit_n = 0
src = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
if not src.get("ok"):
return {"ok": False, "error": src.get("error") or "source_view_fetch_failed"}
dst = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0))
if not dst.get("ok"):
return {"ok": False, "error": dst.get("error") or "target_view_fetch_failed"}
src_ids, src_pos = _view_node_id_pos(src)
dst_ids, _ = _view_node_id_pos(dst)
if limit_n > 0:
src_ids = src_ids[:limit_n]
if not src_ids:
return {
"ok": True,
"action": "copyTopologyViewNodes",
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"copied": 0,
"hint": "Source canvas has no fabric nodes.",
}
need = len(set(dst_ids) | set(src_ids)) if not clear_target else len(src_ids)
if need > 2000:
return {
"ok": False,
"error": "target_capacity_exceeded",
"source_nodes": len(src_ids),
"target_nodes": len(dst_ids),
"hint": "Target soft max is 2000; clear_target or copy a subset (limit).",
}
if dry_run:
return {
"ok": True,
"action": "copyTopologyViewNodes",
"dry_run": True,
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"source_nodes": len(src_ids),
"target_nodes_before": len(dst_ids),
"would_add": len([i for i in src_ids if i not in set(dst_ids)]),
"would_clear_target": clear_target,
"copy_positions": copy_positions,
}
cleared = 0
if clear_target and dst_ids:
rm = _remove_topology_view_nodes(
{"view_id": target_view_id, "fabric_node_ids": list(dst_ids)}
)
if rm.get("ok") is False:
return {
"ok": False,
"error": rm.get("error") or "clear_target_failed",
"detail": rm,
}
cleared = int(rm.get("removed") or len(dst_ids))
dst_ids = []
bump = _ensure_view_max_nodes(target_view_id, min(2000, max(need + 20, 200)))
if isinstance(bump, dict) and bump.get("ok") is False:
return bump
add_ids = [i for i in src_ids if i not in set(dst_ids)]
add_out: dict[str, Any] = {"ok": True, "added": 0}
if add_ids:
add_out = _add_topology_view_nodes(
{
"view_id": target_view_id,
"fabric_node_ids": add_ids,
"layout": "keep",
"max_nodes": min(2000, max(need + 20, 200)),
}
)
if add_out.get("ok") is False:
return {
"ok": False,
"error": add_out.get("error") or "target_add_failed",
"detail": add_out,
}
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if copy_positions:
positions = [
{
"fabric_node_id": nid,
"x": float(src_pos[nid][0] + offset_x),
"y": float(src_pos[nid][1] + offset_y),
}
for nid in src_ids
if nid in src_pos
]
if positions:
patch_out = _patch_positions_chunked(target_view_id, positions)
if not patch_out.get("ok"):
return {
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"cleared": cleared,
"added": int(add_out.get("added", 0)),
"hint": "Target membership may already be changed; source is unchanged. Inspect target before retrying.",
}
dst2 = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0))
if not dst2.get("ok"):
return {
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"detail": dst2,
"hint": "Writes completed but verification failed. Read target before retrying the copy.",
}
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return {
"ok": True,
"action": "copyTopologyViewNodes",
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"source_nodes": len(src_ids),
"cleared": cleared,
"added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated") or 0),
"copy_positions": copy_positions,
"target_nodes": len(dst_after) if dst2.get("ok") else need,
"max_nodes_bump": bump,
"hint": (
"Clone done — source unchanged. Run layoutTopologyView / analyze on target."
),
}
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{
"name": "getTopologyTree",
"description": (
"Get topology folder tree. Top-level folders are nav「根」; each has a unique "
"「根图」/Root map canvas (physical view). Nested regions are canvases themselves. "
"Each folder has ne_count (distinct fabric NEs in subtree). Default compact=true "
"(slim fields for agents). Optional max_depth prunes deep children. Start here; "
"createTopologyFolder if you need a new root/region."
),
"inputSchema": {
"type": "object",
"properties": {
"compact": {
"type": "boolean",
"default": True,
"description": "Default true — slim folder/view fields. Pass false for raw API tree.",
},
"max_depth": {
"type": "integer",
"minimum": 0,
"description": "Optional depth limit from root (0 = root only). Omitted = full depth.",
},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "getTopologyView",
"description": (
"Get a topology view by view_id. Default detail=summary: counts, sample_nodes (x/y), "
"and links[] (undirected NE pairs + link_count for one canvas edge each). "
"For UME layout study on small regions, set sample >= node_count. "
"Pass detail=full only when you need every membership field."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"detail": {
"type": "string",
"enum": ["summary", "full"],
"default": "summary",
},
"sample": {
"type": "integer",
"minimum": 0,
"maximum": 200,
"default": 20,
"description": "Sample node count when detail=summary (use >=node_count for layout study).",
},
},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "createTopologyFolder",
"description": (
"ONLY way to create canvases. Top-level: nav「根」+ auto「根图」physical + view_id. "
"Under 根图/region: creates a sub-region canvas (another physical view_id). "
"Returns folder id + view_id / canvas_folder_id for addTopologyViewNodes. "
"Optional locale zh|en for Root map naming. Requires ne:write."
),
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Root or sub-region display name"},
"parent_id": {
"type": "string",
"description": (
"Omit for new top-level「根」+根图. Pass 根图/region folder id to "
"create a sub-region under it (not a sibling custom view)."
),
},
"locale": {
"type": "string",
"description": "Optional zh|en — labels auto「根图」vs Root map",
},
"sort_order": {"type": "integer", "default": 0},
},
"required": ["name"],
"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). Soft max_nodes is per-view "
"(physical default 2000). Pass max_nodes to raise/clamp the view's "
"membership limit on the SAME physical canvas. "
"Never pass managed/UME ids."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"max_nodes": {
"type": "integer",
"minimum": 1,
"maximum": 2000,
"description": (
"Optional: PATCH this view's membership.max_nodes before add "
"(use on physical 根图/region when soft cap blocks members)."
),
},
"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": "sinkTopologyDualUnits",
"description": (
"Drain a root physical canvas into a child-region sink: detect dual_units on "
"source_view_id, layout_dual_unit each unit (layout_batch default true), "
"then compose_orbit-style block sweep to park onto sink (best partial "
"crossings / overlap / bridge — not fixed right), "
"then remove those fabric ids from the root. "
"Default keep_layers=[core,agg] leaves gravity anchors on the root; "
"sink_layers=[access,other] drains one level phase (set by_level=true "
"to auto deepest remaining). "
"Default one batch/call; until_empty=true loops until source empty "
"(or leftovers). Global polish still via layoutTopologyView. "
"dry_run previews selection. Requires ne:write."
),
"inputSchema": {
"type": "object",
"properties": {
"source_view_id": {
"type": "string",
"description": "Root / source physical view_id to drain.",
},
"sink_view_id": {
"type": "string",
"description": "Child-region physical view_id receiving batches.",
},
"max_units": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 3,
"description": "Max dual_units per batch (default 3).",
},
"min_nodes": {
"type": "integer",
"minimum": 2,
"maximum": 200,
"default": 8,
},
"max_nodes": {
"type": "integer",
"minimum": 2,
"maximum": 400,
"default": 300,
"description": (
"Max nodes per dual_unit candidate (CN eyes often 150–300)."
),
},
"max_batch_nodes": {
"type": "integer",
"minimum": 8,
"maximum": 800,
"default": 400,
},
"layout_batch": {
"type": "boolean",
"default": True,
"description": (
"Default true: run layout_dual_unit per unit before parking "
"(old staging/block layout). false = copy source coords only."
),
},
"unit_gap": {
"type": "number",
"default": 220,
"description": "Gap between laid-out units in a batch strip.",
},
"until_empty": {
"type": "boolean",
"default": False,
"description": "Loop batches until source empty (watch MCP timeout).",
},
"max_batches": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Safety cap when until_empty (default 50).",
},
"include_leftovers": {
"type": "boolean",
"default": True,
"description": "When dual_units exhausted, move leftover NE chunks.",
},
"keep_layers": {
"type": "array",
"items": {"type": "string"},
"default": ["core", "agg"],
"description": (
"Fabric layers that stay on source (not sunk). "
"Default core+agg so root keeps the gravity bar; "
"pass [] to drain everything (legacy)."
),
},
"sink_layers": {
"type": "array",
"items": {"type": "string"},
"default": ["access", "other"],
"description": (
"Only move these layers this phase. Default access+other. "
"Pass [] to allow any non-keep. With by_level=true, auto "
"picks deepest remaining (access→agg→…)."
),
},
"by_level": {
"type": "boolean",
"default": False,
"description": (
"Auto sink deepest remaining non-keep layer each batch "
"(access first, then agg if keep allows)."
),
},
"prefer_pure": {
"type": "boolean",
"default": False,
"description": (
"Legacy: prefer both portals sinkable. Default false so "
"CN/core eyes (portals stay on root) are not deprioritized."
),
},
"prefer_core_eye": {
"type": "boolean",
"default": True,
"description": (
"Alias of prefer_top_eye. Prefer core/agg eyes "
"top→down before access rings."
),
},
"prefer_top_eye": {
"type": "boolean",
"default": True,
"description": (
"Prefer dual_unit portals top→down: core–core, "
"core–agg, agg–agg, then maximize movable coverage."
),
},
"dry_run": {
"type": "boolean",
"default": False,
},
"pad": {
"type": "number",
"default": 280,
"description": "Gap between sink hull and parked batch.",
},
"detect_max_units": {
"type": "integer",
"minimum": 20,
"maximum": 300,
"default": 120,
},
},
"required": ["source_view_id", "sink_view_id"],
"additionalProperties": False,
},
},
{
"name": "copyTopologyViewNodes",
"description": (
"One-shot clone: copy all fabric placements from source_view_id onto "
"target_view_id (optional clear_target first), preserving x/y when "
"copy_positions=true. Source canvas is unchanged — use for test sandboxes "
"instead of re-adding ids. Soft max 2000. Requires ne:write."
),
"inputSchema": {
"type": "object",
"properties": {
"source_view_id": {
"type": "string",
"description": "Canvas to copy FROM.",
},
"target_view_id": {
"type": "string",
"description": "Canvas to copy TO.",
},
"copy_positions": {
"type": "boolean",
"default": True,
"description": "Copy x/y from source (default true).",
},
"clear_target": {
"type": "boolean",
"default": False,
"description": "Remove existing target members before copy.",
},
"offset_x": {
"type": "number",
"default": 0,
"description": "Shift copied x by this amount.",
},
"offset_y": {
"type": "number",
"default": 0,
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 2000,
"description": "Optional cap on how many source nodes to copy.",
},
"dry_run": {"type": "boolean", "default": False},
},
"required": ["source_view_id", "target_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",
"description": "Omit to keep the current label; \"\" clears a rename (follow device name).",
},
"locked": {
"type": "boolean",
"description": "Omit to keep the lock. Locked nodes are skipped unless locked=false (unlock + move).",
},
},
"required": ["fabric_node_id"],
"additionalProperties": False,
},
},
},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "projectTopologyNeighbors",
"description": (
"Project existing fabric neighbors (LLDP/UME) of nodes already on the view onto the canvas. "
"Only places nodes that already exist in fabric. Default detail=summary (not full graph). "
"Optional seed_fabric_node_ids / managed_ne_ids limit expansion to those seeds; "
"omit to expand from every node on the view. "
"Pass region_folder_id to keep only peers whose fabric.region_folder_id matches "
"(reports out_of_region_skipped); use this on regional verify canvases."
),
"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",
},
"region_folder_id": {
"type": "string",
"description": (
"If set, only add neighbors with matching fabric.region_folder_id; "
"skipped peers are counted in out_of_region_skipped."
),
},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview projected graph without saving placements.",
},
"detail": {
"type": "string",
"enum": ["summary", "full"],
"default": "summary",
},
"sample": {"type": "integer", "minimum": 0, "maximum": 100, "default": 20},
},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "queryTopologyFabricNodes",
"description": (
"Fabric inventory (read-only). mode=summary|list|search. "
"Default: q→search, else list. "
"summary=node/edge counts; list=paged filters "
"(keyword/role/link_status/region_folder_id); "
"search=quick name/IP/id (needs q)."
),
"inputSchema": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["summary", "list", "search"],
"description": "Omit to auto-pick: q→search, else list.",
},
"q": {
"type": "string",
"description": "Search needle (mode=search); also aliases keyword for list.",
},
"keyword": {
"type": "string",
"description": "List filter keyword; alias of q for search.",
},
"role": {"type": "string"},
"level": {
"type": "string",
"description": "Exact fabric level e.g. 1.1",
},
"level_major": {
"type": "string",
"description": "Major band e.g. 2 → [2.0, 3.0)",
},
"region_folder_id": {
"type": "string",
"description": "Filter by topo folder id (UME region / canvas folder).",
},
"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},
"limit": {"type": "integer", "description": "Alias of page_size"},
"summary": {
"type": "boolean",
"default": False,
"description": "Shortcut for mode=summary",
},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "classifyTopologyFabricNodes",
"description": (
"Classify Fabric inventory by layout level (major.minor) + region. "
"level: 0=external, 1=core, 2=agg, 3=access; use 1.1 / 2.1 for sub-tiers. "
"action=match → tag(level|role preset, dry_run) → tag. "
"role preset still accepted (maps to *.0). "
"After tagging, queryTopologyFabricNodes by region/level, then place returned "
"fabric_node_ids with addTopologyViewNodes (canvas filters do not support region/level). "
"Requires ne:write."
),
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"match",
"tag",
"patch",
"unmatched",
"preview_rules",
"apply_rules",
"list_rules",
],
"description": "Required. Typical: match → tag(dry_run) → tag.",
},
"pattern": {
"type": "string",
"description": "Regex for match/tag (aliases q).",
},
"q": {"type": "string", "description": "Alias of pattern."},
"match_field": {
"type": "string",
"enum": ["name", "ip", "name_ip"],
"default": "name",
},
"sample_limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50,
},
"fabric_node_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Explicit ids for tag (from match).",
},
"fabric_node_id": {
"type": "string",
"description": "Single id for patch (alias node_id).",
},
"node_id": {"type": "string"},
"level": {
"description": "Layout rank e.g. 0, 1, 1.1, 2.1, 3. null clears.",
},
"role": {
"type": "string",
"description": "Preset alias → level: external|core|aggregation|access|edge.",
},
"region_folder_id": {
"type": "string",
"description": "Topo region folder id; empty + clear_region clears.",
},
"clear_region": {
"type": "boolean",
"default": False,
"description": "Clear region_folder_id on tag/patch.",
},
"dry_run": {
"type": "boolean",
"default": False,
"description": "tag only: preview without write.",
},
"kind": {
"type": "string",
"enum": ["any", "level", "role", "region"],
"default": "any",
"description": "unmatched filter (role aliases level).",
},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"default": 50,
},
"limit": {"type": "integer", "description": "Alias of page_size"},
"skip_manual": {
"type": "boolean",
"default": True,
"description": "apply_rules: skip manually tagged nodes.",
},
"overwrite_manual": {
"type": "boolean",
"default": False,
"description": "apply_rules shortcut: skip_manual=false.",
},
"fill_empty_only": {
"type": "boolean",
"default": False,
"description": "apply_rules: only fill empty role/region.",
},
},
"required": ["action"],
"additionalProperties": False,
},
},
{
"name": "queryTopologyNeighborhood",
"description": (
"Neighborhood around a fabric node (depth 1–3). Returns compact nodes + links[] "
"(undirected NE pairs + link_count). No port/label spam."
),
"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": (
"Fabric adjacency for drawing: default detail=adjacency returns links[] "
"(a_node_id, b_node_id, link_count [, names]) — one canvas edge per pair. "
"With node_id also returns peers[]. Raise page_size if peers_complete is false. "
"Pass detail=ports only when you need port-level rows."
),
"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", "ume", "manual"]},
"detail": {
"type": "string",
"enum": ["adjacency", "ports"],
"default": "adjacency",
"description": "adjacency (default) = NE pairs; ports = raw port rows in items[].",
},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "suggestSinkHubs",
"description": (
"After the one-shot dual_unit eye is fixed: rank remaining hub territories on "
"source_view for non-dual move_nodes(park) batches. Excludes eye portals "
"(source/sink dual_units + exclude_portal_ids). Returns batches[] ranked by "
"remaining territory (agg before core) and move_nodes payload for pick=1. "
"Do NOT call sinkTopologyDualUnits again. Needs ne:read."
),
"inputSchema": {
"type": "object",
"properties": {
"source_view_id": {
"type": "string",
"description": "Root / leftover canvas (required). Alias: view_id.",
},
"view_id": {
"type": "string",
"description": "Alias of source_view_id.",
},
"sink_view_id": {
"type": "string",
"description": "Eye sink canvas; ids already there are dropped from batches.",
},
"exclude_portal_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Extra freeze ids (CN portals). Merged with dual_units portals.",
},
"use_source_dual_portals": {
"type": "boolean",
"default": False,
"description": "Exclude portals from source dual_units (default false).",
},
"use_sink_dual_portals": {
"type": "boolean",
"default": True,
"description": "Exclude portals from sink dual_units when sink_view_id set.",
},
"only_layers": {
"type": "array",
"items": {"type": "string"},
"description": "Hub layers to consider (default [agg, core]).",
},
"min_territory": {
"type": "integer",
"minimum": 0,
"default": 1,
"description": "Skip hubs with remaining_territory below this.",
},
"min_move_n": {
"type": "integer",
"minimum": 1,
"default": 1,
},
"top_n": {
"type": "integer",
"minimum": 1,
"maximum": 40,
"default": 12,
},
"pick": {
"type": "integer",
"minimum": 1,
"default": 1,
"description": "1-based batch to expose as batch + move_nodes.",
},
"include_hub": {
"type": "boolean",
"default": True,
"description": "Include hub id in fabric_node_ids when still on source.",
},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "analyzeTopologyViewLayout",
"description": (
"Layout QA + structure planning (read-only). Returns verdict + overlap/crossing/"
"spacing/sparsity/edges + mid-tier chains(直链成一体)/rings(最小环不被穿) "
"+ score.total∈[0,100] (profiles: auto|default|eye). Pass view_id, or folder_id to sample. "
"detail=structure: graph stats for gravity (core_bar|agg_bar|mixed), hubs, stubs, "
"dual_units (two-portal eye units), soft_blocks, "
"geometry_hint, recipe_preference (compact|corridor|rings) — call BEFORE layout. "
"detail=hotspots|blocks|both: sight{} for hand-drag (crossings, drag_candidates, blocks); "
"both also includes structure{}. "
"score_profile=auto picks eye for large sparse diagonal sinks. "
"For writing positions use layoutTopologyView / updateTopologyViewPositions. Needs ne:read."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {
"type": "string",
"description": "Analyze a single topology view",
},
"folder_id": {
"type": "string",
"description": "Sample physical views under this folder (tree walk)",
},
"detail": {
"type": "string",
"enum": ["summary", "structure", "hotspots", "blocks", "both"],
"default": "summary",
"description": (
"summary=score only; structure=gravity/hubs/recipe hint (phase 0.5); "
"hotspots|blocks=sight; both=structure+sight (hand-drag)"
),
},
"score_profile": {
"type": "string",
"enum": ["auto", "default", "eye"],
"default": "auto",
"description": (
"Scoring weights. auto→eye for large sparse diagonal canvases; "
"eye softens axis/rings (arc-band dual sinks); default=metro."
),
},
"sight_limit": {
"type": "integer",
"minimum": 5,
"maximum": 80,
"default": 40,
"description": "Max crossings / drag candidates when detail≠summary",
},
"sight_cell": {
"type": "number",
"default": 600,
"description": "Grid cell size (px) for detail=blocks",
},
"max_views": {
"type": "integer",
"minimum": 1,
"maximum": 80,
"default": 25,
},
"min_nodes": {"type": "integer", "minimum": 0, "default": 5},
"max_nodes": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 800},
"with_meta": {
"type": "boolean",
"default": False,
"description": "Include IP/vendor caption line in overlap boxes",
},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "layoutTopologyView",
"description": (
"Layout / local polish for a canvas. Prefer local actions over global crush. "
"action=layout: recipe=rings|corridor|compact|unstick (small graphs). "
"action=layout_dual_unit: CN/core dual-portal eye (max membership; "
"residual crossings OK unless params.require_zero_cross=true). "
"action=move_nodes: move fabric_node_ids from "
"source_view_id→view_id; park=true for orbit attach; swap views to reverse. "
"Prefer sinkTopologyDualUnits for dual_units batches. "
"action=orbit_sweep: crossing orbit; preview+node_id / apply+pick; "
"until_limit=true loops single-point to stall (eye polish; default freeze portals); "
"round=true one-batch auto pick#1 (avoid on eye sinks). "
"action=level_bands: snap y by fabric layer (external→core→agg→access). "
"action=polish_crossings: one-shot straighten→press→untangle (no temp scripts). "
"action=clear_edge_hits: eject nodes on non-incident edges (H/V); "
"never raise crossings (even preserve_axis); eye sinks need portal_ids. "
"action=compact_bbox: gated farthest-K shrink toward portals. "
"action=pull_far_chains: scale far corridors/isolates toward portal mid-point. "
"action=align_reference: SAME-network A/B debug only "
"(shared fabric_node_ids); not a cross-network template. "
"action=fix_overlaps: pull apart overlaps (not on eye sinks). "
"action=untangle / straighten_channels: surgical polish (not on eye sinks). "
"action=job_status|job_cancel: poll/cancel background jobs. "
"params.background=true starts an apply action in a durable subprocess; "
"poll params.job_id until done|error|cancelled, then inspect result. "
"Successful apply persists positions; getTopologyView reads them back. "
"preset: loose|balanced|dense. mode: preview|apply. "
"Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
"suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → "
"clear_edge_hits → pull_far_chains → compact_bbox → hand. "
"No golden by default; do not align_reference for delivery. "
"Needs ne:write for apply."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {
"type": "string",
"description": "Target canvas to layout (and write when mode=apply)",
},
"action": {
"type": "string",
"enum": [
"layout",
"fix_overlaps",
"layout_dual_unit",
"straighten_channels",
"untangle",
"polish_crossings",
"clear_edge_hits",
"compact_bbox",
"pull_far_chains",
"align_reference",
"orbit_sweep",
"level_bands",
"move_nodes",
"job_status",
"job_cancel",
],
"default": "layout",
"description": (
"layout=full recipe; layout_dual_unit=dual-portal eye; "
"level_bands=horizontal layer bands; "
"move_nodes=migrate fabric_node_ids; "
"orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; "
"clear_edge_hits=eject edge hits (no crossing rise); "
"pull_far_chains=scale far corridors toward portal mid; "
"compact_bbox=gated farthest-K shrink; "
"align_reference=same-network A/B debug only; "
"fix_overlaps; "
"untangle/straighten_channels; job_status|job_cancel."
),
},
"source_view_id": {
"type": "string",
"description": (
"For action=layout: optional load graph (default=view_id). "
"For action=move_nodes: required FROM canvas (view_id is TO). "
"For action=align_reference: same-network reference (debug only)."
),
},
"recipe": {
"type": "string",
"enum": [
"rings",
"corridor",
"compact",
"unstick",
],
"default": "rings",
"description": (
"Only for action=layout. rings=petals/min-rings; "
"corridor/compact/unstick=Tutte corridor variants."
),
},
"preset": {
"type": "string",
"enum": ["loose", "balanced", "dense"],
"default": "balanced",
},
"mode": {
"type": "string",
"enum": ["preview", "apply"],
"default": "preview",
"description": "preview=no write; apply=PATCH positions",
},
"tune": {
"type": "boolean",
"default": False,
"description": "Small param sweep for action=layout; zero-overlap first",
},
"params": {
"type": "object",
"description": (
"Overrides. layout: target_nn/target_util/…. "
"job_status|job_cancel: job_id (required). "
"layout_dual_unit: unit_id / portal_a+portal_b; "
"require_zero_cross (default false). "
"untangle: max_rounds/max_degree/protect_rigid/focus_ids[]. "
"polish_crossings: portal_ids[]/source_view_ids[], "
"top_n/max_moves/max_sweeps/straighten/untangle_rounds. "
"clear_edge_hits: top_n/thr/margin/max_moves/portal_ids/"
"max_eject_degree/preserve_axis. "
"compact_bbox|pull_far_chains: portal_ids; "
"pull scales toward portal mid (not outer hub); "
"compact defaults farthest-K. "
"orbit_sweep: node_id/pick/round/until_limit/portal_ids[]/"
"freeze_layers[]/freeze_levels[]/"
"max_degree/max_moves/stall_limit/max_stretch/min_delta/"
"max_jump/angle_step/nn_floor; until_limit defaults "
"protect_rigid=portals + objective=crossing; "
"single-node/round default protect_rigid=off. "
"move_nodes: fabric_node_ids[] (required), "
"copy_positions (default true), park, remove_from_source "
"(default true), pad/offset_x/offset_y."
),
"properties": {
"job_id": {"type": "string", "description": "Required for job_status/job_cancel; returned by background start."},
"background": {"type": "boolean", "description": "Run apply computation in a durable subprocess and return job_id immediately."},
"sync": {"type": "boolean", "description": "Force inline computation, disabling automatic background execution."},
},
"additionalProperties": True,
},
"catalog": {
"type": "boolean",
"default": False,
"description": "If true, only return action/recipe/preset catalog",
},
},
"required": [],
"additionalProperties": False,
},
},
]
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"getTopologyTree": _get_topology_tree,
"getTopologyView": _get_topology_view,
"createTopologyFolder": _create_topology_folder,
"addTopologyViewNodes": _add_topology_view_nodes,
"removeTopologyViewNodes": _remove_topology_view_nodes,
"sinkTopologyDualUnits": _sink_topology_dual_units,
"copyTopologyViewNodes": _copy_topology_view_nodes,
"updateTopologyViewPositions": _update_topology_view_positions,
"projectTopologyNeighbors": _project_topology_neighbors,
"queryTopologyFabricNodes": _query_topology_fabric_nodes,
"classifyTopologyFabricNodes": _classify_topology_fabric_nodes,
"queryTopologyNeighborhood": _query_topology_neighborhood,
"queryTopologyEdges": _query_topology_edges,
"suggestSinkHubs": _suggest_sink_hubs,
"analyzeTopologyViewLayout": _analyze_topology_view_layout,
"layoutTopologyView": _layout_topology_view,
}
TOOL_REQUIRED_SCOPE: dict[str, str] = {
"getTopologyTree": "ne:read",
"getTopologyView": "ne:read",
"createTopologyFolder": "ne:write",
"addTopologyViewNodes": "ne:write",
"removeTopologyViewNodes": "ne:write",
"sinkTopologyDualUnits": "ne:write",
"copyTopologyViewNodes": "ne:write",
"updateTopologyViewPositions": "ne:write",
"projectTopologyNeighbors": "ne:write",
"queryTopologyFabricNodes": "ne:read",
"classifyTopologyFabricNodes": "ne:write",
"queryTopologyNeighborhood": "ne:read",
"queryTopologyEdges": "ne:read",
"suggestSinkHubs": "ne:read",
"analyzeTopologyViewLayout": "ne:read",
"layoutTopologyView": "ne:write",
}
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 {})))