mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 15:00:45 +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
209
netx_api/mcp_remote/topology/layout_ops/level_util.py
Normal file
209
netx_api/mcp_remote/topology/layout_ops/level_util.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""Map fabric level / role / name → layout layer key; level y-bands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
_ROLE_TO_LAYER = {
|
||||
"external": "external",
|
||||
"core": "core",
|
||||
"cn": "core",
|
||||
"aggregation": "agg",
|
||||
"aggregate": "agg",
|
||||
"agg": "agg",
|
||||
"an": "agg",
|
||||
"access": "access",
|
||||
"en": "access",
|
||||
"edge": "access",
|
||||
"cpe": "access",
|
||||
}
|
||||
|
||||
# Top → bottom on canvas (smaller fabric level sits higher).
|
||||
_BAND_ORDER = ("external", "core", "agg", "access", "other")
|
||||
|
||||
|
||||
def _parse_level(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
value = float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
lv = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(lv):
|
||||
return None
|
||||
return lv
|
||||
|
||||
|
||||
def infer_layer(
|
||||
name: str,
|
||||
role: str | None = None,
|
||||
level: float | None = None,
|
||||
) -> str:
|
||||
"""Map level / role / name token → external|core|agg|access|other."""
|
||||
lv = _parse_level(level)
|
||||
if lv is not None:
|
||||
maj = int(math.floor(lv))
|
||||
if maj <= 0:
|
||||
return "external"
|
||||
if maj == 1:
|
||||
return "core"
|
||||
if maj == 2:
|
||||
return "agg"
|
||||
return "access"
|
||||
r = str(role or "").strip().lower()
|
||||
if r in _ROLE_TO_LAYER:
|
||||
return _ROLE_TO_LAYER[r]
|
||||
m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
|
||||
if not m:
|
||||
return "other"
|
||||
return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
|
||||
|
||||
|
||||
def apply_level_bands(
|
||||
state: LayoutState,
|
||||
params: LayoutParams | None = None,
|
||||
*,
|
||||
y0: float = 120.0,
|
||||
band_gap: float = 320.0,
|
||||
pitch: float | None = None,
|
||||
preserve_x: bool = True,
|
||||
layers: tuple[str, ...] | None = None,
|
||||
max_per_row: int = 24,
|
||||
row_gap: float | None = None,
|
||||
) -> OpResult:
|
||||
"""Snap nodes into horizontal bands by layer (external→…→access).
|
||||
|
||||
Large bands wrap into multiple rows (``max_per_row``) so hundreds of
|
||||
access nodes are not crushed onto one y-line. Within a row, x is either
|
||||
kept (preserve_x) then gently de-overlapped to ``pitch``, or re-spaced
|
||||
by name order.
|
||||
"""
|
||||
params = params or LayoutParams()
|
||||
step = float(pitch if pitch is not None else max(params.pitch, 200.0))
|
||||
rgap = float(row_gap if row_gap is not None else max(step * 0.85, 170.0))
|
||||
per_row = max(4, int(max_per_row or 24))
|
||||
order = tuple(layers) if layers else _BAND_ORDER
|
||||
by: dict[str, list[str]] = {ly: [] for ly in order}
|
||||
for nid in state.positions:
|
||||
ly = state.layers.get(nid) or "other"
|
||||
if ly not in by:
|
||||
ly = "other"
|
||||
by.setdefault(ly, [])
|
||||
by[ly].append(nid)
|
||||
|
||||
pos = dict(state.positions)
|
||||
moved: set[str] = set()
|
||||
band_notes: list[dict[str, Any]] = []
|
||||
y_cursor = float(y0)
|
||||
|
||||
def _place_row(ids: list[str], y: float) -> None:
|
||||
nonlocal moved
|
||||
if not ids:
|
||||
return
|
||||
if preserve_x:
|
||||
ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
|
||||
xs = [float(pos[n][0]) for n in ids]
|
||||
# Enforce min pitch left→right without reversing order.
|
||||
fixed: list[float] = []
|
||||
for i, x in enumerate(xs):
|
||||
if i == 0:
|
||||
fixed.append(x)
|
||||
else:
|
||||
fixed.append(max(x, fixed[-1] + step))
|
||||
for n, x in zip(ids, fixed):
|
||||
nxt = (x, y)
|
||||
if nxt != pos[n]:
|
||||
moved.add(n)
|
||||
pos[n] = nxt
|
||||
else:
|
||||
ids = sorted(ids, key=lambda n: state.names.get(n, n))
|
||||
for i, n in enumerate(ids):
|
||||
nxt = (40.0 + i * step, y)
|
||||
if nxt != pos.get(n):
|
||||
moved.add(n)
|
||||
pos[n] = nxt
|
||||
|
||||
for ly in order:
|
||||
ids = by.get(ly) or []
|
||||
if not ids:
|
||||
continue
|
||||
if preserve_x:
|
||||
ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
|
||||
else:
|
||||
ids = sorted(ids, key=lambda n: state.names.get(n, n))
|
||||
rows = [ids[i : i + per_row] for i in range(0, len(ids), per_row)]
|
||||
y_band0 = y_cursor
|
||||
for ri, row in enumerate(rows):
|
||||
_place_row(row, y_cursor + ri * rgap)
|
||||
band_h = max(0, len(rows) - 1) * rgap
|
||||
band_notes.append(
|
||||
{
|
||||
"layer": ly,
|
||||
"count": len(ids),
|
||||
"y": y_band0,
|
||||
"rows": len(rows),
|
||||
"y_max": y_band0 + band_h,
|
||||
}
|
||||
)
|
||||
y_cursor = y_band0 + band_h + float(band_gap)
|
||||
|
||||
out = state.copy()
|
||||
out.positions = pos
|
||||
out.meta = dict(out.meta or {})
|
||||
out.meta["level_bands"] = {
|
||||
"bands": band_notes,
|
||||
"preserve_x": preserve_x,
|
||||
"max_per_row": per_row,
|
||||
}
|
||||
return OpResult(
|
||||
state=out,
|
||||
moved=moved,
|
||||
op="level_bands",
|
||||
params={
|
||||
"bands": band_notes,
|
||||
"preserve_x": preserve_x,
|
||||
"y0": y0,
|
||||
"band_gap": band_gap,
|
||||
"pitch": step,
|
||||
"max_per_row": per_row,
|
||||
"moved_n": len(moved),
|
||||
},
|
||||
note=f"level_bands:{len(band_notes)} bands moved={len(moved)}",
|
||||
)
|
||||
|
||||
|
||||
def level_bands_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
if not overrides:
|
||||
return out
|
||||
for key, cast in (
|
||||
("y0", float),
|
||||
("band_gap", float),
|
||||
("pitch", float),
|
||||
("row_gap", float),
|
||||
("max_per_row", int),
|
||||
):
|
||||
if overrides.get(key) is not None:
|
||||
try:
|
||||
out[key] = cast(overrides[key])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "preserve_x" in overrides:
|
||||
out["preserve_x"] = bool(overrides.get("preserve_x"))
|
||||
raw = overrides.get("layers")
|
||||
if isinstance(raw, (list, tuple)) and raw:
|
||||
out["layers"] = tuple(str(x) for x in raw if str(x).strip())
|
||||
return out
|
||||
Loading…
Add table
Add a link
Reference in a new issue