mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 04:00:47 +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
352
netx_api/mcp_remote/topology/layout_ops/recipe.py
Normal file
352
netx_api/mcp_remote/topology/layout_ops/recipe.py
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
"""Compose atomic ops into multipass recipes (supports per-block scope)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Callable
|
||||
|
||||
from netx_api.mcp_remote.topology.layout_ops.score import score_op, score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, map_blocks, select_scope
|
||||
from netx_api.mcp_remote.topology.layout_ops.sides import place_side_branches
|
||||
from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
||||
from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import pack_soft_blocks
|
||||
from netx_api.mcp_remote.topology.layout_ops.transforms import (
|
||||
enforce_min_gap,
|
||||
explode_clusters,
|
||||
normalize_origin,
|
||||
pack_utilization,
|
||||
resolve_overlaps,
|
||||
scale_edge_axes,
|
||||
scale_region,
|
||||
select_pins,
|
||||
soft_nn_scale,
|
||||
)
|
||||
|
||||
OpFn = Callable[..., OpResult]
|
||||
|
||||
REGISTRY: dict[str, OpFn] = {
|
||||
"build_skeleton": build_skeleton,
|
||||
"build_ring_skeleton": build_ring_skeleton,
|
||||
"place_side_branches": place_side_branches,
|
||||
"select_pins": select_pins,
|
||||
"select_scope": select_scope,
|
||||
"map_blocks": map_blocks,
|
||||
"scale_region": scale_region,
|
||||
"scale_edge_axes": scale_edge_axes,
|
||||
"pack_utilization": pack_utilization,
|
||||
"pack_soft_blocks": pack_soft_blocks,
|
||||
"resolve_overlaps": resolve_overlaps,
|
||||
"explode_clusters": explode_clusters,
|
||||
"enforce_min_gap": enforce_min_gap,
|
||||
"soft_nn_scale": soft_nn_scale,
|
||||
"normalize_origin": normalize_origin,
|
||||
"fix_overlaps_local": fix_overlaps_local,
|
||||
"relax_hotspots": relax_hotspots,
|
||||
"score": score_op,
|
||||
}
|
||||
|
||||
|
||||
def smd_corridor_v1_passes() -> list[dict[str, Any]]:
|
||||
"""Global structure, then per-component explode+resolve (scoped)."""
|
||||
return [
|
||||
{"op": "build_skeleton"},
|
||||
{"op": "place_side_branches"},
|
||||
{"op": "select_scope", "kwargs": {"mode": "all"}},
|
||||
{"op": "scale_edge_axes"},
|
||||
{"op": "soft_nn_scale"},
|
||||
{"op": "map_blocks", "kwargs": {"mode": "component"}},
|
||||
# Expanded at runtime by foreach_blocks
|
||||
{
|
||||
"op": "foreach_blocks",
|
||||
"kwargs": {
|
||||
"mode": "component",
|
||||
"passes": [
|
||||
{
|
||||
"op": "explode_clusters",
|
||||
"kwargs": {"thr": 20.0, "gap": 80.0, "axis": "perp"},
|
||||
},
|
||||
{"op": "resolve_overlaps", "kwargs": {"mode": "lateral"}},
|
||||
],
|
||||
},
|
||||
},
|
||||
{"op": "select_scope", "kwargs": {"mode": "all"}},
|
||||
{"op": "normalize_origin"},
|
||||
{"op": "score"},
|
||||
]
|
||||
|
||||
|
||||
def smd_corridor_unstick_v1_passes() -> list[dict[str, Any]]:
|
||||
"""v1 + second per-block free resolve for stubborn overlaps."""
|
||||
return [
|
||||
*smd_corridor_v1_passes()[:-2],
|
||||
{
|
||||
"op": "foreach_blocks",
|
||||
"kwargs": {
|
||||
"mode": "component",
|
||||
"passes": [
|
||||
{"op": "resolve_overlaps", "kwargs": {"mode": "free"}},
|
||||
],
|
||||
},
|
||||
},
|
||||
{"op": "select_scope", "kwargs": {"mode": "all"}},
|
||||
{"op": "normalize_origin"},
|
||||
{"op": "score"},
|
||||
]
|
||||
|
||||
|
||||
def agg_rings_v1_passes() -> list[dict[str, Any]]:
|
||||
"""Rings recipe: dual-hub min-rings when eligible, else UME petals.
|
||||
|
||||
Light overlap fix only — avoid explode that destroys ring geometry.
|
||||
"""
|
||||
return [
|
||||
{"op": "build_ring_skeleton"},
|
||||
{"op": "select_pins", "kwargs": {"mode": "agg"}},
|
||||
{"op": "select_scope", "kwargs": {"mode": "all"}},
|
||||
{"op": "fix_overlaps_local"},
|
||||
{"op": "normalize_origin"},
|
||||
{"op": "score"},
|
||||
]
|
||||
|
||||
|
||||
def smd_corridor_compact_v1_passes() -> list[dict[str, Any]]:
|
||||
"""Corridor skeleton, then pack/unstick **per component** — no global crush.
|
||||
|
||||
Global isotropic pack destroys crossings; islands are compacted in-place.
|
||||
Ends with surgical local overlap fix.
|
||||
"""
|
||||
return [
|
||||
{"op": "build_skeleton"},
|
||||
{"op": "place_side_branches"},
|
||||
{"op": "select_scope", "kwargs": {"mode": "all"}},
|
||||
{"op": "scale_edge_axes"},
|
||||
{"op": "soft_nn_scale"},
|
||||
{"op": "map_blocks", "kwargs": {"mode": "component"}},
|
||||
{
|
||||
"op": "foreach_blocks",
|
||||
"kwargs": {
|
||||
"mode": "component",
|
||||
"min_size": 3,
|
||||
"passes": [
|
||||
{"op": "pack_utilization"},
|
||||
{
|
||||
"op": "explode_clusters",
|
||||
"kwargs": {"thr": 8.0, "gap": 35.0, "axis": "along"},
|
||||
},
|
||||
{"op": "resolve_overlaps", "kwargs": {"mode": "lateral"}},
|
||||
],
|
||||
},
|
||||
},
|
||||
{"op": "select_scope", "kwargs": {"mode": "all"}},
|
||||
{"op": "fix_overlaps_local"},
|
||||
{"op": "normalize_origin"},
|
||||
{"op": "score"},
|
||||
]
|
||||
|
||||
|
||||
RECIPES: dict[str, Callable[[], list[dict[str, Any]]]] = {
|
||||
"smd_corridor_v1": smd_corridor_v1_passes,
|
||||
"smd_corridor_unstick_v1": smd_corridor_unstick_v1_passes,
|
||||
"smd_corridor_compact_v1": smd_corridor_compact_v1_passes,
|
||||
"agg_rings_v1": agg_rings_v1_passes,
|
||||
}
|
||||
|
||||
|
||||
def _apply_op(
|
||||
st: LayoutState,
|
||||
name: str,
|
||||
params: LayoutParams,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
ume_reference: bool,
|
||||
) -> OpResult:
|
||||
if name == "score":
|
||||
return score_op(st, ume_reference=ume_reference)
|
||||
if name == "select_pins":
|
||||
return select_pins(st, **kwargs)
|
||||
if name == "select_scope":
|
||||
return select_scope(st, **kwargs)
|
||||
if name == "map_blocks":
|
||||
return map_blocks(st, **kwargs)
|
||||
if name == "pack_soft_blocks":
|
||||
return pack_soft_blocks(st, params, **kwargs)
|
||||
if name == "scale_region":
|
||||
return scale_region(st, params, **kwargs)
|
||||
if name == "resolve_overlaps":
|
||||
return resolve_overlaps(st, params, **kwargs)
|
||||
if name == "explode_clusters":
|
||||
return explode_clusters(st, params, **kwargs)
|
||||
if name == "enforce_min_gap":
|
||||
return enforce_min_gap(st, params, **kwargs)
|
||||
if name == "fix_overlaps_local":
|
||||
return fix_overlaps_local(st, params)
|
||||
if name == "relax_hotspots":
|
||||
return relax_hotspots(st, params)
|
||||
if name == "build_ring_skeleton":
|
||||
return build_ring_skeleton(st, params)
|
||||
fn = REGISTRY.get(name)
|
||||
if fn is None:
|
||||
raise KeyError(f"unknown op: {name}")
|
||||
return fn(st, params)
|
||||
|
||||
|
||||
def _trace_entry(
|
||||
i: int,
|
||||
op: str,
|
||||
note: str,
|
||||
params: dict[str, Any],
|
||||
moved_n: int,
|
||||
pinned_n: int,
|
||||
metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
m = metrics or {}
|
||||
return {
|
||||
"i": i,
|
||||
"op": op,
|
||||
"note": note,
|
||||
"params": params,
|
||||
"moved_n": moved_n,
|
||||
"pinned_n": pinned_n,
|
||||
"edge_crossings": m.get("edge_crossings"),
|
||||
"nn_p50": m.get("nn_p50"),
|
||||
"footprint_overlap_pairs": m.get("footprint_overlap_pairs"),
|
||||
"label_overlap_pairs": m.get("label_overlap_pairs"),
|
||||
"space_utilization": m.get("space_utilization"),
|
||||
"bbox": m.get("bbox"),
|
||||
"grade": (m.get("grade") or {}).get("overall") if m else None,
|
||||
}
|
||||
|
||||
|
||||
def _expand_foreach(
|
||||
st: LayoutState,
|
||||
kwargs: dict[str, Any],
|
||||
params: LayoutParams,
|
||||
*,
|
||||
ume_reference: bool,
|
||||
base_i: int,
|
||||
) -> tuple[LayoutState, list[dict[str, Any]]]:
|
||||
"""Run sub-passes once per block. Score only once after all blocks (fast)."""
|
||||
mode = str(kwargs.get("mode") or "component")
|
||||
sub_passes: list[dict[str, Any]] = list(kwargs.get("passes") or [])
|
||||
min_size = int(kwargs.get("min_size") or 2)
|
||||
blocks = list_blocks(st, mode=mode)
|
||||
# Record resolved mode (auto → hub_territory|component) for agents/trace.
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
|
||||
|
||||
resolved_mode = resolve_block_mode(st, mode) if mode == "auto" else mode
|
||||
st = st.copy()
|
||||
st.meta["block_mode"] = resolved_mode
|
||||
st.meta["blocks"] = [sorted(b) for b in blocks]
|
||||
trace: list[dict[str, Any]] = []
|
||||
step_i = base_i
|
||||
total_moved = 0
|
||||
|
||||
for bi, block in enumerate(blocks):
|
||||
if len(block) < min_size:
|
||||
continue
|
||||
st = select_scope(st, mode="ids", node_ids=block).state
|
||||
block_moved = 0
|
||||
for step in sub_passes:
|
||||
name = str(step.get("op") or "")
|
||||
kw = dict(step.get("kwargs") or {})
|
||||
result = _apply_op(st, name, params, kw, ume_reference=ume_reference)
|
||||
st = result.state
|
||||
block_moved += len(result.moved)
|
||||
total_moved += len(result.moved)
|
||||
trace.append(
|
||||
_trace_entry(
|
||||
step_i,
|
||||
"foreach_block",
|
||||
f"block[{bi}] n={len(block)} moved≈{block_moved}",
|
||||
{"block_index": bi, "scope_n": len(block), "mode": mode},
|
||||
block_moved,
|
||||
len(st.pinned),
|
||||
)
|
||||
)
|
||||
step_i += 1
|
||||
|
||||
st = select_scope(st, mode="all").state
|
||||
metrics = score_state(st, ume_reference=ume_reference)
|
||||
trace.append(
|
||||
_trace_entry(
|
||||
step_i,
|
||||
"foreach_blocks",
|
||||
f"done blocks={len(blocks)} moved≈{total_moved}",
|
||||
{"mode": mode, "blocks_n": len(blocks), "moved_n": total_moved},
|
||||
total_moved,
|
||||
len(st.pinned),
|
||||
metrics,
|
||||
)
|
||||
)
|
||||
return st, trace
|
||||
|
||||
|
||||
def run_recipe(
|
||||
state: LayoutState,
|
||||
recipe: str | list[dict[str, Any]] = "smd_corridor_v1",
|
||||
params: LayoutParams | None = None,
|
||||
*,
|
||||
ume_reference: bool = False,
|
||||
) -> tuple[LayoutState, list[dict[str, Any]], dict[str, Any]]:
|
||||
"""Run passes; return final state, per-pass trace, final score."""
|
||||
params = params or LayoutParams()
|
||||
passes = RECIPES[recipe]() if isinstance(recipe, str) else recipe
|
||||
st = state.copy()
|
||||
trace: list[dict[str, Any]] = []
|
||||
i = 0
|
||||
|
||||
for step in passes:
|
||||
name = str(step.get("op") or "")
|
||||
kwargs = dict(step.get("kwargs") or {})
|
||||
if name == "foreach_blocks":
|
||||
st, sub = _expand_foreach(
|
||||
st, kwargs, params, ume_reference=ume_reference, base_i=i
|
||||
)
|
||||
trace.extend(sub)
|
||||
i = (trace[-1]["i"] + 1) if trace else i + 1
|
||||
continue
|
||||
|
||||
result = _apply_op(st, name, params, kwargs, ume_reference=ume_reference)
|
||||
st = result.state
|
||||
# Lightweight steps skip full score; score at structural checkpoints + end.
|
||||
want_score = name in {
|
||||
"build_skeleton",
|
||||
"place_side_branches",
|
||||
"soft_nn_scale",
|
||||
"normalize_origin",
|
||||
"score",
|
||||
"pack_utilization",
|
||||
}
|
||||
metrics = score_state(st, ume_reference=ume_reference) if want_score else None
|
||||
trace.append(
|
||||
_trace_entry(
|
||||
i,
|
||||
result.op,
|
||||
result.note,
|
||||
result.params,
|
||||
len(result.moved),
|
||||
len(st.pinned),
|
||||
metrics,
|
||||
)
|
||||
)
|
||||
i += 1
|
||||
|
||||
final = score_state(st, ume_reference=ume_reference)
|
||||
final["recipe"] = recipe if isinstance(recipe, str) else "custom"
|
||||
final["params"] = asdict(params)
|
||||
final["passes"] = len(trace)
|
||||
return st, trace, final
|
||||
|
||||
|
||||
def positions_for_api(state: LayoutState) -> list[dict[str, float | str]]:
|
||||
return [
|
||||
{
|
||||
"fabric_node_id": n,
|
||||
"x": round(state.positions[n][0], 1),
|
||||
"y": round(state.positions[n][1], 1),
|
||||
}
|
||||
for n in state.positions
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue