netx/netx_api/mcp_remote/topology/layout_ops/scope.py
oliver 52679a7cfc 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>
2026-10-16 21:52:08 +08:00

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}",
)