mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 17:03:27 +08:00
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>
This commit is contained in:
parent
918ece86c3
commit
52679a7cfc
150 changed files with 2367 additions and 2812 deletions
286
netx_api/mcp_remote/topology/layout_sight.py
Normal file
286
netx_api/mcp_remote/topology/layout_sight.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""Layout "sight" for agents: crossing hotspots + spatial blocks with coords.
|
||||
|
||||
Gives enough geometry to hand-drag like a human without loading the full canvas
|
||||
into chat. Used by analyzeTopologyViewLayout(detail=hotspots|blocks).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
collapse_links,
|
||||
segments_properly_intersect,
|
||||
top_crossing_edges,
|
||||
top_crossing_nodes,
|
||||
)
|
||||
|
||||
|
||||
def _short_name(name: str) -> str:
|
||||
parts = (name or "").split("-")
|
||||
return parts[1] if len(parts) >= 2 else (name or "")[:16]
|
||||
|
||||
|
||||
def _pos_map(nodes: list[dict[str, Any]]) -> dict[str, tuple[float, float, str]]:
|
||||
out: dict[str, tuple[float, float, str]] = {}
|
||||
for n in nodes:
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
nid = str(n.get("fabric_node_id") or "").strip()
|
||||
if not nid:
|
||||
continue
|
||||
try:
|
||||
x, y = float(n.get("x")), float(n.get("y"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out[nid] = (x, y, str(n.get("name") or nid))
|
||||
return out
|
||||
|
||||
|
||||
def list_crossings(
|
||||
nodes: list[dict[str, Any]],
|
||||
edges: list[dict[str, Any]],
|
||||
*,
|
||||
limit: int = 40,
|
||||
) -> dict[str, Any]:
|
||||
pos = _pos_map(nodes)
|
||||
links = [(a, b) for a, b in collapse_links(edges) if a in pos and b in pos]
|
||||
crosses: list[dict[str, Any]] = []
|
||||
hit: dict[str, int] = {}
|
||||
edge_hit: dict[tuple[str, str], int] = {}
|
||||
total_cross = 0
|
||||
for i, (a, b) in enumerate(links):
|
||||
p1, p2 = (pos[a][0], pos[a][1]), (pos[b][0], pos[b][1])
|
||||
e1 = (a, b) if a < b else (b, a)
|
||||
for c, d in links[i + 1 :]:
|
||||
if len({a, b, c, d}) < 4:
|
||||
continue
|
||||
p3, p4 = (pos[c][0], pos[c][1]), (pos[d][0], pos[d][1])
|
||||
if not segments_properly_intersect(p1, p2, p3, p4):
|
||||
continue
|
||||
total_cross += 1
|
||||
e2 = (c, d) if c < d else (d, c)
|
||||
for nid in (a, b, c, d):
|
||||
hit[nid] = hit.get(nid, 0) + 1
|
||||
edge_hit[e1] = edge_hit.get(e1, 0) + 1
|
||||
edge_hit[e2] = edge_hit.get(e2, 0) + 1
|
||||
if len(crosses) < limit:
|
||||
crosses.append(
|
||||
{
|
||||
"e1": {
|
||||
"a": a,
|
||||
"b": b,
|
||||
"a_name": _short_name(pos[a][2]),
|
||||
"b_name": _short_name(pos[b][2]),
|
||||
},
|
||||
"e2": {
|
||||
"a": c,
|
||||
"b": d,
|
||||
"a_name": _short_name(pos[c][2]),
|
||||
"b_name": _short_name(pos[d][2]),
|
||||
},
|
||||
}
|
||||
)
|
||||
# Rank drag candidates: high crossing participation / low degree.
|
||||
adj: dict[str, set[str]] = {n: set() for n in pos}
|
||||
for a, b in links:
|
||||
adj[a].add(b)
|
||||
adj[b].add(a)
|
||||
|
||||
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_lite_suggest
|
||||
|
||||
pos_xy = {k: (v[0], v[1]) for k, v in pos.items()}
|
||||
names = {k: v[2] for k, v in pos.items()}
|
||||
candidates = []
|
||||
for nid, cnt in sorted(hit.items(), key=lambda kv: -kv[1]):
|
||||
deg = len(adj.get(nid, ()))
|
||||
if deg >= 8:
|
||||
continue
|
||||
x, y, name = pos[nid]
|
||||
suggest = orbit_lite_suggest(
|
||||
nid,
|
||||
pos_xy,
|
||||
links,
|
||||
adj,
|
||||
names,
|
||||
max_jump=360.0,
|
||||
angle_step=45,
|
||||
top_k=3,
|
||||
)
|
||||
best_delta = suggest[0]["delta_crossings_est"] if suggest else 0
|
||||
candidates.append(
|
||||
{
|
||||
"fabric_node_id": nid,
|
||||
"name": name,
|
||||
"short": _short_name(name),
|
||||
"x": round(x, 1),
|
||||
"y": round(y, 1),
|
||||
"degree": deg,
|
||||
"in_crossings": cnt,
|
||||
"score": round(cnt / max(deg, 1), 3),
|
||||
"suggest_xy": suggest[:3],
|
||||
"delta_crossings_est": best_delta,
|
||||
}
|
||||
)
|
||||
if len(candidates) >= min(30, limit):
|
||||
break
|
||||
top5 = top_crossing_nodes(
|
||||
pos_xy, links, names=names, adj=adj, top_n=5, participation=hit
|
||||
)
|
||||
top_e = top_crossing_edges(
|
||||
pos_xy, links, names=names, top_n=5, edge_participation=edge_hit
|
||||
)
|
||||
# Fallback: when no drag_candidates (all hot nodes are high-degree hubs),
|
||||
# suggest from edge_clearance.top and crossing top_nodes regardless of degree.
|
||||
if not candidates:
|
||||
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
|
||||
|
||||
ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5)
|
||||
for eh in (ec.get("top_edge_hits") or [])[:5]:
|
||||
nid = str(eh.get("fabric_node_id") or "")
|
||||
if not nid or nid not in pos:
|
||||
continue
|
||||
x, y, name = pos[nid]
|
||||
deg = len(adj.get(nid, ()))
|
||||
candidates.append({
|
||||
"fabric_node_id": nid,
|
||||
"name": name,
|
||||
"short": _short_name(name),
|
||||
"x": round(x, 1),
|
||||
"y": round(y, 1),
|
||||
"degree": deg,
|
||||
"in_crossings": hit.get(nid, 0),
|
||||
"score": 0.0,
|
||||
"suggest_xy": [],
|
||||
"delta_crossings_est": 0,
|
||||
"reason": "edge_clearance_hit",
|
||||
})
|
||||
for tn in top5:
|
||||
nid = str(tn.get("fabric_node_id") or "")
|
||||
if not nid or nid not in pos:
|
||||
continue
|
||||
if any(c["fabric_node_id"] == nid for c in candidates):
|
||||
continue
|
||||
x, y, name = pos[nid]
|
||||
deg = len(adj.get(nid, ()))
|
||||
candidates.append({
|
||||
"fabric_node_id": nid,
|
||||
"name": name,
|
||||
"short": _short_name(name),
|
||||
"x": round(x, 1),
|
||||
"y": round(y, 1),
|
||||
"degree": deg,
|
||||
"in_crossings": tn.get("crossing_hits", 0),
|
||||
"score": round(tn.get("crossing_hits", 0) / max(deg, 1), 3),
|
||||
"suggest_xy": [],
|
||||
"delta_crossings_est": 0,
|
||||
"reason": "top_crossing_high_degree",
|
||||
})
|
||||
return {
|
||||
"edge_crossings": total_cross,
|
||||
"crossings_listed": len(crosses),
|
||||
"crossings": crosses,
|
||||
"top_nodes": top5,
|
||||
"top_edges": top_e,
|
||||
"drag_candidates": candidates,
|
||||
"tip": (
|
||||
"先看 top_nodes / top_edges(交叉最重前5网元与前5边);"
|
||||
"再动 drag_candidates(低度数可挪点,suggest_xy=orbit_lite);"
|
||||
"完整扫角用 layoutTopologyView action=orbit_sweep(preview,node_id)→pick。"
|
||||
"勿本地穷举坐标。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def spatial_blocks(
|
||||
nodes: list[dict[str, Any]],
|
||||
edges: list[dict[str, Any]],
|
||||
*,
|
||||
cell: float = 600.0,
|
||||
top_k: int = 12,
|
||||
) -> dict[str, Any]:
|
||||
"""Bucket nodes into coarse grid cells; report densest / most-crossing blocks."""
|
||||
pos = _pos_map(nodes)
|
||||
if not pos or cell <= 1:
|
||||
return {"blocks": [], "cell": cell}
|
||||
links = [(a, b) for a, b in collapse_links(edges) if a in pos and b in pos]
|
||||
|
||||
def cell_key(x: float, y: float) -> tuple[int, int]:
|
||||
return (int(math.floor(x / cell)), int(math.floor(y / cell)))
|
||||
|
||||
buckets: dict[tuple[int, int], list[str]] = {}
|
||||
for nid, (x, y, _) in pos.items():
|
||||
buckets.setdefault(cell_key(x, y), []).append(nid)
|
||||
|
||||
# Count crossings whose both midpoints fall in a cell (or either endpoint).
|
||||
cell_cross: dict[tuple[int, int], int] = {k: 0 for k in buckets}
|
||||
for i, (a, b) in enumerate(links):
|
||||
p1 = (pos[a][0], pos[a][1])
|
||||
p2 = (pos[b][0], pos[b][1])
|
||||
for c, d in links[i + 1 :]:
|
||||
if len({a, b, c, d}) < 4:
|
||||
continue
|
||||
p3 = (pos[c][0], pos[c][1])
|
||||
p4 = (pos[d][0], pos[d][1])
|
||||
if not segments_properly_intersect(p1, p2, p3, p4):
|
||||
continue
|
||||
mx = (p1[0] + p2[0] + p3[0] + p4[0]) / 4
|
||||
my = (p1[1] + p2[1] + p3[1] + p4[1]) / 4
|
||||
ck = cell_key(mx, my)
|
||||
cell_cross[ck] = cell_cross.get(ck, 0) + 1
|
||||
|
||||
blocks = []
|
||||
for (cx, cy), ids in buckets.items():
|
||||
xs = [pos[i][0] for i in ids]
|
||||
ys = [pos[i][1] for i in ids]
|
||||
sample = sorted(ids, key=lambda i: pos[i][2])[:8]
|
||||
blocks.append(
|
||||
{
|
||||
"cell": [cx, cy],
|
||||
"bbox": [
|
||||
round(min(xs), 1),
|
||||
round(min(ys), 1),
|
||||
round(max(xs), 1),
|
||||
round(max(ys), 1),
|
||||
],
|
||||
"node_count": len(ids),
|
||||
"crossings_near": cell_cross.get((cx, cy), 0),
|
||||
"sample_nodes": [
|
||||
{
|
||||
"fabric_node_id": i,
|
||||
"short": _short_name(pos[i][2]),
|
||||
"x": round(pos[i][0], 1),
|
||||
"y": round(pos[i][1], 1),
|
||||
}
|
||||
for i in sample
|
||||
],
|
||||
}
|
||||
)
|
||||
blocks.sort(key=lambda b: (-int(b["crossings_near"]), -int(b["node_count"])))
|
||||
return {
|
||||
"cell": cell,
|
||||
"blocks": blocks[:top_k],
|
||||
"tip": (
|
||||
"分块手拖:选 crossings_near 高的 block,"
|
||||
"getTopologyView / queryTopologyNeighborhood 看邻接,"
|
||||
"再 updateTopologyViewPositions 挪该块内的点。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_sight(
|
||||
nodes: list[dict[str, Any]],
|
||||
edges: list[dict[str, Any]],
|
||||
*,
|
||||
mode: str = "hotspots",
|
||||
limit: int = 40,
|
||||
cell: float = 600.0,
|
||||
) -> dict[str, Any]:
|
||||
mode = (mode or "hotspots").strip().lower()
|
||||
out: dict[str, Any] = {"mode": mode}
|
||||
if mode in {"hotspots", "both", "all"}:
|
||||
out["hotspots"] = list_crossings(nodes, edges, limit=limit)
|
||||
if mode in {"blocks", "both", "all"}:
|
||||
out["blocks"] = spatial_blocks(nodes, edges, cell=cell, top_k=min(16, max(6, limit // 3)))
|
||||
return out
|
||||
Loading…
Add table
Add a link
Reference in a new issue