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,295 @@
"""Fold a deg≤2 tentacle into the emptiest cheap sector around a hub.
Agent workflow (stage 2): nearest-ring angular sweep → push low-degree blockers
radially out → place the whole chain on an arc in that sector.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _deg(st: LayoutState) -> dict[str, int]:
return {nid: len(st.adj.get(nid, ())) for nid in st.positions}
def _chain_from(st: LayoutState, hub: str, stub: str, deg: dict[str, int]) -> list[str]:
path = [stub]
prev, cur = hub, stub
while True:
nxt = [x for x in st.adj.get(cur, ()) if x != prev and deg.get(x, 0) <= 2]
if len(nxt) != 1:
break
path.append(nxt[0])
prev, cur = cur, nxt[0]
return path
def _pick_hub(st: LayoutState, deg: dict[str, int], hub_id: str | None) -> str:
if hub_id and hub_id in st.positions:
return hub_id
# Prefer highest degree; break ties by name for stability.
ranked = sorted(
st.positions.keys(),
key=lambda n: (-deg.get(n, 0), st.names.get(n, ""), n),
)
if not ranked:
raise ValueError("fold_chain:empty_graph")
return ranked[0]
def _first_hop_toward(
st: LayoutState, hub: str, target: str
) -> str | None:
"""BFS: first neighbor of hub on a path to target."""
if target in st.adj.get(hub, ()):
return target
from collections import deque
q = deque([hub])
prev: dict[str, str | None] = {hub: None}
while q:
u = q.popleft()
for v in st.adj.get(u, ()):
if v in prev:
continue
prev[v] = u
if v == target:
# Walk back to hop after hub.
cur = target
while prev[cur] is not None and prev[cur] != hub:
cur = prev[cur]
return cur
q.append(v)
return None
def _pick_stub(
st: LayoutState,
hub: str,
deg: dict[str, int],
stub_id: str | None,
*,
min_len: int,
) -> tuple[str, list[str]]:
if stub_id and stub_id in st.positions:
hop = _first_hop_toward(st, hub, stub_id)
if hop is None:
raise ValueError(f"fold_chain:stub_unreachable:{stub_id}")
chain = _chain_from(st, hub, hop, deg)
return hop, chain
best: list[str] = []
best_stub = ""
for nb in sorted(st.adj.get(hub, ()), key=lambda n: (st.names.get(n, ""), n)):
if deg.get(nb, 0) > 2:
continue
chain = _chain_from(st, hub, nb, deg)
if len(chain) > len(best):
best = chain
best_stub = nb
if len(best) < min_len:
raise ValueError(
f"fold_chain:no_tentacle_from_hub:{hub}:need>={min_len},got={len(best)}"
)
return best_stub, best
def _ang_delta(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0)
def fold_chain_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
"""Pull fold_chain knobs from layout params overrides."""
o = overrides or {}
out: dict[str, Any] = {}
def _f(key: str) -> float | None:
if key not in o or o[key] is None:
return None
try:
return float(o[key])
except (TypeError, ValueError):
return None
def _i(key: str) -> int | None:
if key not in o or o[key] is None:
return None
try:
return int(o[key])
except (TypeError, ValueError):
return None
for key in ("hub_id", "stub_id", "chain_start"):
if key in o and o[key] is not None and str(o[key]).strip():
out[key] = str(o[key]).strip()
for key in ("prefer_mid_deg", "r_arc", "chord", "push_r", "min_gap_deg"):
v = _f(key)
if v is not None:
out[key] = v
for key in ("ring_k", "min_chain_len", "protect_deg_ge"):
v = _i(key)
if v is not None:
out[key] = v
if "clear_only" in o and o["clear_only"] is not None:
out["clear_only"] = str(o["clear_only"]).strip().lower() in {
"1",
"true",
"yes",
} or o["clear_only"] is True
if isinstance(o.get("protected_ids"), list):
out["protected_ids"] = [str(x).strip() for x in o["protected_ids"] if str(x).strip()]
return out
def fold_chain_into_sector(
st: LayoutState,
params: LayoutParams,
*,
hub_id: str | None = None,
stub_id: str | None = None,
chain_start: str | None = None,
prefer_mid_deg: float | None = None,
r_arc: float | None = None,
chord: float | None = None,
push_r: float | None = None,
ring_k: int = 14,
min_gap_deg: float = 26.0,
min_chain_len: int = 3,
clear_only: bool = False,
protected_ids: list[str] | None = None,
protect_deg_ge: int = 4,
) -> OpResult:
"""Sweep nearest ring, clear cheapest large gap, fold deg≤2 chain onto arc."""
st = st.copy()
deg = _deg(st)
hub = _pick_hub(st, deg, hub_id)
stub = stub_id or chain_start
stub, chain = _pick_stub(st, hub, deg, stub, min_len=int(min_chain_len))
chain_set = set(chain)
hx, hy = st.positions[hub]
protected = {hub, *chain_set}
for pid in protected_ids or []:
if str(pid).strip():
protected.add(str(pid).strip())
# Auto-protect high-degree nodes near hub
for nid, d in deg.items():
if d >= int(protect_deg_ge):
protected.add(nid)
others: list[tuple[float, float, str]] = []
for nid, (x, y) in st.positions.items():
if nid == hub or nid in chain_set:
continue
r = math.hypot(x - hx, y - hy)
ang = math.degrees(math.atan2(y - hy, x - hx)) % 360.0
others.append((r, ang, nid))
others.sort()
k = max(4, min(int(ring_k), len(others)))
ring = sorted(others[:k], key=lambda t: t[1]) if others else []
gaps: list[tuple[float, float, float, float, str, str]] = []
if len(ring) >= 2:
for i in range(len(ring)):
a0 = ring[i][1]
a1 = ring[(i + 1) % len(ring)][1]
gap = (a1 - a0) % 360.0
mid = (a0 + gap / 2.0) % 360.0
lid, rid = ring[i][2], ring[(i + 1) % len(ring)][2]
cost = 0.0
for bid in (lid, rid):
if bid in protected or deg.get(bid, 0) >= int(protect_deg_ge):
cost += 4.0
elif bid in st.adj.get(hub, ()):
cost += 1.0
gaps.append((cost, -gap, mid, gap, lid, rid))
gaps.sort()
else:
# Empty / sparse: open southish default sector.
mid = float(prefer_mid_deg) if prefer_mid_deg is not None else 90.0
gaps = [(0.0, -60.0, mid % 360.0, 60.0, "", "")]
def mid_penalty(mid: float) -> float:
if prefer_mid_deg is None:
return 0.0
return _ang_delta(mid, float(prefer_mid_deg)) / 30.0
cand = [g for g in gaps if g[3] >= float(min_gap_deg)] or gaps[:3]
cand.sort(key=lambda g: (g[0], mid_penalty(g[2]), -g[3]))
cost, _, sector_mid, best_gap, lid, rid = cand[0]
sector_half = max(min(best_gap / 2.0 - 2.0, 38.0), 28.0)
target_nn = float(params.target_nn or 155.0)
r_push = float(push_r) if push_r is not None else max(480.0, target_nn * 3.0)
arc_r = float(r_arc) if r_arc is not None else max(280.0, target_nn * 2.0)
chord_len = float(chord) if chord is not None else max(170.0, target_nn * 1.15)
moved: set[str] = set()
pushed: list[str] = []
def in_sector(ang: float) -> bool:
return _ang_delta(ang, sector_mid) <= sector_half
for r, ang, nid in others:
if not in_sector(ang) or nid in protected or deg.get(nid, 0) >= 3:
continue
if r >= r_push:
continue
nr = max(r_push, r + 200.0)
rad = math.radians(ang)
st.positions[nid] = (hx + nr * math.cos(rad), hy + nr * math.sin(rad))
moved.add(nid)
pushed.append(nid)
folded: list[str] = []
if not clear_only:
n = len(chain)
d_ang = math.degrees(2 * math.asin(min(0.95, chord_len / (2.0 * arc_r))))
span = d_ang * (n - 1)
a0 = sector_mid - span / 2.0
for i, nid in enumerate(chain):
ang = math.radians((a0 + i * d_ang) % 360.0)
st.positions[nid] = (hx + arc_r * math.cos(ang), hy + arc_r * math.sin(ang))
moved.add(nid)
folded.append(nid)
st.last_moved = set(moved)
st.meta["fold_chain"] = {
"hub_id": hub,
"stub_id": stub,
"chain": chain,
"sector_mid": round(sector_mid, 2),
"sector_half": round(sector_half, 2),
"gap_deg": round(best_gap, 2),
"gap_cost": cost,
"gap_bounds": [lid, rid],
"prefer_mid_deg": prefer_mid_deg,
"r_arc": arc_r,
"chord": chord_len,
"push_r": r_push,
"pushed": pushed,
"folded": folded,
"clear_only": bool(clear_only),
"ring_k": k,
}
note = (
f"fold_chain hub={hub[:8]} chain={len(chain)} "
f"mid={sector_mid:.0f} gap={best_gap:.0f} pushed={len(pushed)}"
)
return OpResult(
state=st,
moved=moved,
op="fold_chain_into_sector",
params={
"hub_id": hub,
"stub_id": stub,
"sector_mid": sector_mid,
"gap_deg": best_gap,
"clear_only": bool(clear_only),
},
note=note,
)