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:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View 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