mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 20:33:19 +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
181
netx_api/mcp_remote/topology/layout_ops/ring_faces.py
Normal file
181
netx_api/mcp_remote/topology/layout_ops/ring_faces.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Minimal ring faces: detect chordless cycles and piercing by foreign edges.
|
||||
|
||||
Stage-1/2 use this to keep ring interiors hollow (metro face integrity).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||
from netx_api.mcp_remote.topology.layout_topology_quality import (
|
||||
RING_MAX_LEN,
|
||||
find_short_chordless_cycles,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RingFace:
|
||||
node_ids: tuple[str, ...]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"n": len(self.node_ids), "node_ids": list(self.node_ids)}
|
||||
|
||||
|
||||
def extract_ring_faces(
|
||||
state: LayoutState,
|
||||
*,
|
||||
max_len: int = min(6, RING_MAX_LEN),
|
||||
max_cycles: int = 80,
|
||||
) -> list[RingFace]:
|
||||
cycles = find_short_chordless_cycles(
|
||||
state.adj, max_len=max_len, max_cycles=max_cycles
|
||||
)
|
||||
faces = [RingFace(tuple(c)) for c in cycles]
|
||||
faces.sort(key=lambda f: (len(f.node_ids), f.node_ids[0] if f.node_ids else ""))
|
||||
return faces
|
||||
|
||||
|
||||
def _point_in_poly(
|
||||
x: float, y: float, poly: list[tuple[float, float]]
|
||||
) -> bool:
|
||||
"""Ray casting; boundary counts as outside (conservative)."""
|
||||
n = len(poly)
|
||||
if n < 3:
|
||||
return False
|
||||
inside = False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = poly[i]
|
||||
xj, yj = poly[j]
|
||||
if ((yi > y) != (yj > y)) and (
|
||||
x < (xj - xi) * (y - yi) / (yj - yi + 1e-15) + xi
|
||||
):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def ring_polygon(
|
||||
face: RingFace, pos: dict[str, tuple[float, float]]
|
||||
) -> list[tuple[float, float]] | None:
|
||||
pts = [pos[n] for n in face.node_ids if n in pos]
|
||||
if len(pts) < 3:
|
||||
return None
|
||||
return pts
|
||||
|
||||
|
||||
def count_ring_pierces(
|
||||
pos: dict[str, tuple[float, float]],
|
||||
links: list[tuple[str, str]],
|
||||
faces: list[RingFace],
|
||||
) -> dict[str, Any]:
|
||||
"""Foreign edges that properly cross a ring boundary edge."""
|
||||
pierced = 0
|
||||
pierce_x = 0
|
||||
for face in faces:
|
||||
rset = set(face.node_ids)
|
||||
poly = ring_polygon(face, pos)
|
||||
if not poly:
|
||||
continue
|
||||
n = len(face.node_ids)
|
||||
ring_edges = []
|
||||
for i in range(n):
|
||||
a, b = face.node_ids[i], face.node_ids[(i + 1) % n]
|
||||
if a in pos and b in pos:
|
||||
ring_edges.append((a, b))
|
||||
hit = False
|
||||
for a, b in links:
|
||||
if a not in pos or b not in pos:
|
||||
continue
|
||||
# foreign if at least one endpoint outside ring
|
||||
if a in rset and b in rset:
|
||||
continue
|
||||
p1, p2 = pos[a], pos[b]
|
||||
for u, v in ring_edges:
|
||||
if len({a, b, u, v}) < 4:
|
||||
continue
|
||||
if segments_properly_intersect(p1, p2, pos[u], pos[v]):
|
||||
pierce_x += 1
|
||||
hit = True
|
||||
if hit:
|
||||
pierced += 1
|
||||
return {
|
||||
"ring_count": len(faces),
|
||||
"rings_pierced": pierced,
|
||||
"pierce_crossings": pierce_x,
|
||||
}
|
||||
|
||||
|
||||
def place_ring_rectangle(
|
||||
face: RingFace,
|
||||
*,
|
||||
center: tuple[float, float],
|
||||
width: float,
|
||||
height: float,
|
||||
start_angle: float = -math.pi / 2,
|
||||
) -> dict[str, tuple[float, float]]:
|
||||
"""Place ring nodes on an axis-aligned rectangle (hollow face)."""
|
||||
nodes = list(face.node_ids)
|
||||
n = len(nodes)
|
||||
if n < 3:
|
||||
return {}
|
||||
cx, cy = center
|
||||
hw, hh = width / 2, height / 2
|
||||
# Perimeter parametrization 0..1 around rectangle
|
||||
perim = 2 * (width + height)
|
||||
out: dict[str, tuple[float, float]] = {}
|
||||
for i, nid in enumerate(nodes):
|
||||
t = (i / n + (start_angle + math.pi / 2) / (2 * math.pi)) % 1.0
|
||||
d = t * perim
|
||||
if d <= width:
|
||||
x, y = cx - hw + d, cy - hh
|
||||
elif d <= width + height:
|
||||
x, y = cx + hw, cy - hh + (d - width)
|
||||
elif d <= 2 * width + height:
|
||||
x, y = cx + hw - (d - width - height), cy + hh
|
||||
else:
|
||||
x, y = cx - hw, cy + hh - (d - 2 * width - height)
|
||||
out[nid] = (x, y)
|
||||
return out
|
||||
|
||||
|
||||
def eject_intruders(
|
||||
pos: dict[str, tuple[float, float]],
|
||||
faces: list[RingFace],
|
||||
*,
|
||||
push: float = 40.0,
|
||||
protected: set[str] | None = None,
|
||||
) -> dict[str, tuple[float, float]]:
|
||||
"""Push non-ring nodes out of ring polygons."""
|
||||
protected = protected or set()
|
||||
out = dict(pos)
|
||||
for face in faces:
|
||||
rset = set(face.node_ids)
|
||||
poly = ring_polygon(face, out)
|
||||
if not poly:
|
||||
continue
|
||||
cx = sum(p[0] for p in poly) / len(poly)
|
||||
cy = sum(p[1] for p in poly) / len(poly)
|
||||
for nid, (x, y) in list(out.items()):
|
||||
if nid in rset or nid in protected:
|
||||
continue
|
||||
if not _point_in_poly(x, y, poly):
|
||||
continue
|
||||
dx, dy = x - cx, y - cy
|
||||
dist = math.hypot(dx, dy) or 1.0
|
||||
out[nid] = (cx + dx / dist * (dist + push), cy + dy / dist * (dist + push))
|
||||
return out
|
||||
|
||||
|
||||
def ring_faces_report(state: LayoutState) -> dict[str, Any]:
|
||||
faces = extract_ring_faces(state)
|
||||
pierces = count_ring_pierces(state.positions, state.links, faces)
|
||||
return {
|
||||
**pierces,
|
||||
"sample": [f.as_dict() for f in faces[:10]],
|
||||
"tip": "最小环应中空;channel_metro / protect_rings 优先降 pierced。",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue