mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 21:53: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
219
tests/mcp_remote/topology/test_densify_sweep.py
Normal file
219
tests/mcp_remote/topology/test_densify_sweep.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""Tests for inward densify_sweep top-3 / round / corridor scan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.densify_sweep import (
|
||||
apply_densify_pick,
|
||||
densify_corridor_scan,
|
||||
densify_sweep_node,
|
||||
densify_sweep_round,
|
||||
)
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _incident_stretch
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
|
||||
from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog, run_layout_on_graph
|
||||
|
||||
|
||||
def _sparse_spoke():
|
||||
"""Hub + three near stubs + one far spoke (stretchy)."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "HHHHHH-EN-H", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-A", "x": 180.0, "y": 0.0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-B", "x": 0.0, "y": 180.0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-EN-C", "x": -180.0, "y": 0.0},
|
||||
{"fabric_node_id": "far", "name": "FFFFFF-EN-F", "x": 2400.0, "y": 0.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "h", "b_node_id": "b"},
|
||||
{"a_node_id": "h", "b_node_id": "c"},
|
||||
{"a_node_id": "h", "b_node_id": "far"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def _two_island_bridge():
|
||||
"""Two compact islands linked by a long bridge — corridor densify target."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "a1", "name": "A1A1A1-EN-1", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "a2", "name": "A2A2A2-EN-2", "x": 160.0, "y": 0.0},
|
||||
{"fabric_node_id": "a3", "name": "A3A3A3-EN-3", "x": 80.0, "y": 140.0},
|
||||
{"fabric_node_id": "b1", "name": "B1B1B1-EN-1", "x": 4000.0, "y": 0.0},
|
||||
{"fabric_node_id": "b2", "name": "B2B2B2-EN-2", "x": 4160.0, "y": 0.0},
|
||||
{"fabric_node_id": "b3", "name": "B3B3B3-EN-3", "x": 4080.0, "y": 140.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "a3"},
|
||||
{"a_node_id": "a3", "b_node_id": "a1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b2", "b_node_id": "b3"},
|
||||
{"a_node_id": "b3", "b_node_id": "b1"},
|
||||
{"a_node_id": "a2", "b_node_id": "b1"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_densify_sweep_node_pulls_inward() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
out = densify_sweep_node(
|
||||
st,
|
||||
"far",
|
||||
protect_rigid="off",
|
||||
max_pull=2000,
|
||||
nn_floor=40.0,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
cands = out["candidates"]
|
||||
assert 1 <= len(cands) <= 3
|
||||
best = cands[0]
|
||||
# Moved toward hub (x decreases).
|
||||
assert best["x"] < 2400.0 - 50.0
|
||||
assert best["crossings"]["global"] <= g0
|
||||
assert best["delta"]["global"] <= 0
|
||||
# Closer to hub than start.
|
||||
d0 = 2400.0
|
||||
d1 = abs(best["x"] - 0.0) # hub at 0
|
||||
assert d1 < d0
|
||||
|
||||
|
||||
def test_densify_nn_floor_rejects_crush() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
# Huge nn_floor should leave few/no candidates (can't get near hub).
|
||||
out = densify_sweep_node(
|
||||
st,
|
||||
"far",
|
||||
protect_rigid="off",
|
||||
max_pull=2000,
|
||||
nn_floor=500.0,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
for c in out["candidates"]:
|
||||
# Any accepted candidate must keep nn to all others ≥ floor.
|
||||
trial = dict(st.positions)
|
||||
trial["far"] = (c["x"], c["y"])
|
||||
for oid, (ox, oy) in trial.items():
|
||||
if oid == "far":
|
||||
continue
|
||||
d = ((c["x"] - ox) ** 2 + (c["y"] - oy) ** 2) ** 0.5
|
||||
assert d >= 500.0 - 1e-3
|
||||
|
||||
|
||||
def test_apply_densify_pick_moves() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
sweep = densify_sweep_node(st, "far", protect_rigid="off", max_pull=1800, nn_floor=40)
|
||||
assert sweep["ok"] and sweep["candidates"]
|
||||
op = apply_densify_pick(st, sweep, pick=1)
|
||||
assert "far" in op.moved
|
||||
chosen = sweep["candidates"][0]
|
||||
assert abs(op.state.positions["far"][0] - chosen["x"]) < 0.2
|
||||
|
||||
|
||||
def test_densify_round_lowers_stretch_not_x() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
params = LayoutParams(target_nn=155.0)
|
||||
stretch0 = _incident_stretch("far", st.positions, st.adj, 155.0)
|
||||
op = densify_sweep_round(
|
||||
st,
|
||||
params=params,
|
||||
top_n=4,
|
||||
max_degree=9,
|
||||
protect_rigid="off",
|
||||
max_pull=2000,
|
||||
nn_floor=40.0,
|
||||
focus_ids=["far"],
|
||||
)
|
||||
g1 = count_edge_crossings(op.state.positions, op.state.links)
|
||||
assert g1 <= g0
|
||||
stretch1 = _incident_stretch("far", op.state.positions, op.state.adj, 155.0)
|
||||
if op.moved:
|
||||
assert stretch1 < stretch0
|
||||
|
||||
|
||||
def test_corridor_scan_raises_util_or_reverts_clean() -> None:
|
||||
nodes, edges = _two_island_bridge()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
groups = [
|
||||
{"key": "ua", "node_ids": ["a1", "a2", "a3"], "pivots": []},
|
||||
{"key": "ub", "node_ids": ["b1", "b2", "b3"], "pivots": []},
|
||||
]
|
||||
st.meta["compose_views"] = {"rigid_groups": groups}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
op = densify_corridor_scan(
|
||||
st,
|
||||
groups=groups,
|
||||
corridor_caps=[800.0, 1600.0, 3200.0],
|
||||
pulls=[0.5, 0.65],
|
||||
iters=4,
|
||||
)
|
||||
g1 = count_edge_crossings(op.state.positions, op.state.links)
|
||||
meta = op.params or {}
|
||||
assert g1 <= g0 + int(meta.get("x_slack") or 5)
|
||||
if not meta.get("reverted"):
|
||||
assert float(meta.get("end_util") or 0) >= float(meta.get("start_util") or 0)
|
||||
assert meta.get("chosen")
|
||||
|
||||
|
||||
def test_run_layout_densify_unpublished() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="densify_sweep",
|
||||
params={"node_id": "far", "protect_rigid": "off", "max_pull": 1800, "nn_floor": 40},
|
||||
)
|
||||
raise AssertionError("densify_sweep should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
|
||||
|
||||
def test_catalog_omits_densify_sweep() -> None:
|
||||
cat = list_layout_catalog()
|
||||
assert "densify_sweep" not in cat["actions"]
|
||||
|
||||
|
||||
def test_densify_default_protect_off_may_move_shared_portal() -> None:
|
||||
from netx_api.mcp_remote.topology.layout_ops.densify_sweep import densify_params_from_overrides
|
||||
|
||||
knobs = densify_params_from_overrides({"node_id": "hub"})
|
||||
assert knobs["protect_rigid"] == "off"
|
||||
nodes = [
|
||||
{"fabric_node_id": "hub", "name": "HUBHUB-EN-1", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "a1", "name": "AAAAAA-EN-1", "x": 200.0, "y": 0.0},
|
||||
{"fabric_node_id": "b1", "name": "BBBBBB-EN-1", "x": 2000.0, "y": 0.0},
|
||||
{"fabric_node_id": "b2", "name": "BBBBBB-EN-2", "x": 2200.0, "y": 0.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "hub", "b_node_id": "a1"},
|
||||
{"a_node_id": "hub", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
groups = [
|
||||
{"key": "ua", "node_ids": ["hub", "a1"], "pivots": ["hub"]},
|
||||
{"key": "ub", "node_ids": ["hub", "b1", "b2"], "pivots": ["hub"]},
|
||||
]
|
||||
st.meta["compose_views"] = {"rigid_groups": groups}
|
||||
# Default off: shared portal may densify (not shared_portal error).
|
||||
ok = densify_sweep_node(st, "hub", groups=groups, max_pull=1800, nn_floor=40)
|
||||
assert ok["ok"] is True
|
||||
# Opt-in freeze still works.
|
||||
blocked = densify_sweep_node(
|
||||
st, "hub", protect_rigid="portals", groups=groups, frozen_ids={"hub"}
|
||||
)
|
||||
assert blocked["ok"] is False
|
||||
assert blocked.get("error") in {"frozen", "shared_portal"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue