mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 18:10:55 +08:00
- 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>
153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
"""Block / scope selection — ops act only on active nodes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Iterable
|
|
|
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components
|
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
|
|
|
|
|
def active_nodes(state: LayoutState) -> set[str]:
|
|
"""Nodes currently in scope (all if scope is None/empty meaning unrestricted)."""
|
|
if state.scope is None:
|
|
return set(state.positions.keys())
|
|
return {n for n in state.scope if n in state.positions}
|
|
|
|
|
|
def movable_nodes(state: LayoutState, *, respect_pins: bool = True) -> set[str]:
|
|
"""Nodes an op may move: in scope, not hard agg/core, optionally not soft-pinned."""
|
|
act = active_nodes(state)
|
|
hard = {n for n in act if state.layers.get(n) in ("agg", "core")}
|
|
out = act - hard
|
|
if respect_pins:
|
|
out -= set(state.pinned)
|
|
return out
|
|
|
|
|
|
def list_blocks(
|
|
state: LayoutState,
|
|
*,
|
|
mode: str = "component",
|
|
) -> list[set[str]]:
|
|
"""Partition graph into blocks for per-block recipes.
|
|
|
|
Modes:
|
|
- component: hard CC (default; existing recipes)
|
|
- layer / bbox_quad: coarse buckets
|
|
- hub_territory: soft blocks by hub-seeded BFS (our algo)
|
|
- leiden / soft: hub territories + optional igraph Leiden on leftovers
|
|
"""
|
|
ids = set(state.positions.keys())
|
|
m = (mode or "component").strip().lower()
|
|
if m == "auto":
|
|
from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
|
|
|
|
m = resolve_block_mode(state, "auto")
|
|
if m == "component":
|
|
active = {
|
|
n
|
|
for n in ids
|
|
if state.layers.get(n) in ("core", "agg", "access")
|
|
}
|
|
comps = connected_components(active, state.adj)
|
|
blocks = [set(c) for c in comps if c]
|
|
# orphans / other
|
|
rest = ids - {n for b in blocks for n in b}
|
|
if rest:
|
|
blocks.append(rest)
|
|
return blocks
|
|
if m in {"hub_territory", "leiden", "soft"}:
|
|
from netx_api.mcp_remote.topology.layout_ops.partition import (
|
|
partition_soft_blocks,
|
|
soft_blocks_as_sets,
|
|
)
|
|
|
|
return soft_blocks_as_sets(partition_soft_blocks(state, mode=m))
|
|
if m == "layer":
|
|
by: dict[str, set[str]] = {}
|
|
for n in ids:
|
|
by.setdefault(state.layers.get(n, "other"), set()).add(n)
|
|
return [by[k] for k in sorted(by.keys()) if by[k]]
|
|
if m == "bbox_quad":
|
|
if not ids:
|
|
return []
|
|
x0, y0, x1, y1 = bbox(state.positions)
|
|
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
|
|
quads: list[set[str]] = [set() for _ in range(4)]
|
|
for n, (x, y) in state.positions.items():
|
|
qi = (0 if x < mx else 1) + (0 if y < my else 2)
|
|
quads[qi].add(n)
|
|
return [q for q in quads if q]
|
|
# single full block
|
|
return [ids] if ids else []
|
|
|
|
|
|
def select_scope(
|
|
state: LayoutState,
|
|
*,
|
|
mode: str = "all",
|
|
node_ids: Iterable[str] | None = None,
|
|
component_index: int | None = None,
|
|
layer: str | None = None,
|
|
bbox_region: tuple[float, float, float, float] | None = None,
|
|
) -> OpResult:
|
|
"""Set state.scope. mode=all clears scope (full graph)."""
|
|
st = state.copy()
|
|
pos = st.positions
|
|
if mode == "all" or (mode == "ids" and not node_ids):
|
|
st.scope = None
|
|
return OpResult(
|
|
state=st, moved=set(), op="select_scope", params={"mode": "all"}, note="scope=all"
|
|
)
|
|
|
|
if mode == "ids" and node_ids is not None:
|
|
scope = {n for n in node_ids if n in pos}
|
|
elif mode == "component":
|
|
blocks = list_blocks(st, mode="component")
|
|
idx = 0 if component_index is None else int(component_index)
|
|
scope = blocks[idx] if 0 <= idx < len(blocks) else set()
|
|
elif mode == "layer":
|
|
lyr = layer or "access"
|
|
scope = {n for n in pos if st.layers.get(n) == lyr}
|
|
elif mode == "bbox" and bbox_region is not None:
|
|
x0, y0, x1, y1 = bbox_region
|
|
scope = {
|
|
n
|
|
for n, (x, y) in pos.items()
|
|
if x0 <= x <= x1 and y0 <= y <= y1
|
|
}
|
|
elif mode == "bbox_quad":
|
|
blocks = list_blocks(st, mode="bbox_quad")
|
|
idx = 0 if component_index is None else int(component_index)
|
|
scope = blocks[idx] if 0 <= idx < len(blocks) else set()
|
|
else:
|
|
scope = set(pos.keys())
|
|
|
|
st.scope = scope
|
|
return OpResult(
|
|
state=st,
|
|
moved=set(),
|
|
op="select_scope",
|
|
params={"mode": mode, "scope_n": len(scope), "component_index": component_index},
|
|
note=f"scope={mode} n={len(scope)}",
|
|
)
|
|
|
|
|
|
def map_blocks(
|
|
state: LayoutState,
|
|
*,
|
|
mode: str = "component",
|
|
) -> OpResult:
|
|
"""Record block partition in meta (for agents / foreach). Does not change scope."""
|
|
st = state.copy()
|
|
blocks = list_blocks(st, mode=mode)
|
|
st.meta["blocks"] = [sorted(b) for b in blocks]
|
|
st.meta["block_mode"] = mode
|
|
return OpResult(
|
|
state=st,
|
|
moved=set(),
|
|
op="map_blocks",
|
|
params={"mode": mode, "blocks_n": len(blocks), "sizes": [len(b) for b in blocks]},
|
|
note=f"blocks={len(blocks)} mode={mode}",
|
|
)
|