mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 19:23:22 +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
295
netx_api/mcp_remote/topology/layout_ops/fold_chain.py
Normal file
295
netx_api/mcp_remote/topology/layout_ops/fold_chain.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue