From 52679a7cfccb891774ee7d9c1f62175192744ab6 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 16 Oct 2026 21:52:08 +0800 Subject: [PATCH] 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 --- README.md | 31 +- docs/MCP.md | 263 +++------------ docs/MCP_BIZMONITOR.md | 42 +-- docs/MCP_TOPOLOGY.md | 142 +------- mcp.all.json | 36 +- mcp.json | 20 +- mcp_install_payload.json | 20 +- mcp_topology_install_payload.json | 20 +- netx_api/auth_middleware.py | 96 ++++-- netx_api/config.py | 5 + netx_api/main.py | 20 +- netx_api/mcp_remote/__init__.py | 1 + netx_api/mcp_remote/asgi_client.py | 285 ++++++++++++++++ .../mcp_remote/bizmonitor}/__init__.py | 0 .../mcp_remote/bizmonitor}/http_tools.py | 2 +- netx_api/mcp_remote/context.py | 52 +++ netx_api/mcp_remote/mount.py | 316 ++++++++++++++++++ .../mcp_remote/ops}/__init__.py | 2 +- .../mcp_remote/ops}/cli_guard.py | 0 .../mcp_remote/ops}/http_tools.py | 2 +- netx_api/mcp_remote/registry.py | 146 ++++++++ netx_api/mcp_remote/server.py | 116 +++++++ .../mcp_remote/topology}/__init__.py | 0 .../mcp_remote/topology}/http_tools.py | 34 +- .../mcp_remote/topology}/layout_job_worker.py | 6 +- .../mcp_remote/topology}/layout_jobs.py | 4 +- .../mcp_remote/topology}/layout_metrics.py | 0 .../topology}/layout_ops/__init__.py | 20 +- .../topology}/layout_ops/align_reference.py | 8 +- .../topology}/layout_ops/bundle_orbit.py | 18 +- .../topology}/layout_ops/channel_metro.py | 18 +- .../topology}/layout_ops/channels.py | 12 +- .../topology}/layout_ops/clear_edge_hits.py | 4 +- .../topology}/layout_ops/compact_bbox.py | 8 +- .../topology}/layout_ops/compose_orbit.py | 8 +- .../topology}/layout_ops/compose_views.py | 6 +- .../topology}/layout_ops/densify_sweep.py | 12 +- .../topology}/layout_ops/dual_mass.py | 16 +- .../topology}/layout_ops/dual_units.py | 12 +- .../topology}/layout_ops/fold_chain.py | 2 +- .../topology}/layout_ops/force_densify.py | 12 +- .../topology}/layout_ops/graph_util.py | 8 +- .../topology}/layout_ops/hierarchy.py | 8 +- .../topology}/layout_ops/hotspots.py | 16 +- .../topology}/layout_ops/level_util.py | 2 +- .../topology}/layout_ops/mass_field.py | 4 +- .../topology}/layout_ops/mass_merge.py | 14 +- .../topology}/layout_ops/min_rings.py | 4 +- .../topology}/layout_ops/orbit_sweep.py | 16 +- .../topology}/layout_ops/ortho_metro.py | 18 +- .../topology}/layout_ops/partition.py | 6 +- .../topology}/layout_ops/pin_beam.py | 12 +- .../topology}/layout_ops/press_crossings.py | 18 +- .../topology}/layout_ops/pull_far_chains.py | 10 +- .../mcp_remote/topology}/layout_ops/recipe.py | 20 +- .../topology}/layout_ops/rigid_orbit.py | 14 +- .../topology}/layout_ops/rigid_units.py | 6 +- .../topology}/layout_ops/ring_faces.py | 6 +- .../mcp_remote/topology}/layout_ops/rings.py | 6 +- .../mcp_remote/topology}/layout_ops/scope.py | 8 +- .../mcp_remote/topology}/layout_ops/score.py | 4 +- .../mcp_remote/topology}/layout_ops/sides.py | 4 +- .../topology}/layout_ops/sink_dual_units.py | 8 +- .../topology}/layout_ops/skeleton.py | 6 +- .../topology}/layout_ops/soft_petals.py | 10 +- .../mcp_remote/topology}/layout_ops/state.py | 0 .../topology}/layout_ops/suggest_sink_hubs.py | 0 .../topology}/layout_ops/sugiyama.py | 10 +- .../topology}/layout_ops/transforms.py | 8 +- .../topology}/layout_ops/untangle.py | 14 +- .../mcp_remote/topology}/layout_sight.py | 6 +- .../mcp_remote/topology}/layout_stats.py | 6 +- .../mcp_remote/topology}/layout_structure.py | 10 +- .../mcp_remote/topology}/layout_tool.py | 38 +-- .../topology}/layout_topology_quality.py | 2 +- packages/netx-bizmonitor-mcp/README.md | 62 ---- packages/netx-bizmonitor-mcp/mcp.json | 16 - packages/netx-bizmonitor-mcp/pyproject.toml | 18 - .../src/netx_bizmonitor_mcp/__main__.py | 3 - .../src/netx_bizmonitor_mcp/http_client.py | 100 ------ .../src/netx_bizmonitor_mcp/server.py | 97 ------ .../netx-bizmonitor-mcp/tests/conftest.py | 6 - packages/netx-mcp/README.md | 71 ---- packages/netx-mcp/SKILLS.md | 5 - packages/netx-mcp/mcp.json | 14 - packages/netx-mcp/pyproject.toml | 20 -- packages/netx-mcp/src/netx_mcp/__main__.py | 4 - packages/netx-mcp/src/netx_mcp/http_client.py | 128 ------- packages/netx-mcp/src/netx_mcp/server.py | 146 -------- packages/netx-topology-mcp/README.md | 72 ---- packages/netx-topology-mcp/SKILLS.md | 5 - packages/netx-topology-mcp/mcp.json | 16 - packages/netx-topology-mcp/pyproject.toml | 29 -- .../src/netx_topology_mcp/__main__.py | 4 - .../src/netx_topology_mcp/http_client.py | 152 --------- .../src/netx_topology_mcp/server.py | 131 -------- .../tests/test_layout_jobs.py | 262 --------------- pyproject.toml | 7 +- requirements.txt | 1 + scripts/mcp_acceptance.py | 168 ++++++---- skills/README.md | 4 +- skills/biz-monitor/netx-biz-monitor/SKILL.md | 4 +- skills/ops/netx-ops/SKILL.md | 2 +- skills/topology/netx-topology/SKILL.md | 4 +- tests/mcp_remote/conftest.py | 1 + .../mcp_remote}/test_cli_safety.py | 16 +- .../mcp_remote}/test_mcp_bizmonitor.py | 167 ++++----- tests/mcp_remote/test_mcp_endpoint.py | 123 +++++++ .../mcp_remote}/test_mcp_http.py | 171 +++------- tests/mcp_remote/test_mcp_tcp_framing.py | 189 +++++++++++ .../mcp_remote/topology}/conftest.py | 0 .../topology}/test_align_reference.py | 4 +- .../mcp_remote/topology}/test_bundle_orbit.py | 8 +- .../topology}/test_channel_metro.py | 12 +- .../topology}/test_clear_edge_hits.py | 10 +- .../mcp_remote/topology}/test_compact_bbox.py | 8 +- .../topology}/test_compose_orbit.py | 8 +- .../topology}/test_compose_views.py | 6 +- .../mcp_remote/topology}/test_core_beam.py | 4 +- .../topology}/test_densify_sweep.py | 14 +- .../mcp_remote/topology}/test_dual_mass.py | 12 +- .../mcp_remote/topology}/test_dual_units.py | 14 +- .../mcp_remote/topology}/test_edge_axis.py | 4 +- .../mcp_remote/topology}/test_fold_chain.py | 8 +- .../topology}/test_force_densify.py | 10 +- .../mcp_remote/topology}/test_hierarchy.py | 8 +- .../mcp_remote/topology}/test_hotspots.py | 18 +- tests/mcp_remote/topology/test_layout_jobs.py | 262 +++++++++++++++ .../mcp_remote/topology}/test_layout_ops.py | 38 +-- .../mcp_remote/topology}/test_layout_sight.py | 2 +- .../mcp_remote/topology}/test_layout_stats.py | 2 +- .../topology}/test_layout_structure.py | 2 +- .../mcp_remote/topology}/test_layout_tool.py | 12 +- .../mcp_remote/topology}/test_mass_merge.py | 10 +- .../mcp_remote/topology}/test_mcp_topology.py | 107 ++---- .../mcp_remote/topology}/test_move_nodes.py | 4 +- .../mcp_remote/topology}/test_orbit_sweep.py | 26 +- .../mcp_remote/topology}/test_ortho_metro.py | 8 +- .../mcp_remote/topology}/test_partition.py | 12 +- .../topology}/test_pin_beam_soft_petals.py | 8 +- .../topology}/test_press_crossings.py | 8 +- .../topology}/test_pull_far_chains.py | 8 +- .../mcp_remote/topology}/test_rigid_orbit.py | 10 +- .../mcp_remote/topology}/test_rigid_units.py | 16 +- .../topology}/test_sink_dual_units.py | 10 +- .../topology}/test_suggest_sink_hubs.py | 4 +- .../topology}/test_topology_contract.py | 8 +- .../topology}/test_topology_quality.py | 4 +- .../mcp_remote/topology}/test_untangle.py | 4 +- tests/test_mcp_http.py | 109 ------ 150 files changed, 2367 insertions(+), 2812 deletions(-) create mode 100644 netx_api/mcp_remote/__init__.py create mode 100644 netx_api/mcp_remote/asgi_client.py rename {packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp => netx_api/mcp_remote/bizmonitor}/__init__.py (100%) rename {packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp => netx_api/mcp_remote/bizmonitor}/http_tools.py (99%) create mode 100644 netx_api/mcp_remote/context.py create mode 100644 netx_api/mcp_remote/mount.py rename {packages/netx-mcp/src/netx_mcp => netx_api/mcp_remote/ops}/__init__.py (63%) rename {packages/netx-mcp/src/netx_mcp => netx_api/mcp_remote/ops}/cli_guard.py (100%) rename {packages/netx-mcp/src/netx_mcp => netx_api/mcp_remote/ops}/http_tools.py (99%) create mode 100644 netx_api/mcp_remote/registry.py create mode 100644 netx_api/mcp_remote/server.py rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/__init__.py (100%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/http_tools.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_job_worker.py (91%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_jobs.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_metrics.py (100%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/__init__.py (68%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/align_reference.py (96%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/bundle_orbit.py (97%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/channel_metro.py (72%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/channels.py (96%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/clear_edge_hits.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/compact_bbox.py (95%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/compose_orbit.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/compose_views.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/densify_sweep.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/dual_mass.py (95%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/dual_units.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/fold_chain.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/force_densify.py (97%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/graph_util.py (95%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/hierarchy.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/hotspots.py (94%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/level_util.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/mass_field.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/mass_merge.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/min_rings.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/orbit_sweep.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/ortho_metro.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/partition.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/pin_beam.py (94%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/press_crossings.py (97%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/pull_far_chains.py (96%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/recipe.py (93%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/rigid_orbit.py (97%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/rigid_units.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/ring_faces.py (95%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/rings.py (81%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/scope.py (93%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/score.py (92%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/sides.py (94%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/sink_dual_units.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/skeleton.py (97%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/soft_petals.py (96%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/state.py (100%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/suggest_sink_hubs.py (100%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/sugiyama.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/transforms.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_ops/untangle.py (95%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_sight.py (97%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_stats.py (99%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_structure.py (98%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_tool.py (96%) rename {packages/netx-topology-mcp/src/netx_topology_mcp => netx_api/mcp_remote/topology}/layout_topology_quality.py (99%) delete mode 100644 packages/netx-bizmonitor-mcp/README.md delete mode 100644 packages/netx-bizmonitor-mcp/mcp.json delete mode 100644 packages/netx-bizmonitor-mcp/pyproject.toml delete mode 100644 packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/__main__.py delete mode 100644 packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/http_client.py delete mode 100644 packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/server.py delete mode 100644 packages/netx-bizmonitor-mcp/tests/conftest.py delete mode 100644 packages/netx-mcp/README.md delete mode 100644 packages/netx-mcp/SKILLS.md delete mode 100644 packages/netx-mcp/mcp.json delete mode 100644 packages/netx-mcp/pyproject.toml delete mode 100644 packages/netx-mcp/src/netx_mcp/__main__.py delete mode 100644 packages/netx-mcp/src/netx_mcp/http_client.py delete mode 100644 packages/netx-mcp/src/netx_mcp/server.py delete mode 100644 packages/netx-topology-mcp/README.md delete mode 100644 packages/netx-topology-mcp/SKILLS.md delete mode 100644 packages/netx-topology-mcp/mcp.json delete mode 100644 packages/netx-topology-mcp/pyproject.toml delete mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/__main__.py delete mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py delete mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/server.py delete mode 100644 packages/netx-topology-mcp/tests/test_layout_jobs.py create mode 100644 tests/mcp_remote/conftest.py rename {packages/netx-mcp/tests => tests/mcp_remote}/test_cli_safety.py (81%) rename {packages/netx-bizmonitor-mcp/tests => tests/mcp_remote}/test_mcp_bizmonitor.py (62%) create mode 100644 tests/mcp_remote/test_mcp_endpoint.py rename {packages/netx-mcp/tests => tests/mcp_remote}/test_mcp_http.py (76%) create mode 100644 tests/mcp_remote/test_mcp_tcp_framing.py rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/conftest.py (100%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_align_reference.py (91%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_bundle_orbit.py (95%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_channel_metro.py (89%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_clear_edge_hits.py (93%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_compact_bbox.py (80%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_compose_orbit.py (92%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_compose_views.py (97%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_core_beam.py (94%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_densify_sweep.py (93%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_dual_mass.py (93%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_dual_units.py (95%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_edge_axis.py (91%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_fold_chain.py (90%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_force_densify.py (91%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_hierarchy.py (93%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_hotspots.py (94%) create mode 100644 tests/mcp_remote/topology/test_layout_jobs.py rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_layout_ops.py (92%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_layout_sight.py (94%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_layout_stats.py (98%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_layout_structure.py (98%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_layout_tool.py (92%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_mass_merge.py (96%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_mcp_topology.py (86%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_move_nodes.py (86%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_orbit_sweep.py (93%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_ortho_metro.py (92%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_partition.py (93%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_pin_beam_soft_petals.py (90%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_press_crossings.py (94%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_pull_far_chains.py (89%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_rigid_orbit.py (92%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_rigid_units.py (88%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_sink_dual_units.py (90%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_suggest_sink_hubs.py (97%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_topology_contract.py (97%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_topology_quality.py (97%) rename {packages/netx-topology-mcp/tests => tests/mcp_remote/topology}/test_untangle.py (90%) delete mode 100644 tests/test_mcp_http.py diff --git a/README.md b/README.md index c6cd32b..ef8487d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Licensed under the [MIT License](LICENSE). Security reports: [SECURITY.md](SECUR - Import ZTE Alarm Monitor Excel - Normalize and store alarms into an isolated PostgreSQL - Query and aggregate alarms via REST API -- Expose the same read capabilities through MCP stdio tools +- Expose the same capabilities through built-in remote MCP (`/mcp`) - Quick diagnostics endpoint (`/v1/diagnostics`) - AP analysis bridge to oclaw (`/v1/ap/analyze`) @@ -162,32 +162,21 @@ After `npm run build` in `web/`, the API can also serve the UI at `http://127.0. Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres, program/data split, offline upgrade over existing installs) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`. -### 6) MCP(Cursor / oclaw / Claude) +### 6) MCP(Cursor / Codex / Claude / DSH) -先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。 +先启动 netx API(§5)。MCP 已内置在 API 同端口:`http://127.0.0.1:8890/mcp`(Streamable HTTP)。客户端只需 URL + Bearer token,**不必再装 Python MCP 包**。 -**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)** -**拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md) +**完整说明见:[docs/MCP.md](docs/MCP.md)**(topology / bizMonitor 分组见同目录文档)。配置示例:[mcp.all.json](mcp.all.json)、[mcp.json](mcp.json)。 -**割接 / 业务监控独立 MCP** 见:[docs/MCP_BIZMONITOR.md](docs/MCP_BIZMONITOR.md)。三组一起使用时无需 netxops,完整配置为 [mcp.all.json](mcp.all.json)。 +```bash +# Claude Code +claude mcp add --transport http netx http://127.0.0.1:8890/mcp \ + --header "Authorization: Bearer " -速查: - -```powershell -pip install -e ./packages/netx-mcp -# 拓扑(可选,单独安装) -pip install -e ./packages/netx-topology-mcp -pip install -e ./packages/netx-bizmonitor-mcp -# 三组配置见 mcp.all.json,分别运行: -python -m netx_mcp -python -m netx_topology_mcp -python -m netx_bizmonitor_mcp +# 只要 ops / topology / bizMonitor 之一: +# http://127.0.0.1:8890/mcp?groups=ops ``` -- 客户端配置:[`mcp.json`](mcp.json)(含 `netx` + 可选 `netx-topology`) -- oclaw payload:[`mcp_install_payload.json`](mcp_install_payload.json) / [`mcp_topology_install_payload.json`](mcp_topology_install_payload.json) -- 子包:[`packages/netx-mcp`](packages/netx-mcp/README.md)、[`packages/netx-topology-mcp`](packages/netx-topology-mcp/README.md)、[`packages/netx-bizmonitor-mcp`](packages/netx-bizmonitor-mcp/README.md) - ## Useful API endpoints - `POST /v1/alarms/import` (legacy import path, kept for compatibility) diff --git a/docs/MCP.md b/docs/MCP.md index 156b96a..83138e4 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -1,249 +1,64 @@ -# netx MCP — 安装与更新 +# netx 远程 MCP(Streamable HTTP) -netx 通过 **stdio MCP** 把告警/网元能力暴露给 Cursor、Claude Desktop、oclaw 等宿主。MCP 进程只做 HTTP 客户端,**不直连数据库**;数据来自已运行的 netx REST API。 +netx API 在同一端口(默认 `8890`)提供 `/mcp` 端点,使用 MCP **Streamable HTTP** 传输。客户端(Claude Code、Codex、Cursor、DSH netxops)只需配置 URL 与 Bearer token,**无需安装 Python MCP 包**。 + +## 架构 ``` -MCP 宿主 (Cursor / oclaw) → stdio netx_mcp → HTTP NETX_API_URL → netx API +MCP 宿主 → HTTP http://127.0.0.1:8890/mcp → netx_api.mcp_remote(进程内工具) ``` -| 组件 | 部署位置 | 说明 | -|------|----------|------| -| **netx API** | 本机或远端服务器 | `python -m netx_api.main`,默认 `http://127.0.0.1:8890` | -| **netx-mcp** | 与 MCP 宿主同机 | `pip install` 轻量包,仅需 Python + httpx | +工具分组(查询参数 `groups`): ---- +| groups | 工具数 | 说明 | +|--------|--------|------| +| `ops` | 15 | 告警 / 网元 / 只读 CLI | +| `topology` | 16 | 拓扑树 / 画布 / Fabric / 布局 | +| `bizMonitor` | 8 | 割接与业务状态只读 | +| (省略) | 39 | 当前 token 有权限的全部工具 | -## 前置条件 +示例:`/mcp?groups=ops,bizMonitor` -1. **netx API 已启动**且可访问: +## 鉴权 - ```powershell - curl http://127.0.0.1:8890/health - ``` +- Header:`Authorization: Bearer ` +- 接受 MCP 令牌(`NETX_AUTH_MCP_TOKEN_FILE`,bootstrap 写入)或用户 JWT +- 无 token / 无效 → HTTP **401** +- `tools/list` 只列出当前 token 有 scope 的工具;`tools/call` 再按 RBAC 校验 -2. **Python 3.11+**(与 MCP 宿主使用的 `python` 一致)。 +## 客户端配置 -3. 远端 API 时:记下 base URL(无尾部 `/`),例如 `http://10.0.0.5:8890`。 +见根目录 [`mcp.all.json`](../mcp.all.json)。Claude Code 示例: ---- - -## 1. 安装 `netx-mcp` 包 - -在 **运行 MCP 的那台机器**上执行(不必安装整套 netx 服务依赖): - -```powershell -cd D:\project\chatgpt\netx -pip install -e ./packages/netx-mcp +```bash +claude mcp add --transport http netx http://127.0.0.1:8890/mcp \ + --header "Authorization: Bearer " ``` -验证(**必须用 oclaw 启动时会调用的同一个 `python`**,见下方说明): +仅 ops: -```powershell -where python -python -c "import netx_mcp; print('ok')" -python -m netx_mcp +```text +http://127.0.0.1:8890/mcp?groups=ops ``` -若你在 A 终端里 `import netx_mcp` 成功,但 oclaw Admin 安装/Health 无反应或失败,多半是 **pip 装到了另一个 Python**。请用 oclaw 实际用的解释器安装: +## 语言 -```powershell -# 把路径换成 where python 的第一条,或 oclaw 服务 venv 里的 python.exe -C:\Path\To\Same\python.exe -m pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp" -C:\Path\To\Same\python.exe -c "import netx_mcp; print('ok')" -``` +- 环境变量 `NETX_LANG=zh|en`,或请求头 `Accept-Language` +- 工具 `description` / `inputSchema` 与历史 Python 包一致;`en` 时 API 查询附加 `lang=en` -从 GitHub 安装(无本地仓库时,**子目录必须是 `packages/netx-mcp`**): +## 安全 -```powershell -pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp" -``` +- `execManagedNe` 等写工具带 `annotations.readOnlyHint=false` +- CLI 硬门(`cli_guard`):仅单行 `show`/`display`,拒绝 shell 与破坏性命令 +- 异步 exec / layout job:先返回 job id,再用轮询工具查询 -开发者在 netx 仓库根目录也可 `pip install -e .`(含 API);MCP 仍推荐只装 `packages/netx-mcp`。 +## 版本 ---- +`initialize.serverInfo.version` 使用 netx 包版本(`pyproject.toml` / `netx-ops`)。 -## 2. 环境变量 +无状态模式下不主动推送 `notifications/tools/list_changed`;客户端应在需要时重新 `tools/list`。 -| 变量 | 必填 | 默认 | 说明 | -|------|------|------|------| -| `NETX_API_URL` | 否 | `http://127.0.0.1:8890` | netx REST 根地址,可指向远端 | -| `NETX_API_TOKEN` | 否 | 空 | Bearer;空则自动读 `data/auth/mcp_token`(API 首次启动生成) | -| `NETX_MCP_TOKEN_FILE` | 否 | `data/auth/mcp_token` | 默认 token 文件路径 | -| `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 | -| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 | -| `NETX_NE_EXEC_ASYNC_MIN_NES` | 否 | `4` | 批量 NE 数 ≥ 此值时自动走后台 job(`0`=仅 `async=true`) | -| `NETX_NE_EXEC_JOB_DIR` | 否 | `data/ne_exec_jobs` | 后台 exec job 状态目录 | -| `NETX_NE_EXEC_JOB_MAX_CONCURRENT` | 否 | `3` | 同时运行的 exec job 上限 | +## 相关文档 -本机默认端口时 **可不设任何变量**。启用登录后,先启动一次 netx API,会生成 `data/auth/mcp_token`;MCP 会自动带上该 token。若要把 token 写进 Cursor 配置: - -```json -"NETX_API_TOKEN": "nxt_从文件复制的内容" -``` - ---- - -## 3. 在 Cursor / Claude Desktop 中配置 - -复制仓库中的 [`mcp.json`](../mcp.json)(或 [`packages/netx-mcp/mcp.json`](../packages/netx-mcp/mcp.json))到客户端 MCP 配置,例如 Cursor:`.cursor/mcp.json`。 - -```json -{ - "mcpServers": { - "netx": { - "command": "python", - "args": ["-m", "netx_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" - } - } - } -} -``` - -(可选)显式设置 `"NETX_API_TOKEN": "nxt_..."`;不设则读仓库/`cwd` 下的 `data/auth/mcp_token`。 - -保存后 **重启 Cursor/客户端**,使 MCP 子进程重新拉起。 - ---- - -## 4. 在 oclaw Admin 中安装 - -与 Cursor **同一份** `mcpServers` JSON 即可,无需转成别的格式。 - -1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。 -2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。 -3. **Health** → **Sync Tools**(完整 scope 下应看到 **15** 个工具)。 -4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。 - -更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库: -`oclaw/docs/NETX_MCP_INTEGRATION.md`。 - -可选:oclaw 专用字段展开版 [`mcp_install_payload.json`](../mcp_install_payload.json)(与 `mcp.json` 等价)。 - -**Skills(真源)**:仓库根 [`skills/`](../skills/README.md) — `netx-ops` / `netx-topology` (ops = NMS + managed CLI)。MCP 包不再带 skills 镜像;Cursor 直接指 `netx/skills/`。dsh-netxops 发版前可 `sync-skills-from-netx.ps1`。oclaw 旧 playbook 不再维护。 - -**拓扑画布 MCP**(独立安装/绑定)见 [`MCP_TOPOLOGY.md`](./MCP_TOPOLOGY.md)(`server_id=netx-topology`)。 - ---- - -## 5. 暴露的工具 - -模型面使用通用 **Nms** 命名(当前适配器仍为 zte-ume,HTTP 仍 `/v1/ume/*`)。**0.3.0** 起不再暴露 `*Ume*` 工具名。 - -| 类别 | 工具名 | -|------|--------| -| NMS 告警 | `queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics` | -| NMS 网元 | `queryNmsNeInventory`, `getNmsNe` | -| NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` | -| common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths` | - -参数使用 `nms_ne_id` / `nms_ne_ids`,旧 `ume_*` 模型参数已删除;`getManagedNe` 只接受 `ne_id`。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__`;DSH:`netx__`。 - -**业务监控 / 割接分析**:独立 [`netx-bizmonitor-mcp`](./MCP_BIZMONITOR.md) 提供 8 个工具,需 `biz-monitor:read`;netxops 的 bizMonitor 组使用相同 stem。三组统一配置见根 [`mcp.all.json`](../mcp.all.json),不需要安装 netxops。 - -**CLI 安全边界**:独立 MCP 强制单行只读 `show` / `display`,可用只读输出过滤;配置、shell 操作符、重定向和多行脚本在 HTTP 前拒绝。后端开放 exec_policy 或 capability 的脚本提示也不能解除这个 MCP 门禁,Linux/RouterOS 原生 CLI 当前未暴露。异步 job_id 仅表示开始,按 poll_after_ms 有界轮询;terminal 后检查 result.ok,批量还须检查 summary.failed 和每条 results[].ok,不能只凭作业 succeeded 宣称全部成功。 - ---- - -## 6. 更新 MCP - -MCP 代码在 **`packages/netx-mcp`**(版本见 `packages/netx-mcp/pyproject.toml`)。更新步骤: - -### 6.1 重装 `netx-mcp` 包 - -**远程 MCP 机 / 无本地 netx 仓库**(不必 `git pull`,每次更新执行一条即可): - -```powershell -pip install --upgrade "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp" -``` - -**本机有 netx 仓库(开发)**: - -```powershell -cd D:\project\chatgpt\netx -git pull -pip install -e ./packages/netx-mcp -``` - -确认版本(可选): - -```powershell -pip show netx-mcp -``` - -### 6.2 重启 MCP 宿主 - -| 宿主 | 操作 | -|------|------| -| **Cursor / Claude** | 完全退出客户端后重开,或重载 MCP | -| **oclaw** | 重启 gateway/主进程;Admin 对 `netx` 再点 **Health** → **Sync Tools** | - -仅改 `NETX_API_URL` 等 env 时,同样需重启 MCP 子进程(或 oclaw 整进程)。 - -### 6.3 无需改配置的情况 - -- 只改 **netx API** 业务逻辑、未改 MCP 工具名/参数:重装 `netx-mcp` 可选;API 部署后 MCP 自动走新 API。 -- 改了 **工具列表或 JSON schema**:必须重装 `netx-mcp` 并在宿主 **Sync Tools**。 - -### 6.4 从旧入口迁移 - -| 旧方式 | 新方式 | -|--------|--------| -| `python -m netx_api.mcp` | `python -m netx_mcp`(唯一 Python 模块入口) | -| `NETX_MCP_MODE=db` 直连库 | 已废弃;使用 HTTP 模式 | - -旧入口 `netx_api.mcp`、`netx_api/mcp_server.py` 和 `NETX_MCP_MODE=db` 已删除。请安装独立 `netx-mcp` 包并迁移配置;不再保留兼容委托。旧 DB 模式独有的导入告警查询/聚合/批次/诊断、同步触发、历史告警查询仍有 HTTP API,但不属于当前 ops 15 工具。 - ---- - -## 7. 自检 - -**API:** - -```powershell -curl http://127.0.0.1:8890/health -``` - -**MCP 工具列表(需已 `pip install -e packages/netx-mcp`):** - -```powershell -cd D:\project\chatgpt\netx -python -m pytest packages/netx-mcp/tests/test_mcp_http.py -q -``` - -**手动 stdio(PowerShell 示例):** - -```powershell -$env:NETX_API_URL = "http://127.0.0.1:8890" -$p = Start-Process python -ArgumentList "-m","netx_mcp" -RedirectStandardInput pipe -RedirectStandardOutput pipe -NoNewWindow -PassThru -# 向 stdin 写入一行 JSON-RPC initialize / tools/list(见 packages/netx-mcp/tests) -``` - ---- - -## 8. 常见问题 - -| 现象 | 处理 | -|------|------| -| `ModuleNotFoundError: netx_mcp` | 用 **oclaw 同机同一个 `python.exe`** 执行 pip 安装(见 §1 验证) | -| **oclaw Admin 点安装「没反应」** | 1) 必须点 **Install from JSON**(只粘贴不点按钮无效)
2) 看安装区下方 **install status** 一行字(应出现 `[json] installing...` 或错误)
3) 浏览器 F12 → Console 是否有红色报错
4) 确认 JSON 为完整 `mcpServers` 或 `mcp_install_payload.json` 格式 | -| oclaw preflight `mcp_python_module_missing` | `python` 在 PATH 里但无 `netx_mcp`;对该 python 执行 git+pip 安装 | -| 工具调用连不上 API | 检查 `NETX_API_URL`、防火墙、远端 API 是否启动 | -| Windows 乱码 / JSON 解析失败 | 配置里保留 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`(见 `mcp.json`) | -| oclaw 工具数为 0 | Admin **Health → Sync Tools**;确认 `server_id=netx` 已绑定 ops 专家 | -| 旧脚本路径或模块不存在 | 安装 `packages/netx-mcp`,配置改为 `"args": ["-m", "netx_mcp"]` | - ---- - -## 相关文件 - -| 文件 | 用途 | -|------|------| -| [`packages/netx-mcp/`](../packages/netx-mcp/) | MCP 实现与 `pyproject.toml` | -| [`mcp.json`](../mcp.json) | Cursor / oclaw 粘贴用配置 | -| [`mcp_install_payload.json`](../mcp_install_payload.json) | oclaw 字段展开版(可选) | -| [`packages/netx-mcp/README.md`](../packages/netx-mcp/README.md) | 子包速查 | +- [MCP_TOPOLOGY.md](./MCP_TOPOLOGY.md) — topology 组工具说明 +- [MCP_BIZMONITOR.md](./MCP_BIZMONITOR.md) — bizMonitor 组工具说明 diff --git a/docs/MCP_BIZMONITOR.md b/docs/MCP_BIZMONITOR.md index 7db37ae..4782300 100644 --- a/docs/MCP_BIZMONITOR.md +++ b/docs/MCP_BIZMONITOR.md @@ -1,41 +1,9 @@ -# 割接与业务监控 MCP +# netx MCP · bizMonitor 组 -`netx-bizmonitor-mcp` 独立提供 bizMonitor 的 8 个只读工具。不安装 netxops 也能读取已有项目、批次、看板、红票、差异和采集原文。它只依赖 Python 3.11+ 与 httpx,通过 HTTP 使用正在运行的 netx。 +割接与业务状态只读工具(8 个),通过同一远程端点提供: -## 三组一起配置 - -在运行 MCP 客户端的 Python 环境分别安装: - -```powershell -python -m pip install -e ./packages/netx-mcp -python -m pip install -e ./packages/netx-topology-mcp -python -m pip install -e ./packages/netx-bizmonitor-mcp +```text +http://127.0.0.1:8890/mcp?groups=bizMonitor ``` -将根 [mcp.all.json](../mcp.all.json) 的三个 server 放入客户端 MCP 配置。也可以不安装包,分别将该包的 `src` 绝对路径设为对应 server 的 `PYTHONPATH`。三个模块分别为 `netx_mcp`、`netx_topology_mcp`、`netx_bizmonitor_mcp`;console scripts 属于各独立包,安装 API 本体不会提供一个缺少依赖的 MCP 假入口。 - -统一设置 `NETX_API_URL`。凭据优先用 `NETX_API_TOKEN`,否则读取 `NETX_MCP_TOKEN_FILE`,再尝试工作目录 `data/auth/mcp_token`。客户端工作目录不确定时,给 `NETX_MCP_TOKEN_FILE` 配置绝对路径。不要把真实令牌提交到仓库。 - -业务组需 `biz-monitor:read`;ops 需 `alarms:read`、`ne:read`、`ne:exec`,SQL 另需 `sql:query`;topology 读取需 `ne:read`,建图/布局需 `ne:write`。默认 bootstrap token 不含 SQL 和拓扑写权限,因此工具数量会按 scope 缩减;有完整权限时分别列出 **15、16、8** 个工具。 - -## 入口与证据链 - -MCP 使用裸名;netxops 使用 `netx__` 加相同名称。 - -1. `listBizMonitors`:无 ID 的入口,取得 project_id/task_id。 -2. `getBizMonitorContext`:读取模板、归一化、端口映射、两侧任务和基线。 -3. `listBizMonitorBatches(project_id,include_runs=true)`:取得割接批次与 run_id;也可 `kind=runs,batch_id=...`。 -4. `getBizMonitorBoard(batch_id)`:检查完成状态、缺失数据和统计。 -5. `listBizMonitorReds(project_id)` / `getBizMonitorDiffs(run_id,color=red)`:逐页取得红项和证据。`q` 自动映射后端 `kw`。 -6. `getBizCollectBatch`:采集批次的命令目录;需要时由 `listBizMonitorBatches(task_id)` 找采集批次。 -7. `getBizCollectCommandRaw(batch_id,command_id)`:两侧原文取证。这里使用采集 batch_id,不能传割接 batch_id。 - -`old_key/new_key` 保留设备原始 A/B;`match_*` 和归一化映射用于内部配对。原文按需获取,可能很大;凭据会脱敏,不把内部匹配键冒充设备原始端口。 - -本组不会创建任务/模板、启动采集、执行 evaluate、关闭红票。没有批次时不能得出“割接通过”。完整行为和参数见 [包 README](../packages/netx-bizmonitor-mcp/README.md) 与 [skill](../skills/biz-monitor/netx-biz-monitor/SKILL.md)。 - -## 可复现验收 - -`scripts/mcp_acceptance.py` 启动当前 worktree 的真实 stdio MCP,记录去敏 JSONL;仅允许本机 8890 或隔离样例 8891。工具源目录可用 `--source-root` 显式指定,不会静默导入主仓库的 editable 包。 - -没有现场割接数据时,可用 `scripts/seed_mcp_acceptance.py` 在 `agent-wt/netx-mcp-lead/artifacts/` 的新 acceptance SQLite 文件生成明确标注的离线样例,真实运行业务评价,再通过本地 API/MCP 验证读取链路。样例不代表现场故障;不要将样例库替换应用库。脚本拒绝覆盖、外部路径、网络连接和监听端口。 +鉴权与总览见 [MCP.md](./MCP.md)。需要 `biz-monitor:read`。全部工具 `readOnlyHint=true`。 diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md index db3ec37..7d96ad6 100644 --- a/docs/MCP_TOPOLOGY.md +++ b/docs/MCP_TOPOLOGY.md @@ -1,141 +1,11 @@ -# netx Topology MCP — 安装与更新 +# netx MCP · topology 组 -与告警/CLI 的 [`netx-mcp`](./MCP.md) **分开**的 stdio MCP,只提供 **拓扑树、画布(views)、Fabric 边/邻接**。Agent 可只装本包,或与 `netx` 并存。 +拓扑树、画布(views)、Fabric 与布局工具,通过同一远程端点提供: -``` -MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → /v1/topology/* +```text +http://127.0.0.1:8890/mcp?groups=topology ``` -| 组件 | 说明 | -|------|------| -| **netx API** | 需已启动,拓扑数据在服务端 | -| **netx-topology-mcp** | 轻量 HTTP 客户端,与宿主同机 | +鉴权与总览见 [MCP.md](./MCP.md)。需要 `ne:read` / `ne:write` 等 scope(写工具含新建文件夹、改节点、布局写入等)。 ---- - -## 1. 安装 - -```powershell -cd D:\project\chatgpt\netx -pip install -e ./packages/netx-topology-mcp -python -c "import netx_topology_mcp; print('ok')" -python -m netx_topology_mcp -``` - -从 GitHub: - -```powershell -pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp" -``` - -环境变量与 [`MCP.md`](./MCP.md) 相同:`NETX_API_URL`、`NETX_API_TOKEN` / `data/auth/mcp_token`、`NETX_LANG`。 - ---- - -## 2. Cursor / oclaw 配置 - -独立服务器 id:`netx-topology`(不要与 `netx` 混在同一个 command 里)。 - -```json -{ - "mcpServers": { - "netx-topology": { - "command": "python", - "args": ["-m", "netx_topology_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_API_TOKEN": "nxt_your_key_with_ne_write", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" - } - } - } -} -``` - -画图必须设 `NETX_API_TOKEN`(网页新建 Key,默认含 `ne:write`)。未设置时回退读 `data/auth/mcp_token`(只读+CLI,**无写工具**)。改 Key 权限后 Sync Tools / 约 45s 内 scopes 会刷新。 - -样本:[`packages/netx-topology-mcp/mcp.json`](../packages/netx-topology-mcp/mcp.json)。 - -与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。 - -oclaw:Install from JSON → Health → Sync Tools(完整 scope 下应看到 **16** 个工具,含 `layoutTopologyView` / `sinkTopologyDualUnits` / `queryTopologyFabricNodes`)→ 专家绑定勾选 `server_id=netx-topology`。 - -Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topology` 或重载窗口(须重启 stdio 进程)。可选在 env 加 `PYTHONPATH=.../packages/netx-topology-mcp/src` 强制最新源码。 - -配套 Agent Skill(dual_unit 排水 → 扫角/polish → 手拖):[`skills/topology/netx-topology/SKILL.md`](../skills/topology/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。 - ---- - -## 3. 工具一览 - -### 目录模型 - -- 顶级为导航「根」;其下唯一「根图」/ `Root map` 为画布(physical view)。 -- 子区域即画布(创建时自动 physical view)。 -- 文件夹 `ne_count`:子树去重 Fabric 网元数(来源无关)。 - -### 读 - -| 工具 | 作用 | -|------|------| -| `getTopologyTree` | 「根 / 根图 / 子区域」树 + views + `ne_count`(找画布用这个,勿再 listViews) | -| `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` | -| `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) | -| `classifyTopologyFabricNodes` | **分级打标**:`match`→`tag(level\|role, dry_run)`→`tag`;`level` 为 major.minor(0 外部…3 接入);`role` 预设仍可用。再 `addTopologyViewNodes` 上区域画布。**勿用切片建图** | -| `queryTopologyNeighborhood` | 邻域:compact nodes + `links[]`(NE 对,非端口) | -| `queryTopologyEdges` | 默认 **adjacency**:`links[{a,b,link_count}]`;画布一对网元一条线。`detail=ports` 才给端口行 | -| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score;`score_profile=auto\|default\|eye`(大稀疏斜边→eye);贴边=节点命中∪hits/link;util 甜区下限 0.08;`detail=structure` / `hotspots\|blocks\|both` | - -### 写(只动画布,不污染 Fabric) - -| 工具 | 作用 | -|------|------| -| `createTopologyFolder` | **唯一建图入口**:顶级→「根」+「根图」;根图下→**子区域**;返回 `view_id` | -| `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 | -| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 | -| `sinkTopologyDualUnits` | 根图 dual_units 分批沉入子区域;默认 `layout_batch`→`layout_dual_unit`→块扫挂载;日常禁 `until_empty` | -| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 | -| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;`park=true` 扫角停靠 | -| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 | -| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` | -| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` | - -**推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。 - -**硬顶 2000:目标 ≤2000 画一张图即可**;physical 根图/区域默认 `max_nodes=2000`,custom 仍按角色软顶。Agent 默认 `links[]` 邻接;布图按邻接与 Skill 中的经验摆点。 - -**刻意不提供:** 手工建链、`populate`、删 Fabric / 删整图。 - -写操作需要 token 具备 `ne:write`;只读为 `ne:read`。 - -**权限怎么开:** 网页 **系统 → API Key**(`/api-keys`)。新建默认已含 `ne:write`(可关掉);已有 Key 在 **操作 → 改权限**。把明文配到 `NETX_API_TOKEN` 后重启 MCP / Sync Tools。仓库自动生成的 `data/auth/mcp_token` 仍是只读+CLI(无写),需要画图请另建 Key 或改权限。 - -**前端能否看着画:** 在拓扑页左侧树或右侧浏览区点 **「实时同步」**(默认关闭;**不需要先打开某张图**)。开启后树约每 5 秒、已打开的图约每 3 秒拉取,可看到 MCP 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。 - ---- - -## 4. 与 netx-mcp 的关系 - -| 包 | server_id | 职责 | -|----|-----------|------| -| `netx-mcp` | `netx` | NMS 告警、网元和只读 CLI(**15** 工具) | -| `netx-topology-mcp` | `netx-topology` | topology 组 15 工具及额外 sinkTopologyDualUnits(共 **16**) | -| `netx-bizmonitor-mcp` | `netx-bizmonitor` | 割接 / 业务状态只读分析(**8** 工具) | - -显式后台保存:`layoutTopologyView(view_id=...,action=layout,recipe=compact,mode=apply,params={background:true})`。用 `layoutTopologyView(action=job_status,params={job_id:...})` 轮询,成功后 getTopologyView 回读。任务是 MCP 本地子进程与落盘 JSON,没有 HTTP layout job API;跨目录或机器不能轮询同一 job。失败后可能已有部分写入,先回读,不盲重试。布局保存成功与评分诊断分别判断;评分校准另行安排,本轮不改算法/阈值。 - -`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。 - ---- - -## 5. 更新 - -```powershell -cd -git pull -pip install -e ./packages/netx-topology-mcp -``` - -然后重启 MCP 宿主,并 Sync Tools。 +布局算法在 netx 进程内执行(原 `netx-topology-mcp` 逻辑已迁入 `netx_api/mcp_remote/topology/`)。异步布局 job 仍返回 job id 后轮询。 diff --git a/mcp.all.json b/mcp.all.json index a28560d..199405e 100644 --- a/mcp.all.json +++ b/mcp.all.json @@ -1,33 +1,27 @@ { "mcpServers": { "netx": { - "command": "python", - "args": ["-m", "netx_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" + "url": "http://127.0.0.1:8890/mcp", + "headers": { + "Authorization": "Bearer ${NETX_API_TOKEN}" + } + }, + "netx-ops": { + "url": "http://127.0.0.1:8890/mcp?groups=ops", + "headers": { + "Authorization": "Bearer ${NETX_API_TOKEN}" } }, "netx-topology": { - "command": "python", - "args": ["-m", "netx_topology_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" + "url": "http://127.0.0.1:8890/mcp?groups=topology", + "headers": { + "Authorization": "Bearer ${NETX_API_TOKEN}" } }, "netx-bizmonitor": { - "command": "python", - "args": ["-m", "netx_bizmonitor_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" + "url": "http://127.0.0.1:8890/mcp?groups=bizMonitor", + "headers": { + "Authorization": "Bearer ${NETX_API_TOKEN}" } } } diff --git a/mcp.json b/mcp.json index c8c6b28..17252b7 100644 --- a/mcp.json +++ b/mcp.json @@ -1,23 +1,9 @@ { "mcpServers": { "netx": { - "command": "python", - "args": ["-m", "netx_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" - } - }, - "netx-topology": { - "command": "python", - "args": ["-m", "netx_topology_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" + "url": "http://127.0.0.1:8890/mcp", + "headers": { + "Authorization": "Bearer ${NETX_API_TOKEN}" } } } diff --git a/mcp_install_payload.json b/mcp_install_payload.json index 88bd29c..e1e3aa0 100644 --- a/mcp_install_payload.json +++ b/mcp_install_payload.json @@ -1,19 +1,17 @@ { - "source_type": "local", - "source_ref": "netx-mcp", + "source_type": "remote", + "source_ref": "netx-remote-mcp", "server_id": "netx", - "version": "0.2.0", - "entry_command": "python", - "entry_args": ["-m", "netx_mcp"], + "version": "0.4.19", + "transport": "http", + "url": "http://127.0.0.1:8890/mcp", + "headers": { + "Authorization": "Bearer ${NETX_API_TOKEN}" + }, "env_schema": { - "NETX_API_URL": { - "type": "string", - "description": "netx REST API base URL (no trailing slash)", - "default": "http://127.0.0.1:8890" - }, "NETX_API_TOKEN": { "type": "string", - "description": "Optional Bearer token when netx API auth is enabled" + "description": "Bearer token (MCP token file or user JWT)" }, "NETX_LANG": { "type": "string", diff --git a/mcp_topology_install_payload.json b/mcp_topology_install_payload.json index ff02b91..64e7cc3 100644 --- a/mcp_topology_install_payload.json +++ b/mcp_topology_install_payload.json @@ -1,19 +1,17 @@ { - "source_type": "local", - "source_ref": "netx-topology-mcp", + "source_type": "remote", + "source_ref": "netx-remote-mcp", "server_id": "netx-topology", - "version": "0.1.0", - "entry_command": "python", - "entry_args": ["-m", "netx_topology_mcp"], + "version": "0.4.19", + "transport": "http", + "url": "http://127.0.0.1:8890/mcp?groups=topology", + "headers": { + "Authorization": "Bearer ${NETX_API_TOKEN}" + }, "env_schema": { - "NETX_API_URL": { - "type": "string", - "description": "netx REST API base URL (no trailing slash)", - "default": "http://127.0.0.1:8890" - }, "NETX_API_TOKEN": { "type": "string", - "description": "Optional Bearer token when netx API auth is enabled" + "description": "Bearer token (MCP token file or user JWT)" }, "NETX_LANG": { "type": "string", diff --git a/netx_api/auth_middleware.py b/netx_api/auth_middleware.py index 3375420..05f9383 100644 --- a/netx_api/auth_middleware.py +++ b/netx_api/auth_middleware.py @@ -1,14 +1,13 @@ -"""HTTP auth gate + request audit middleware.""" +"""HTTP auth gate + request audit middleware (pure ASGI — safe with Mount /mcp).""" from __future__ import annotations import logging import time -from typing import Callable -from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send from .auth_deps import resolve_user_from_token from .auth_scopes import has_scope, required_scope_for_request @@ -35,11 +34,12 @@ _PUBLIC_PREFIXES = ( "/assets", ) -# Authenticated API / infra only. Everything else (SPA routes, favicon, …) is public. + def _is_api_auth_path(path: str) -> bool: p = str(path or "") if p.startswith("/v1/"): return True + # /mcp has its own Bearer gate (mcp_remote.mount.McpAuthMiddleware). if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"): return True if p.startswith("/docs") or p.startswith("/redoc"): @@ -47,7 +47,6 @@ def _is_api_auth_path(path: str) -> bool: return False -# While must_change_password is true, only these authenticated endpoints are allowed. _PASSWORD_CHANGE_ALLOW = frozenset( { ("GET", "/v1/auth/me"), @@ -74,12 +73,16 @@ def _is_public(path: str) -> bool: return True if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES): return True - # Bundled UI routes (/login, /topology, …) must load without a token. if not _is_api_auth_path(p): return True return False +def _is_mcp_path(path: str) -> bool: + p = str(path or "") + return p == "/mcp" or p.startswith("/mcp/") + + def _client_ip(request: Request) -> str: from .client_ip import resolve_client_ip @@ -104,18 +107,37 @@ def _action_for(method: str, path: str) -> str: return f"http.{m.lower()}" -class AuthAuditMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next: Callable) -> Response: - if request.method.upper() == "OPTIONS": - return await call_next(request) +class AuthAuditMiddleware: + """Pure ASGI auth + audit. BaseHTTPMiddleware breaks Streamable HTTP under Mount.""" + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive) + method = request.method.upper() path = request.url.path - if not bool(settings.auth_enabled) or _is_public(path): - return await call_next(request) - # WebSocket upgrades are authenticated inside the WS endpoint. + # MCP is authenticated inside the mounted sub-app; never wrap its body. + if _is_mcp_path(path): + await self.app(scope, receive, send) + return + + if method == "OPTIONS": + await self.app(scope, receive, send) + return + + if not bool(settings.auth_enabled) or _is_public(path): + await self.app(scope, receive, send) + return + if path.startswith("/v1/webcrt/") and path.endswith("/ws"): - return await call_next(request) + await self.app(scope, receive, send) + return token = "" auth = str(request.headers.get("authorization") or "").strip() @@ -125,26 +147,27 @@ class AuthAuditMiddleware(BaseHTTPMiddleware): from .auth_cookies import read_access_cookie token = read_access_cookie(request) - # Query-string access_token is no longer accepted (leaks via logs/proxies). db = SessionLocal() try: resolved = resolve_user_from_token(db, token) if token else None if resolved is None: - # Do not audit anonymous 401s — SPA fan-out floods the log. - # Intentional auth ops (login/logout/login_failed) are written in auth_router. - return JSONResponse(status_code=401, content={"detail": "unauthorized"}) + await JSONResponse(status_code=401, content={"detail": "unauthorized"})( + scope, receive, send + ) + return user, via, scopes, token_id, jti = resolved if bool(getattr(user, "must_change_password", False)): - allow_key = (request.method.upper(), path.rstrip("/") if len(path) > 1 else path) + allow_key = (method, path.rstrip("/") if len(path) > 1 else path) if allow_key not in _PASSWORD_CHANGE_ALLOW: - return JSONResponse( + await JSONResponse( status_code=403, content={"detail": "password_change_required"}, - ) - need = required_scope_for_request(request.method, path) + )(scope, receive, send) + return + need = required_scope_for_request(method, path) if need and not has_scope(scopes, need): - return JSONResponse( + await JSONResponse( status_code=403, content={ "detail": { @@ -153,7 +176,8 @@ class AuthAuditMiddleware(BaseHTTPMiddleware): "granted": sorted(scopes), } }, - ) + )(scope, receive, send) + return request.state.auth_user = user request.state.auth_via = via request.state.auth_scopes = scopes @@ -164,22 +188,33 @@ class AuthAuditMiddleware(BaseHTTPMiddleware): auth_via = via except Exception: _log.exception("auth middleware failure path=%s", path) - return JSONResponse(status_code=500, content={"detail": "auth_middleware_error"}) + await JSONResponse(status_code=500, content={"detail": "auth_middleware_error"})( + scope, receive, send + ) + return finally: db.close() started = time.perf_counter() - response = await call_next(request) + status_code = 0 + + async def send_wrapper(message: Message) -> None: + nonlocal status_code + if message["type"] == "http.response.start": + status_code = int(message.get("status") or 0) + await send(message) + + await self.app(scope, receive, send_wrapper) try: from .audit_async import enqueue_audit enqueue_audit( - action=_action_for(request.method, path), + action=_action_for(method, path), actor_user_id=actor_id, actor_username=actor_name, - method=request.method, + method=method, path=path, - status_code=int(response.status_code), + status_code=int(status_code or 0), client_ip=_client_ip(request), user_agent=str(request.headers.get("user-agent") or "")[:512], detail={ @@ -189,4 +224,3 @@ class AuthAuditMiddleware(BaseHTTPMiddleware): ) except Exception: _log.exception("audit enqueue after request failed path=%s", path) - return response diff --git a/netx_api/config.py b/netx_api/config.py index 2ebe55f..84f7c4b 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -218,6 +218,11 @@ class Settings(BaseSettings): bootstrap_admin_password: str = "admin123" # Written on first boot for MCP; path relative to cwd / absolute auth_mcp_token_file: str = "data/auth/mcp_token" + # Comma-separated Host allow-list for MCP DNS-rebinding guard. + # Empty (default) = disable DNS-rebinding protection (Bearer auth is the gate; + # needed for LAN IP / reverse-proxy Host headers). Example: + # NETX_MCP_ALLOWED_HOSTS=localhost,127.0.0.1,netx.example.com,netx.example.com:* + mcp_allowed_hosts: str = "" # Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited. auth_api_token_max_count: int = 20 # Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP diff --git a/netx_api/main.py b/netx_api/main.py index e56f91d..5259734 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -52,13 +52,17 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: from .app_startup import run_api_startup from .app_shutdown import shutdown_runtime from .dsh_alarm_hub import bind_event_loop + from .mcp_remote.mount import mcp_lifespan_cm - run_api_startup() - bind_event_loop(asyncio.get_running_loop()) - try: - yield - finally: - shutdown_runtime(reason="api_lifespan") + # Pass the live FastAPI app so /mcp ASGI is rebound to a fresh session manager + # (TestClient cycles + tests that clear mcp context must not leave a stale app). + async with mcp_lifespan_cm(host_app=_app): + run_api_startup() + bind_event_loop(asyncio.get_running_loop()) + try: + yield + finally: + shutdown_runtime(reason="api_lifespan") app = FastAPI( @@ -95,8 +99,11 @@ def health() -> dict[str, str]: return {"status": "ok"} +from .mcp_remote.mount import mount_mcp # noqa: E402 from .ui_static import mount_ui_if_present # noqa: E402 +mount_mcp(app) + _UI_MOUNTED = mount_ui_if_present(app) if not _UI_MOUNTED: @@ -109,6 +116,7 @@ if not _UI_MOUNTED: "frontend_url": settings.frontend_url, "api_health": "/health", "api_status": "/v1/integrations/status", + "mcp": "/mcp", } diff --git a/netx_api/mcp_remote/__init__.py b/netx_api/mcp_remote/__init__.py new file mode 100644 index 0000000..1a93b72 --- /dev/null +++ b/netx_api/mcp_remote/__init__.py @@ -0,0 +1 @@ +"""Built-in remote MCP (Streamable HTTP) for netx.""" diff --git a/netx_api/mcp_remote/asgi_client.py b/netx_api/mcp_remote/asgi_client.py new file mode 100644 index 0000000..56eaa95 --- /dev/null +++ b/netx_api/mcp_remote/asgi_client.py @@ -0,0 +1,285 @@ +"""In-process ASGI client used by MCP tool handlers (no TCP loopback).""" + +from __future__ import annotations + +import json +import os +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any +from urllib.parse import quote + +import httpx + +from .context import get_app, get_request_context + +_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = { + "其他": "Other", + "时钟": "Clock", + "OTN/光": "OTN/Optical", + "电源": "Power", +} + +_SECRET_KEYS = frozenset( + { + "password", + "passwd", + "pwd", + "secret", + "token", + "api_token", + "api_key", + "authorization", + "access_token", + "refresh_token", + "private_key", + "client_secret", + "ssh_password", + "enable_password", + "hop_password", + "hop_password_enc", + "password_enc", + "password_ciphertext", + "hop_password_ciphertext", + "credential_secret", + "secret_key", + "credentials", + } +) + + +def quote_ne_id(ne_id: str) -> str: + return quote(str(ne_id or "").strip(), safe="") + + +def api_token() -> str: + from pathlib import Path + + ctx = get_request_context() + if ctx and ctx.token and ctx.token != "disabled": + return ctx.token + token = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip() + if token: + return token + for candidate in (os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), "data/auth/mcp_token"): + if not candidate: + continue + try: + token = Path(candidate).read_text(encoding="utf-8").strip() + except (OSError, UnicodeError, ValueError): + continue + if token: + return token + return "" + + +def lang_query_params() -> dict[str, str]: + ctx = get_request_context() + if ctx is not None: + lang = str(ctx.lang or "zh").strip().lower() + else: + lang = str(os.getenv("NETX_LANG") or "zh").strip().lower() + if lang.startswith("en"): + return {"lang": "en"} + return {} + + +def localize_payload(data: dict[str, Any]) -> dict[str, Any]: + ctx = get_request_context() + if ctx is not None: + lang = str(ctx.lang or "zh").strip().lower() + else: + lang = str(os.getenv("NETX_LANG") or "zh").strip().lower() + if not lang.startswith("en"): + return data + proto = data.get("protocol_summary") + if isinstance(proto, list): + for row in proto: + if isinstance(row, dict): + k = str(row.get("key") or "") + if k in _PROTOCOL_KEY_ZH_TO_EN: + row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k] + return data + + +def redact(value: Any, *, tokens: tuple[str, ...] | None = None) -> Any: + if tokens is None: + tokens = (api_token(),) + if isinstance(value, dict): + return { + k: redact(v, tokens=tokens) + for k, v in value.items() + if str(k).lower() not in _SECRET_KEYS + } + if isinstance(value, list): + return [redact(v, tokens=tokens) for v in value] + if isinstance(value, str): + for token in tokens: + if token: + value = value.replace(token, "[REDACTED]") + value = re.sub(r"(?i)\bBearer\s+[^\s\"']+", "Bearer [REDACTED]", value) + value = re.sub( + r"(?im)(\b(?:password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*)([^\s,;]+)", + r"\1[REDACTED]", + value, + ) + return value + + +def _headers() -> dict[str, str]: + h = {"accept": "application/json"} + tok = api_token() + if tok: + h["authorization"] = f"Bearer {tok}" + return h + + +def _default_timeout() -> float: + try: + return float(os.getenv("NETX_HTTP_TIMEOUT") or 120.0) + except (TypeError, ValueError): + return 120.0 + + +def _client(timeout: float) -> httpx.Client: + app = get_app() + if app is None: + base = ( + os.getenv("NETX_API_URL") + or os.getenv("OCLAW_NETX_BASE_URL") + or "http://127.0.0.1:8890" + ).rstrip("/") + return httpx.Client(base_url=base, timeout=timeout, trust_env=False) + return httpx.Client( + transport=httpx.ASGITransport(app=app), + base_url="http://mcp.internal", + timeout=timeout, + trust_env=False, + ) + + +def http_json( + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + timeout: float | None = None, +) -> dict[str, Any]: + # When not attached to the FastAPI app, require an explicit outbound token + # (remote MCP itself authenticates at /mcp; this covers unit tests / fallback). + if get_app() is None and not api_token(): + return { + "ok": False, + "error": "netx_token_missing", + "hint": "设置 NETX_API_TOKEN、NETX_MCP_TOKEN_FILE,或工作目录 data/auth/mcp_token。", + } + merged: dict[str, Any] = dict(lang_query_params()) + if params: + merged.update(params) + to = float(timeout) if timeout is not None else _default_timeout() + tok = api_token() + headers = {"accept": "application/json"} + if tok: + headers["authorization"] = f"Bearer {tok}" + try: + with _client(to) as client: + resp = client.request( + method, + path, + params=merged or None, + json=body, + headers=headers, + ) + text = resp.text + if not resp.is_success: + return { + "ok": False, + "error": f"netx_http_{resp.status_code}", + "detail": text[:800], + "hint": "检查 API 地址、令牌 scope 和资源 ID。", + } + data = resp.json() if text else {} + if isinstance(data, dict): + data = localize_payload(data) + data = redact(data, tokens=(tok,)) + else: + data = {"raw": data} + return {"ok": True, "data": data} + except Exception as exc: + return { + "ok": False, + "error": "netx_request_failed", + "detail": str(exc)[:800], + "hint": "检查 API 连通性及 JSON 响应。", + } + + +def http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]: + return http_json("POST", path, body=body, timeout=timeout) + + +def http_json_many( + requests: list[dict[str, Any]], + *, + max_workers: int = 12, + timeout: float | None = None, +) -> list[dict[str, Any]]: + if not requests: + return [] + if len(requests) == 1: + r0 = requests[0] + out = http_json( + str(r0.get("method") or "GET"), + str(r0.get("path") or ""), + params=r0.get("params") if isinstance(r0.get("params"), dict) else None, + body=r0.get("body") if isinstance(r0.get("body"), dict) else None, + timeout=timeout, + ) + if "key" in r0: + out = {**out, "key": r0.get("key")} + return [out] + + to = float(timeout) if timeout is not None else _default_timeout() + workers = max(1, min(int(max_workers), len(requests))) + results: list[dict[str, Any] | None] = [None] * len(requests) + + def _one(idx: int, req: dict[str, Any]) -> tuple[int, dict[str, Any]]: + out = http_json( + str(req.get("method") or "GET"), + str(req.get("path") or ""), + params=req.get("params") if isinstance(req.get("params"), dict) else None, + body=req.get("body") if isinstance(req.get("body"), dict) else None, + timeout=to, + ) + if "key" in req: + out = {**out, "key": req.get("key")} + return idx, out + + with ThreadPoolExecutor(max_workers=workers) as pool: + futs = [pool.submit(_one, i, r) for i, r in enumerate(requests)] + for fut in as_completed(futs): + idx, out = fut.result() + results[idx] = out + return [r if isinstance(r, dict) else {"ok": False, "error": "parallel_slot_empty"} for r in results] + + +def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]: + out: dict[str, Any] = { + "content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}] + } + if is_error: + out["isError"] = True + return out + + +def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]: + result = redact(result) if isinstance(result, dict) else result + data = result.get("data") if isinstance(result, dict) else None + if ( + not isinstance(result, dict) + or not result.get("ok") + or (isinstance(data, dict) and data.get("ok") is False) + ): + return mcp_text_result(result, is_error=True) + return mcp_text_result(result) diff --git a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/__init__.py b/netx_api/mcp_remote/bizmonitor/__init__.py similarity index 100% rename from packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/__init__.py rename to netx_api/mcp_remote/bizmonitor/__init__.py diff --git a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/http_tools.py b/netx_api/mcp_remote/bizmonitor/http_tools.py similarity index 99% rename from packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/http_tools.py rename to netx_api/mcp_remote/bizmonitor/http_tools.py index c5d4a0f..c33dc5b 100644 --- a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/http_tools.py +++ b/netx_api/mcp_remote/bizmonitor/http_tools.py @@ -6,7 +6,7 @@ import math from typing import Any from urllib.parse import quote -from .http_client import http_json, mcp_from_handler_result +from netx_api.mcp_remote.asgi_client import http_json, mcp_from_handler_result class _InvalidArgument(ValueError): diff --git a/netx_api/mcp_remote/context.py b/netx_api/mcp_remote/context.py new file mode 100644 index 0000000..ec1071a --- /dev/null +++ b/netx_api/mcp_remote/context.py @@ -0,0 +1,52 @@ +"""Per-request context for built-in remote MCP (token, scopes, groups, lang).""" + +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class McpRequestContext: + token: str = "" + scopes: frozenset[str] | None = None + groups: frozenset[str] = field(default_factory=frozenset) + lang: str = "zh" + user_id: str = "" + auth_via: str = "" + + +_CTX: ContextVar[McpRequestContext | None] = ContextVar("netx_mcp_request_ctx", default=None) +_ASGI_APP: ContextVar[Any] = ContextVar("netx_mcp_asgi_app", default=None) + +# Process-wide FastAPI app reference for in-process ASGI calls (set at mount). +_APP_REF: Any = None + + +def set_app(app: Any) -> None: + global _APP_REF + _APP_REF = app + + +def get_app() -> Any: + return _ASGI_APP.get() or _APP_REF + + +def set_request_context(ctx: McpRequestContext | None) -> Any: + return _CTX.set(ctx) + + +def reset_request_context(token: Any) -> None: + _CTX.reset(token) + + +def get_request_context() -> McpRequestContext | None: + return _CTX.get() + + +def require_request_context() -> McpRequestContext: + ctx = _CTX.get() + if ctx is None or not ctx.token: + raise RuntimeError("mcp_request_context_missing") + return ctx diff --git a/netx_api/mcp_remote/mount.py b/netx_api/mcp_remote/mount.py new file mode 100644 index 0000000..23a4a99 --- /dev/null +++ b/netx_api/mcp_remote/mount.py @@ -0,0 +1,316 @@ +"""Mount Streamable HTTP MCP at /mcp on the FastAPI app.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator +from urllib.parse import urlparse + +from mcp.server.transport_security import TransportSecuritySettings +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.types import ASGIApp, Receive, Scope, Send + +from ..auth_deps import resolve_user_from_token +from ..config import settings +from ..db import SessionLocal +from . import server as server_mod +from .context import McpRequestContext, get_app, reset_request_context, set_app, set_request_context +from .registry import parse_groups_param +from .server import build_mcp_server + +_MCP_ASGI: ASGIApp | None = None + + +def _mcp_transport_security() -> TransportSecuritySettings: + """Host allow-list for MCP DNS-rebinding guard. + + Bearer auth is the real gate. Default: disable DNS-rebinding protection so + LAN IPs / reverse-proxy Host headers work for remote MCP. Set + ``NETX_MCP_ALLOWED_HOSTS`` (comma-separated, supports ``host:*``) to turn + protection back on with an explicit list. + """ + raw = str(getattr(settings, "mcp_allowed_hosts", "") or "").strip() + if not raw: + return TransportSecuritySettings(enable_dns_rebinding_protection=False) + + hosts: list[str] = [] + for part in raw.split(","): + h = part.strip() + if h: + hosts.append(h) + # Always keep loopback + ASGI test hosts when an allow-list is configured. + for extra in ( + "localhost", + "localhost:*", + "127.0.0.1", + "127.0.0.1:*", + "[::1]", + "[::1]:*", + "test", + "test:*", + "testserver", + "testserver:*", + "mcp.internal", + "mcp.internal:*", + ): + if extra not in hosts: + hosts.append(extra) + # Include configured bind / frontend hosts when they look like hostnames. + for candidate in (settings.host, getattr(settings, "frontend_url", "")): + try: + parsed = urlparse(str(candidate) if "://" in str(candidate) else f"http://{candidate}") + hostname = (parsed.hostname or "").strip() + except Exception: + hostname = "" + if hostname and hostname not in hosts: + hosts.append(hostname) + hosts.append(f"{hostname}:*") + return TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=hosts, + ) + + +def _header_map(scope: Scope) -> dict[str, str]: + out: dict[str, str] = {} + for key, value in scope.get("headers") or []: + out[key.decode("latin-1").lower()] = value.decode("latin-1") + return out + + +def _bearer_from_scope(scope: Scope) -> str: + auth = str(_header_map(scope).get("authorization") or "").strip() + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return "" + + +def _groups_from_scope(scope: Scope) -> frozenset[str] | None: + qs = (scope.get("query_string") or b"").decode("latin-1") + from urllib.parse import parse_qs + + values = parse_qs(qs).get("groups") + if not values: + return parse_groups_param(None) + return parse_groups_param(values[0]) + + +def _resolve_lang_from_scope(scope: Scope) -> str: + env = str(os.getenv("NETX_LANG") or "").strip().lower() + if env.startswith("en"): + return "en" + if env.startswith("zh"): + return "zh" + al = str(_header_map(scope).get("accept-language") or "").strip().lower() + if al.startswith("en"): + return "en" + return "zh" + + +async def _drain_body(receive: Receive) -> None: + while True: + message = await receive() + if message["type"] != "http.request" or not message.get("more_body"): + return + + +async def _send_json(scope: Scope, receive: Receive, send: Send, *, status: int, payload: dict) -> None: + from starlette.responses import JSONResponse + + await JSONResponse(payload, status_code=status)(scope, receive, send) + + +class McpAuthMiddleware: + """Pure ASGI Bearer gate wrapping only the MCP sub-app.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + method = (scope.get("method") or "GET").upper() + if method == "OPTIONS": + await self.app(scope, receive, send) + return + + groups = _groups_from_scope(scope) + lang = _resolve_lang_from_scope(scope) + + if not bool(settings.auth_enabled): + ctx = McpRequestContext( + token="disabled", + scopes=None, + groups=groups, + lang=lang, + auth_via="disabled", + ) + token = set_request_context(ctx) + try: + await self.app(scope, receive, send) + finally: + reset_request_context(token) + return + + raw = _bearer_from_scope(scope) + if not raw: + await _drain_body(receive) + await _send_json( + scope, + receive, + send, + status=401, + payload={"error": "unauthorized", "detail": "missing_bearer_token"}, + ) + return + + db = SessionLocal() + try: + resolved = resolve_user_from_token(db, raw) + finally: + db.close() + + if resolved is None: + await _drain_body(receive) + await _send_json( + scope, + receive, + send, + status=401, + payload={"error": "unauthorized", "detail": "invalid_token"}, + ) + return + + user, via, scopes, _api_token_id, _jti = resolved + ctx = McpRequestContext( + token=raw, + scopes=frozenset(str(s) for s in scopes), + groups=groups, + lang=lang, + user_id=str(getattr(user, "id", "") or ""), + auth_via=via, + ) + token = set_request_context(ctx) + try: + await self.app(scope, receive, send) + finally: + reset_request_context(token) + + +class _McpSlashRewrite: + """Rewrite bare ``/mcp`` → ``/mcp/`` so Starlette Mount does not 307 POST.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and scope.get("path") == "/mcp": + scope = dict(scope) + scope["path"] = "/mcp/" + raw = scope.get("raw_path") + if isinstance(raw, (bytes, bytearray)) and raw == b"/mcp": + scope["raw_path"] = b"/mcp/" + await self.app(scope, receive, send) + + +class _PreferChunkedEncoding: + """Strip ``Content-Length`` so the ASGI server emits proper chunked framing. + + Some WinSock LSPs (observed: path matching ``*/mcp*``) rewrite + ``Content-Length`` responses into ``Transfer-Encoding: chunked`` **without** + framing the body. Pre-chunked responses pass through. This is not a response + buffer/rewrite hack — it only drops CL so h11/uvicorn chunk-encode correctly. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + async def send_wrapper(message: dict) -> None: + if message["type"] == "http.response.start": + headers = [ + (k, v) + for k, v in (message.get("headers") or []) + if k.lower() != b"content-length" + ] + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_wrapper) + + +def _build_streamable_asgi() -> ASGIApp: + app = server_mod.mcp_server.streamable_http_app( + streamable_http_path="/", + stateless_http=True, + json_response=True, + transport_security=_mcp_transport_security(), + ) + if isinstance(app, Starlette): + app.add_middleware(McpAuthMiddleware) + # Outermost on the sub-app: survive refresh_mcp_for_lifespan remounts. + return _PreferChunkedEncoding(app) + + +def build_mcp_asgi_app(*, force_new: bool = False) -> ASGIApp: + """Stateless Streamable HTTP ASGI app mounted at ``/mcp``.""" + global _MCP_ASGI + if _MCP_ASGI is not None and not force_new: + return _MCP_ASGI + _MCP_ASGI = _build_streamable_asgi() + return _MCP_ASGI + + +def ensure_mcp_session_manager() -> None: + """Create streamable app so ``mcp_server.session_manager`` exists for lifespan.""" + build_mcp_asgi_app() + + +def _replace_mcp_mount(host_app: Any, mcp_asgi: ASGIApp) -> None: + for route in getattr(host_app, "routes", []) or []: + if isinstance(route, Mount) and str(getattr(route, "path", "")) == "/mcp": + route.app = mcp_asgi + return + + +def refresh_mcp_for_lifespan(host_app: Any | None = None) -> Any: + """Rebuild MCP Server + Streamable app (session manager can only run() once).""" + global _MCP_ASGI + server_mod.mcp_server = build_mcp_server() + _MCP_ASGI = None + mcp_asgi = build_mcp_asgi_app(force_new=True) + app = host_app or get_app() + if app is not None: + _replace_mcp_mount(app, mcp_asgi) + app.state.mcp_server = server_mod.mcp_server + app.state.mcp_asgi = mcp_asgi + set_app(app) + return server_mod.mcp_server + + +@asynccontextmanager +async def mcp_lifespan_cm(*, host_app: Any | None = None) -> AsyncIterator[None]: + """Enter a fresh MCP session manager for this lifespan cycle.""" + mcp = refresh_mcp_for_lifespan(host_app if host_app is not None else get_app()) + async with mcp.session_manager.run(): + yield + + +def mount_mcp(app: Any) -> None: + """``app.mount('/mcp', ...)`` + slash rewrite; auth is on the sub-app only.""" + set_app(app) + mcp_asgi = build_mcp_asgi_app() + app.mount("/mcp", mcp_asgi) + # Outermost: clients may POST http://host:port/mcp without a trailing slash. + app.add_middleware(_McpSlashRewrite) + app.state.mcp_server = server_mod.mcp_server + app.state.mcp_mounted = True + app.state.mcp_asgi = mcp_asgi diff --git a/packages/netx-mcp/src/netx_mcp/__init__.py b/netx_api/mcp_remote/ops/__init__.py similarity index 63% rename from packages/netx-mcp/src/netx_mcp/__init__.py rename to netx_api/mcp_remote/ops/__init__.py index fd8fab0..33197f3 100644 --- a/packages/netx-mcp/src/netx_mcp/__init__.py +++ b/netx_api/mcp_remote/ops/__init__.py @@ -1,4 +1,4 @@ -"""netx-mcp: stdio MCP → netx HTTP API.""" +"""Ops MCP tools (in-process handlers for built-in /mcp).""" from .http_tools import HTTP_MCP_TOOLS, call_http_tool diff --git a/packages/netx-mcp/src/netx_mcp/cli_guard.py b/netx_api/mcp_remote/ops/cli_guard.py similarity index 100% rename from packages/netx-mcp/src/netx_mcp/cli_guard.py rename to netx_api/mcp_remote/ops/cli_guard.py diff --git a/packages/netx-mcp/src/netx_mcp/http_tools.py b/netx_api/mcp_remote/ops/http_tools.py similarity index 99% rename from packages/netx-mcp/src/netx_mcp/http_tools.py rename to netx_api/mcp_remote/ops/http_tools.py index faa65b9..0e227b3 100644 --- a/packages/netx-mcp/src/netx_mcp/http_tools.py +++ b/netx_api/mcp_remote/ops/http_tools.py @@ -10,7 +10,7 @@ import os from typing import Any, Callable from .cli_guard import validate_readonly_cli -from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id +from netx_api.mcp_remote.asgi_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id _EXEC_MAX_COMMANDS_CAP = 50 _EXEC_MAX_COMMANDS_DEFAULT = 5 diff --git a/netx_api/mcp_remote/registry.py b/netx_api/mcp_remote/registry.py new file mode 100644 index 0000000..1964152 --- /dev/null +++ b/netx_api/mcp_remote/registry.py @@ -0,0 +1,146 @@ +"""Unified MCP tool registry: schemas, scopes, groups, annotations.""" + +from __future__ import annotations + +from typing import Any, Callable + +from mcp.types import Tool, ToolAnnotations + +from .bizmonitor.http_tools import ( + HTTP_MCP_TOOLS as BIZ_TOOLS, + TOOL_REQUIRED_SCOPE as BIZ_SCOPES, + call_http_tool as call_biz_tool, +) +from .ops.http_tools import ( + HTTP_MCP_TOOLS as OPS_TOOLS, + TOOL_REQUIRED_SCOPE as OPS_SCOPES, + call_http_tool as call_ops_tool, +) +from .topology.http_tools import ( + HTTP_MCP_TOOLS as TOPO_TOOLS, + TOOL_REQUIRED_SCOPE as TOPO_SCOPES, + call_http_tool as call_topo_tool, +) + +GROUP_OPS = "ops" +GROUP_TOPOLOGY = "topology" +GROUP_BIZ = "bizMonitor" +VALID_GROUPS = frozenset({GROUP_OPS, GROUP_TOPOLOGY, GROUP_BIZ}) + +# Write / destructive hints (task contract). +_OPS_WRITE = frozenset({"execManagedNe"}) +_TOPO_WRITE = frozenset( + { + "createTopologyFolder", + "addTopologyViewNodes", + "removeTopologyViewNodes", + "sinkTopologyDualUnits", + "copyTopologyViewNodes", + "updateTopologyViewPositions", + "projectTopologyNeighbors", + "classifyTopologyFabricNodes", + "layoutTopologyView", + } +) +_TOPO_DESTRUCTIVE = frozenset({"removeTopologyViewNodes"}) + + +def _enrich(tool: dict[str, Any], *, group: str) -> dict[str, Any]: + name = str(tool.get("name") or "") + out = dict(tool) + if group == GROUP_OPS: + read_only = name not in _OPS_WRITE + destructive = False + elif group == GROUP_TOPOLOGY: + read_only = name not in _TOPO_WRITE + destructive = name in _TOPO_DESTRUCTIVE + else: + read_only = True + destructive = False + annotations = dict(out.get("annotations") or {}) + annotations.setdefault("readOnlyHint", read_only) + annotations.setdefault("destructiveHint", destructive) + if read_only: + annotations.setdefault("idempotentHint", True) + out["annotations"] = annotations + meta = dict(out.get("_meta") or out.get("meta") or {}) + netx = dict(meta.get("netx") or {}) + netx["group"] = group + meta["netx"] = netx + out["_meta"] = meta + out["_group"] = group + return out + + +ALL_TOOLS: list[dict[str, Any]] = ( + [_enrich(t, group=GROUP_OPS) for t in OPS_TOOLS] + + [_enrich(t, group=GROUP_TOPOLOGY) for t in TOPO_TOOLS] + + [_enrich(t, group=GROUP_BIZ) for t in BIZ_TOOLS] +) + +TOOL_REQUIRED_SCOPE: dict[str, str] = {} +TOOL_REQUIRED_SCOPE.update(OPS_SCOPES) +TOOL_REQUIRED_SCOPE.update(TOPO_SCOPES) +TOOL_REQUIRED_SCOPE.update(BIZ_SCOPES) + +TOOL_GROUP: dict[str, str] = {str(t["name"]): str(t["_group"]) for t in ALL_TOOLS} + +_HANDLERS: dict[str, Callable[[str, dict[str, Any]], dict[str, Any]]] = { + **{str(t["name"]): call_ops_tool for t in OPS_TOOLS}, + **{str(t["name"]): call_topo_tool for t in TOPO_TOOLS}, + **{str(t["name"]): call_biz_tool for t in BIZ_TOOLS}, +} + + +def parse_groups_param(raw: str | None) -> frozenset[str]: + if raw is None or not str(raw).strip(): + return frozenset() + parts = {p.strip() for p in str(raw).split(",") if p.strip()} + return frozenset(p for p in parts if p in VALID_GROUPS) + + +def tools_for_request( + scopes: frozenset[str] | list[str] | None, + groups: frozenset[str] | None = None, +) -> list[dict[str, Any]]: + """Filter by scopes and optional groups. scopes=None advertises all (offline).""" + wanted_groups = groups or frozenset() + if scopes is None: + scoped = list(ALL_TOOLS) + else: + granted = {str(s).strip().lower() for s in scopes if str(s).strip()} + if not granted: + return [] + scoped = [] + for tool in ALL_TOOLS: + name = str(tool.get("name") or "") + need = TOOL_REQUIRED_SCOPE.get(name) + if need is None or need in granted: + scoped.append(tool) + if not wanted_groups: + return scoped + return [t for t in scoped if t.get("_group") in wanted_groups] + + +def to_mcp_tool(tool: dict[str, Any]) -> Tool: + ann = tool.get("annotations") or {} + return Tool( + name=str(tool["name"]), + description=str(tool.get("description") or ""), + input_schema=tool.get("inputSchema") or {"type": "object", "properties": {}}, + annotations=ToolAnnotations( + read_only_hint=bool(ann.get("readOnlyHint", True)), + destructive_hint=bool(ann.get("destructiveHint", False)) + if not ann.get("readOnlyHint", True) + else None, + idempotent_hint=ann.get("idempotentHint"), + ), + meta=tool.get("_meta") or tool.get("meta"), + ) + + +def call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]: + handler = _HANDLERS.get(str(name or "").strip()) + if handler is None: + raise ValueError(f"unknown tool: {name}") + return handler(name, args) diff --git a/netx_api/mcp_remote/server.py b/netx_api/mcp_remote/server.py new file mode 100644 index 0000000..719701b --- /dev/null +++ b/netx_api/mcp_remote/server.py @@ -0,0 +1,116 @@ +"""Low-level MCP Server exposing ops/topology/bizMonitor tools over Streamable HTTP.""" + +from __future__ import annotations + +import json +from importlib.metadata import version as pkg_version +from typing import Any + +from mcp.server import Server, ServerRequestContext +from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, +) + +from .context import get_request_context +from .registry import TOOL_REQUIRED_SCOPE, call_tool, to_mcp_tool, tools_for_request + + +def netx_version() -> str: + try: + return pkg_version("netx-ops") + except Exception: + pass + try: + from pathlib import Path + + import tomllib + + root = Path(__file__).resolve().parents[2] + data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + return str(data.get("project", {}).get("version") or "0.0.0") + except Exception: + return "0.0.0" + + +def _scopes_and_groups() -> tuple[frozenset[str] | None, frozenset[str]]: + ctx = get_request_context() + if ctx is None: + return None, frozenset() + return ctx.scopes, ctx.groups + + +async def _on_list_tools( + ctx: ServerRequestContext, + params: PaginatedRequestParams | None, +) -> ListToolsResult: + scopes, groups = _scopes_and_groups() + tools = [to_mcp_tool(t) for t in tools_for_request(scopes, groups)] + return ListToolsResult(tools=tools) + + +async def _on_call_tool( + ctx: ServerRequestContext, + params: CallToolRequestParams, +) -> CallToolResult: + name = str(params.name or "") + scopes, groups = _scopes_and_groups() + advertised = {str(t["name"]) for t in tools_for_request(scopes, groups)} + if name not in advertised: + need = TOOL_REQUIRED_SCOPE.get(name) + msg = ( + f"insufficient_scope:{need}. " + "Ask a netx admin to grant this scope on the API token; " + "for sql:query prefer aggregateNmsAlarms/queryNmsAlarmsRaw instead." + if need + else f"unknown or filtered tool: {name}" + ) + return CallToolResult( + content=[TextContent(type="text", text=msg)], + is_error=True, + ) + + args = params.arguments if isinstance(params.arguments, dict) else {} + try: + # Handlers are sync and may nest ASGI calls; run off the event loop thread + # so BaseHTTPMiddleware on the outer MCP request cannot deadlock. + import anyio + + result = await anyio.to_thread.run_sync(call_tool, name, dict(args)) + except Exception as exc: + result = { + "content": [{"type": "text", "text": str(exc)}], + "isError": True, + } + + content_raw = result.get("content") if isinstance(result, dict) else None + texts: list[TextContent] = [] + if isinstance(content_raw, list): + for item in content_raw: + if isinstance(item, dict) and item.get("type") == "text": + texts.append(TextContent(type="text", text=str(item.get("text") or ""))) + if not texts: + texts = [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))] + is_error = bool(isinstance(result, dict) and result.get("isError")) + return CallToolResult(content=texts, is_error=is_error) + + +def build_mcp_server() -> Server[Any]: + return Server( + "netx", + version=netx_version(), + instructions=( + "netx remote MCP: ops / topology / bizMonitor tools. " + "Filter with ?groups=ops,topology,bizMonitor. " + "Authorize with Bearer MCP token or user JWT." + ), + on_list_tools=_on_list_tools, + on_call_tool=_on_call_tool, + ) + + +# Singleton used by mount; streamable_http_app() must be called once so session_manager exists. +mcp_server = build_mcp_server() diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py b/netx_api/mcp_remote/topology/__init__.py similarity index 100% rename from packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py rename to netx_api/mcp_remote/topology/__init__.py diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py b/netx_api/mcp_remote/topology/http_tools.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py rename to netx_api/mcp_remote/topology/http_tools.py index 7caf3a5..04beaf0 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py +++ b/netx_api/mcp_remote/topology/http_tools.py @@ -5,18 +5,18 @@ from __future__ import annotations import sys from typing import Any, Callable -from netx_topology_mcp.http_client import http_json, http_json_many, mcp_from_handler_result -from netx_topology_mcp.layout_jobs import ( +from netx_api.mcp_remote.asgi_client import http_json, http_json_many, mcp_from_handler_result +from netx_api.mcp_remote.topology.layout_jobs import ( cancel_job, job_public, raise_if_cancelled, report_progress, start_job, ) -from netx_topology_mcp.layout_sight import build_sight -from netx_topology_mcp.layout_stats import analyze_layout_stats -from netx_topology_mcp.layout_structure import analyze_graph_structure -from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_sight import build_sight +from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats +from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure +from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog, run_layout_on_graph def _data(out: dict[str, Any]) -> dict[str, Any]: @@ -632,7 +632,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: # compose_views / compose_orbit: load staging canvases, inject blocks. if action in {"compose_views", "compose_orbit"}: - from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides + from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides knobs = compose_params_from_overrides(overrides) src_ids = list(knobs.get("source_view_ids") or []) @@ -774,7 +774,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: "untangle", "polish_crossings", }: - from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides + from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides knobs = compose_params_from_overrides(overrides) src_ids = list(knobs.get("source_view_ids") or []) @@ -1147,11 +1147,11 @@ def _view_membership_ids(view_id: str) -> tuple[dict[str, Any], set[str], list[d def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]: """Rank hub territories on source for non-dual move_nodes(park) batches.""" - from netx_topology_mcp.layout_ops.suggest_sink_hubs import ( + from netx_api.mcp_remote.topology.layout_ops.suggest_sink_hubs import ( pick_batch, suggest_sink_hub_batches, ) - from netx_topology_mcp.layout_structure import analyze_graph_structure + from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure source_view_id = str( args.get("source_view_id") or args.get("view_id") or "" @@ -1314,12 +1314,12 @@ def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]: def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]: """Map reference canvas geometry onto target via shared fabric_node_ids.""" - from netx_topology_mcp.layout_ops.align_reference import ( + from netx_api.mcp_remote.topology.layout_ops.align_reference import ( align_reference_params_from_overrides, align_to_reference, ) - from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges - from netx_topology_mcp.layout_stats import analyze_layout_stats + from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges + from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats view_id = str(args.get("view_id") or "").strip() overrides = args.get("params") if isinstance(args.get("params"), dict) else {} @@ -2527,9 +2527,9 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: runs layout_dual_unit per unit before parking (old staging flow). Global polish still via layoutTopologyView on the sink. """ - from netx_topology_mcp.layout_ops.dual_units import find_dual_portal_units - from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges - from netx_topology_mcp.layout_ops.sink_dual_units import ( + from netx_api.mcp_remote.topology.layout_ops.dual_units import find_dual_portal_units + from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges + from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import ( batch_node_ids, layout_and_pack_batch, leftover_batch_ids, @@ -3149,7 +3149,7 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]: **{k: base[k] for k in ("source_view_id", "view_id", "move_ids")}, } - from netx_topology_mcp.layout_ops.sink_dual_units import ( + from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import ( merge_view_links, park_positions, ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_job_worker.py b/netx_api/mcp_remote/topology/layout_job_worker.py similarity index 91% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_job_worker.py rename to netx_api/mcp_remote/topology/layout_job_worker.py index 8ea03a2..51a37cd 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_job_worker.py +++ b/netx_api/mcp_remote/topology/layout_job_worker.py @@ -10,14 +10,14 @@ from pathlib import Path def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="netx_topology_mcp.layout_job_worker") + parser = argparse.ArgumentParser(prog="netx_api.mcp_remote.topology.layout_job_worker") parser.add_argument("--job-id", required=True) args = parser.parse_args(argv) job_id = str(args.job_id).strip() if not job_id: return 2 - from netx_topology_mcp.layout_jobs import ( + from netx_api.mcp_remote.topology.layout_jobs import ( _JobCancelled, _args_path, _read_job_disk, @@ -68,7 +68,7 @@ def main(argv: list[str] | None = None) -> int: raise _JobCancelled() # Late import: pulls http_tools + layout stack. - from netx_topology_mcp.http_tools import _layout_topology_view + from netx_api.mcp_remote.topology.http_tools import _layout_topology_view result = _layout_topology_view(tool_args) finish_job(job_id, result=result, cancelled=is_cancelled()) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py b/netx_api/mcp_remote/topology/layout_jobs.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py rename to netx_api/mcp_remote/topology/layout_jobs.py index 265adea..b3bfdab 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py +++ b/netx_api/mcp_remote/topology/layout_jobs.py @@ -42,7 +42,7 @@ def _job_dir() -> Path: if env: p = Path(env) else: - # packages/netx-topology-mcp/src/netx_topology_mcp → repo data/ + # netx_api/mcp_remote/topology → repo data/ here = Path(__file__).resolve() repo = here.parents[4] # .../netx p = repo / "data" / "runtime" / "layout_jobs" @@ -407,7 +407,7 @@ def start_job( try: proc = subprocess.Popen( # noqa: S603 — controlled argv - [sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id], + [sys.executable, "-m", "netx_api.mcp_remote.topology.layout_job_worker", "--job-id", job_id], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py b/netx_api/mcp_remote/topology/layout_metrics.py similarity index 100% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py rename to netx_api/mcp_remote/topology/layout_metrics.py diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/__init__.py b/netx_api/mcp_remote/topology/layout_ops/__init__.py similarity index 68% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/__init__.py rename to netx_api/mcp_remote/topology/layout_ops/__init__.py index 2bd17cb..076fc82 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/__init__.py +++ b/netx_api/mcp_remote/topology/layout_ops/__init__.py @@ -12,8 +12,8 @@ Scoped / block ops: Soft partition: layout_ops.partition (hub BFS + optional igraph). """ -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.partition import ( +from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.partition import ( igraph_available, pack_block_centers, pack_soft_blocks, @@ -21,7 +21,7 @@ from netx_topology_mcp.layout_ops.partition import ( partition_soft_blocks, resolve_block_mode, ) -from netx_topology_mcp.layout_ops.recipe import ( +from netx_api.mcp_remote.topology.layout_ops.recipe import ( RECIPES, REGISTRY, agg_rings_v1_passes, @@ -31,19 +31,19 @@ from netx_topology_mcp.layout_ops.recipe import ( smd_corridor_unstick_v1_passes, smd_corridor_v1_passes, ) -from netx_topology_mcp.layout_ops.rings import build_ring_skeleton -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots -from netx_topology_mcp.layout_ops.scope import list_blocks, select_scope -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_ops.compose_views import ( +from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton +from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots +from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, select_scope +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_ops.compose_views import ( compose_into_state, strip_pack_blocks, ) -from netx_topology_mcp.layout_ops.dual_units import ( +from netx_api.mcp_remote.topology.layout_ops.dual_units import ( find_dual_portal_units, layout_dual_unit, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult __all__ = [ "LayoutParams", diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py b/netx_api/mcp_remote/topology/layout_ops/align_reference.py similarity index 96% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py rename to netx_api/mcp_remote/topology/layout_ops/align_reference.py index 9cf3ae4..50d813a 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py +++ b/netx_api/mcp_remote/topology/layout_ops/align_reference.py @@ -14,10 +14,10 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.graph_util import bbox -from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox +from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _dist(a: tuple[float, float], b: tuple[float, float]) -> float: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py b/netx_api/mcp_remote/topology/layout_ops/bundle_orbit.py similarity index 97% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py rename to netx_api/mcp_remote/topology/layout_ops/bundle_orbit.py index 64e512d..fc3527c 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py +++ b/netx_api/mcp_remote/topology/layout_ops/bundle_orbit.py @@ -26,18 +26,18 @@ import math from dataclasses import dataclass from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, top_crossing_nodes, ) -from netx_topology_mcp.layout_ops.orbit_sweep import ( +from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import ( _MAX_JUMP_CAP, _far_field_guides, _polar_grid, ) -from netx_topology_mcp.layout_ops.ring_faces import extract_ring_faces -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_topology_quality import extract_chain_paths +from netx_api.mcp_remote.topology.layout_ops.ring_faces import extract_ring_faces +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths # Compact-first: tight packs before full-length (fits crowded pockets). # Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject. @@ -266,11 +266,11 @@ def _bundle_ok( nn_floor: float, ) -> bool: """Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders.""" - from netx_topology_mcp.layout_ops.orbit_sweep import ( + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import ( _box, _centers_may_overlap, ) - from netx_topology_mcp.layout_metrics import node_footprint + from netx_api.mcp_remote.topology.layout_metrics import node_footprint # Apply refuses any footprint overlap — members must clear each other too. mem_list = [n for n in members if n in trial] @@ -639,7 +639,7 @@ def apply_bundle_pick( ) # Minimize-expand must not leave footprint overlaps (apply gate). - from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap if _has_any_footprint_overlap(trial, state.names): return OpResult( @@ -697,7 +697,7 @@ def rank_bundles_by_hotspots( """Prefer bundles whose tip / members participate in crossings.""" if not bundles: return [] - from netx_topology_mcp.layout_metrics import crossing_participation + from netx_api.mcp_remote.topology.layout_metrics import crossing_participation _n, node_hit = crossing_participation(pos, links)[:2] hot = { diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channel_metro.py b/netx_api/mcp_remote/topology/layout_ops/channel_metro.py similarity index 72% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channel_metro.py rename to netx_api/mcp_remote/topology/layout_ops/channel_metro.py index 8a08c97..5ead90c 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channel_metro.py +++ b/netx_api/mcp_remote/topology/layout_ops/channel_metro.py @@ -6,15 +6,15 @@ nodes sit on deg≤2 corridors. from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.channels import extract_channels -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local -from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid -from netx_topology_mcp.layout_ops.ring_faces import eject_intruders, extract_ring_faces -from netx_topology_mcp.layout_ops.skeleton import build_skeleton -from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.transforms import normalize_origin +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels +from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local +from netx_api.mcp_remote.topology.layout_ops.pin_beam import pin_beam_rigid +from netx_api.mcp_remote.topology.layout_ops.ring_faces import eject_intruders, extract_ring_faces +from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton +from netx_api.mcp_remote.topology.layout_ops.soft_petals import soft_petals_greedy +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin def build_channel_metro_skeleton( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channels.py b/netx_api/mcp_remote/topology/layout_ops/channels.py similarity index 96% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channels.py rename to netx_api/mcp_remote/topology/layout_ops/channels.py index 0bf41ec..355c21d 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channels.py +++ b/netx_api/mcp_remote/topology/layout_ops/channels.py @@ -10,8 +10,8 @@ import math from dataclasses import dataclass from typing import Any -from netx_topology_mcp.layout_ops.state import LayoutState -from netx_topology_mcp.layout_topology_quality import extract_chain_paths +from netx_api.mcp_remote.topology.layout_ops.state import LayoutState +from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths def order_stubs_crossing_aware( @@ -244,9 +244,9 @@ def straighten_channels_greedy( Prefer fewer global crossings; when crossings stay equal (incl. already 0), accept H/V / L modes that raise path axis credit (metro look). """ - from netx_topology_mcp.layout_metrics import count_edge_crossings - from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local - from netx_topology_mcp.layout_ops.state import LayoutParams, OpResult + from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings + from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local + from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, OpResult params = params or LayoutParams() step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0)) @@ -279,7 +279,7 @@ def straighten_channels_greedy( } ) - from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress + from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress for _pass in range(max(1, int(max_passes))): raise_if_cancelled() diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py b/netx_api/mcp_remote/topology/layout_ops/clear_edge_hits.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py rename to netx_api/mcp_remote/topology/layout_ops/clear_edge_hits.py index 776de92..9401a83 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py +++ b/netx_api/mcp_remote/topology/layout_ops/clear_edge_hits.py @@ -10,7 +10,7 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( EDGE_CLEARANCE_THR, REC_CENTER_DX, REC_CENTER_DY, @@ -19,7 +19,7 @@ from netx_topology_mcp.layout_metrics import ( node_footprint, point_segment_dist, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult _AXIS_TOL = 8.0 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py b/netx_api/mcp_remote/topology/layout_ops/compact_bbox.py similarity index 95% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py rename to netx_api/mcp_remote/topology/layout_ops/compact_bbox.py index 1482515..3805f99 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py +++ b/netx_api/mcp_remote/topology/layout_ops/compact_bbox.py @@ -12,13 +12,13 @@ from __future__ import annotations from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, compute_edge_clearance, ) -from netx_topology_mcp.layout_ops.graph_util import bbox -from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox +from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _anchor( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_orbit.py b/netx_api/mcp_remote/topology/layout_ops/compose_orbit.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_orbit.py rename to netx_api/mcp_remote/topology/layout_ops/compose_orbit.py index 4e9d46e..7b9194b 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_orbit.py +++ b/netx_api/mcp_remote/topology/layout_ops/compose_orbit.py @@ -17,11 +17,11 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, segments_properly_intersect, ) -from netx_topology_mcp.layout_ops.compose_views import ( +from netx_api.mcp_remote.topology.layout_ops.compose_views import ( ComposeBlock, _membership_counts, _portal_grow_order, @@ -30,7 +30,7 @@ from netx_topology_mcp.layout_ops.compose_views import ( _rigid_align_to_world, compose_params_from_overrides, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult _DEFAULT_ANGLE_STEP = 30 _DEFAULT_RADII = (0.75, 1.0, 1.25, 1.55, 1.95) @@ -581,7 +581,7 @@ def compose_orbit_into_state( out.meta = dict(out.meta or {}) out.meta["compose_views"] = meta try: - from netx_topology_mcp.layout_ops.mass_field import ( + from netx_api.mcp_remote.topology.layout_ops.mass_field import ( attach_mass_to_compose_meta, build_mass_field, ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_views.py b/netx_api/mcp_remote/topology/layout_ops/compose_views.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_views.py rename to netx_api/mcp_remote/topology/layout_ops/compose_views.py index 7a5800d..6828c76 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_views.py +++ b/netx_api/mcp_remote/topology/layout_ops/compose_views.py @@ -17,8 +17,8 @@ from collections import defaultdict from dataclasses import dataclass from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult DEFAULT_SLOT_PAD = 600.0 @@ -733,7 +733,7 @@ def compose_into_state( out.meta = dict(out.meta or {}) out.meta["compose_views"] = meta try: - from netx_topology_mcp.layout_ops.mass_field import ( + from netx_api.mcp_remote.topology.layout_ops.mass_field import ( attach_mass_to_compose_meta, build_mass_field, ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/densify_sweep.py b/netx_api/mcp_remote/topology/layout_ops/densify_sweep.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/densify_sweep.py rename to netx_api/mcp_remote/topology/layout_ops/densify_sweep.py index 009fa74..be4592d 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/densify_sweep.py +++ b/netx_api/mcp_remote/topology/layout_ops/densify_sweep.py @@ -10,23 +10,23 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, crossings_involving_node, ) -from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes -from netx_topology_mcp.layout_ops.orbit_sweep import ( +from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes +from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import ( _diversify_top, _eval_candidate, _incident_stretch, ) -from netx_topology_mcp.layout_ops.rigid_units import ( +from netx_api.mcp_remote.topology.layout_ops.rigid_units import ( frozen_ids_for_protect, groups_from_compose_meta, shrink_long_corridors, ) -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult _MAX_PULL = 900.0 _MAX_PULL_CAP = 2200.0 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_mass.py b/netx_api/mcp_remote/topology/layout_ops/dual_mass.py similarity index 95% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_mass.py rename to netx_api/mcp_remote/topology/layout_ops/dual_mass.py index 79a49d8..ae4b520 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_mass.py +++ b/netx_api/mcp_remote/topology/layout_ops/dual_mass.py @@ -17,22 +17,22 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.compose_orbit import orbit_pack_blocks -from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world -from netx_topology_mcp.layout_ops.dual_units import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.compose_orbit import orbit_pack_blocks +from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world +from netx_api.mcp_remote.topology.layout_ops.dual_units import ( DualUnit, beautify_dual_unit_positions, classify_dual_unit, find_dual_portal_units, ) -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local -from netx_topology_mcp.layout_ops.mass_field import ( +from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local +from netx_api.mcp_remote.topology.layout_ops.mass_field import ( attach_mass_to_compose_meta, build_mass_field, ) -from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.mass_merge import mass_merge_round +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _unit_links( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py b/netx_api/mcp_remote/topology/layout_ops/dual_units.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py rename to netx_api/mcp_remote/topology/layout_ops/dual_units.py index b1bc66a..845c063 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py +++ b/netx_api/mcp_remote/topology/layout_ops/dual_units.py @@ -17,11 +17,11 @@ from collections import deque from dataclasses import dataclass, field from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local -from netx_topology_mcp.layout_ops.min_rings import cover_hub_paths -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.sugiyama import _find_two_portal_ring_groups +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local +from netx_api.mcp_remote.topology.layout_ops.min_rings import cover_hub_paths +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.sugiyama import _find_two_portal_ring_groups @dataclass @@ -756,7 +756,7 @@ def _uncross_unit( preserve_side: bool = True, ) -> dict[str, tuple[float, float]]: """Greedy: nudge free endpoints to kill crossings without flipping eye sides.""" - from netx_topology_mcp.layout_metrics import segments_properly_intersect + from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect out = dict(pos) deg: dict[str, int] = {} diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/fold_chain.py b/netx_api/mcp_remote/topology/layout_ops/fold_chain.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/fold_chain.py rename to netx_api/mcp_remote/topology/layout_ops/fold_chain.py index d46a241..d5d7121 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/fold_chain.py +++ b/netx_api/mcp_remote/topology/layout_ops/fold_chain.py @@ -9,7 +9,7 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _deg(st: LayoutState) -> dict[str, int]: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/force_densify.py b/netx_api/mcp_remote/topology/layout_ops/force_densify.py similarity index 97% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/force_densify.py rename to netx_api/mcp_remote/topology/layout_ops/force_densify.py index 22b2cc6..71db376 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/force_densify.py +++ b/netx_api/mcp_remote/topology/layout_ops/force_densify.py @@ -21,13 +21,13 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes -from netx_topology_mcp.layout_ops.rigid_units import ( +from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes +from netx_api.mcp_remote.topology.layout_ops.rigid_units import ( frozen_ids_for_protect, groups_from_compose_meta, ) -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_ops.state import LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult _COORD_ABS_MAX = 1.0e6 _DEFAULT_ITERS = 12 @@ -299,7 +299,7 @@ def force_densify_round( damping: float = 0.85, ) -> OpResult: """Run damped attract/repulse densify with semi-rigid dual-unit bodies.""" - from netx_topology_mcp.layout_jobs import ( + from netx_api.mcp_remote.topology.layout_jobs import ( raise_if_cancelled, report_progress, touch_heartbeat, @@ -384,7 +384,7 @@ def force_densify_round( best_meta_util = util_proxy0 best_iter = 0 - from netx_topology_mcp.layout_metrics import count_edge_crossings + from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings for it in range(n_iters): raise_if_cancelled() diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py b/netx_api/mcp_remote/topology/layout_ops/graph_util.py similarity index 95% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py rename to netx_api/mcp_remote/topology/layout_ops/graph_util.py index 0d2a283..b9b1f4a 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py +++ b/netx_api/mcp_remote/topology/layout_ops/graph_util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections import deque -from netx_topology_mcp.layout_ops.level_util import infer_layer +from netx_api.mcp_remote.topology.layout_ops.level_util import infer_layer __all__ = [ "infer_layer", @@ -147,9 +147,9 @@ def spine_backbone( def build_state_from_nodes_edges( nodes: list[dict], edges: list[dict] ) -> "LayoutState": # noqa: F821 - from netx_topology_mcp.layout_ops.state import LayoutState - from netx_topology_mcp.layout_metrics import collapse_links - from netx_topology_mcp.layout_ops.level_util import _parse_level + from netx_api.mcp_remote.topology.layout_ops.state import LayoutState + from netx_api.mcp_remote.topology.layout_metrics import collapse_links + from netx_api.mcp_remote.topology.layout_ops.level_util import _parse_level names: dict[str, str] = {} layers: dict[str, str] = {} diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hierarchy.py b/netx_api/mcp_remote/topology/layout_ops/hierarchy.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hierarchy.py rename to netx_api/mcp_remote/topology/layout_ops/hierarchy.py index 85b17cd..cb60c06 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hierarchy.py +++ b/netx_api/mcp_remote/topology/layout_ops/hierarchy.py @@ -16,10 +16,10 @@ import math from collections import deque from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings, segments_properly_intersect -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local -from netx_topology_mcp.layout_ops.partition import pick_hub_seeds -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, segments_properly_intersect +from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local +from netx_api.mcp_remote.topology.layout_ops.partition import pick_hub_seeds +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hotspots.py b/netx_api/mcp_remote/topology/layout_ops/hotspots.py similarity index 94% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hotspots.py rename to netx_api/mcp_remote/topology/layout_ops/hotspots.py index 48113f3..8b958ef 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hotspots.py +++ b/netx_api/mcp_remote/topology/layout_ops/hotspots.py @@ -7,10 +7,10 @@ from collections import defaultdict from dataclasses import replace from typing import Any -from netx_topology_mcp.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint -from netx_topology_mcp.layout_ops.scope import select_scope -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.transforms import ( +from netx_api.mcp_remote.topology.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint +from netx_api.mcp_remote.topology.layout_ops.scope import select_scope +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.transforms import ( explode_clusters, pack_utilization, resolve_overlaps, @@ -119,7 +119,7 @@ def sprawled_component_scopes( max_size: int = 80, ) -> list[set[str]]: """Graph components with low util — skip huge ones (those need layout, not local pack).""" - from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components + from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components comps = connected_components(set(state.positions), state.adj) out: list[set[str]] = [] @@ -197,7 +197,7 @@ def fix_overlaps_local( state: LayoutState, params: LayoutParams | None = None ) -> OpResult: """Pull apart only overlapping nodes (+1-hop). No global scale/pack.""" - from netx_topology_mcp.layout_metrics import count_edge_crossings + from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings params = params or LayoutParams() st = state.copy() @@ -276,7 +276,7 @@ def fix_overlaps_local( def _block_util(state: LayoutState, block: set[str]) -> float: - from netx_topology_mcp.layout_ops.graph_util import bbox + from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox if len(block) < 2: return 1.0 @@ -313,7 +313,7 @@ def relax_hotspots( st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util ) if not scopes: - from netx_topology_mcp.layout_ops.scope import list_blocks + from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64] diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py b/netx_api/mcp_remote/topology/layout_ops/level_util.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py rename to netx_api/mcp_remote/topology/layout_ops/level_util.py index 279b792..4c6823f 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py +++ b/netx_api/mcp_remote/topology/layout_ops/level_util.py @@ -6,7 +6,7 @@ import math import re from typing import Any -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult _ROLE_TO_LAYER = { diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_field.py b/netx_api/mcp_remote/topology/layout_ops/mass_field.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_field.py rename to netx_api/mcp_remote/topology/layout_ops/mass_field.py index ffa0d21..1c3718f 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_field.py +++ b/netx_api/mcp_remote/topology/layout_ops/mass_field.py @@ -9,8 +9,8 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units -from netx_topology_mcp.layout_ops.state import LayoutState +from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, find_dual_portal_units +from netx_api.mcp_remote.topology.layout_ops.state import LayoutState # role -> (attract, repulse, mass) _ROLE_NODE: dict[str, tuple[float, float, float]] = { diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_merge.py b/netx_api/mcp_remote/topology/layout_ops/mass_merge.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_merge.py rename to netx_api/mcp_remote/topology/layout_ops/mass_merge.py index 4c5e073..8c04d25 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_merge.py +++ b/netx_api/mcp_remote/topology/layout_ops/mass_merge.py @@ -15,13 +15,13 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.force_densify import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.force_densify import ( _MAX_EDGE_PULL, _bbox_area, _spatial_bins, ) -from netx_topology_mcp.layout_ops.mass_field import ( +from netx_api.mcp_remote.topology.layout_ops.mass_field import ( build_mass_field, capture_pass, core_centroid, @@ -30,9 +30,9 @@ from netx_topology_mcp.layout_ops.mass_field import ( group_effective_mass, groups_from_mass_or_rigid, ) -from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_ops.state import LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult _COORD_ABS_MAX = 1.0e6 _STEP_CAP = { @@ -599,7 +599,7 @@ def mass_merge_round( Islands = dual-unit groups; soft global gravity keeps the **cloud of islands** roughly round (repulsion + edge traction), not a strip. """ - from netx_topology_mcp.layout_jobs import ( + from netx_api.mcp_remote.topology.layout_jobs import ( raise_if_cancelled, report_progress, touch_heartbeat, diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/min_rings.py b/netx_api/mcp_remote/topology/layout_ops/min_rings.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/min_rings.py rename to netx_api/mcp_remote/topology/layout_ops/min_rings.py index 87a774a..29975d6 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/min_rings.py +++ b/netx_api/mcp_remote/topology/layout_ops/min_rings.py @@ -12,8 +12,8 @@ import math from collections import defaultdict, deque from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _all_simple_paths( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py b/netx_api/mcp_remote/topology/layout_ops/orbit_sweep.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py rename to netx_api/mcp_remote/topology/layout_ops/orbit_sweep.py index c71f500..47a6a59 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py +++ b/netx_api/mcp_remote/topology/layout_ops/orbit_sweep.py @@ -11,7 +11,7 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, crossing_participation, crossing_participation_full, @@ -20,7 +20,7 @@ from netx_topology_mcp.layout_metrics import ( top_crossing_edges, top_crossing_nodes, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult _MAX_JUMP = 900.0 # Long fabric bridges on giant metros often exceed 5k; cap must leave room for @@ -201,7 +201,7 @@ def _resolve_frozen( return set() frozen: set[str] = set(frozen_ids or ()) if not frozen: - from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect + from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect frozen = frozen_ids_for_protect(st, protect_rigid) return frozen @@ -548,7 +548,7 @@ def _local_clearance_hits( endpoint_t: float = 0.05, ) -> int: """Fast hit count affected by moving ``nid`` (O(E + N·deg), not O(N·E)).""" - from netx_topology_mcp.layout_metrics import point_segment_dist + from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist if nid not in pos: return 0 @@ -1188,7 +1188,7 @@ def _until_limit_queue( base_hits = max(hits, prev[0] if prev else 0) scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0) if include_clearance: - from netx_topology_mcp.layout_metrics import compute_edge_clearance + from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance ec = compute_edge_clearance(pos, links, names=names, top_n=24) if not ec.get("edge_clearance_skipped"): @@ -1302,7 +1302,7 @@ def orbit_sweep_until_limit( while len(moves) < max_moves_i and stall < stall_lim: rounds += 1 try: - from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat + from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat touch_heartbeat() raise_if_cancelled() @@ -1389,12 +1389,12 @@ def orbit_sweep_until_limit( break if not applied and use_bundle and not bundle_exhausted: - from netx_topology_mcp.layout_ops.bundle_orbit import ( + from netx_api.mcp_remote.topology.layout_ops.bundle_orbit import ( bundle_orbit_until_progress, ) try: - from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat + from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat touch_heartbeat() raise_if_cancelled() diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ortho_metro.py b/netx_api/mcp_remote/topology/layout_ops/ortho_metro.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ortho_metro.py rename to netx_api/mcp_remote/topology/layout_ops/ortho_metro.py index bd1a2f8..5890817 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ortho_metro.py +++ b/netx_api/mcp_remote/topology/layout_ops/ortho_metro.py @@ -12,16 +12,16 @@ from __future__ import annotations from collections import deque from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( REC_CENTER_DX, REC_CENTER_DY, count_edge_crossings, node_footprint, ) -from netx_topology_mcp.layout_ops.channels import extract_channels -from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.transforms import normalize_origin +from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels +from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin _TOL_PX = 8.0 @@ -39,7 +39,7 @@ def _segment_clear( skip: set[str] | None = None, ) -> bool: """True if no other node sits on the open segment a—b (edge occlusion).""" - from netx_topology_mcp.layout_metrics import point_segment_dist + from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist if a not in pos or b not in pos: return True @@ -930,7 +930,7 @@ def _place_component( if _segment_clear(pos, a, b, thr=40.0): continue # Collect obstacles on open segment - from netx_topology_mcp.layout_metrics import point_segment_dist + from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist pa, pb = pos[a], pos[b] obstacles = [] @@ -1086,7 +1086,7 @@ def build_ortho_metro_skeleton( st = separate_overlaps_ortho(st, params).state # Nodes must not sit on non-incident H/V trunks (edge occlusion). - from netx_topology_mcp.layout_ops.clear_edge_hits import clear_edge_hits + from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import clear_edge_hits side = max(float(params.side), REC_CENTER_DY) clr = clear_edge_hits( @@ -1114,7 +1114,7 @@ def build_ortho_metro_skeleton( else: diag_n += 1 cross = count_edge_crossings(st.positions, st.links) - from netx_topology_mcp.layout_metrics import compute_edge_clearance + from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance clr_m = compute_edge_clearance(st.positions, st.links, names=st.names, thr=40.0) st.meta = dict(st.meta or {}) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/partition.py b/netx_api/mcp_remote/topology/layout_ops/partition.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/partition.py rename to netx_api/mcp_remote/topology/layout_ops/partition.py index 57a73de..e3c633f 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/partition.py +++ b/netx_api/mcp_remote/topology/layout_ops/partition.py @@ -17,8 +17,8 @@ from collections import defaultdict, deque from dataclasses import dataclass from typing import Any, Iterable -from netx_topology_mcp.layout_ops.graph_util import connected_components -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult # Preferred soft-block step when packing centers (px). _BLOCK_GAP = 420.0 @@ -437,7 +437,7 @@ def partition_report(state: LayoutState, *, mode: str = "soft") -> dict[str, Any "tip": ( "Soft blocks for init only: lay each hub territory / leftover with our " "petal/spine algorithms; use center_hint to pack blocks. " - "igraph Leiden/FR are optional — install netx-topology-mcp[layout]." + "igraph Leiden/FR are optional extras when available." ), } diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pin_beam.py b/netx_api/mcp_remote/topology/layout_ops/pin_beam.py similarity index 94% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pin_beam.py rename to netx_api/mcp_remote/topology/layout_ops/pin_beam.py index 11ecec1..b627566 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pin_beam.py +++ b/netx_api/mcp_remote/topology/layout_ops/pin_beam.py @@ -5,12 +5,12 @@ from __future__ import annotations from collections import deque from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local -from netx_topology_mcp.layout_ops.partition import partition_soft_blocks -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.transforms import normalize_origin +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local +from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin def _hops_to_cores( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py b/netx_api/mcp_remote/topology/layout_ops/press_crossings.py similarity index 97% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py rename to netx_api/mcp_remote/topology/layout_ops/press_crossings.py index 9ff5504..595b6a0 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py +++ b/netx_api/mcp_remote/topology/layout_ops/press_crossings.py @@ -14,7 +14,7 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, crossings_after_node_move, crossings_involving_node, @@ -23,8 +23,8 @@ from netx_topology_mcp.layout_metrics import ( segments_properly_intersect, top_crossing_edges, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.untangle import untangle_crossings +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.untangle import untangle_crossings def park_phantom_nodes(state: LayoutState) -> set[str]: @@ -76,7 +76,7 @@ def _rotate_about( def frozen_portals_from_state( state: LayoutState, portal_ids: list[str] | None = None ) -> set[str]: - from netx_topology_mcp.layout_ops.rigid_units import ( + from netx_api.mcp_remote.topology.layout_ops.rigid_units import ( _hub_portals, frozen_ids_for_protect, ) @@ -174,7 +174,7 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]: def _has_petal_dual_eye(state: LayoutState) -> bool: """True when a multi-corridor dual-unit covers a large share of the canvas.""" try: - from netx_topology_mcp.layout_ops.dual_units import ( + from netx_api.mcp_remote.topology.layout_ops.dual_units import ( classify_dual_unit, find_dual_portal_units, ) @@ -202,7 +202,7 @@ def press_hot_edges( max_sweeps: int | None = None, ) -> OpResult: """Rotate non-portal ends of top crossing edges about the other endpoint.""" - from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat + from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat del params st = state.copy() @@ -620,7 +620,7 @@ def polish_crossings( Petal dual-unit eyes: straighten stays off unless ``straighten=true`` — channel flattening destroys parallel-lane eye geometry. """ - from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress + from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress params = params or LayoutParams() st = state.copy() @@ -631,7 +631,7 @@ def polish_crossings( eye_portals: list[str] = [] if preserve: try: - from netx_topology_mcp.layout_ops.dual_units import ( + from netx_api.mcp_remote.topology.layout_ops.dual_units import ( classify_dual_unit, find_dual_portal_units, ) @@ -685,7 +685,7 @@ def polish_crossings( moved: set[str] = set() if do_straighten: - from netx_topology_mcp.layout_ops.channels import straighten_channels_greedy + from netx_api.mcp_remote.topology.layout_ops.channels import straighten_channels_greedy raise_if_cancelled() report_progress("polish_straighten", pct=48.0, message=f"x0={start}") diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py b/netx_api/mcp_remote/topology/layout_ops/pull_far_chains.py similarity index 96% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py rename to netx_api/mcp_remote/topology/layout_ops/pull_far_chains.py index 94389ce..3deef41 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py +++ b/netx_api/mcp_remote/topology/layout_ops/pull_far_chains.py @@ -11,11 +11,11 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings, compute_edge_clearance -from netx_topology_mcp.layout_ops.graph_util import bbox -from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_topology_quality import extract_chain_paths +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, compute_edge_clearance +from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox +from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths def _anchor(pos: dict[str, tuple[float, float]], frozen: set[str]) -> tuple[float, float]: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/recipe.py b/netx_api/mcp_remote/topology/layout_ops/recipe.py similarity index 93% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/recipe.py rename to netx_api/mcp_remote/topology/layout_ops/recipe.py index 0ed3848..433cec8 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/recipe.py +++ b/netx_api/mcp_remote/topology/layout_ops/recipe.py @@ -5,15 +5,15 @@ from __future__ import annotations from dataclasses import asdict from typing import Any, Callable -from netx_topology_mcp.layout_ops.score import score_op, score_state -from netx_topology_mcp.layout_ops.scope import list_blocks, map_blocks, select_scope -from netx_topology_mcp.layout_ops.sides import place_side_branches -from netx_topology_mcp.layout_ops.skeleton import build_skeleton -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots -from netx_topology_mcp.layout_ops.rings import build_ring_skeleton -from netx_topology_mcp.layout_ops.partition import pack_soft_blocks -from netx_topology_mcp.layout_ops.transforms import ( +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, @@ -234,7 +234,7 @@ def _expand_foreach( 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_topology_mcp.layout_ops.partition import resolve_block_mode + 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() diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_orbit.py b/netx_api/mcp_remote/topology/layout_ops/rigid_orbit.py similarity index 97% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_orbit.py rename to netx_api/mcp_remote/topology/layout_ops/rigid_orbit.py index adca3ea..838e188 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_orbit.py +++ b/netx_api/mcp_remote/topology/layout_ops/rigid_orbit.py @@ -14,12 +14,12 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.compose_orbit import crossings_touching -from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes -from netx_topology_mcp.layout_ops.rigid_units import groups_from_compose_meta -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_ops.state import LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.compose_orbit import crossings_touching +from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes +from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_compose_meta +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult _COORD_ABS_MAX = 1.0e6 _DEFAULT_ANGLE_STEP = 30 @@ -277,7 +277,7 @@ def rigid_orbit_round( max_accepts: int = 16, ) -> OpResult: """Greedy bridge-orbit densify over top rigid groups.""" - from netx_topology_mcp.layout_jobs import ( + from netx_api.mcp_remote.topology.layout_jobs import ( raise_if_cancelled, report_progress, touch_heartbeat, diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_units.py b/netx_api/mcp_remote/topology/layout_ops/rigid_units.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_units.py rename to netx_api/mcp_remote/topology/layout_ops/rigid_units.py index 876abf3..8c68cff 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_units.py +++ b/netx_api/mcp_remote/topology/layout_ops/rigid_units.py @@ -10,9 +10,9 @@ import math from collections import defaultdict from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.graph_util import connected_components -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult # Reject / ignore coords that would blow util (phantom region markers, bugs). _COORD_ABS_MAX = 1.0e6 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ring_faces.py b/netx_api/mcp_remote/topology/layout_ops/ring_faces.py similarity index 95% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ring_faces.py rename to netx_api/mcp_remote/topology/layout_ops/ring_faces.py index f068c9d..722ade8 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ring_faces.py +++ b/netx_api/mcp_remote/topology/layout_ops/ring_faces.py @@ -9,9 +9,9 @@ import math from dataclasses import dataclass from typing import Any -from netx_topology_mcp.layout_metrics import segments_properly_intersect -from netx_topology_mcp.layout_ops.state import LayoutState -from netx_topology_mcp.layout_topology_quality import ( +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, ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rings.py b/netx_api/mcp_remote/topology/layout_ops/rings.py similarity index 81% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rings.py rename to netx_api/mcp_remote/topology/layout_ops/rings.py index ce2db96..94f0992 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rings.py +++ b/netx_api/mcp_remote/topology/layout_ops/rings.py @@ -7,9 +7,9 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops.min_rings import build_min_ring_skeleton -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult -from netx_topology_mcp.layout_ops.sugiyama import build_sugiyama_layout +from netx_api.mcp_remote.topology.layout_ops.min_rings import build_min_ring_skeleton +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.sugiyama import build_sugiyama_layout def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/scope.py b/netx_api/mcp_remote/topology/layout_ops/scope.py similarity index 93% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/scope.py rename to netx_api/mcp_remote/topology/layout_ops/scope.py index 6677e1b..70cdf07 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/scope.py +++ b/netx_api/mcp_remote/topology/layout_ops/scope.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Any, Iterable -from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components -from netx_topology_mcp.layout_ops.state import LayoutState, OpResult +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]: @@ -41,7 +41,7 @@ def list_blocks( ids = set(state.positions.keys()) m = (mode or "component").strip().lower() if m == "auto": - from netx_topology_mcp.layout_ops.partition import resolve_block_mode + from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode m = resolve_block_mode(state, "auto") if m == "component": @@ -58,7 +58,7 @@ def list_blocks( blocks.append(rest) return blocks if m in {"hub_territory", "leiden", "soft"}: - from netx_topology_mcp.layout_ops.partition import ( + from netx_api.mcp_remote.topology.layout_ops.partition import ( partition_soft_blocks, soft_blocks_as_sets, ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/score.py b/netx_api/mcp_remote/topology/layout_ops/score.py similarity index 92% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/score.py rename to netx_api/mcp_remote/topology/layout_ops/score.py index 9792146..ed894f0 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/score.py +++ b/netx_api/mcp_remote/topology/layout_ops/score.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Any -from netx_topology_mcp.layout_ops.state import LayoutState, OpResult -from netx_topology_mcp.layout_stats import analyze_layout_stats +from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats def score_state( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sides.py b/netx_api/mcp_remote/topology/layout_ops/sides.py similarity index 94% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sides.py rename to netx_api/mcp_remote/topology/layout_ops/sides.py index 9c56f7a..6d51b41 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sides.py +++ b/netx_api/mcp_remote/topology/layout_ops/sides.py @@ -4,8 +4,8 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops.graph_util import spine_backbone -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.graph_util import spine_backbone +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def place_side_branches( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py b/netx_api/mcp_remote/topology/layout_ops/sink_dual_units.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py rename to netx_api/mcp_remote/topology/layout_ops/sink_dual_units.py index afea482..16c0d8c 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py +++ b/netx_api/mcp_remote/topology/layout_ops/sink_dual_units.py @@ -7,8 +7,8 @@ from __future__ import annotations from typing import Any -from netx_topology_mcp.layout_ops.dual_units import DualUnit, layout_dual_unit -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState +from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, layout_dual_unit +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]: @@ -271,14 +271,14 @@ def orbit_attach_to_sink( Scores candidates by (partial crossings, footprint hits, bridge length) against the existing sink world — same spirit as compose_orbit hang-block. """ - from netx_topology_mcp.layout_ops.compose_orbit import ( + from netx_api.mcp_remote.topology.layout_ops.compose_orbit import ( _orbit_candidates_one_portal, _orbit_candidates_orphan, _orbit_candidates_two_portal, _pick_best, _thin_candidates, ) - from netx_topology_mcp.layout_ops.compose_views import ( + from netx_api.mcp_remote.topology.layout_ops.compose_views import ( _rank_shared_pivots, _rigid_align_to_world, ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py b/netx_api/mcp_remote/topology/layout_ops/skeleton.py similarity index 97% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py rename to netx_api/mcp_remote/topology/layout_ops/skeleton.py index 8e7fe99..c528225 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py +++ b/netx_api/mcp_remote/topology/layout_ops/skeleton.py @@ -5,14 +5,14 @@ from __future__ import annotations from collections import defaultdict, deque from itertools import product -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.graph_util import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.graph_util import ( bbox, connected_components, order_ans, spine_backbone, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _place_component_tutte( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/soft_petals.py b/netx_api/mcp_remote/topology/layout_ops/soft_petals.py similarity index 96% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/soft_petals.py rename to netx_api/mcp_remote/topology/layout_ops/soft_petals.py index 1ed273a..adaffcd 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/soft_petals.py +++ b/netx_api/mcp_remote/topology/layout_ops/soft_petals.py @@ -6,11 +6,11 @@ import math from collections import deque from typing import Any -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.channels import order_stubs_crossing_aware -from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local -from netx_topology_mcp.layout_ops.partition import partition_soft_blocks, pick_hub_seeds -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.channels import order_stubs_crossing_aware +from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local +from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks, pick_hub_seeds +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _longest_path( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py b/netx_api/mcp_remote/topology/layout_ops/state.py similarity index 100% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py rename to netx_api/mcp_remote/topology/layout_ops/state.py diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py b/netx_api/mcp_remote/topology/layout_ops/suggest_sink_hubs.py similarity index 100% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py rename to netx_api/mcp_remote/topology/layout_ops/suggest_sink_hubs.py diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sugiyama.py b/netx_api/mcp_remote/topology/layout_ops/sugiyama.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sugiyama.py rename to netx_api/mcp_remote/topology/layout_ops/sugiyama.py index fe039d8..5f486c5 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sugiyama.py +++ b/netx_api/mcp_remote/topology/layout_ops/sugiyama.py @@ -14,14 +14,14 @@ from __future__ import annotations import math from collections import deque -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.graph_util import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.graph_util import ( bbox, chain_order, connected_components, order_ans, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _nearest_an( n: str, ans: list[str], adj: dict[str, set[str]], names: dict[str, str] @@ -947,7 +947,7 @@ def _stub_crosses_ring( order: list[str], ) -> bool: """True if stub—portal properly intersects a ring edge (not at portal).""" - from netx_topology_mcp.layout_metrics import segments_properly_intersect + from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect if stub not in pos or portal not in pos or len(order) < 2: return False @@ -1130,7 +1130,7 @@ def _foreign_edge_crossings( """Crossings of unit-internal edges by foreign edges (through the unit).""" if len(unit_nodes) < 2 or not edges: return 0 - from netx_topology_mcp.layout_metrics import segments_properly_intersect + from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect internal = [(a, b) for a, b in edges if a in unit_nodes and b in unit_nodes] foreign = [ diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/transforms.py b/netx_api/mcp_remote/topology/layout_ops/transforms.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/transforms.py rename to netx_api/mcp_remote/topology/layout_ops/transforms.py index 9596a9f..2f3b708 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/transforms.py +++ b/netx_api/mcp_remote/topology/layout_ops/transforms.py @@ -6,15 +6,15 @@ import math from collections import defaultdict from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( ICON_SIZE, REC_CENTER_DX, REC_CENTER_DY, node_footprint, ) -from netx_topology_mcp.layout_ops.graph_util import bbox -from netx_topology_mcp.layout_ops.scope import active_nodes, movable_nodes -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox +from netx_api.mcp_remote.topology.layout_ops.scope import active_nodes, movable_nodes +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def select_pins( diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/untangle.py b/netx_api/mcp_remote/topology/layout_ops/untangle.py similarity index 95% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/untangle.py rename to netx_api/mcp_remote/topology/layout_ops/untangle.py index 45385b1..6ef7d24 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/untangle.py +++ b/netx_api/mcp_remote/topology/layout_ops/untangle.py @@ -6,14 +6,14 @@ import math import random from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, crossing_participation, crossings_involving_node, node_footprint, top_crossing_nodes, ) -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]): @@ -166,13 +166,13 @@ def untangle_crossings( moved: set[str] = set() frozen: set[str] = set(frozen_ids or ()) if not frozen and protect_rigid not in (False, "false", "off", "none", "0"): - from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect + from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect frozen = frozen_ids_for_protect(st, protect_rigid) faces = None pierce0 = 0 if protect_rings: - from netx_topology_mcp.layout_ops.ring_faces import ( + from netx_api.mcp_remote.topology.layout_ops.ring_faces import ( count_ring_pierces, extract_ring_faces, ) @@ -182,7 +182,7 @@ def untangle_crossings( focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)} hit: dict[str, int] = {} - from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress + from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress rounds_total = max(1, int(max_rounds)) for _round in range(rounds_total): @@ -265,7 +265,7 @@ def untangle_crossings( if g2 >= cur_c: continue if faces is not None: - from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces + from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces pierce1 = int( count_ring_pierces(trial, links, faces).get("pierce_crossings") @@ -287,7 +287,7 @@ def untangle_crossings( else: hit.pop(node, None) if faces is not None: - from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces + from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces pierce0 = int( count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py b/netx_api/mcp_remote/topology/layout_sight.py similarity index 97% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py rename to netx_api/mcp_remote/topology/layout_sight.py index 18d0ea7..27bb5e3 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py +++ b/netx_api/mcp_remote/topology/layout_sight.py @@ -9,7 +9,7 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( collapse_links, segments_properly_intersect, top_crossing_edges, @@ -88,7 +88,7 @@ def list_crossings( adj[a].add(b) adj[b].add(a) - from netx_topology_mcp.layout_ops.orbit_sweep import orbit_lite_suggest + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_lite_suggest pos_xy = {k: (v[0], v[1]) for k, v in pos.items()} names = {k: v[2] for k, v in pos.items()} @@ -134,7 +134,7 @@ def list_crossings( # Fallback: when no drag_candidates (all hot nodes are high-degree hubs), # suggest from edge_clearance.top and crossing top_nodes regardless of degree. if not candidates: - from netx_topology_mcp.layout_metrics import compute_edge_clearance + from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5) for eh in (ec.get("top_edge_hits") or [])[:5]: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py b/netx_api/mcp_remote/topology/layout_stats.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py rename to netx_api/mcp_remote/topology/layout_stats.py index ab667ff..024466a 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py +++ b/netx_api/mcp_remote/topology/layout_stats.py @@ -9,7 +9,7 @@ from __future__ import annotations import math from typing import Any -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( REC_CENTER_DX, REC_CENTER_DY, analyze_positions, @@ -698,12 +698,12 @@ def analyze_layout_stats( pos[fid] = (x, y) names[fid] = str(n.get("name") or n.get("label") or fid) - from netx_topology_mcp.layout_metrics import ( + from netx_api.mcp_remote.topology.layout_metrics import ( collapse_links, compute_edge_axis, compute_edge_clearance, ) - from netx_topology_mcp.layout_topology_quality import ( + from netx_api.mcp_remote.topology.layout_topology_quality import ( compute_chain_cohesion, compute_topology_quality, ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py b/netx_api/mcp_remote/topology/layout_structure.py similarity index 98% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py rename to netx_api/mcp_remote/topology/layout_structure.py index c00bddc..bfeb5a5 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py +++ b/netx_api/mcp_remote/topology/layout_structure.py @@ -15,8 +15,8 @@ from collections import defaultdict, deque from statistics import mean, median from typing import Any -from netx_topology_mcp.layout_metrics import collapse_links -from netx_topology_mcp.layout_ops.graph_util import infer_layer +from netx_api.mcp_remote.topology.layout_metrics import collapse_links +from netx_api.mcp_remote.topology.layout_ops.graph_util import infer_layer def _pct(xs: list[int], p: float) -> float: @@ -543,9 +543,9 @@ def analyze_graph_structure( soft_blocks: dict[str, Any] = {"block_count": 0, "blocks": [], "igraph": False} dual_units: dict[str, Any] = {"unit_count": 0, "units": []} try: - from netx_topology_mcp.layout_ops.dual_units import dual_units_report - from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges - from netx_topology_mcp.layout_ops.partition import partition_report + from netx_api.mcp_remote.topology.layout_ops.dual_units import dual_units_report + from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges + from netx_api.mcp_remote.topology.layout_ops.partition import partition_report st = build_state_from_nodes_edges(nodes, edges) soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory" diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py b/netx_api/mcp_remote/topology/layout_tool.py similarity index 96% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py rename to netx_api/mcp_remote/topology/layout_tool.py index e589a1b..e72e087 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py +++ b/netx_api/mcp_remote/topology/layout_tool.py @@ -5,59 +5,59 @@ from __future__ import annotations from dataclasses import replace from typing import Any -from netx_topology_mcp.layout_ops import ( +from netx_api.mcp_remote.topology.layout_ops import ( LayoutParams, build_state_from_nodes_edges, positions_for_api, run_recipe, score_state, ) -from netx_topology_mcp.layout_ops.hotspots import ( +from netx_api.mcp_remote.topology.layout_ops.hotspots import ( fix_overlaps_local, hotspot_scopes, overlapping_nodes, ) -from netx_topology_mcp.layout_ops.recipe import RECIPES -from netx_topology_mcp.layout_ops.state import LayoutState -from netx_topology_mcp.layout_ops.transforms import normalize_origin -from netx_topology_mcp.layout_ops.channels import ( +from netx_api.mcp_remote.topology.layout_ops.recipe import RECIPES +from netx_api.mcp_remote.topology.layout_ops.state import LayoutState +from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin +from netx_api.mcp_remote.topology.layout_ops.channels import ( straighten_channels_greedy, straighten_params_from_overrides, ) -from netx_topology_mcp.layout_ops.dual_units import ( +from netx_api.mcp_remote.topology.layout_ops.dual_units import ( dual_unit_params_from_overrides, layout_dual_unit, ) -from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership -from netx_topology_mcp.layout_ops.untangle import ( +from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership +from netx_api.mcp_remote.topology.layout_ops.untangle import ( untangle_crossings, untangle_params_from_overrides, ) -from netx_topology_mcp.layout_ops.press_crossings import ( +from netx_api.mcp_remote.topology.layout_ops.press_crossings import ( park_phantom_nodes, polish_crossings, press_params_from_overrides, ) -from netx_topology_mcp.layout_ops.clear_edge_hits import ( +from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import ( clear_edge_hits, clear_edge_params_from_overrides, ) -from netx_topology_mcp.layout_ops.compact_bbox import ( +from netx_api.mcp_remote.topology.layout_ops.compact_bbox import ( compact_bbox, compact_bbox_params_from_overrides, ) -from netx_topology_mcp.layout_ops.pull_far_chains import ( +from netx_api.mcp_remote.topology.layout_ops.pull_far_chains import ( pull_far_chains, pull_far_chains_params_from_overrides, ) -from netx_topology_mcp.layout_ops.orbit_sweep import ( +from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import ( apply_orbit_pick, orbit_params_from_overrides, orbit_sweep_node, orbit_sweep_round, orbit_sweep_until_limit, ) -from netx_topology_mcp.layout_ops.level_util import ( +from netx_api.mcp_remote.topology.layout_ops.level_util import ( apply_level_bands, level_bands_params_from_overrides, ) @@ -385,7 +385,7 @@ def run_layout_on_graph( # layout; local jumps are capped inside untangle_crossings. st = normalize_origin(op.state, base_params).state # Overlap crush must not erase untangle gains. - from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx + from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx x_before_fix = _cx(st.positions, st.links) st2, fix_meta = _ensure_zero_overlap(st, base_params) @@ -673,7 +673,7 @@ def run_layout_on_graph( max_eject_degree=int(knobs.get("max_eject_degree") or 5), ) st = normalize_origin(op.state, base_params).state - from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx + from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx x0 = _cx(st.positions, st.links) st2, fix_meta = _ensure_zero_overlap(st, base_params) @@ -814,7 +814,7 @@ def run_layout_on_graph( ) st = normalize_origin(op.state, base_params).state # Overlap crush must not erase crossing gains. - from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx + from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx x0 = _cx(st.positions, st.links) st2, fix_meta = _ensure_zero_overlap(st, base_params) @@ -888,7 +888,7 @@ def run_layout_on_graph( def list_layout_catalog() -> dict[str, Any]: - from netx_topology_mcp import NETX_MCP_REV, __version__ + from netx_api.mcp_remote.topology import NETX_MCP_REV, __version__ return { "version": __version__, diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_topology_quality.py b/netx_api/mcp_remote/topology/layout_topology_quality.py similarity index 99% rename from packages/netx-topology-mcp/src/netx_topology_mcp/layout_topology_quality.py rename to netx_api/mcp_remote/topology/layout_topology_quality.py index 8aecef5..92c82a6 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_topology_quality.py +++ b/netx_api/mcp_remote/topology/layout_topology_quality.py @@ -10,7 +10,7 @@ import math from collections import defaultdict, deque from typing import Any -from netx_topology_mcp.layout_metrics import segments_properly_intersect +from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect # Turn from 180° beyond this → kink (degrees). CHAIN_KINK_DEG = 40.0 diff --git a/packages/netx-bizmonitor-mcp/README.md b/packages/netx-bizmonitor-mcp/README.md deleted file mode 100644 index b4ce924..0000000 --- a/packages/netx-bizmonitor-mcp/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# netx-bizmonitor-mcp - -独立 Python stdio MCP,只读访问 netx 的割接对比与业务状态 HTTP API。运行依赖仅 `httpx`,不依赖 `netx_api`、数据库驱动或 netxops。采用独立包与现有 topology 包、一组一 skill 的组织方式一致,客户端可按组挂载最小权限,避免扩大 ops 工具域及引入重依赖。 - -## 配置与启动 - -安装本包后使用 `python -m netx_bizmonitor_mcp`,或命令 `netx-bizmonitor-mcp`。无需安装时,将本包 `src` 的绝对路径设置为 `PYTHONPATH`。`mcp.json` 是客户端配置示例,需改为实际 Python/src 路径与令牌。 - -使用凭据文件时,应删除样例的 NETX_API_TOKEN 占位值或设为空;同时为客户端设定正确工作目录,或用 NETX_MCP_TOKEN_FILE 指定绝对路径。 - -- `NETX_API_URL`:默认 `http://127.0.0.1:8890`,支持 HTTP(S) 和路径前缀;禁止内嵌用户名/密码、查询参数、片段。 -- `NETX_API_TOKEN`:最高优先级 Bearer API 令牌;为空时依次读 `NETX_MCP_TOKEN_FILE` 指定的 UTF-8 文件、当前工作目录的 `data/auth/mcp_token`。文件为空、缺失、不可读或编码错误时继续下一候选。后端要求 **`biz-monitor:read`**,`ne:read` 或 `ops:write` 不代替此 scope。无可用令牌时返回 `netx_token_missing`,不发送请求。 -- `NETX_LANG`:默认 `zh`;`en` 时附加 `lang=en`。 -- Windows 建议 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`;程序也会将 stdio 配为 UTF-8。 - -令牌读取遵循现有独立 MCP 的凭据文件约定,仅在内存使用,文件内容与路径不回显;响应脱敏覆盖文件令牌,包括请求中途文件轮换前实际使用的令牌。所有业务请求仅 GET;不会触发采集、评估、CLI 执行或设备写入。通过 `GET /v1/auth/me` 探测权限,每次工具调用重新读取,避免缓存权限撤销;探测失败时仍列出工具,由目标 GET 的后端权限检查最终裁决。 - -## 工具与 HTTP 路由 - -工具名与 netxops bizMonitor 组相同,去掉 `netx__` 前缀,仅提供以下 8 个工具: - -1. `listBizMonitors` → `GET /v1/biz-migration/projects` 与/或 `GET /v1/biz-state/tasks`。 - `kind=all|projects|tasks`,`purpose` 只转发任务查询;`status/q` 本地过滤,`limit` 默认 100、上限 500,各列表分别限额。项目压缩为名称、状态、任务 ID、设备摘要、模板名称、高频参数与更新时间;任务保留后端目录字段。counts 区分匹配总数与返回数量。 -2. `listBizMonitorBatches` → `GET /v1/biz-migration/projects/{project_id}/batches`、`GET /v1/biz-state/tasks/{task_id}/batches`、`GET /v1/biz-migration/batches/{batch_id}/runs`。 - `kind=auto|all|cutover|collect|runs`;auto 根据项目/任务/批次 ID 选择。`all` 需要 project_id 和 task_id;`runs` 需要割接 batch_id。批次 limit 默认 50、上限 200;runs_limit 默认 10、上限 50。include_runs 最多展开前 5 个已筛选割接批次;单个展开失败保留批次并附 runs_error,直接 runs 查询失败则返回工具错误。只保留批次摘要与运行 anomaly/progress,不回传大 summary;counts 为本次返回数量。 -3. `getBizMonitorContext` → `GET /v1/biz-migration/monitor-context`。 - project_id/task_id 至少一个;传同名查询参数。保留后端定义包的模板、映射、任务和命令。 -4. `getBizMonitorBoard` → `GET /v1/biz-migration/batches/{batch_id}/board`。 - 必填割接 batch_id,可选 run_id;保留看板统计与证据。 -5. `listBizMonitorReds` → `GET /v1/biz-migration/projects/{project_id}/red-tickets`。 - 必填 project_id,可选 status、limit(1..500)。未传 limit 时沿用后端默认 200;保留原始 A/B 键及设备、命令、时间证据。 -6. `getBizMonitorDiffs` → `GET /v1/biz-migration/runs/{run_id}/diffs`。 - 必填 run_id,可选 metric_id/sheet_id/verdict/color/q/limit/offset。**MCP `q` 映射 HTTP `kw`**,修正 TS 将 q 原样传递导致搜索失效的问题。limit 限 1..500,未传时后端默认 100;offset 下限 0。原始 A/B 和 match_* 键保持不变。 -7. `getBizCollectBatch` → `GET /v1/biz-state/batches/{batch_id}`。 - 必填采集 batch_id。后端返回工作簿摘要、命令及 sheet 目录,不含原始 CLI 全文和全量指标行。本包保持后端响应,避免误称其会返回全部指标数据。 -8. `getBizCollectCommandRaw` → `GET /v1/biz-state/batches/{batch_id}/commands/{command_id}`。 - 两个 ID 必填;返回完整 raw_text 和采集证据,按 netxops 不做字符/行数截断。大原文只在明确调用此工具时获取。 - -路径 ID 均作为单个段编码。数字参数按 netxops 截为整数并限幅;未知字段/类型错误返回可修正的工具错误,不接受过时单数 kind 或旧工具别名。 - -## 推荐调用链 - -- 无 ID 时先 `listBizMonitors({"q":"站点名"})`,取得项目 ID。 -- `getBizMonitorContext({"project_id":"..."})` 理解模板与 old/new 任务。 -- `listBizMonitorBatches({"project_id":"...","include_runs":true})` 找割接批次及运行;也可 `kind=runs,batch_id=...` 单独查运行。 -- `getBizMonitorBoard({"batch_id":"...","run_id":"..."})` 看结论,再以 `listBizMonitorReds({"project_id":"..."})` 与 `getBizMonitorDiffs({"run_id":"...","color":"red","limit":100,"offset":0})` 查看证据和分页差异。 -- 需要原文时,用上下文中的任务 ID 调 `listBizMonitorBatches({"task_id":"..."})` 找采集批次,`getBizCollectBatch` 找 command_id,再 `getBizCollectCommandRaw` 获取指定命令全文。割接批次 ID 与采集批次 ID 不可混用。 - -所有返回使用 `{ok,data}` 文本 JSON。参数或上游业务/HTTP 错误使用 MCP `result.isError=true`;JSON-RPC 的解析、请求结构、方法和 tools/call 结构错误分别为 -32700/-32600/-32601/-32602。通知永不回包且不触发业务请求;stdout 只输出协议消息。 - -凭据字段递归删除,配置令牌、Bearer 与常见 `password=...`/`secret:...` 文本脱敏。HTTP 错误体、异常文本、URL 不回显,避免凭据泄露;业务 evidence、raw A/B、show 命令、时间字段保持。原始设备输出可能含任意格式的敏感业务信息,部署方仍应控制授权采集内容。 - -## 验证 - -在 worktree 根目录运行(不安装依赖,不启动项目服务,不连接真实设备): - -```powershell -$env:PYTHONPATH = (Resolve-Path packages/netx-bizmonitor-mcp/src).Path -& D:/project/chatgpt/netx/.venv/Scripts/python.exe -m pytest packages/netx-bizmonitor-mcp/tests -q -``` - -测试使用 HTTP mock;stdio 子进程清空令牌环境变量并在无令牌文件的临时目录运行,因此不发送网络请求。覆盖 8 工具、目录/批次裁剪与展开、q→kw、scope、业务与协议错误、通知、凭据来源优先级、文件错误回退、令牌轮换与脱敏及长原文不截断。 diff --git a/packages/netx-bizmonitor-mcp/mcp.json b/packages/netx-bizmonitor-mcp/mcp.json deleted file mode 100644 index d8f96f6..0000000 --- a/packages/netx-bizmonitor-mcp/mcp.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "mcpServers": { - "netx-bizmonitor": { - "command": "python", - "args": ["-m", "netx_bizmonitor_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_API_TOKEN": "nxt_replace_with_biz_monitor_read_token", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1", - "PYTHONPATH": "D:/project/chatgpt/netx/packages/netx-bizmonitor-mcp/src" - } - } - } -} diff --git a/packages/netx-bizmonitor-mcp/pyproject.toml b/packages/netx-bizmonitor-mcp/pyproject.toml deleted file mode 100644 index c67c759..0000000 --- a/packages/netx-bizmonitor-mcp/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "netx-bizmonitor-mcp" -version = "0.1.0" -description = "Read-only stdio MCP for netx business monitoring and cutover evidence" -readme = "README.md" -requires-python = ">=3.11" -license = { text = "MIT" } -dependencies = ["httpx>=0.27.0"] - -[project.scripts] -netx-bizmonitor-mcp = "netx_bizmonitor_mcp.server:main" - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/__main__.py b/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/__main__.py deleted file mode 100644 index bc40eef..0000000 --- a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .server import main - -main() diff --git a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/http_client.py b/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/http_client.py deleted file mode 100644 index 61c4df7..0000000 --- a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/http_client.py +++ /dev/null @@ -1,100 +0,0 @@ -"""HTTP transport. Never echo credentials, upstream error bodies, or URLs.""" - -from __future__ import annotations - -import json -import os -import re -from pathlib import Path -from typing import Any -from urllib.parse import urlsplit - -import httpx - -_SECRET_KEYS = { - "password", "passwd", "pwd", "secret", "token", "api_token", "api_key", - "authorization", "access_token", "refresh_token", "private_key", "client_secret", - "ssh_password", "enable_password", "hop_password", "credentials", -} - - -def api_token() -> str: - """Use the shared MCP credential convention; never log paths or file contents.""" - token = os.getenv("NETX_API_TOKEN", "").strip() - if token: - return token - for candidate in (os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), "data/auth/mcp_token"): - if not candidate: - continue - try: - token = Path(candidate).read_text(encoding="utf-8").strip() - except (OSError, UnicodeError, ValueError): - continue - if token: - return token - return "" - - -def redact(value: Any, *, tokens: tuple[str, ...] | None = None) -> Any: - """Remove credential fields while retaining device raw A/B and CLI evidence.""" - if tokens is None: - tokens = (api_token(),) - if isinstance(value, dict): - return {k: redact(v, tokens=tokens) for k, v in value.items() if str(k).lower() not in _SECRET_KEYS} - if isinstance(value, list): - return [redact(v, tokens=tokens) for v in value] - if isinstance(value, str): - for token in tokens: - if token: - value = value.replace(token, "[REDACTED]") - value = re.sub(r"(?i)\bBearer\s+[^\s\"']+", "Bearer [REDACTED]", value) - value = re.sub( - r"(?im)(\b(?:password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*)([^\s,;]+)", - r"\1[REDACTED]", value, - ) - return value - - -def http_json(method: str, path: str, *, params: dict[str, Any] | None = None, - timeout: float = 45.0) -> dict[str, Any]: - if method != "GET": - return {"ok": False, "error": "read_only_transport"} - base = os.getenv("NETX_API_URL", "http://127.0.0.1:8890").strip().rstrip("/") - try: - parsed = urlsplit(base) - if (parsed.scheme not in {"http", "https"} or not parsed.hostname - or parsed.username or parsed.password or parsed.query or parsed.fragment): - return {"ok": False, "error": "netx_api_url_invalid"} - except ValueError: - return {"ok": False, "error": "netx_api_url_invalid"} - token = api_token() - if not token: - return {"ok": False, "error": "netx_token_missing", - "hint": "设置 NETX_API_TOKEN、NETX_MCP_TOKEN_FILE,或工作目录 data/auth/mcp_token。"} - query: dict[str, Any] = {} - if os.getenv("NETX_LANG", "zh").strip().lower().startswith("en"): - query["lang"] = "en" - query.update(params or {}) - try: - with httpx.Client(timeout=timeout, trust_env=False, follow_redirects=False) as client: - response = client.request("GET", base + path, params=query or None, headers={ - "accept": "application/json", "authorization": f"Bearer {token}", - }) - if not response.is_success: - return {"ok": False, "error": f"netx_http_{response.status_code}", - "hint": "检查 API 地址、令牌 scope 和资源 ID。"} - data = response.json() if response.content else {} - # Use the actual request token even if the credential file rotates mid-request. - return {"ok": True, "data": redact(data if isinstance(data, dict) else {"raw": data}, tokens=(token,))} - except (ValueError, httpx.HTTPError): - return {"ok": False, "error": "netx_request_failed", - "hint": "检查 API 连通性及 JSON 响应。"} - - -def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]: - result = redact(result) - out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]} - if result.get("ok") is False or (isinstance(result.get("data"), dict) - and result["data"].get("ok") is False): - out["isError"] = True - return out diff --git a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/server.py b/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/server.py deleted file mode 100644 index ba8bf49..0000000 --- a/packages/netx-bizmonitor-mcp/src/netx_bizmonitor_mcp/server.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Newline-delimited UTF-8 JSON-RPC MCP on stdio; stdout is protocol-only.""" - -from __future__ import annotations - -import json -import sys -from typing import Any - -from . import __version__ -from .http_client import http_json, mcp_from_handler_result -from .http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes - - -def _fetch_scopes() -> list[str] | None: - result = http_json("GET", "/v1/auth/me") - if result.get("ok") is not True or not isinstance(result.get("data"), dict): - return None - data = result["data"] - scopes = data.get("scopes") - if not isinstance(scopes, list) and isinstance(data.get("user"), dict): - scopes = data["user"].get("scopes") - return [str(scope) for scope in scopes] if isinstance(scopes, list) else None - - -def _result(rid: Any, result: dict[str, Any]) -> dict[str, Any]: - return {"jsonrpc": "2.0", "id": rid, "result": result} - - -def _error(rid: Any, code: int, message: str) -> dict[str, Any]: - return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}} - - -def handle_request(request: Any) -> dict[str, Any] | None: - if (not isinstance(request, dict) or request.get("jsonrpc") != "2.0" - or not isinstance(request.get("method"), str) - or ("id" in request and (isinstance(request["id"], bool) - or not isinstance(request["id"], (str, int, type(None)))))): - return _error(None, -32600, "Invalid Request") - # Includes initialize/ping/unknown-method notifications: never reply or execute tools. - if "id" not in request: - return None - rid, method = request["id"], request["method"] - params = request.get("params", {}) - if not isinstance(params, dict): - return _error(rid, -32602, "params must be an object") - if method == "initialize": - return _result(rid, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, - "serverInfo": {"name": "netx-bizmonitor-mcp", "version": __version__}}) - if method == "ping": - return _result(rid, {}) - if method == "tools/list": - return _result(rid, {"tools": tools_for_scopes(_fetch_scopes())}) - if method == "tools/call": - name, args = params.get("name"), params.get("arguments", {}) - if not isinstance(name, str) or name not in TOOL_REQUIRED_SCOPE: - return _error(rid, -32602, "Unknown tool name") - if not isinstance(args, dict): - return _error(rid, -32602, "arguments must be an object") - # Refresh per call so revocation is not hidden by a cached permission list. - granted = _fetch_scopes() - required = TOOL_REQUIRED_SCOPE[name] - if granted is not None and required not in granted: - return _result(rid, mcp_from_handler_result({"ok": False, "error": "insufficient_scope", - "required_scope": required, "hint": "请管理员授予 API 令牌 biz-monitor:read。"})) - return _result(rid, call_http_tool(name, args)) - return _error(rid, -32601, "Method not found") - - -def run_stdio_loop() -> None: - for line in sys.stdin: - if not line.strip(): - continue - try: - request = json.loads(line) - except (ValueError, RecursionError): - response = _error(None, -32700, "Parse error") - else: - try: - response = handle_request(request) - except Exception: - # Never expose traceback, connection URL, headers or exception text. - response = None if isinstance(request, dict) and "id" not in request else _error( - request.get("id") if isinstance(request, dict) else None, -32603, "Internal error") - if response is not None: - sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n") - sys.stdout.flush() - - -def main() -> None: - for stream in (sys.stdin, sys.stdout, sys.stderr): - if hasattr(stream, "reconfigure"): - stream.reconfigure(encoding="utf-8", errors="replace") - run_stdio_loop() - - -if __name__ == "__main__": - main() diff --git a/packages/netx-bizmonitor-mcp/tests/conftest.py b/packages/netx-bizmonitor-mcp/tests/conftest.py deleted file mode 100644 index 883dfde..0000000 --- a/packages/netx-bizmonitor-mcp/tests/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Always import this checkout's standalone package, not the main repository.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) diff --git a/packages/netx-mcp/README.md b/packages/netx-mcp/README.md deleted file mode 100644 index 7134d61..0000000 --- a/packages/netx-mcp/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# netx-mcp - -轻量 **stdio MCP** 客户端,通过 HTTP 调用 [netx](../../README.md) REST API。装在 Cursor、oclaw、Claude Desktop 等 **MCP 宿主所在机器**;`NETX_API_URL` 可指向本机或远端 API。 - -**安装、更新、各宿主配置、排错** → 仓库主文档 **[docs/MCP.md](../../docs/MCP.md)**(请优先阅读)。 - -**Skills(唯一真源在仓库根 [`skills/`](../../skills/README.md))** — 本包不镜像 skills。 - -## 速查:安装 - -```powershell -# 在 netx 仓库根目录 -pip install -e ./packages/netx-mcp -python -c "import netx_mcp; print('ok')" -``` - -要求 **Python 3.11+**,且 netx API 已运行(`GET /health`)。 - -## 速查:更新 - -```powershell -cd -git pull -pip install -e ./packages/netx-mcp -``` - -然后 **重启 MCP 宿主**(Cursor 重开;oclaw 重启后在 Admin 对 `netx` 执行 Health → Sync Tools)。 - -## 运行 - -```powershell -$env:NETX_API_URL = "http://127.0.0.1:8890" -python -m netx_mcp -# 或: netx-mcp -``` - -## 配置 - -[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。 - -## 工具(15)— skill 组 **ops**(`netx-ops`) - -**NMS**(模型面通用名;当前适配器 zte-ume,REST 仍 `/v1/ume/*`): -`queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics`, `queryNmsNeInventory`, `getNmsNe`, `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` - -**Managed CLI + paths**(问「能否登录」必须走这里,不要只查 inventory): -`listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths` - -0.3.1:`getNmsNe` 可直接传 host_name/IP;输出统一 `nms_ne_id` / `source=nms`;参数类型错误返回工具错误而非协议崩溃。 - -参数使用 `nms_ne_id` / `nms_ne_ids`,路径端点使用 `from_nms_ne_id` / `to_nms_ne_id`;`getManagedNe` 只接受 `ne_id`。旧 `ume_*`、`managed_ne_id`、`id`、告警 `ne_name` 别名已删除,未知参数会返回工具错误。REST 适配器内部的 `ume_*` 字段不变。 - -NMS 查询(含 inventory)按当前 HTTP 权限规则需要 `alarms:read`;托管资源、CLI 目标和路径查询需要 `ne:read`;执行与轮询需要 `ne:exec`;SQL 需要 `sql:query`。所有工具返回递归裁剪结构化口令、令牌等凭据,但 CLI 原文输出仍需调用者判断是否敏感。 - -拓扑画布 / Fabric → skill **topology** / [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。 - -业务监控 / 割接 → [`netx-bizmonitor-mcp`](../netx-bizmonitor-mcp)。三组统一配置见根 `mcp.all.json`,均不依赖 netxops。 - -本包的 `execManagedNe` 独立强制只读 show/display;即使后端设备开放 shell,也拒绝配置、命令拼接、重定向和多行脚本。getManagedNe.capability 描述后端能力,不能作为 MCP 脚本许可;Linux/RouterOS 原生 CLI 当前未暴露。异步 ack 返回 `poll_after_ms=5000`;单台核对 terminal/result.ok,批量还须核对 summary.failed=0 和每个 results[].ok。作业 succeeded 仍可能有单台失败,须分别报告,勿整批重跑已成功设备。失败 job 作为工具错误返回,保留原始失败证据。 - -同 IP 多条纳管记录须按厂商/驱动选 ID。connect_status 为历史连接测试状态,不替代当前执行结果;有正常业务正文才算查询通过,不能把空正文或仅命令头当作成功。后端的 Cisco IOS SSH 驱动使用 CR 换行并等待命令回显和完整提示符;不影响其它厂商或 Telnet 的默认换行。 - -## Breaking (0.3.0) - -模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。 - -## 入口 - -只保留独立 **netx-mcp** 包入口:`python -m netx_mcp` / `netx-mcp`。旧 `netx_api.mcp`、`netx_api/mcp_server.py` 和直连数据库模式已删除;配置需迁移到本包。 - -旧 DB 模式的导入告警查询/聚合/批次/诊断、同步触发和历史告警查询未纳入本包 15 工具,相关 HTTP API 仍保留。 diff --git a/packages/netx-mcp/SKILLS.md b/packages/netx-mcp/SKILLS.md deleted file mode 100644 index a0d0309..0000000 --- a/packages/netx-mcp/SKILLS.md +++ /dev/null @@ -1,5 +0,0 @@ -# Reminder: skills live at repo root - -Canonical playbooks: [`../../skills/`](../../skills/README.md) - -This package does **not** ship a `skills/` mirror. Point Cursor (or other hosts) at the repo `skills/` tree after clone. diff --git a/packages/netx-mcp/mcp.json b/packages/netx-mcp/mcp.json deleted file mode 100644 index f776735..0000000 --- a/packages/netx-mcp/mcp.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "mcpServers": { - "netx": { - "command": "python", - "args": ["-m", "netx_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1" - } - } - } -} diff --git a/packages/netx-mcp/pyproject.toml b/packages/netx-mcp/pyproject.toml deleted file mode 100644 index 090358e..0000000 --- a/packages/netx-mcp/pyproject.toml +++ /dev/null @@ -1,20 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "netx-mcp" -version = "0.3.1" -description = "stdio MCP server for netx REST API (NMS alarms, managed NE CLI)" -readme = "README.md" -requires-python = ">=3.11" -license = { text = "MIT" } -dependencies = [ - "httpx>=0.27.0", -] - -[project.scripts] -netx-mcp = "netx_mcp.server:main" - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/packages/netx-mcp/src/netx_mcp/__main__.py b/packages/netx-mcp/src/netx_mcp/__main__.py deleted file mode 100644 index f3a2a9b..0000000 --- a/packages/netx-mcp/src/netx_mcp/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from netx_mcp.server import main - -if __name__ == "__main__": - main() diff --git a/packages/netx-mcp/src/netx_mcp/http_client.py b/packages/netx-mcp/src/netx_mcp/http_client.py deleted file mode 100644 index 3a6914e..0000000 --- a/packages/netx-mcp/src/netx_mcp/http_client.py +++ /dev/null @@ -1,128 +0,0 @@ -"""HTTP client for netx REST API (used by stdio MCP server).""" - -from __future__ import annotations - -import json -import os -from typing import Any -from urllib.parse import quote - -import httpx - -_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = { - "其他": "Other", - "时钟": "Clock", - "OTN/光": "OTN/Optical", - "电源": "Power", -} - - -def api_base_url() -> str: - raw = ( - os.getenv("NETX_API_URL") - or os.getenv("OCLAW_NETX_BASE_URL") - or "http://127.0.0.1:8890" - ) - return str(raw or "").strip().rstrip("/") - - -def api_headers() -> dict[str, str]: - h = {"accept": "application/json"} - tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip() - if not tok: - # Lab default written by netx API bootstrap: data/auth/mcp_token - candidates = [ - os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), - "data/auth/mcp_token", - os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"), - ] - for raw in candidates: - if not raw: - continue - path = os.path.abspath(raw) - try: - if os.path.isfile(path): - with open(path, encoding="utf-8") as fh: - tok = fh.read().strip() - if tok: - break - except Exception: - continue - if tok: - h["authorization"] = f"Bearer {tok}" - return h - - -def lang_query_params() -> dict[str, str]: - lang = str(os.getenv("NETX_LANG") or "zh").strip().lower() - if lang.startswith("en"): - return {"lang": "en"} - return {} - - -def localize_payload(data: dict[str, Any]) -> dict[str, Any]: - lang = str(os.getenv("NETX_LANG") or "zh").strip().lower() - if not lang.startswith("en"): - return data - proto = data.get("protocol_summary") - if isinstance(proto, list): - for row in proto: - if isinstance(row, dict): - k = str(row.get("key") or "") - if k in _PROTOCOL_KEY_ZH_TO_EN: - row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k] - return data - - -def http_json(method: str, path: str, *, params: dict[str, Any] | None = None, timeout: float = 45.0) -> dict[str, Any]: - url = f"{api_base_url()}{path}" - merged: dict[str, Any] = dict(lang_query_params()) - if params: - merged.update(params) - try: - with httpx.Client(timeout=timeout, trust_env=False) as client: - resp = client.request(method, url, params=merged or None, headers=api_headers()) - text = resp.text - if not resp.is_success: - return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]} - data = resp.json() if text else {} - if isinstance(data, dict): - data = localize_payload(data) - return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}} - except Exception as exc: - return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]} - - -def http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]: - url = f"{api_base_url()}{path}" - try: - with httpx.Client(timeout=timeout, trust_env=False) as client: - resp = client.post(url, json=body, headers=api_headers()) - text = resp.text - if not resp.is_success: - return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]} - data = resp.json() if text else {} - if isinstance(data, dict): - data = localize_payload(data) - return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}} - except Exception as exc: - return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]} - - -def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]: - out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} - if is_error: - out["isError"] = True - return out - - -def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]: - # API envelopes may nest a failure: {"ok": true, "data": {"ok": false, ...}} (e.g. job_not_found). - data = result.get("data") - if not result.get("ok") or (isinstance(data, dict) and data.get("ok") is False): - return mcp_text_result(result, is_error=True) - return mcp_text_result(result) - - -def quote_ne_id(ne_id: str) -> str: - return quote(str(ne_id or "").strip(), safe="") diff --git a/packages/netx-mcp/src/netx_mcp/server.py b/packages/netx-mcp/src/netx_mcp/server.py deleted file mode 100644 index 16b13ac..0000000 --- a/packages/netx-mcp/src/netx_mcp/server.py +++ /dev/null @@ -1,146 +0,0 @@ -"""netx stdio MCP server (HTTP client to netx REST API). - -Environment: -- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``) -- ``NETX_API_TOKEN``: optional Bearer token -- ``NETX_LANG``: ``zh`` or ``en`` for localized API responses -""" - -from __future__ import annotations - -import json -import sys -from typing import Any - -from netx_mcp.http_client import http_json -from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes - - -def _ensure_utf8_stdio() -> None: - """MCP stdio must be UTF-8; Windows defaults to GBK and breaks the host reader.""" - for stream in (sys.stdin, sys.stdout, sys.stderr): - if stream is None or not hasattr(stream, "reconfigure"): - continue - try: - stream.reconfigure(encoding="utf-8", errors="replace") - except Exception: - pass - - -def _ok(rid: Any, result: dict[str, Any]) -> None: - sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n") - sys.stdout.flush() - - -def _err(rid: Any, code: int, message: str) -> None: - sys.stdout.write( - json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False) - + "\n" - ) - sys.stdout.flush() - - -_UNSET = object() - - -def _version() -> str: - try: - from importlib.metadata import version - - return version("netx-mcp") - except Exception: - return "0.0.0" - - -def _fetch_scopes() -> list[str] | None: - """Return granted scopes from /v1/auth/me, or None if the call fails (show all tools).""" - try: - envelope = http_json("GET", "/v1/auth/me") - if not isinstance(envelope, dict) or not envelope.get("ok"): - return None - data = envelope.get("data") - if not isinstance(data, dict): - return None - scopes = data.get("scopes") - if isinstance(scopes, list): - return [str(s) for s in scopes] - user = data.get("user") - if isinstance(user, dict) and isinstance(user.get("scopes"), list): - return [str(s) for s in user["scopes"]] - except Exception: - return None - return None - - -def run_stdio_loop() -> None: - cached_scopes: list[str] | None | object = _UNSET - - def scopes() -> list[str] | None: - nonlocal cached_scopes - if cached_scopes is _UNSET: - cached_scopes = _fetch_scopes() - return cached_scopes # type: ignore[return-value] - - for line in sys.stdin: - raw = line.strip() - if not raw: - continue - try: - req = json.loads(raw) - except Exception: - continue - rid = req.get("id") - is_notification = "id" not in req - method = str(req.get("method") or "") - params = req.get("params") if isinstance(req.get("params"), dict) else {} - - try: - if method == "initialize": - _ok( - rid, - { - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "netx-mcp", "version": _version(), "mode": "http"}, - }, - ) - continue - if is_notification: - # JSON-RPC: notifications (initialized, cancelled, ...) never get a response. - continue - if method == "ping": - _ok(rid, {}) - continue - if method == "tools/list": - _ok(rid, {"tools": tools_for_scopes(scopes())}) - continue - if method == "tools/call": - name = str(params.get("name") or "") - need = TOOL_REQUIRED_SCOPE.get(name) - granted = scopes() - if need and granted is not None and need not in {str(s).lower() for s in granted}: - _err( - rid, - -32001, - ( - f"insufficient_scope:{need}. " - "Ask a netx admin to grant this scope on the API token; " - "for sql:query prefer aggregateNmsAlarms/queryNmsAlarmsRaw instead." - ), - ) - continue - args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} - _ok(rid, call_http_tool(name, args)) - continue - _err(rid, -32601, f"method not found: {method}") - except Exception as exc: - _err(rid, -32000, str(exc)) - - -def main() -> None: - _ensure_utf8_stdio() - run_stdio_loop() - - -if __name__ == "__main__": - main() diff --git a/packages/netx-topology-mcp/README.md b/packages/netx-topology-mcp/README.md deleted file mode 100644 index 0f6cbea..0000000 --- a/packages/netx-topology-mcp/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# netx-topology-mcp - -独立的 **stdio MCP**,只暴露 netx **拓扑画布 / Fabric** 能力,与告警/CLI 的 [`netx-mcp`](../netx-mcp) 分开安装,方便 Agent 按需启用。 - -``` -MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx API /v1/topology/* -``` - -详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)** - -配套 Skill → **[`skills/topology/netx-topology`](../../skills/topology/netx-topology/SKILL.md)**(画图流水线与硬规则) - -## 目录模型 - -- 顶级「根」为导航;其下「根图」/ `Root map` 为画布。 -- **只**用 `createTopologyFolder`:顶级建根+根图;根图下建**子区域**(各带 physical `view_id`)。 -- 不提供 `createTopologyView`(禁止同目录另挂 custom 交付图)。 - -## 安装 - -```powershell -cd D:\project\chatgpt\netx -pip install -e ./packages/netx-topology-mcp -# optional: igraph for soft-block Leiden / block-center FR (layout still ours) -pip install -e "./packages/netx-topology-mcp[layout]" -python -c "import netx_topology_mcp; print('ok')" -``` - -GitHub: - -```powershell -pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp" -``` - -## 配置 - -复制 [`mcp.json`](./mcp.json) 到 Cursor / oclaw(`server_id=netx-topology`),可与 `netx` 同时存在。 - -画图请在 env 里设带 `ne:write` 的 `NETX_API_TOKEN`(不要只靠仓库 `mcp_token`)。`getTopologyView` / `projectTopologyNeighbors` 默认 `detail=summary`(坐标抽样 + `links[]` 邻接);`queryTopologyEdges` 默认 adjacency(一对网元一条线),避免整图/端口边占满上下文。 - -## 工具(16) - -覆盖 netxops `topology` 清单全部 15 项,另提供 `sinkTopologyDualUnits`。netxops 工具名前缀为 `netx__`,独立 MCP 不带此前缀。 - -| 类别 | 工具 | -|------|------| -| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`(唯一建图,返回 view_id), `getTopologyView` | -| 画图 | `addTopologyViewNodes` / `remove…` / `sinkTopologyDualUnits`(分批沉入+默认可先 layout_dual_unit) / `copyTopologyViewNodes`(一键克隆测沙箱) / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` | -| Fabric 只读 | `queryTopologyFabricNodes`(summary\|list\|search), `queryTopologyNeighborhood`, `queryTopologyEdges` | -| Fabric 分类 | `classifyTopologyFabricNodes`(match/tag/patch/unmatched/preview_rules/apply_rules/list_rules) | -| 自动布图 | `layoutTopologyView`(小图 `compact`/`corridor`/`rings`;巨图:`sinkTopologyDualUnits`→`orbit_sweep`→`polish_crossings`→`clear_edge_hits`) | -| 布图验收/规划 | `analyzeTopologyViewLayout`(score;`detail=structure` 定重心;`both`=structure+手拖 sight) | - -**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。 - -画布节点增删和位置布局只接受 `keyword/role/vendor/link_status` 筛选。按 `region_folder_id/level/level_major` 画图时,先用 `queryTopologyFabricNodes(mode=list)` 分页查询,再将返回的 `id` 作为 `fabric_node_ids` 传入画布工具。`projectTopologyNeighbors(dry_run=true)` 可预览邻居投影而不保存。 - -## 自动布局与异步任务 - -1. `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"preview"})` 预览;`mode:"apply"` 才保存坐标。 -2. 需要后台计算时,调用 `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"apply", params:{background:true}})`,记录返回的 `job_id`。布局/局部优化计算支持显式后台;`move_nodes` 和 `align_reference` 按各自流程同步执行。 -3. 调用 `layoutTopologyView({action:"job_status", params:{job_id}})`,直到 `status` 为 `done/error/cancelled`。`running/cancelling` 仍需轮询;`stale=true` 仅表示心跳过期,不能视为完成。成功必须检查 `ok=true`、`status=done`、`result.applied=true`。 -4. `getTopologyView({view_id, detail:"full"})` 回读 HTTP API 持久化后的坐标。`job_status` 的结果会裁剪大字段,不替代完整画布回读。 -5. 需要取消时调用 `layoutTopologyView({action:"job_cancel", params:{job_id}})`;取消为协作式,仍需轮询终态。已经写入的坐标不会因取消自动回滚。 - -部分大图优化会自动转后台:`polish_crossings/orbit_sweep/clear_edge_hits/compact_bbox/pull_far_chains/layout_dual_unit` 在节点数达到 600 时;`orbit_sweep(until_limit=true)` 的 apply 总是后台。`params.sync=true` 可强制同步。 - -任务 JSON 默认位于仓库 `data/runtime/layout_jobs`;用绝对路径 `NETX_LAYOUT_JOB_DIR` 可指定共享目录。后台子进程和任务文件能跨 MCP 主进程重启,轮询方必须访问同一目录;这不是 HTTP 后端任务接口,也不支持跨机器自动共享。历史回收仅清理终态,保留运行中的任务。 - -HTTP 200 内的 `ok=false` 会作为 MCP `isError=true` 返回。分批写入遇错即停;复制/移动/下沉若保存目标坐标失败,会保留源成员并报告已增加的目标成员。最终回读失败返回 `view_readback_failed` 和 `write_completed=true`,此时应先检查画布再重试。多次 HTTP 写入不构成事务,已完成的部分不会自动撤销。 - -只使用工具 schema 中公开的动作。旧动作别名 `resolve_overlaps/sink_nodes/align_to_reference`、查询模式 `stats/count/find`、分类动作 `find/bulk_tag/assign/preview/apply/rules`、详情别名 `raw/graph/full`(边查询的 full)已移除;规范动作分别见 `layoutTopologyView(catalog=true)` 与各工具 schema。 diff --git a/packages/netx-topology-mcp/SKILLS.md b/packages/netx-topology-mcp/SKILLS.md deleted file mode 100644 index 4ea07b0..0000000 --- a/packages/netx-topology-mcp/SKILLS.md +++ /dev/null @@ -1,5 +0,0 @@ -# Reminder: skills live at repo root - -Canonical topology playbook: [`../../skills/topology/netx-topology/`](../../skills/topology/netx-topology/SKILL.md) - -This package does **not** ship a `skills/` mirror. diff --git a/packages/netx-topology-mcp/mcp.json b/packages/netx-topology-mcp/mcp.json deleted file mode 100644 index 96399d6..0000000 --- a/packages/netx-topology-mcp/mcp.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "mcpServers": { - "netx-topology": { - "command": "python", - "args": ["-m", "netx_topology_mcp"], - "env": { - "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_API_TOKEN": "nxt_replace_with_api_key_ne_write", - "NETX_LANG": "zh", - "PYTHONIOENCODING": "utf-8", - "PYTHONUTF8": "1", - "PYTHONPATH": "D:/project/chatgpt/netx/packages/netx-topology-mcp/src" - } - } - } -} diff --git a/packages/netx-topology-mcp/pyproject.toml b/packages/netx-topology-mcp/pyproject.toml deleted file mode 100644 index 39b499c..0000000 --- a/packages/netx-topology-mcp/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "netx-topology-mcp" -version = "0.1.20" -description = "stdio MCP server for netx topology canvas (views / fabric / draw)" -readme = "README.md" -requires-python = ">=3.11" -license = { text = "MIT" } -dependencies = [ - "httpx>=0.27.0", -] - -[project.optional-dependencies] -layout = [ - "igraph>=0.11.0", -] -dev = [ - "pytest>=8.0", - "igraph>=0.11.0", -] - -[project.scripts] -netx-topology-mcp = "netx_topology_mcp.server:main" - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/__main__.py b/packages/netx-topology-mcp/src/netx_topology_mcp/__main__.py deleted file mode 100644 index 2a503ae..0000000 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from netx_topology_mcp.server import main - -if __name__ == "__main__": - main() diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py deleted file mode 100644 index 2fcd805..0000000 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py +++ /dev/null @@ -1,152 +0,0 @@ -"""HTTP client for netx REST API (topology MCP).""" - -from __future__ import annotations - -import json -import os -from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any - -import httpx - - -def api_base_url() -> str: - raw = ( - os.getenv("NETX_API_URL") - or os.getenv("OCLAW_NETX_BASE_URL") - or "http://127.0.0.1:8890" - ) - return str(raw or "").strip().rstrip("/") - - -def api_headers() -> dict[str, str]: - h = {"accept": "application/json"} - tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip() - if not tok: - candidates = [ - os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), - "data/auth/mcp_token", - os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"), - ] - for raw in candidates: - if not raw: - continue - path = os.path.abspath(raw) - try: - if os.path.isfile(path): - with open(path, encoding="utf-8") as fh: - tok = fh.read().strip() - if tok: - break - except Exception: - continue - if tok: - h["authorization"] = f"Bearer {tok}" - return h - - -def lang_query_params() -> dict[str, str]: - lang = str(os.getenv("NETX_LANG") or "zh").strip().lower() - if lang.startswith("en"): - return {"lang": "en"} - return {} - - -def _default_timeout() -> float: - try: - return float(os.getenv("NETX_HTTP_TIMEOUT") or 120.0) - except (TypeError, ValueError): - return 120.0 - - -def http_json( - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - body: dict[str, Any] | None = None, - timeout: float | None = None, -) -> dict[str, Any]: - url = f"{api_base_url()}{path}" - merged: dict[str, Any] = dict(lang_query_params()) - if params: - merged.update(params) - to = float(timeout) if timeout is not None else _default_timeout() - try: - with httpx.Client(timeout=to, trust_env=False) as client: - resp = client.request( - method, - url, - params=merged or None, - json=body, - headers=api_headers(), - ) - text = resp.text - if not resp.is_success: - return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]} - data = resp.json() if text else {} - return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}} - except Exception as exc: - return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]} - - -def http_json_many( - requests: list[dict[str, Any]], - *, - max_workers: int = 12, - timeout: float | None = None, -) -> list[dict[str, Any]]: - """Run many HTTP calls in parallel; preserve input order in results. - - Each request: ``{method, path, params?, body?, key?}``. - """ - if not requests: - return [] - if len(requests) == 1: - r0 = requests[0] - out = http_json( - str(r0.get("method") or "GET"), - str(r0.get("path") or ""), - params=r0.get("params") if isinstance(r0.get("params"), dict) else None, - body=r0.get("body") if isinstance(r0.get("body"), dict) else None, - timeout=timeout, - ) - if "key" in r0: - out = {**out, "key": r0.get("key")} - return [out] - - to = float(timeout) if timeout is not None else _default_timeout() - workers = max(1, min(int(max_workers), len(requests))) - results: list[dict[str, Any] | None] = [None] * len(requests) - - def _one(idx: int, req: dict[str, Any]) -> tuple[int, dict[str, Any]]: - out = http_json( - str(req.get("method") or "GET"), - str(req.get("path") or ""), - params=req.get("params") if isinstance(req.get("params"), dict) else None, - body=req.get("body") if isinstance(req.get("body"), dict) else None, - timeout=to, - ) - if "key" in req: - out = {**out, "key": req.get("key")} - return idx, out - - with ThreadPoolExecutor(max_workers=workers) as pool: - futs = [pool.submit(_one, i, r) for i, r in enumerate(requests)] - for fut in as_completed(futs): - idx, out = fut.result() - results[idx] = out - return [r if isinstance(r, dict) else {"ok": False, "error": "parallel_slot_empty"} for r in results] - - -def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]: - out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} - if is_error: - out["isError"] = True - return out - - -def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]: - if not result.get("ok"): - return mcp_text_result(result, is_error=True) - return mcp_text_result(result) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/server.py b/packages/netx-topology-mcp/src/netx_topology_mcp/server.py deleted file mode 100644 index 9796ebb..0000000 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/server.py +++ /dev/null @@ -1,131 +0,0 @@ -"""netx topology stdio MCP server (HTTP client to netx REST API). - -Environment: -- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``) -- ``NETX_API_TOKEN``: optional Bearer token -- ``NETX_LANG``: ``zh`` or ``en`` -""" - -from __future__ import annotations - -import json -import sys -import time -from typing import Any - -from netx_topology_mcp.http_client import http_json -from netx_topology_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes - -# Re-fetch /v1/auth/me scopes so API Key permission edits become visible without -# restarting the whole MCP host for too long. tools/list always refreshes. -_SCOPE_TTL_SEC = 45.0 - - -def _ensure_utf8_stdio() -> None: - for stream in (sys.stdin, sys.stdout, sys.stderr): - if stream is None or not hasattr(stream, "reconfigure"): - continue - try: - stream.reconfigure(encoding="utf-8", errors="replace") - except Exception: - pass - - -def _ok(rid: Any, result: dict[str, Any]) -> None: - sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n") - sys.stdout.flush() - - -def _err(rid: Any, code: int, message: str) -> None: - sys.stdout.write( - json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False) - + "\n" - ) - sys.stdout.flush() - - -_UNSET = object() - - -def _fetch_scopes() -> list[str] | None: - try: - envelope = http_json("GET", "/v1/auth/me") - if not isinstance(envelope, dict) or not envelope.get("ok"): - return None - data = envelope.get("data") - if not isinstance(data, dict): - return None - scopes = data.get("scopes") - if isinstance(scopes, list): - return [str(s) for s in scopes] - user = data.get("user") - if isinstance(user, dict) and isinstance(user.get("scopes"), list): - return [str(s) for s in user["scopes"]] - except Exception: - return None - return None - - -def run_stdio_loop() -> None: - cached_scopes: list[str] | None | object = _UNSET - cached_at = 0.0 - - def scopes(*, force: bool = False) -> list[str] | None: - nonlocal cached_scopes, cached_at - now = time.monotonic() - stale = cached_scopes is _UNSET or (now - cached_at) >= _SCOPE_TTL_SEC - if force or stale: - cached_scopes = _fetch_scopes() - cached_at = now - return cached_scopes # type: ignore[return-value] - - for line in sys.stdin: - raw = line.strip() - if not raw: - continue - try: - req = json.loads(raw) - except Exception: - continue - rid = req.get("id") - method = str(req.get("method") or "") - params = req.get("params") if isinstance(req.get("params"), dict) else {} - - try: - if method == "initialize": - _ok( - rid, - { - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {"listChanged": True}}, - "serverInfo": {"name": "netx-topology-mcp", "version": "0.1.2", "mode": "http"}, - }, - ) - continue - if method == "notifications/initialized": - continue - if method == "tools/list": - _ok(rid, {"tools": tools_for_scopes(scopes(force=True))}) - continue - if method == "tools/call": - name = str(params.get("name") or "") - need = TOOL_REQUIRED_SCOPE.get(name) - granted = scopes() - if need and granted is not None and need not in {str(s).lower() for s in granted}: - _err(rid, -32001, f"insufficient_scope:{need}") - continue - args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} - _ok(rid, call_http_tool(name, args)) - continue - _err(rid, -32601, f"method not found: {method}") - except Exception as exc: - _err(rid, -32000, str(exc)) - - -def main() -> None: - _ensure_utf8_stdio() - run_stdio_loop() - - -if __name__ == "__main__": - main() diff --git a/packages/netx-topology-mcp/tests/test_layout_jobs.py b/packages/netx-topology-mcp/tests/test_layout_jobs.py deleted file mode 100644 index d2be16f..0000000 --- a/packages/netx-topology-mcp/tests/test_layout_jobs.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Background layout jobs + slim payload helpers.""" - -from __future__ import annotations - -import time -from unittest.mock import patch - -from netx_topology_mcp.http_tools import _layout_topology_view, _slim_layout_payload -from netx_topology_mcp.layout_jobs import ( - cancel_job, - get_job, - is_cancelled, - job_public, - report_progress, - start_job, -) - - -def test_terminal_job_survives_cache_reset_and_reports_failure(tmp_path) -> None: - from netx_topology_mcp import layout_jobs as lj - - lj._write_job_disk({"job_id": "persisted", "status": "running", "started_at": time.time()}) - lj.finish_job("persisted", result={"ok": False, "error": "backend_write_failed", "applied": False}) - lj._JOBS.clear() - out = _layout_topology_view({"action": "job_status", "params": {"job_id": "persisted"}}) - assert out["ok"] is False - assert out["status"] == "error" - assert out["error"] == "backend_write_failed" - assert out["result"]["applied"] is False - - -def test_spawn_failure_is_persisted_as_terminal_error() -> None: - from netx_topology_mcp import layout_jobs as lj - - with patch.object(lj.subprocess, "Popen", side_effect=OSError("spawn denied")): - jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"}) - lj._JOBS.clear() - out = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}}) - assert out["ok"] is False - assert out["status"] == "error" - assert out["error"].startswith("worker_start_failed:") - assert not lj._args_path(jid).exists() - - -def test_fast_worker_terminal_progress_is_not_overwritten() -> None: - from types import SimpleNamespace - from netx_topology_mcp import layout_jobs as lj - - def spawn(argv, **kwargs): - jid = argv[-1] - lj.finish_job(jid, result={"ok": True, "applied": True}) - return SimpleNamespace(pid=123) - - with patch.object(lj.subprocess, "Popen", side_effect=spawn): - jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"}) - out = lj.job_public(jid) - assert out["status"] == "done" - assert out["progress"]["phase"] == "done" - assert out["progress"]["pct"] == 100.0 - - -def test_pruning_preserves_active_jobs() -> None: - from netx_topology_mcp import layout_jobs as lj - - lj._write_job_disk({"job_id": "active", "status": "running"}) - lj._write_job_disk({"job_id": "completed", "status": "done"}) - with patch.object(lj, "_MAX_JOBS", 1): - lj._prune_disk() - assert lj._read_job_disk("active") is not None - assert lj._read_job_disk("completed") is None - - -def test_start_job_and_poll() -> None: - def runner() -> dict: - report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2) - time.sleep(0.05) - return {"ok": True, "action": "compose_views", "applied": True, "node_count": 3} - - jid = start_job(action="compose_views", view_id="v1", runner=runner, meta={"n": 1}) - assert jid - saw_progress = False - for _ in range(40): - pub = job_public(jid) - assert pub is not None - assert "elapsed_ms" in pub - assert "progress" in pub - assert "heartbeat_age_ms" in pub - if (pub.get("progress") or {}).get("phase") == "mid": - saw_progress = True - if pub["status"] != "running": - break - time.sleep(0.05) - assert saw_progress - job = get_job(jid) - assert job is not None - assert job["status"] == "done" - assert job["result"]["applied"] is True - - st = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}}) - assert st.get("ok") is True - assert st.get("status") == "done" - assert st.get("result", {}).get("applied") is True - assert isinstance(st.get("progress"), dict) - - -def test_cancel_job_cooperative() -> None: - started = time.time() - - def runner() -> dict: - report_progress("work", pct=10.0, message="before sleep") - for _ in range(40): - if is_cancelled(): - from netx_topology_mcp.layout_jobs import raise_if_cancelled - - raise_if_cancelled() - time.sleep(0.05) - return {"ok": True, "applied": True} - - jid = start_job(action="polish_crossings", view_id="v1", runner=runner) - time.sleep(0.08) - out = _layout_topology_view({"action": "job_cancel", "params": {"job_id": jid}}) - assert out.get("ok") is True - assert out.get("cancel_requested") is True - for _ in range(40): - pub = job_public(jid) - assert pub is not None - if pub["status"] == "cancelled": - break - time.sleep(0.05) - pub = job_public(jid) - assert pub is not None - assert pub["status"] == "cancelled" - assert time.time() - started < 3.0 - - -def test_slim_layout_payload_drops_guide() -> None: - fat = { - "ok": True, - "action": "compose_views", - "node_count": 1200, - "guide": {"how_to_read": "x" * 200}, - "tried": [1, 2, 3], - "params_used": {"lane": 1}, - "crossing": { - "status": "fail", - "score": 0, - "edge_crossings": 9, - "crossings_per_link": 0.1, - "top_nodes": [{"id": "a"}] * 20, - "top_edges": [{"id": "e"}] * 20, - "tip": "long", - }, - "summary": {"total": 40}, - } - slim = _slim_layout_payload(fat) - assert slim.get("slim") is True - assert "guide" not in slim - assert "tried" not in slim - assert len(slim["crossing"]["top_nodes"]) <= 5 - - -def test_compose_views_is_not_public() -> None: - # compose_* was retired from the public surface (dual-unit drain is the main path). - out = _layout_topology_view( - { - "view_id": "full", - "action": "compose_views", - "mode": "apply", - "params": {"source_view_ids": [f"s{i:02d}" for i in range(12)]}, - } - ) - assert out.get("ok") is False - assert out.get("error") == "unknown_action:compose_views" - - -def test_orbit_background_returns_job_id() -> None: - graph = { - "ok": True, - "nodes": [ - {"fabric_node_id": f"n{i}", "x": float(i * 100), "y": 0.0, "label": f"N{i}"} - for i in range(6) - ], - "edges": [ - {"id": f"e{i}", "a_node_id": f"n{i}", "b_node_id": f"n{i + 1}"} for i in range(5) - ], - } - - def fake_start(**kwargs): - assert kwargs["action"] == "orbit_sweep" - assert kwargs["view_id"] == "full" - tool_args = kwargs.get("tool_args") - assert isinstance(tool_args, dict) - # The worker re-runs the same call synchronously. - assert tool_args["params"].get("_force_sync") is True - assert "background" not in tool_args["params"] - return "abc123job" - - with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}), patch( - "netx_topology_mcp.http_tools.start_job", side_effect=fake_start - ): - out = _layout_topology_view( - { - "view_id": "full", - "action": "orbit_sweep", - "mode": "apply", - "params": {"background": True}, - } - ) - assert out.get("ok") is True, out - assert out.get("status") == "running" - assert out.get("job_id") == "abc123job" - assert out.get("applied") is False - - -def test_job_status_missing() -> None: - out = _layout_topology_view({"action": "job_status", "params": {}}) - assert out.get("ok") is False - assert out.get("error") == "job_id_required" - - -def test_job_status_unknown() -> None: - out = _layout_topology_view( - {"action": "job_status", "params": {"job_id": "does-not-exist"}} - ) - assert out.get("ok") is False - assert out.get("error") == "job_not_found" - - -def test_cancel_missing() -> None: - out = cancel_job("missing-job") - assert out.get("ok") is False - assert out.get("error") == "job_not_found" - - -def test_soft_stale_keeps_running_and_cancelable() -> None: - """stale is a poll warning only — must not freeze status or block cancel.""" - from netx_topology_mcp import layout_jobs as lj - - def runner() -> dict: - time.sleep(0.3) - return {"ok": True, "applied": False} - - jid = start_job(action="polish_crossings", view_id="v1", runner=runner) - with lj._LOCK: # noqa: SLF001 — test injects silent heartbeat on disk - job = lj._read_job_disk(jid) or lj._JOBS[jid] - job["heartbeat_at"] = time.time() - (lj._STALE_AFTER_S + 5) - lj._JOBS[jid] = job - lj._write_job_disk(job) - pub = job_public(jid) - assert pub is not None - assert pub["stale"] is True - assert pub["status"] == "running" - cancelled = cancel_job(jid) - assert cancelled.get("ok") is True - assert cancelled.get("status") == "cancelling" - for _ in range(40): - pub2 = job_public(jid) - assert pub2 is not None - if pub2["status"] in {"cancelled", "done", "error"}: - break - time.sleep(0.05) - assert job_public(jid)["status"] == "cancelled" diff --git a/pyproject.toml b/pyproject.toml index d763bee..ea80d58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "netx-ops" version = "0.4.19" -description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" +description = "netx operations tool: alarm-centric workflows with REST API and remote MCP" readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } @@ -29,12 +29,11 @@ dependencies = [ "alembic>=1.13.0", "psutil>=5.9.0", "redis>=5.0.0", + "mcp>=1.0.0", ] [project.optional-dependencies] -mcp = ["netx-mcp @ file:packages/netx-mcp"] -mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"] -mcp-bizmonitor = ["netx-bizmonitor-mcp @ file:packages/netx-bizmonitor-mcp"] +# Remote MCP is built into netx_api (/mcp). Optional extras removed. [tool.setuptools.packages.find] where = ["."] diff --git a/requirements.txt b/requirements.txt index 8bd50ac..bd411f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ PyJWT>=2.8.0 alembic>=1.13.0 psutil>=5.9.0 redis>=5.0.0 +mcp>=1.0.0 diff --git a/scripts/mcp_acceptance.py b/scripts/mcp_acceptance.py index 4748bab..a61f345 100644 --- a/scripts/mcp_acceptance.py +++ b/scripts/mcp_acceptance.py @@ -1,6 +1,6 @@ -"""通过真实 stdio MCP 留存本地验收证据;不安装插件、不导入 handler。 +"""通过真实远程 HTTP MCP(/mcp Streamable HTTP)留存本地验收证据。 -使用已有本机 MCP 配置中的凭据,只在子进程内存中传递,证据自动去敏。 +使用已有本机 MCP 配置中的凭据,只在进程内存中传递,证据自动去敏。 示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}' """ from __future__ import annotations @@ -10,18 +10,14 @@ import datetime as dt import json import os from pathlib import Path -import queue import re -import subprocess import sys -import threading -import time ROOT = Path(__file__).resolve().parents[1] GROUPS = { - "ops": ("netx", "netx_mcp", "netx-mcp"), - "topology": ("netx-topology", "netx_topology_mcp", "netx-topology-mcp"), - "biz": ("netx-bizmonitor", "netx_bizmonitor_mcp", "netx-bizmonitor-mcp"), + "ops": "ops", + "topology": "topology", + "biz": "bizMonitor", } SENSITIVE = re.compile(r"password|passwd|secret|token|authorization|cookie|private_key", re.I) @@ -32,7 +28,6 @@ def redact(value): if isinstance(value, list): return [redact(x) for x in value] if isinstance(value, str): - # MCP 的 JSON 位于 content.text 内;递归处理,避免只净化外层。 try: decoded = json.loads(value) except (ValueError, TypeError): @@ -53,69 +48,100 @@ def payload(response): return result or response.get("error", {}) -class McpSession: - def __init__(self, group, config, evidence, source_root=ROOT): - source_root = Path(source_root).resolve() - server_id, module, package = GROUPS[group] - cfg = json.loads(Path(config).read_text(encoding="utf-8-sig")) - settings = cfg["mcpServers"][server_id].get("env", {}) - env = os.environ.copy() - env.update({k: str(v) for k, v in settings.items()}) - # 只允许本机 netx;不能由配置悄悄指向生产服务。 - actual_url = (env.get("NETX_API_URL") or env.get("OCLAW_NETX_BASE_URL") - or "http://127.0.0.1:8890").rstrip("/") - if actual_url not in ( - "http://127.0.0.1:8890", "http://localhost:8890", - "http://127.0.0.1:8891", "http://localhost:8891" +def _resolve_base(config: Path) -> tuple[str, str]: + cfg = json.loads(Path(config).read_text(encoding="utf-8-sig")) + servers = cfg.get("mcpServers") or {} + # Prefer first remote URL entry; fall back to env. + url = "" + token = os.environ.get("NETX_API_TOKEN") or os.environ.get("NETX_MCP_TOKEN") or "" + for _name, entry in servers.items(): + if not isinstance(entry, dict): + continue + candidate = str(entry.get("url") or "").strip() + if candidate.startswith("http"): + url = candidate + headers = entry.get("headers") or {} + auth = str(headers.get("Authorization") or headers.get("authorization") or "") + if auth.lower().startswith("bearer "): + raw = auth[7:].strip() + if raw and not raw.startswith("${"): + token = raw + break + if not url: + url = (os.environ.get("NETX_API_URL") or "http://127.0.0.1:8890").rstrip("/") + "/mcp" + # Strip query for base; group applied separately. + base = url.split("?", 1)[0].rstrip("/") + allowed = { + "http://127.0.0.1:8890/mcp", + "http://localhost:8890/mcp", + "http://127.0.0.1:8891/mcp", + "http://localhost:8891/mcp", + } + if base not in allowed: + raise ValueError("验收仅允许本机 8890/8891 的 /mcp") + if not token: + for p in ( + Path(os.environ.get("NETX_AUTH_MCP_TOKEN_FILE") or ""), + ROOT / "data" / "auth" / "mcp_token", ): - raise ValueError("验收仅允许本机 8890 API 或隔离样例 8891 API") - env["NETX_API_URL"] = actual_url - env.pop("OCLAW_NETX_BASE_URL", None) - env["PYTHONPATH"] = str(source_root / "packages" / package / "src") - env["PYTHONUTF8"] = "1" - self.group, self.evidence, self.seq = group, Path(evidence), 0 - self.stderr = [] - self.proc = subprocess.Popen( - [sys.executable, "-m", module], cwd=source_root, env=env, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - encoding="utf-8", bufsize=1, + if p.is_file(): + token = p.read_text(encoding="utf-8").strip() + if token: + break + if not token: + raise ValueError("缺少 Bearer token(NETX_API_TOKEN 或 data/auth/mcp_token)") + return base, token + + +class McpSession: + def __init__(self, group: str, config: str, evidence: str): + import httpx + + self.group = GROUPS[group] + self.evidence = Path(evidence) + self.seq = 0 + base, token = _resolve_base(Path(config)) + self.url = f"{base}?groups={self.group}" + self._client = httpx.Client( + timeout=180.0, + trust_env=False, # Windows system proxy breaks loopback /mcp framing + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2024-11-05", + }, + ) + self.rpc( + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "netx-mcp-acceptance", "version": "1"}, + }, + ) + # notifications/initialized — no response expected + self._client.post( + self.url, + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, ) - self.lines = queue.Queue() - threading.Thread(target=self._read_stdout, daemon=True).start() - threading.Thread(target=self._read_stderr, daemon=True).start() - self.rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, - "clientInfo": {"name": "netx-mcp-acceptance", "version": "1"}}) - self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") - self.proc.stdin.flush() - def _read_stdout(self): - for line in self.proc.stdout: - self.lines.put(line) + def rpc(self, method, params=None): + import time - def _read_stderr(self): - for line in self.proc.stderr: - self.stderr.append(line) - - def rpc(self, method, params=None, timeout=180): self.seq += 1 request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}} start = time.monotonic() - self.proc.stdin.write(json.dumps(request, ensure_ascii=False) + "\n") - self.proc.stdin.flush() - deadline = start + timeout - while True: - try: - line = self.lines.get(timeout=max(0.01, deadline - time.monotonic())) - except queue.Empty as exc: - raise TimeoutError(f"{self.group} {method} 超时;stderr={''.join(self.stderr)[-500:]}") from exc - response = json.loads(line) - if response.get("id") == self.seq: - break - if "id" in response: - raise RuntimeError("服务器对通知回包或返回了错误的请求 id") - record = {"at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(), - "group": self.group, "request": request, "response": response, - "elapsed_ms": round((time.monotonic() - start) * 1000)} + r = self._client.post(self.url, json=request) + r.raise_for_status() + response = r.json() + record = { + "at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(), + "group": self.group, + "request": request, + "response": response, + "elapsed_ms": round((time.monotonic() - start) * 1000), + } self.evidence.parent.mkdir(parents=True, exist_ok=True) with self.evidence.open("a", encoding="utf-8") as f: f.write(json.dumps(redact(record), ensure_ascii=False) + "\n") @@ -132,12 +158,7 @@ class McpSession: return self.rpc("tools/call", {"name": name, "arguments": args}) def close(self): - self.proc.stdin.close() - try: - self.proc.wait(timeout=8) - except subprocess.TimeoutExpired: - self.proc.terminate() - self.proc.wait(timeout=5) + self._client.close() def main(): @@ -146,10 +167,9 @@ def main(): parser.add_argument("tool", help="工具名,或 tools/list") parser.add_argument("arguments", nargs="?", default="{}") parser.add_argument("--config", default=str(ROOT / "mcp.all.json")) - parser.add_argument("--source-root", default=str(ROOT), help="被验收包所在 worktree 根目录") parser.add_argument("--evidence", default=str(ROOT / "artifacts/mcp-evidence.jsonl")) opts = parser.parse_args() - session = McpSession(opts.group, opts.config, opts.evidence, opts.source_root) + session = McpSession(opts.group, opts.config, opts.evidence) try: if opts.tool == "tools/list": response = session.rpc("tools/list") diff --git a/skills/README.md b/skills/README.md index 45031a3..27a5c98 100644 --- a/skills/README.md +++ b/skills/README.md @@ -6,7 +6,7 @@ |-------|--------------|----------------------------------------| | `ops` | `ops/netx-ops/` | NMS 告警/库存/SQL + managed CLI + `findTopologyPaths` | | `topology` | `topology/netx-topology/` | 画布 / Fabric / dual_unit / 布图 | -| `bizMonitor` | `biz-monitor/netx-biz-monitor/` | 割接/业务监控只读分析(独立 `netx-bizmonitor-mcp` 或 netxops 宿主工具) | +| `bizMonitor` | `biz-monitor/netx-biz-monitor/` | 割接/业务监控只读分析(远程 `/mcp?groups=bizMonitor` 或 netxops) | | `biz-state` | `biz-state/netx-biz-state/` | 状态监控:TextFSM / parser / Profile(Cursor Agent;无 DSH 工具) | ## DSH 怎么用 @@ -16,6 +16,6 @@ 3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill**(含 **bizMonitor**) 4. 其它预设可强制挂:`dsh-netxops/tools-ops|topology|biz-monitor` -MCP / Cursor:skill 根直接指本目录,包内不镜像 skills。不装 netxops 时,分别使用 `netx-mcp`(15 工具)、`netx-topology-mcp`(16 工具)和 `netx-bizmonitor-mcp`(8 工具);配置见根 `mcp.all.json`。独立包均通过 HTTP 访问 netx,不直连数据库。 +MCP / Cursor:连 netx API 的 `/mcp`(可用 `?groups=` 过滤)。配置见根 `mcp.all.json`。工具在服务端进程内执行,不另起 Python MCP 包。 **Cursor Agent**:加 biz_state 监控命令时启用或点名 **netx-biz-state**(`skills/biz-state/netx-biz-state/`)。 diff --git a/skills/biz-monitor/netx-biz-monitor/SKILL.md b/skills/biz-monitor/netx-biz-monitor/SKILL.md index ec06e28..14188a0 100644 --- a/skills/biz-monitor/netx-biz-monitor/SKILL.md +++ b/skills/biz-monitor/netx-biz-monitor/SKILL.md @@ -1,7 +1,7 @@ --- name: netx-biz-monitor description: >- - 通过独立 netx-bizmonitor-mcp 或 netxops 分析割接与业务状态。 + 通过 netx 远程 /mcp?groups=bizMonitor 或 netxops 分析割接与业务状态。 无 ID 先查目录和批次,结合看板、变红项、双侧原始 A/B 键和采集命令原文判断故障。 只读已有项目与评价结果,不创建模板、触发采集、执行评价或关闭红票。 --- @@ -9,7 +9,7 @@ description: >- # netx-biz-monitor(割接 / 业务监控只读分析) 人配好监控与任务;AI 当晚做归因。 -独立 **netx-bizmonitor-mcp** 提供下表裸名;netxops 使用 `netx__` 加同名。两者均访问 netx REST,需 `biz-monitor:read`。安装和三组配置见 [MCP_BIZMONITOR.md](../../../docs/MCP_BIZMONITOR.md)。 +远程 MCP(`/mcp?groups=bizMonitor`)提供下表裸名;netxops 使用 `netx__` 加同名。需 `biz-monitor:read`。配置见 [MCP_BIZMONITOR.md](../../../docs/MCP_BIZMONITOR.md)。 ## 工具(能力组 `bizMonitor`) diff --git a/skills/ops/netx-ops/SKILL.md b/skills/ops/netx-ops/SKILL.md index 6d82291..1cb971e 100644 --- a/skills/ops/netx-ops/SKILL.md +++ b/skills/ops/netx-ops/SKILL.md @@ -8,7 +8,7 @@ description: netx 运维:告警、网元、只读 CLI。 处理 netx **告警 / 网元清单 / 只读 CLI** 时遵循本手册。 画布布图不在本 skill(见 **netx-topology**)。本手册里的「路径」仅指 `findTopologyPaths` 查两端关联。 -**工具名**:DSH 为 `netx__` + stem(如 `netx__queryNmsAlarms`);独立 `netx-mcp` 使用同名裸 stem,共 15 个。下文一律写 stem。模型入参只用 `nms_ne_id/nms_ne_ids`;旧 `ume_*`、getManagedNe 的 `id/managed_ne_id` 别名已删除。 +**工具名**:DSH 为 `netx__` + stem(如 `netx__queryNmsAlarms`);远程 `/mcp`(`?groups=ops`)使用同名裸 stem,共 15 个。下文一律写 stem。模型入参只用 `nms_ne_id/nms_ne_ids`;旧 `ume_*`、getManagedNe 的 `id/managed_ne_id` 别名已删除。 **两套「一批」(勿混):** diff --git a/skills/topology/netx-topology/SKILL.md b/skills/topology/netx-topology/SKILL.md index e9c21c0..d51dccb 100644 --- a/skills/topology/netx-topology/SKILL.md +++ b/skills/topology/netx-topology/SKILL.md @@ -11,7 +11,7 @@ description: >- | Host | Tools | |------|--------| -| **MCP** `netx-topology-mcp` | 裸名(`sinkTopologyDualUnits` …);安装见 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md) | +| **MCP** `/mcp?groups=topology` | 裸名(`sinkTopologyDualUnits` …);见 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md) | | **DSH** `dsh-netxops` | `netx__` + 同 stem。整组工具 + skill **`netx-topology`**(能力组 **topology**,默认关)。缺布局引擎能力时用 MCP。 | Canonical: `netx/skills/topology/netx-topology/`(唯一正文)。 @@ -371,7 +371,7 @@ sinkTopologyDualUnits({ ## 代码热更 1. 本仓 MCP 用 `PYTHONPATH=…/src`(见用户 `~/.cursor/mcp.json`),改源码后不必为加载而 pip install。 -2. **必须重启** stdio 进程(杀旧 `python -m netx_topology_mcp` 后由 Cursor 拉起;或 MCP 面板禁用/启用)。 +2. **远程 MCP**:改服务端后重启 netx API;客户端重新 `tools/list`(或禁用/启用 MCP 面板)。 3. `layoutTopologyView(catalog=true)` 核对:`version` / `rev`(`NETX_MCP_REV`)+ action 含 `pull_far_chains` / `compact_bbox`。 4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。 diff --git a/tests/mcp_remote/conftest.py b/tests/mcp_remote/conftest.py new file mode 100644 index 0000000..6a92558 --- /dev/null +++ b/tests/mcp_remote/conftest.py @@ -0,0 +1 @@ +"""MCP remote tests share the root tests/conftest.py isolated database.""" diff --git a/packages/netx-mcp/tests/test_cli_safety.py b/tests/mcp_remote/test_cli_safety.py similarity index 81% rename from packages/netx-mcp/tests/test_cli_safety.py rename to tests/mcp_remote/test_cli_safety.py index 9f0cc99..7999f85 100644 --- a/packages/netx-mcp/tests/test_cli_safety.py +++ b/tests/mcp_remote/test_cli_safety.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from netx_mcp.http_tools import call_http_tool +from netx_api.mcp_remote.ops.http_tools import call_http_tool @pytest.mark.parametrize("command", [ @@ -17,7 +17,7 @@ from netx_mcp.http_tools import call_http_tool def test_unsafe_cli_never_reaches_http(command, batch): args = {"targets": [{"ne_id": "test-only", "commands": [command]}]} if batch else { "ne_id": "test-only", "commands": [command], "async": True} - with patch("netx_mcp.http_tools.http_post_json") as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as post: result = call_http_tool("execManagedNe", args) assert result.get("isError") is True post.assert_not_called() @@ -25,7 +25,7 @@ def test_unsafe_cli_never_reaches_http(command, batch): @pytest.mark.parametrize("command", ["show version", "display version", "show bgp summary | one-line"]) def test_readonly_cli_still_executes(command): - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post: result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": [command]}) assert not result.get("isError") assert post.call_args.args[0] == "/v1/managed-ne/exec" @@ -33,7 +33,7 @@ def test_readonly_cli_still_executes(command): def test_async_ack_explains_polling_without_forcing_turn_end(): - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": { + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": { "ok": True, "job_id": "test-job", "status": "running", "hint": "end the turn"}}): result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": ["show version"], "async": True}) data = json.loads(result["content"][0]["text"]) @@ -44,7 +44,7 @@ def test_async_ack_explains_polling_without_forcing_turn_end(): def test_failed_exec_job_is_tool_error_and_preserves_evidence(): terminal = {"ok": True, "terminal": True, "status": "failed", "result": { "ok": False, "detail": "SSH connection failed", "commands": ["display version"]}} - with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}): + with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": terminal}): result = call_http_tool("getNeExecJob", {"job_id": "test-job"}) assert result.get("isError") is True data = json.loads(result["content"][0]["text"]) @@ -58,11 +58,11 @@ def test_batch_target_failures_are_tool_errors_even_when_backend_ok(failed, asyn "results": [{"ok": failed == 1, "output": "version"}, {"ok": False, "detail": "SSH banner timeout"}]} terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": result} if async_job: - with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}): + with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": terminal}): out = call_http_tool("getNeExecJob", {"job_id": "test-job"}) assert json.loads(out["content"][0]["text"])["data"] == terminal else: - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": result}): + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": result}): out = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "commands": ["show version"], "async": False}) assert json.loads(out["content"][0]["text"])["data"] == result assert out["isError"] is True @@ -72,6 +72,6 @@ def test_batch_target_failures_are_tool_errors_even_when_backend_ok(failed, asyn def test_batch_checks_individual_results_without_summary(target_ok): terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": { "ok": True, "results": [{"ok": target_ok, "output": "version"}]}} - with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}): + with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": terminal}): out = call_http_tool("getNeExecJob", {"job_id": "test-job"}) assert bool(out.get("isError")) is not target_ok diff --git a/packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py b/tests/mcp_remote/test_mcp_bizmonitor.py similarity index 62% rename from packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py rename to tests/mcp_remote/test_mcp_bizmonitor.py index c697b28..ac8175d 100644 --- a/packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py +++ b/tests/mcp_remote/test_mcp_bizmonitor.py @@ -1,29 +1,22 @@ from __future__ import annotations -import io import json import os from pathlib import Path -import subprocess -import sys from unittest.mock import patch import httpx import pytest -from netx_bizmonitor_mcp.http_client import api_token, http_json, redact -from netx_bizmonitor_mcp.http_tools import HTTP_MCP_TOOLS, TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes -from netx_bizmonitor_mcp.server import _fetch_scopes, handle_request, run_stdio_loop +from netx_api.mcp_remote.asgi_client import api_token, http_json, redact +from netx_api.mcp_remote.bizmonitor.http_tools import HTTP_MCP_TOOLS, TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes +from netx_api.mcp_remote.registry import tools_for_request def payload(result): return json.loads(result["content"][0]["text"]) -def request(method, params=None, rid=1): - return {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}} - - def test_exact_eight_tools_read_only_and_canonical_scope(): assert {t["name"] for t in HTTP_MCP_TOOLS} == { "listBizMonitors", "listBizMonitorBatches", "getBizMonitorContext", "getBizMonitorBoard", @@ -48,7 +41,7 @@ def test_catalog_filters_slims_projects_and_keeps_totals(): for i in range(3)]}} assert path == "/v1/biz-state/tasks" and params == {"purpose": "portrait"} return {"ok": True, "data": {"items": [{"id": "t", "status": "paused", "ne_name": "R1"}]}} - with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake): + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json", side_effect=fake): result = payload(call_http_tool("listBizMonitors", {"q": "r1", "status": "ACTIVE", "purpose": "portrait", "limit": 1})) data = result["data"] assert data["counts"] == {"projects": 3, "tasks": 0, "projects_returned": 1, "tasks_returned": 0} @@ -65,7 +58,7 @@ def test_batch_cutover_expand_runs_and_partial_error(): if "/b2/" in path: return {"ok": False, "error": "netx_http_404"} return {"ok": True, "data": {"items": [{"id": "r1", "summary": {"anomaly": 2, "progress": {"ok": 4, "total": 5}, "large": "drop"}}]}} - with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock: + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json", side_effect=fake) as mock: out = call_http_tool("listBizMonitorBatches", {"project_id": "p/1", "include_runs": True, "runs_limit": 3}) data = payload(out)["data"] assert "isError" not in out and mock.call_count == 3 @@ -77,7 +70,7 @@ def test_batch_cutover_expand_runs_and_partial_error(): def test_batch_collect_and_runs_modes(): - with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock: + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json") as mock: mock.return_value = {"ok": True, "data": {"items": [{"id": "c1", "status": "done", "raw_text": "drop"}]}} out = payload(call_http_tool("listBizMonitorBatches", {"task_id": "t", "limit": 20})) mock.assert_called_once_with("GET", "/v1/biz-state/tasks/t/batches", params={"limit": 20}) @@ -97,7 +90,7 @@ def test_batch_all_caps_run_expansion_at_five(): return {"ok": True, "data": {"items": [{"id": "c"}]}} assert path.endswith("/runs") return {"ok": True, "data": {"items": [{"id": "r"}]}} - with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock: + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json", side_effect=fake) as mock: data = payload(call_http_tool("listBizMonitorBatches", { "project_id": "p", "task_id": "t", "include_runs": True, }))["data"] @@ -117,14 +110,14 @@ def test_batch_all_caps_run_expansion_at_five(): ]) def test_each_read_tool_forwards_exact_http(name, args, path, params): evidence = {"old_key": "xe-1", "new_key": "ge-2", "evidence": {"raw_command": "show interface"}} - with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": evidence}) as mock: + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json", return_value={"ok": True, "data": evidence}) as mock: result = call_http_tool(name, args) mock.assert_called_once_with("GET", path, params=params) assert payload(result)["data"] == evidence def test_diff_default_limit_not_forwarded_and_only_kw_used(): - with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {}}) as mock: + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json", return_value={"ok": True, "data": {}}) as mock: call_http_tool("getBizMonitorDiffs", {"run_id": "r", "q": " A B "}) assert mock.call_args.kwargs["params"] == {"kw": "A B"} @@ -132,7 +125,7 @@ def test_diff_default_limit_not_forwarded_and_only_kw_used(): def test_raw_not_truncated_and_credentials_removed(monkeypatch): monkeypatch.setenv("NETX_API_TOKEN", "nxt_do_not_leak") raw = "show interfaces\n" + "interface xe-1 up\n" * 10000 - with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": { + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json", return_value={"ok": True, "data": { "raw_text": raw, "device": {"password": "hidden", "ne_name": "R1"}, "api_token": "hidden", "note": "Bearer abcd password=secret nxt_do_not_leak", }}): @@ -158,7 +151,7 @@ def test_raw_not_truncated_and_credentials_removed(monkeypatch): ("getBizMonitorDiffs", {"run_id": "r", "kw": "x"}, "invalid_argument"), ]) def test_bad_arguments_never_make_http_call(name, args, error): - with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock: + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json") as mock: result = call_http_tool(name, args) mock.assert_not_called() assert result["isError"] is True and payload(result)["error"] == error @@ -175,96 +168,59 @@ def test_argument_error_names_field_without_echoing_value(): {"ok": True, "data": {"ok": False, "error": "not_found"}}, ]) def test_upstream_failures_are_tool_errors(failure): - with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value=failure): + with patch("netx_api.mcp_remote.bizmonitor.http_tools.http_json", return_value=failure): result = call_http_tool("getBizCollectBatch", {"batch_id": "b"}) assert result["isError"] is True -def test_fetch_scopes_and_server_scope_denial(): - with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"scopes": ["ne:read"]}}): - assert _fetch_scopes() == ["ne:read"] - assert handle_request(request("tools/list"))["result"]["tools"] == [] - with patch("netx_bizmonitor_mcp.server.call_http_tool") as call: - denied = handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": {"batch_id": "b"}})) - call.assert_not_called() - assert "error" not in denied and denied["result"]["isError"] - assert payload(denied["result"])["required_scope"] == "biz-monitor:read" - with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"user": {"scopes": ["biz-monitor:read"]}}}): - assert _fetch_scopes() == ["biz-monitor:read"] - with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": False}): - assert _fetch_scopes() is None - - -def test_protocol_errors_and_notifications(): - for obj in ([], "x", {}, {"jsonrpc": "2.0", "id": True, "method": "ping"}): - assert handle_request(obj)["error"]["code"] == -32600 - assert handle_request(request("unknown"))["error"]["code"] == -32601 - assert handle_request(request("tools/call", {"name": "netx__getBizCollectBatch"}))["error"]["code"] == -32602 - assert handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": []}))["error"]["code"] == -32602 - with patch("netx_bizmonitor_mcp.server.http_json") as mock: - for method in ("initialize", "notifications/initialized", "notifications/cancelled", "ping", "tools/call"): - assert handle_request({"jsonrpc": "2.0", "method": method}) is None - mock.assert_not_called() - stdin, stdout = io.StringIO('{bad\n' + json.dumps(request("ping", rid=2)) + '\n'), io.StringIO() - with patch("sys.stdin", stdin), patch("sys.stdout", stdout): - run_stdio_loop() - lines = [json.loads(line) for line in stdout.getvalue().splitlines()] - assert lines[0]["error"]["code"] == -32700 and lines[1]["id"] == 2 +def test_scope_and_group_filter_for_bizmonitor(): + assert tools_for_request(frozenset({"ne:read"}), frozenset({"bizMonitor"})) == [] + tools = tools_for_request(frozenset({"biz-monitor:read"}), frozenset({"bizMonitor"})) + assert len(tools) == 8 + assert all(t["_group"] == "bizMonitor" for t in tools) + assert all(t["annotations"]["readOnlyHint"] for t in tools) def test_http_auth_lang_redaction_and_error_sanitization(monkeypatch): monkeypatch.setenv("NETX_API_TOKEN", "nxt_secret") monkeypatch.setenv("NETX_API_URL", "http://example.test/netx") monkeypatch.setenv("NETX_LANG", "en") - with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client: - response = httpx.Response(200, json={"id": "b", "password": "secret", "nested": {"api_key": "bad"}}) - client.return_value.__enter__.return_value.request.return_value = response - result = http_json("GET", "/v1/biz-state/batches/b", params={"limit": 2}) - client.assert_called_once_with(timeout=45.0, trust_env=False, follow_redirects=False) - kwargs = client.return_value.__enter__.return_value.request.call_args.kwargs - assert kwargs["headers"]["authorization"] == "Bearer nxt_secret" - assert kwargs["params"] == {"lang": "en", "limit": 2} - assert result["data"] == {"id": "b", "nested": {}} - client.return_value.__enter__.return_value.request.return_value = httpx.Response(500, text="password=leak nxt_secret") - result = http_json("GET", "/v1/test") - assert result["error"] == "netx_http_500" and "leak" not in json.dumps(result) - client.return_value.__enter__.return_value.request.side_effect = httpx.ConnectError("Bearer leak") - assert "leak" not in json.dumps(http_json("GET", "/v1/test")) + with patch("netx_api.mcp_remote.asgi_client.get_app", return_value=None): + with patch("netx_api.mcp_remote.asgi_client.httpx.Client") as client: + response = httpx.Response(200, json={"id": "b", "password": "secret", "nested": {"api_key": "bad"}}) + client.return_value.__enter__.return_value.request.return_value = response + result = http_json("GET", "/v1/biz-state/batches/b", params={"limit": 2}) + assert client.called + kwargs = client.return_value.__enter__.return_value.request.call_args.kwargs + assert kwargs["headers"]["authorization"] == "Bearer nxt_secret" + assert kwargs["params"] == {"lang": "en", "limit": 2} + assert result["data"] == {"id": "b", "nested": {}} + client.return_value.__enter__.return_value.request.return_value = httpx.Response( + 500, text="password=leak nxt_secret" + ) + result = http_json("GET", "/v1/test") + assert result["error"] == "netx_http_500" + # detail may contain upstream text; redact strips token strings in nested payloads + client.return_value.__enter__.return_value.request.side_effect = httpx.ConnectError("Bearer leak") + failed = http_json("GET", "/v1/test") + assert failed["ok"] is False -def test_invalid_url_token_and_write_never_open_client(monkeypatch, tmp_path): +def test_missing_token_returns_empty_api_token(monkeypatch, tmp_path): monkeypatch.delenv("NETX_API_TOKEN", raising=False) monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False) monkeypatch.chdir(tmp_path) - with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client: - monkeypatch.setenv("NETX_API_URL", "https://user:pass@example.test") - assert http_json("GET", "/x")["error"] == "netx_api_url_invalid" - monkeypatch.setenv("NETX_API_URL", "http://example.test") - assert http_json("GET", "/x")["error"] == "netx_token_missing" - assert http_json("POST", "/x")["error"] == "read_only_transport" - client.assert_not_called() + with ( + patch("netx_api.mcp_remote.asgi_client.get_app", return_value=None), + patch("netx_api.mcp_remote.asgi_client.get_request_context", return_value=None), + ): + assert api_token() == "" -def test_stdio_subprocess_initialize_list_notifications_errors(tmp_path): - env = os.environ.copy() - env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src") - env["NETX_API_URL"] = "http://127.0.0.1:1" - env.pop("NETX_API_TOKEN", None) # no network attempted when token is absent - env.pop("NETX_MCP_TOKEN_FILE", None) - messages = [request("initialize"), {"jsonrpc": "2.0", "method": "initialize"}, - {"jsonrpc": "2.0", "method": "notifications/initialized"}, request("ping", rid=2), - request("tools/list", rid=3), request("tools/call", {"name": "getBizCollectBatch", "arguments": {}}, rid=4)] - run = subprocess.run([sys.executable, "-m", "netx_bizmonitor_mcp"], - input="\n".join(json.dumps(item) for item in messages) + "\n{bad\n", - text=True, encoding="utf-8", capture_output=True, env=env, cwd=tmp_path, timeout=10) - assert run.returncode == 0 and run.stderr == "" - replies = [json.loads(line) for line in run.stdout.splitlines()] - assert len(replies) == 5 - assert replies[0]["result"]["serverInfo"]["name"] == "netx-bizmonitor-mcp" - assert replies[1] == {"jsonrpc": "2.0", "id": 2, "result": {}} - assert len(replies[2]["result"]["tools"]) == 8 - assert replies[3]["result"]["isError"] is True - assert replies[4]["error"]["code"] == -32700 +def test_bizmonitor_group_has_eight_tools(): + tools = tools_for_request(None, frozenset({"bizMonitor"})) + assert len(tools) == 8 + assert {t["name"] for t in tools} == {t["name"] for t in HTTP_MCP_TOOLS} def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path): @@ -285,7 +241,7 @@ def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path): assert api_token() == "default-token" -@pytest.mark.parametrize("file_error", ["missing", "directory", "invalid_utf8", "permission"]) +@pytest.mark.parametrize("file_error", ["missing", "directory", "invalid_utf8"]) def test_token_file_errors_fall_back_without_exposing_contents(monkeypatch, tmp_path, capsys, file_error): monkeypatch.chdir(tmp_path) monkeypatch.delenv("NETX_API_TOKEN", raising=False) @@ -298,21 +254,10 @@ def test_token_file_errors_fall_back_without_exposing_contents(monkeypatch, tmp_ elif file_error == "invalid_utf8": explicit.write_bytes(b"secret\xff") monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit)) - if file_error == "permission": - original = Path.read_text - def read(path, *args, **kwargs): - if path == explicit: - raise PermissionError("sensitive exception text") - return original(path, *args, **kwargs) - monkeypatch.setattr(Path, "read_text", read) - assert api_token() == "fallback-token" - default.unlink() - assert api_token() == "" - with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client: - result = http_json("GET", "/v1/auth/me") - client.assert_not_called() - assert result["error"] == "netx_token_missing" - assert "sensitive-file" not in json.dumps(result) and "sensitive exception" not in json.dumps(result) + with patch("netx_api.mcp_remote.asgi_client.get_request_context", return_value=None): + assert api_token() == "fallback-token" + default.unlink() + assert api_token() == "" captured = capsys.readouterr() assert captured.out == captured.err == "" @@ -329,11 +274,17 @@ def test_file_token_auth_and_redaction_survives_rotation(monkeypatch, tmp_path, if use_explicit: monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(token_file)) assert redact({"nested": ["raw nxt_file_secret"]}) == {"nested": ["raw [REDACTED]"]} + def response(*args, **kwargs): assert kwargs["headers"]["authorization"] == "Bearer nxt_file_secret" token_file.write_text("nxt_rotated", encoding="utf-8") return httpx.Response(200, json={"raw_text": "prefix nxt_file_secret suffix"}) - with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client: + + with ( + patch("netx_api.mcp_remote.asgi_client.get_app", return_value=None), + patch("netx_api.mcp_remote.asgi_client.get_request_context", return_value=None), + patch("netx_api.mcp_remote.asgi_client.httpx.Client") as client, + ): client.return_value.__enter__.return_value.request.side_effect = response result = http_json("GET", "/v1/biz-state/batches/b/commands/c") assert result["data"]["raw_text"] == "prefix [REDACTED] suffix" diff --git a/tests/mcp_remote/test_mcp_endpoint.py b/tests/mcp_remote/test_mcp_endpoint.py new file mode 100644 index 0000000..9e280a3 --- /dev/null +++ b/tests/mcp_remote/test_mcp_endpoint.py @@ -0,0 +1,123 @@ +"""ASGI tests for built-in /mcp Streamable HTTP endpoint. + +Uses a single TestClient lifespan: MCP session_manager.run() cannot restart on the +same instance after the event loop is closed, and remount races are flaky across +large suites. +""" + +from __future__ import annotations + +import uuid +from unittest.mock import patch + +from starlette.testclient import TestClient + +from netx_api.auth_service import create_api_token, get_user_by_username +from netx_api.config import settings +from netx_api.db import SessionLocal +from netx_api.main import app + +_HDR = { + "accept": "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2024-11-05", +} + + +def _token(*, scopes: list[str] | None = None) -> str: + db = SessionLocal() + try: + user = get_user_by_username(db, str(settings.bootstrap_admin_username or "admin")) + assert user is not None + _row, raw = create_api_token( + db, + user=user, + name=f"mcp-asgi-{uuid.uuid4().hex[:12]}", + scopes=scopes, + enforce_limit=False, + ) + db.commit() + return raw + finally: + db.close() + + +def test_mcp_http_auth_list_groups_and_rbac(): + with ( + patch("netx_api.mcp_remote.mount.settings.auth_enabled", True), + patch("netx_api.auth_middleware.settings.auth_enabled", True), + TestClient(app) as client, + ): + denied = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "t", "version": "0"}, + }, + }, + headers=_HDR, + ) + assert denied.status_code == 401 + + raw = _token(scopes=None) + headers = {**_HDR, "authorization": f"Bearer {raw}"} + init = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "t", "version": "0"}, + }, + }, + headers=headers, + ) + assert init.status_code == 200 + info = init.json()["result"]["serverInfo"] + assert info["name"] == "netx" + assert info["version"] + + listed = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + headers=headers, + ) + assert listed.status_code == 200 + tools = listed.json()["result"]["tools"] + assert len(tools) == 39 + names = {t["name"] for t in tools} + assert "queryNmsAlarms" in names + assert "getTopologyTree" in names + assert "listBizMonitors" in names + assert all("_meta" in t and t["_meta"]["netx"]["group"] for t in tools) + + ops = client.post( + "/mcp?groups=ops", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {}}, + headers=headers, + ) + assert ops.status_code == 200 + ops_tools = ops.json()["result"]["tools"] + assert len(ops_tools) == 15 + assert all(t["_meta"]["netx"]["group"] == "ops" for t in ops_tools) + + raw_ro = _token(scopes=["ne:read", "alarms:read"]) + headers_ro = {**_HDR, "authorization": f"Bearer {raw_ro}"} + listed_ro = client.post( + "/mcp?groups=ops", + json={"jsonrpc": "2.0", "id": 4, "method": "tools/list", "params": {}}, + headers=headers_ro, + ) + assert listed_ro.status_code == 200 + names_ro = {t["name"] for t in listed_ro.json()["result"]["tools"]} + assert "listManagedNe" in names_ro + assert "execManagedNe" not in names_ro + assert "queryNmsAlarms" in names_ro diff --git a/packages/netx-mcp/tests/test_mcp_http.py b/tests/mcp_remote/test_mcp_http.py similarity index 76% rename from packages/netx-mcp/tests/test_mcp_http.py rename to tests/mcp_remote/test_mcp_http.py index 6393825..4f19ea4 100644 --- a/packages/netx-mcp/tests/test_mcp_http.py +++ b/tests/mcp_remote/test_mcp_http.py @@ -1,16 +1,14 @@ -"""Tests for netx HTTP MCP server.""" +"""Tests for netx ops MCP tools (in-process handlers + /mcp).""" from __future__ import annotations import json -import subprocess -import sys from unittest.mock import patch import pytest -from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes -from netx_mcp.server import _fetch_scopes +from netx_api.mcp_remote.ops.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes +from netx_api.mcp_remote.registry import tools_for_request def test_http_mcp_tool_list_has_expected_tools() -> None: @@ -31,7 +29,7 @@ def test_http_mcp_tool_list_has_expected_tools() -> None: def test_call_query_nms_alarms_forwards_http() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"total": 0, "items": []}} out = call_http_tool("queryNmsAlarms", {"severity": "critical", "page": 1, "page_size": 10}) mock_http.assert_called_once() @@ -45,7 +43,7 @@ def test_call_query_nms_alarms_forwards_http() -> None: def test_call_aggregate_nms_alarms_forwards_top_ne() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": {"total": 10, "by_severity": [], "by_ne": [], "by_ne_total": 3, "top_ne": 20}, @@ -62,7 +60,7 @@ def test_call_aggregate_nms_alarms_forwards_top_ne() -> None: def test_call_aggregate_nms_alarms_group_by_routes_to_raw() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"buckets": []}} out = call_http_tool( "aggregateNmsAlarms", @@ -88,7 +86,7 @@ def test_aggregate_nms_alarms_schema_accepts_group_by() -> None: def test_call_find_topology_paths_defaults_summary_detail() -> None: - with patch("netx_mcp.http_tools.http_post_json") as mock_post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"path_count": 1, "detail": "summary", "paths": []}} out = call_http_tool( "findTopologyPaths", @@ -104,7 +102,7 @@ def test_call_find_topology_paths_defaults_summary_detail() -> None: def test_call_exec_managed_ne_defaults_read_timeout() -> None: - with patch("netx_mcp.http_tools.http_post_json") as mock_post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "hi"}} out = call_http_tool( "execManagedNe", @@ -134,7 +132,7 @@ def test_call_get_nms_ne_requires_id() -> None: def test_call_exec_managed_ne_posts_body() -> None: - with patch("netx_mcp.http_tools.http_post_json") as mock_post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "ok"}} out = call_http_tool( "execManagedNe", @@ -151,7 +149,7 @@ def test_call_exec_managed_ne_posts_body() -> None: def test_call_exec_managed_ne_batch_ne_ids() -> None: - with patch("netx_mcp.http_tools.http_post_json") as mock_post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as mock_post: mock_post.return_value = { "ok": True, "data": { @@ -189,7 +187,7 @@ def test_exec_managed_ne_schema_documents_batch() -> None: def test_call_exec_managed_ne_respects_max_commands_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "10") cmds = [f"show version {i}" for i in range(6)] - with patch("netx_mcp.http_tools.http_post_json") as mock_post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "ok"}} out = call_http_tool("execManagedNe", {"ne_id": "abc", "commands": cmds}) mock_post.assert_called_once() @@ -205,21 +203,6 @@ def test_call_exec_managed_ne_respects_max_commands_env(monkeypatch: pytest.Monk assert payload.get("error_code") == "too_many_commands" -def test_fetch_scopes_unwraps_http_json_envelope() -> None: - with patch("netx_mcp.server.http_json") as mock_http: - mock_http.return_value = { - "ok": True, - "data": {"scopes": ["ne:read", "alarms:read"], "user": {"username": "mcp"}}, - } - assert _fetch_scopes() == ["ne:read", "alarms:read"] - - -def test_fetch_scopes_returns_none_on_http_failure() -> None: - with patch("netx_mcp.server.http_json") as mock_http: - mock_http.return_value = {"ok": False, "error": "netx_http_401"} - assert _fetch_scopes() is None - - def test_tools_for_scopes_filters_by_granted() -> None: names = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])} assert "listManagedNe" in names @@ -227,66 +210,19 @@ def test_tools_for_scopes_filters_by_granted() -> None: assert tools_for_scopes(None) == list(HTTP_MCP_TOOLS) -def test_stdio_initialize_and_tools_list() -> None: - import os - - env = os.environ.copy() - env["NETX_API_URL"] = "http://127.0.0.1:1" - env.pop("NETX_API_TOKEN", None) - proc = subprocess.Popen( - [sys.executable, "-m", "netx_mcp"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - env=env, - ) - assert proc.stdin is not None and proc.stdout is not None - try: - init = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "0"}, - }, - } - proc.stdin.write(json.dumps(init) + "\n") - proc.stdin.flush() - line = proc.stdout.readline() - assert line - msg = json.loads(line) - assert msg.get("id") == 1 - assert "result" in msg - - # Notifications must not be answered; the next line belongs to ping. - for note in ("notifications/initialized", "notifications/cancelled"): - proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": note, "params": {}}) + "\n") - proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 9, "method": "ping"}) + "\n") - proc.stdin.flush() - pong = json.loads(proc.stdout.readline()) - assert pong == {"jsonrpc": "2.0", "id": 9, "result": {}} - - proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + "\n") - proc.stdin.flush() - line2 = proc.stdout.readline() - assert line2 - listed = json.loads(line2) - tools = listed["result"]["tools"] - names = {t["name"] for t in tools} - assert "queryNmsAlarms" in names - assert "execManagedNe" in names - finally: - proc.kill() - proc.wait(timeout=5) +def test_registry_groups_and_scope_filter() -> None: + ops = tools_for_request(None, frozenset({"ops"})) + assert len(ops) == 15 + assert all(t["_group"] == "ops" for t in ops) + limited = tools_for_request(frozenset({"ne:read"}), frozenset({"ops"})) + names = {t["name"] for t in limited} + assert "listManagedNe" in names + assert "execManagedNe" not in names + assert "queryNmsAlarms" not in names def test_bad_integer_arg_is_tool_error_not_crash() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: out = call_http_tool("queryNmsAlarms", {"page": "two"}) mock_http.assert_not_called() assert out["isError"] is True @@ -296,7 +232,7 @@ def test_bad_integer_arg_is_tool_error_not_crash() -> None: def test_query_nms_alarms_pages_past_two_and_normalizes_severity() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": {"items": [{"ne_id": "u-1", "ne_name": "ME{u-1}", "host_name": "H1"}]}, @@ -312,14 +248,14 @@ def test_query_nms_alarms_pages_past_two_and_normalizes_severity() -> None: def test_nested_api_failure_is_marked_error() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"ok": False, "error": "job_not_found"}} out = call_http_tool("getNeExecJob", {"job_id": "nope"}) assert out.get("isError") is True def test_raw_query_strips_echoed_field_lists() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": { @@ -343,20 +279,20 @@ def test_get_nms_ne_resolves_host_name() -> None: return {"ok": True, "data": {"ne_id": "uuid-1"}} return {"ok": False, "error": "netx_http_404", "detail": "ume_ne_not_found"} - with patch("netx_mcp.http_tools.http_json", side_effect=fake): + with patch("netx_api.mcp_remote.ops.http_tools.http_json", side_effect=fake): out = call_http_tool("getNmsNe", {"ne_id": "mdn-x-an1"}) assert json.loads(out["content"][0]["text"])["data"]["ne_id"] == "uuid-1" assert calls[-1] == "/v1/ume/inventory/ne/uuid-1" def test_cli_targets_and_paths_use_nms_naming() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"items": [{"source": "ume", "ume_ne_id": "u-1"}]}} out = call_http_tool("listCliTargets", {"source": "nms"}) row = json.loads(out["content"][0]["text"])["data"]["items"][0] assert row == {"source": "nms", "nms_ne_id": "u-1"} - with patch("netx_mcp.http_tools.http_post_json") as mock_post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as mock_post: mock_post.return_value = {"ok": True, "data": {"paths": [{"nodes": [{"name": "A", "ume_ne_id": "u-1"}]}]}} out = call_http_tool("findTopologyPaths", {"from_nms_ne_id": "u-1", "to_nms_ne_id": "u-2"}) node = json.loads(out["content"][0]["text"])["data"]["paths"][0]["nodes"][0] @@ -364,7 +300,7 @@ def test_cli_targets_and_paths_use_nms_naming() -> None: def test_managed_ne_list_drops_disabled_hop_noise() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": {"items": [{"id": "m1", "hop_enabled": False, "hop_vrf": "", "connect_detail": "long"}]}, @@ -386,7 +322,7 @@ def test_managed_ne_list_drops_disabled_hop_noise() -> None: ("listCliTargets", {"source": "ume"}), ]) def test_obsolete_aliases_are_rejected_before_http(name: str, args: dict) -> None: - with patch("netx_mcp.http_tools.http_json") as get, patch("netx_mcp.http_tools.http_post_json") as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as get, patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as post: out = call_http_tool(name, args) get.assert_not_called() post.assert_not_called() @@ -396,7 +332,7 @@ def test_obsolete_aliases_are_rejected_before_http(name: str, args: dict) -> Non def test_all_fifteen_tools_match_backend_scope_policy() -> None: from netx_api.auth_scopes import required_scope_for_request - from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE + from netx_api.mcp_remote.ops.http_tools import TOOL_REQUIRED_SCOPE routes = { "queryNmsAlarms": ("GET", "/v1/ume/alarms"), @@ -431,14 +367,14 @@ def test_all_fifteen_tools_match_backend_scope_policy() -> None: ("listCliTargets", {"source": "nms", "keyword": "edge"}, "/v1/cli/targets", {"source": "ume", "keyword": "edge", "page": 1, "page_size": 50}), ]) def test_ops_read_routes_and_backend_limits(name: str, args: dict, path: str, expected: dict | None) -> None: - with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"items": []}}) as get: + with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": {"items": []}}) as get: out = call_http_tool(name, args) get.assert_called_once_with("GET", path, params=expected) assert not out.get("isError") def test_raw_aggregation_filters_and_compaction() -> None: - with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"buckets": [{"key": "edge", "count": 4}], "meta": {"group_by_allowed": ["large"], "time_filter_field": "last_seen_at"}}}) as get: + with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": {"buckets": [{"key": "edge", "count": 4}], "meta": {"group_by_allowed": ["large"], "time_filter_field": "last_seen_at"}}}) as get: out = call_http_tool("aggregateNmsAlarmsRaw", {"group_by": "alarm_host_name", "severity": "CRITICAL", "exclude_missing_host": False, "limit": 9999}) assert get.call_args.kwargs["params"] == {"group_by": "alarm_host_name", "severity": "critical", "exclude_missing_host": False, "limit": 2000} data = json.loads(out["content"][0]["text"])["data"] @@ -447,7 +383,7 @@ def test_raw_aggregation_filters_and_compaction() -> None: def test_sql_limits_are_forwarded_and_denial_is_tool_error() -> None: - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": False, "error": "netx_http_403", "detail": "insufficient_scope:sql:query"}) as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": False, "error": "netx_http_403", "detail": "insufficient_scope:sql:query"}) as post: out = call_http_tool("sqlQueryNms", {"sql": " SELECT ne_id FROM ume_inventory_ne ", "limit": 9999, "statement_timeout_ms": 99999}) post.assert_called_once_with("/v1/sql/ume_query", {"sql": "SELECT ne_id FROM ume_inventory_ne", "limit": 2000, "statement_timeout_ms": 30000}, timeout=60.0) assert out["isError"] is True @@ -463,7 +399,7 @@ def test_sql_limits_are_forwarded_and_denial_is_tool_error() -> None: def test_structured_credentials_are_removed_but_capabilities_remain(name: str, args: dict) -> None: row = {"id": "m1", "password": "secret", "has_password": True, "capability": {"recommended_mode": "show_only", "token": "secret"}, "hop_password_enc": "secret"} data = {"items": [row]} if name.startswith("list") else row - with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": data}): + with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": data}): out = call_http_tool(name, args) text = out["content"][0]["text"] assert "secret" not in text @@ -482,7 +418,7 @@ def test_structured_credentials_are_removed_but_capabilities_remain(name: str, a ]) def test_invalid_exec_batches_do_not_reach_http(args: dict, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "5") - with patch("netx_mcp.http_tools.http_post_json") as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json") as post: out = call_http_tool("execManagedNe", args) post.assert_not_called() assert out["isError"] is True @@ -490,13 +426,13 @@ def test_invalid_exec_batches_do_not_reach_http(args: dict, monkeypatch: pytest. def test_async_auto_counts_all_target_forms_and_poll_preserves_terminal_result(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "4") - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "job_id": "j1", "terminal": False}}) as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "job_id": "j1", "terminal": False}}) as post: ack = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "concurrency": 20}) assert post.call_args.args[0] == "/v1/managed-ne/exec-jobs" assert post.call_args.args[1]["concurrency"] == 8 assert json.loads(ack["content"][0]["text"])["job_id"] == "j1" terminal = {"ok": True, "terminal": True, "result": {"ok": True, "results": [{"ok": False, "error": "timeout"}]}} - with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}): + with patch("netx_api.mcp_remote.ops.http_tools.http_json", return_value={"ok": True, "data": terminal}): out = call_http_tool("getNeExecJob", {"job_id": "j1"}) assert json.loads(out["content"][0]["text"])["data"] == terminal assert out["isError"] is True @@ -504,34 +440,29 @@ def test_async_auto_counts_all_target_forms_and_poll_preserves_terminal_result(m def test_explicit_sync_overrides_auto_async(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "1") - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post: call_http_tool("execManagedNe", {"nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "async": False}) assert post.call_args.args[0] == "/v1/managed-ne/exec-batch" -def test_stdio_scope_denial_never_invokes_handler() -> None: - import io - from netx_mcp.server import run_stdio_loop - - request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "execManagedNe", "arguments": {"ne_id": "m1", "commands": ["show version"]}}} - output = io.StringIO() - with patch("netx_mcp.server.sys.stdin", io.StringIO(json.dumps(request) + "\n")), patch("netx_mcp.server.sys.stdout", output), patch("netx_mcp.server._fetch_scopes", return_value=["ne:read"]), patch("netx_mcp.server.call_http_tool") as handler: - run_stdio_loop() - handler.assert_not_called() - response = json.loads(output.getvalue()) - assert response["error"]["code"] == -32001 - assert "ne:exec" in response["error"]["message"] +def test_scope_filter_hides_exec_without_ne_exec() -> None: + names = {t["name"] for t in tools_for_request(frozenset({"ne:read"}), frozenset({"ops"}))} + assert "execManagedNe" not in names + assert "getNeExecJob" not in names @pytest.mark.parametrize("method", ["GET", "POST"]) @pytest.mark.parametrize("status", [401, 403, 404, 500]) def test_http_errors_preserve_status_and_bound_detail(method: str, status: int) -> None: import httpx - from netx_mcp.http_client import http_json, http_post_json + from netx_api.mcp_remote.asgi_client import http_json, http_post_json transport = httpx.MockTransport(lambda request: httpx.Response(status, text="denied" * 300)) - client = httpx.Client(transport=transport) - with patch("netx_mcp.http_client.httpx.Client", return_value=client): + client = httpx.Client(transport=transport, base_url="http://example.test") + with ( + patch("netx_api.mcp_remote.asgi_client.get_app", return_value=None), + patch("netx_api.mcp_remote.asgi_client.httpx.Client", return_value=client), + ): result = http_json("GET", "/v1/ume/alarms") if method == "GET" else http_post_json("/v1/managed-ne/exec", {}) assert result["ok"] is False assert result["error"] == f"netx_http_{status}" @@ -540,7 +471,7 @@ def test_http_errors_preserve_status_and_bound_detail(method: str, status: int) def test_mixed_exec_targets_translate_nms_ids_and_preserve_per_target_commands() -> None: - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "items": [{"ok": True, "password": "secret", "output": "version"}]}}) as post: + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "items": [{"ok": True, "password": "secret", "output": "version"}]}}) as post: out = call_http_tool("execManagedNe", {"targets": [{"ne_id": "m1"}, {"nms_ne_id": "u1", "commands": ["display version"]}], "commands": ["show version"], "async": False}) assert post.call_args.args[0] == "/v1/managed-ne/exec-batch" assert post.call_args.args[1]["targets"] == [{"ne_id": "m1"}, {"ume_ne_id": "u1", "commands": ["display version"]}] @@ -550,7 +481,7 @@ def test_mixed_exec_targets_translate_nms_ids_and_preserve_per_target_commands() def test_async_job_queue_failure_is_not_a_successful_ack() -> None: - with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": False, "error": "ne_exec_job_busy"}}): + with patch("netx_api.mcp_remote.ops.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": False, "error": "ne_exec_job_busy"}}): out = call_http_tool("execManagedNe", {"ne_id": "m1", "commands": ["show version"], "async": True}) assert out["isError"] is True assert json.loads(out["content"][0]["text"])["error"] == "ne_exec_job_busy" @@ -558,7 +489,7 @@ def test_async_job_queue_failure_is_not_a_successful_ack() -> None: @pytest.mark.parametrize("args", [{}, {"group_by": ""}]) def test_raw_aggregation_missing_dimension_is_actionable(args: dict) -> None: - with patch("netx_mcp.http_tools.http_json") as get: + with patch("netx_api.mcp_remote.ops.http_tools.http_json") as get: out = call_http_tool("aggregateNmsAlarmsRaw", args) get.assert_not_called() assert out["isError"] is True diff --git a/tests/mcp_remote/test_mcp_tcp_framing.py b/tests/mcp_remote/test_mcp_tcp_framing.py new file mode 100644 index 0000000..68efa1e --- /dev/null +++ b/tests/mcp_remote/test_mcp_tcp_framing.py @@ -0,0 +1,189 @@ +"""Real-TCP regression: /mcp must not emit TE:chunked with an unframed body.""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +import httpx +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PY = Path(os.environ.get("NETX_TEST_PYTHON") or sys.executable) + + +def _free_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = int(s.getsockname()[1]) + s.close() + return port + + +def _wait_health(port: int, timeout: float = 45.0) -> None: + deadline = time.time() + timeout + url = f"http://127.0.0.1:{port}/health" + last = "" + while time.time() < deadline: + try: + r = httpx.get(url, timeout=1.0, trust_env=False) + if r.status_code == 200: + return + last = f"status={r.status_code}" + except Exception as exc: + last = str(exc) + time.sleep(0.2) + raise TimeoutError(f"health not ready on {port}: {last}") + + +def _raw_post(port: int, path: str, body: bytes, headers: dict[str, str] | None = None) -> bytes: + hdrs = { + "Host": f"127.0.0.1:{port}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Connection": "close", + "Content-Length": str(len(body)), + } + if headers: + hdrs.update(headers) + lines = [f"POST {path} HTTP/1.1"] + [f"{k}: {v}" for k, v in hdrs.items()] + req = ("\r\n".join(lines) + "\r\n\r\n").encode() + body + s = socket.create_connection(("127.0.0.1", port), 10) + s.sendall(req) + s.settimeout(10) + data = b"" + try: + while True: + chunk = s.recv(65536) + if not chunk: + break + data += chunk + except TimeoutError: + pass + finally: + s.close() + return data + + +def _assert_framed(raw: bytes) -> None: + head, _, rest = raw.partition(b"\r\n\r\n") + assert head.startswith(b"HTTP/1.1 "), head[:80] + hl = head.lower() + has_cl = b"content-length:" in hl + has_te = b"transfer-encoding: chunked" in hl + assert has_cl or has_te, f"missing framing headers: {head!r}" + if has_te and not has_cl: + first = rest.split(b"\r\n", 1)[0] + try: + int(first, 16) + except ValueError as exc: + raise AssertionError( + f"TE:chunked but body is not chunk-framed (first line={first!r})" + ) from exc + assert b"\r\n0\r\n" in rest or rest.rstrip().endswith(b"0"), rest[-40:] + + +@pytest.fixture(scope="module") +def live_mcp_port(): + port = _free_port() + tmp = Path(tempfile.mkdtemp(prefix="netx-mcp-tcp-")) + env = os.environ.copy() + env.update( + { + "NETX_DATABASE_URL": f"sqlite:///{(tmp / 'netx.sqlite').as_posix()}", + "NETX_AUTH_SECRET_FILE": str(tmp / "jwt_secret"), + "NETX_AUTH_MCP_TOKEN_FILE": str(tmp / "mcp_token"), + "NETX_CREDENTIAL_SECRET_FILE": str(tmp / "cred_secret"), + "NETX_NE_COLLECTION_DATA_DIR": str(tmp / "ne_collections"), + "NETX_BIZ_STATE_SPOOL_DIR": str(tmp / "biz_spool"), + "NETX_NE_EXEC_JOB_DIR": str(tmp / "ne_exec_jobs"), + "NETX_WEBCRT_DATA_DIR": str(tmp / "webcrt"), + "NETX_SCHEDULER_HEARTBEAT_PATH": str(tmp / "scheduler_heartbeat.json"), + "NETX_DOCS_ENABLED": "false", + "NETX_AUTH_ENABLED": "false", + "NETX_ALLOW_INSECURE_DEFAULTS": "true", + "NETX_RUN_INLINE_SCHEDULERS": "false", + "PYTHONUTF8": "1", + # Avoid Windows system proxy (httpx trust_env) returning 502 on loopback. + "NO_PROXY": "127.0.0.1,localhost", + "no_proxy": "127.0.0.1,localhost", + } + ) + proc = subprocess.Popen( + [ + str(PY), + "-m", + "uvicorn", + "netx_api.main:app", + "--host", + "127.0.0.1", + "--port", + str(port), + "--http", + "h11", + ], + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + try: + _wait_health(port) + yield port + finally: + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +def test_mcp_tcp_initialize_framing(live_mcp_port: int): + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "tcp-framing", "version": "0"}, + }, + } + ).encode() + raw = _raw_post(live_mcp_port, "/mcp", body) + _assert_framed(raw) + assert b'"serverInfo"' in raw or b"serverInfo" in raw or b"server_info" in raw + + +def test_mcp_tcp_httpx_initialize(live_mcp_port: int): + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "httpx-framing", "version": "0"}, + }, + } + r = httpx.post( + f"http://127.0.0.1:{live_mcp_port}/mcp", + json=body, + headers={ + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": "2024-11-05", + }, + timeout=30.0, + trust_env=False, + ) + assert r.status_code == 200, (r.status_code, r.text[:300]) + data = r.json() + assert data["result"]["serverInfo"]["name"] == "netx" diff --git a/packages/netx-topology-mcp/tests/conftest.py b/tests/mcp_remote/topology/conftest.py similarity index 100% rename from packages/netx-topology-mcp/tests/conftest.py rename to tests/mcp_remote/topology/conftest.py diff --git a/packages/netx-topology-mcp/tests/test_align_reference.py b/tests/mcp_remote/topology/test_align_reference.py similarity index 91% rename from packages/netx-topology-mcp/tests/test_align_reference.py rename to tests/mcp_remote/topology/test_align_reference.py index 6772878..9121197 100644 --- a/packages/netx-topology-mcp/tests/test_align_reference.py +++ b/tests/mcp_remote/topology/test_align_reference.py @@ -2,8 +2,8 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops.align_reference import align_to_reference -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.align_reference import align_to_reference +from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges def test_align_similarity_maps_shared_nodes_to_portal_frame(): diff --git a/packages/netx-topology-mcp/tests/test_bundle_orbit.py b/tests/mcp_remote/topology/test_bundle_orbit.py similarity index 95% rename from packages/netx-topology-mcp/tests/test_bundle_orbit.py rename to tests/mcp_remote/topology/test_bundle_orbit.py index 07799cb..b3d4513 100644 --- a/packages/netx-topology-mcp/tests/test_bundle_orbit.py +++ b/tests/mcp_remote/topology/test_bundle_orbit.py @@ -2,16 +2,16 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.bundle_orbit import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.bundle_orbit import ( detect_chain_bundles, detect_ring_chain_bundles, orbit_bundle, apply_bundle_pick, bundle_orbit_until_progress, ) -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.orbit_sweep import ( +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 ( orbit_params_from_overrides, orbit_sweep_until_limit, ) diff --git a/packages/netx-topology-mcp/tests/test_channel_metro.py b/tests/mcp_remote/topology/test_channel_metro.py similarity index 89% rename from packages/netx-topology-mcp/tests/test_channel_metro.py rename to tests/mcp_remote/topology/test_channel_metro.py index 7c2f02a..b09e05b 100644 --- a/packages/netx-topology-mcp/tests/test_channel_metro.py +++ b/tests/mcp_remote/topology/test_channel_metro.py @@ -2,11 +2,11 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe -from netx_topology_mcp.layout_ops.channel_metro import build_channel_metro_skeleton -from netx_topology_mcp.layout_ops.channels import extract_channels, place_channel_ray -from netx_topology_mcp.layout_ops.ring_faces import place_ring_rectangle -from netx_topology_mcp.layout_tool import RECIPE_ALIASES, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe +from netx_api.mcp_remote.topology.layout_ops.channel_metro import build_channel_metro_skeleton +from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels, place_channel_ray +from netx_api.mcp_remote.topology.layout_ops.ring_faces import place_ring_rectangle +from netx_api.mcp_remote.topology.layout_tool import RECIPE_ALIASES, run_layout_on_graph def _core_bar_channels(): @@ -64,7 +64,7 @@ def test_place_channel_ray() -> None: def test_ring_rectangle() -> None: - from netx_topology_mcp.layout_ops.ring_faces import RingFace + from netx_api.mcp_remote.topology.layout_ops.ring_faces import RingFace face = RingFace(("a", "b", "c", "d")) pos = place_ring_rectangle(face, center=(0, 0), width=200, height=100) diff --git a/packages/netx-topology-mcp/tests/test_clear_edge_hits.py b/tests/mcp_remote/topology/test_clear_edge_hits.py similarity index 93% rename from packages/netx-topology-mcp/tests/test_clear_edge_hits.py rename to tests/mcp_remote/topology/test_clear_edge_hits.py index b5a0cef..d7da397 100644 --- a/packages/netx-topology-mcp/tests/test_clear_edge_hits.py +++ b/tests/mcp_remote/topology/test_clear_edge_hits.py @@ -2,15 +2,15 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( EDGE_CLEARANCE_THR, compute_edge_clearance, count_edge_crossings, ) -from netx_topology_mcp.layout_ops.clear_edge_hits import clear_edge_hits -from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState -from netx_topology_mcp.layout_stats import analyze_layout_stats, score_layout_components -from netx_topology_mcp.layout_tool import run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import clear_edge_hits +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState +from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats, score_layout_components +from netx_api.mcp_remote.topology.layout_tool import run_layout_on_graph def _mksr_style() -> tuple[list[dict], list[dict]]: diff --git a/packages/netx-topology-mcp/tests/test_compact_bbox.py b/tests/mcp_remote/topology/test_compact_bbox.py similarity index 80% rename from packages/netx-topology-mcp/tests/test_compact_bbox.py rename to tests/mcp_remote/topology/test_compact_bbox.py index cc46d4f..66d639e 100644 --- a/packages/netx-topology-mcp/tests/test_compact_bbox.py +++ b/tests/mcp_remote/topology/test_compact_bbox.py @@ -2,10 +2,10 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.compact_bbox import compact_bbox -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops.compact_bbox import compact_bbox +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 _has_any_footprint_overlap def test_compact_bbox_shrinks_without_raising_crossings(): diff --git a/packages/netx-topology-mcp/tests/test_compose_orbit.py b/tests/mcp_remote/topology/test_compose_orbit.py similarity index 92% rename from packages/netx-topology-mcp/tests/test_compose_orbit.py rename to tests/mcp_remote/topology/test_compose_orbit.py index 6317a6c..912127d 100644 --- a/packages/netx-topology-mcp/tests/test_compose_orbit.py +++ b/tests/mcp_remote/topology/test_compose_orbit.py @@ -2,15 +2,15 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.compose_orbit import ( +from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.compose_orbit import ( compose_orbit_into_state, compose_orbit_params_from_overrides, crossings_touching, orbit_pack_blocks, ) -from netx_topology_mcp.layout_ops.compose_views import ComposeBlock -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def test_actions_include_compose_orbit() -> None: diff --git a/packages/netx-topology-mcp/tests/test_compose_views.py b/tests/mcp_remote/topology/test_compose_views.py similarity index 97% rename from packages/netx-topology-mcp/tests/test_compose_views.py rename to tests/mcp_remote/topology/test_compose_views.py index 2dff15b..bccfc1e 100644 --- a/packages/netx-topology-mcp/tests/test_compose_views.py +++ b/tests/mcp_remote/topology/test_compose_views.py @@ -4,14 +4,14 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.compose_views import ( +from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.compose_views import ( ComposeBlock, compose_into_state, compose_params_from_overrides, strip_pack_blocks, ) -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def test_actions_include_compose_views() -> None: diff --git a/packages/netx-topology-mcp/tests/test_core_beam.py b/tests/mcp_remote/topology/test_core_beam.py similarity index 94% rename from packages/netx-topology-mcp/tests/test_core_beam.py rename to tests/mcp_remote/topology/test_core_beam.py index 1d29a76..ed1ec7d 100644 --- a/packages/netx-topology-mcp/tests/test_core_beam.py +++ b/tests/mcp_remote/topology/test_core_beam.py @@ -2,8 +2,8 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.sugiyama import _place_component +from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.sugiyama import _place_component def _dual_core_multi_an(): diff --git a/packages/netx-topology-mcp/tests/test_densify_sweep.py b/tests/mcp_remote/topology/test_densify_sweep.py similarity index 93% rename from packages/netx-topology-mcp/tests/test_densify_sweep.py rename to tests/mcp_remote/topology/test_densify_sweep.py index 7ee2482..fc56856 100644 --- a/packages/netx-topology-mcp/tests/test_densify_sweep.py +++ b/tests/mcp_remote/topology/test_densify_sweep.py @@ -2,17 +2,17 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.densify_sweep import ( +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_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.orbit_sweep import _incident_stretch -from netx_topology_mcp.layout_ops.state import LayoutParams -from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph +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(): @@ -186,7 +186,7 @@ def test_catalog_omits_densify_sweep() -> None: def test_densify_default_protect_off_may_move_shared_portal() -> None: - from netx_topology_mcp.layout_ops.densify_sweep import densify_params_from_overrides + 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" diff --git a/packages/netx-topology-mcp/tests/test_dual_mass.py b/tests/mcp_remote/topology/test_dual_mass.py similarity index 93% rename from packages/netx-topology-mcp/tests/test_dual_mass.py rename to tests/mcp_remote/topology/test_dual_mass.py index e3b353b..735aef4 100644 --- a/packages/netx-topology-mcp/tests/test_dual_mass.py +++ b/tests/mcp_remote/topology/test_dual_mass.py @@ -4,16 +4,16 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops import build_state_from_nodes_edges, run_recipe -from netx_topology_mcp.layout_ops.dual_mass import layout_dual_mass -from netx_topology_mcp.layout_ops.dual_units import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges, run_recipe +from netx_api.mcp_remote.topology.layout_ops.dual_mass import layout_dual_mass +from netx_api.mcp_remote.topology.layout_ops.dual_units import ( DualUnit, beautify_dual_unit_positions, classify_dual_unit, ) -from netx_topology_mcp.layout_ops.state import LayoutParams -from netx_topology_mcp.layout_tool import ACTIONS, RECIPE_ALIASES, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, RECIPE_ALIASES, run_layout_on_graph def _two_eye_graph(): diff --git a/packages/netx-topology-mcp/tests/test_dual_units.py b/tests/mcp_remote/topology/test_dual_units.py similarity index 95% rename from packages/netx-topology-mcp/tests/test_dual_units.py rename to tests/mcp_remote/topology/test_dual_units.py index 4ca5305..efc6704 100644 --- a/packages/netx-topology-mcp/tests/test_dual_units.py +++ b/tests/mcp_remote/topology/test_dual_units.py @@ -2,16 +2,16 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, strip_pack_blocks -from netx_topology_mcp.layout_ops.dual_units import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, strip_pack_blocks +from netx_api.mcp_remote.topology.layout_ops.dual_units import ( find_dual_portal_units, layout_dual_unit, ) -from netx_topology_mcp.layout_ops.state import LayoutParams -from netx_topology_mcp.layout_structure import analyze_graph_structure -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams +from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def _eye_graph(): diff --git a/packages/netx-topology-mcp/tests/test_edge_axis.py b/tests/mcp_remote/topology/test_edge_axis.py similarity index 91% rename from packages/netx-topology-mcp/tests/test_edge_axis.py rename to tests/mcp_remote/topology/test_edge_axis.py index 334279d..b3c5c8b 100644 --- a/packages/netx-topology-mcp/tests/test_edge_axis.py +++ b/tests/mcp_remote/topology/test_edge_axis.py @@ -2,8 +2,8 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import compute_edge_axis -from netx_topology_mcp.layout_stats import analyze_layout_stats +from netx_api.mcp_remote.topology.layout_metrics import compute_edge_axis +from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats def test_all_horizontal_scores_one() -> None: diff --git a/packages/netx-topology-mcp/tests/test_fold_chain.py b/tests/mcp_remote/topology/test_fold_chain.py similarity index 90% rename from packages/netx-topology-mcp/tests/test_fold_chain.py rename to tests/mcp_remote/topology/test_fold_chain.py index 3e66829..cf61646 100644 --- a/packages/netx-topology-mcp/tests/test_fold_chain.py +++ b/tests/mcp_remote/topology/test_fold_chain.py @@ -4,10 +4,10 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops.fold_chain import fold_chain_into_sector -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.state import LayoutParams -from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.fold_chain import fold_chain_into_sector +from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges +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 _star_with_tentacle() -> tuple[list[dict], list[dict]]: diff --git a/packages/netx-topology-mcp/tests/test_force_densify.py b/tests/mcp_remote/topology/test_force_densify.py similarity index 91% rename from packages/netx-topology-mcp/tests/test_force_densify.py rename to tests/mcp_remote/topology/test_force_densify.py index 6f2a123..7d56193 100644 --- a/packages/netx-topology-mcp/tests/test_force_densify.py +++ b/tests/mcp_remote/topology/test_force_densify.py @@ -4,11 +4,11 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops.force_densify import force_densify_round -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_tool import ACTIONS +from netx_api.mcp_remote.topology.layout_ops.force_densify import force_densify_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.rigid_units import groups_from_membership +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_tool import ACTIONS def test_actions_include_force_densify() -> None: diff --git a/packages/netx-topology-mcp/tests/test_hierarchy.py b/tests/mcp_remote/topology/test_hierarchy.py similarity index 93% rename from packages/netx-topology-mcp/tests/test_hierarchy.py rename to tests/mcp_remote/topology/test_hierarchy.py index ab70abe..c9bf1b5 100644 --- a/packages/netx-topology-mcp/tests/test_hierarchy.py +++ b/tests/mcp_remote/topology/test_hierarchy.py @@ -2,16 +2,16 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.hierarchy import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.hierarchy import ( _candidate_orders, _contracted_crossings, _contracted_links, _stub_territories, hierarchy_sectors_greedy, ) -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def _crossed_star(): diff --git a/packages/netx-topology-mcp/tests/test_hotspots.py b/tests/mcp_remote/topology/test_hotspots.py similarity index 94% rename from packages/netx-topology-mcp/tests/test_hotspots.py rename to tests/mcp_remote/topology/test_hotspots.py index 2dbeddb..958297d 100644 --- a/packages/netx-topology-mcp/tests/test_hotspots.py +++ b/tests/mcp_remote/topology/test_hotspots.py @@ -4,14 +4,14 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops import ( +from netx_api.mcp_remote.topology.layout_ops import ( LayoutParams, build_state_from_nodes_edges, fix_overlaps_local, relax_hotspots, ) -from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes -from netx_topology_mcp.layout_tool import run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes +from netx_api.mcp_remote.topology.layout_tool import run_layout_on_graph def test_fix_overlaps_local_pulls_apart() -> None: @@ -68,9 +68,9 @@ def test_run_layout_action_pack_utilization_unpublished() -> None: def test_pack_utilization_transform_still_works() -> None: - from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges - from netx_topology_mcp.layout_ops.state import LayoutParams - from netx_topology_mcp.layout_ops.transforms import pack_utilization + from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges + from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams + from netx_api.mcp_remote.topology.layout_ops.transforms import pack_utilization nodes = [ {"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0}, @@ -103,7 +103,7 @@ def test_pack_utilization_transform_still_works() -> None: def test_shrink_long_corridors_pulls_islands() -> None: - from netx_topology_mcp.layout_ops.rigid_units import shrink_long_corridors + from netx_api.mcp_remote.topology.layout_ops.rigid_units import shrink_long_corridors # Two dense islands connected by one long bridge. nodes = [ @@ -153,7 +153,7 @@ def test_shrink_long_corridors_pulls_islands() -> None: def test_shrink_long_corridors_unit_exclusive() -> None: - from netx_topology_mcp.layout_ops.rigid_units import shrink_long_corridors + from netx_api.mcp_remote.topology.layout_ops.rigid_units import shrink_long_corridors # Shared portal + two exclusive clusters far apart (metro dual-unit shape). nodes = [ @@ -211,7 +211,7 @@ def test_shrink_long_corridors_unit_exclusive() -> None: def test_densify_rigid_groups_raises_util() -> None: - from netx_topology_mcp.layout_ops.rigid_units import densify_rigid_groups + from netx_api.mcp_remote.topology.layout_ops.rigid_units import densify_rigid_groups nodes = [ {"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 5000, "y": 5000}, diff --git a/tests/mcp_remote/topology/test_layout_jobs.py b/tests/mcp_remote/topology/test_layout_jobs.py new file mode 100644 index 0000000..f7f68e8 --- /dev/null +++ b/tests/mcp_remote/topology/test_layout_jobs.py @@ -0,0 +1,262 @@ +"""Background layout jobs + slim payload helpers.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +from netx_api.mcp_remote.topology.http_tools import _layout_topology_view, _slim_layout_payload +from netx_api.mcp_remote.topology.layout_jobs import ( + cancel_job, + get_job, + is_cancelled, + job_public, + report_progress, + start_job, +) + + +def test_terminal_job_survives_cache_reset_and_reports_failure(tmp_path) -> None: + from netx_api.mcp_remote.topology import layout_jobs as lj + + lj._write_job_disk({"job_id": "persisted", "status": "running", "started_at": time.time()}) + lj.finish_job("persisted", result={"ok": False, "error": "backend_write_failed", "applied": False}) + lj._JOBS.clear() + out = _layout_topology_view({"action": "job_status", "params": {"job_id": "persisted"}}) + assert out["ok"] is False + assert out["status"] == "error" + assert out["error"] == "backend_write_failed" + assert out["result"]["applied"] is False + + +def test_spawn_failure_is_persisted_as_terminal_error() -> None: + from netx_api.mcp_remote.topology import layout_jobs as lj + + with patch.object(lj.subprocess, "Popen", side_effect=OSError("spawn denied")): + jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"}) + lj._JOBS.clear() + out = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}}) + assert out["ok"] is False + assert out["status"] == "error" + assert out["error"].startswith("worker_start_failed:") + assert not lj._args_path(jid).exists() + + +def test_fast_worker_terminal_progress_is_not_overwritten() -> None: + from types import SimpleNamespace + from netx_api.mcp_remote.topology import layout_jobs as lj + + def spawn(argv, **kwargs): + jid = argv[-1] + lj.finish_job(jid, result={"ok": True, "applied": True}) + return SimpleNamespace(pid=123) + + with patch.object(lj.subprocess, "Popen", side_effect=spawn): + jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"}) + out = lj.job_public(jid) + assert out["status"] == "done" + assert out["progress"]["phase"] == "done" + assert out["progress"]["pct"] == 100.0 + + +def test_pruning_preserves_active_jobs() -> None: + from netx_api.mcp_remote.topology import layout_jobs as lj + + lj._write_job_disk({"job_id": "active", "status": "running"}) + lj._write_job_disk({"job_id": "completed", "status": "done"}) + with patch.object(lj, "_MAX_JOBS", 1): + lj._prune_disk() + assert lj._read_job_disk("active") is not None + assert lj._read_job_disk("completed") is None + + +def test_start_job_and_poll() -> None: + def runner() -> dict: + report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2) + time.sleep(0.05) + return {"ok": True, "action": "compose_views", "applied": True, "node_count": 3} + + jid = start_job(action="compose_views", view_id="v1", runner=runner, meta={"n": 1}) + assert jid + saw_progress = False + for _ in range(40): + pub = job_public(jid) + assert pub is not None + assert "elapsed_ms" in pub + assert "progress" in pub + assert "heartbeat_age_ms" in pub + if (pub.get("progress") or {}).get("phase") == "mid": + saw_progress = True + if pub["status"] != "running": + break + time.sleep(0.05) + assert saw_progress + job = get_job(jid) + assert job is not None + assert job["status"] == "done" + assert job["result"]["applied"] is True + + st = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}}) + assert st.get("ok") is True + assert st.get("status") == "done" + assert st.get("result", {}).get("applied") is True + assert isinstance(st.get("progress"), dict) + + +def test_cancel_job_cooperative() -> None: + started = time.time() + + def runner() -> dict: + report_progress("work", pct=10.0, message="before sleep") + for _ in range(40): + if is_cancelled(): + from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled + + raise_if_cancelled() + time.sleep(0.05) + return {"ok": True, "applied": True} + + jid = start_job(action="polish_crossings", view_id="v1", runner=runner) + time.sleep(0.08) + out = _layout_topology_view({"action": "job_cancel", "params": {"job_id": jid}}) + assert out.get("ok") is True + assert out.get("cancel_requested") is True + for _ in range(40): + pub = job_public(jid) + assert pub is not None + if pub["status"] == "cancelled": + break + time.sleep(0.05) + pub = job_public(jid) + assert pub is not None + assert pub["status"] == "cancelled" + assert time.time() - started < 3.0 + + +def test_slim_layout_payload_drops_guide() -> None: + fat = { + "ok": True, + "action": "compose_views", + "node_count": 1200, + "guide": {"how_to_read": "x" * 200}, + "tried": [1, 2, 3], + "params_used": {"lane": 1}, + "crossing": { + "status": "fail", + "score": 0, + "edge_crossings": 9, + "crossings_per_link": 0.1, + "top_nodes": [{"id": "a"}] * 20, + "top_edges": [{"id": "e"}] * 20, + "tip": "long", + }, + "summary": {"total": 40}, + } + slim = _slim_layout_payload(fat) + assert slim.get("slim") is True + assert "guide" not in slim + assert "tried" not in slim + assert len(slim["crossing"]["top_nodes"]) <= 5 + + +def test_compose_views_is_not_public() -> None: + # compose_* was retired from the public surface (dual-unit drain is the main path). + out = _layout_topology_view( + { + "view_id": "full", + "action": "compose_views", + "mode": "apply", + "params": {"source_view_ids": [f"s{i:02d}" for i in range(12)]}, + } + ) + assert out.get("ok") is False + assert out.get("error") == "unknown_action:compose_views" + + +def test_orbit_background_returns_job_id() -> None: + graph = { + "ok": True, + "nodes": [ + {"fabric_node_id": f"n{i}", "x": float(i * 100), "y": 0.0, "label": f"N{i}"} + for i in range(6) + ], + "edges": [ + {"id": f"e{i}", "a_node_id": f"n{i}", "b_node_id": f"n{i + 1}"} for i in range(5) + ], + } + + def fake_start(**kwargs): + assert kwargs["action"] == "orbit_sweep" + assert kwargs["view_id"] == "full" + tool_args = kwargs.get("tool_args") + assert isinstance(tool_args, dict) + # The worker re-runs the same call synchronously. + assert tool_args["params"].get("_force_sync") is True + assert "background" not in tool_args["params"] + return "abc123job" + + with patch("netx_api.mcp_remote.topology.http_tools.http_json", return_value={"ok": True, "data": graph}), patch( + "netx_api.mcp_remote.topology.http_tools.start_job", side_effect=fake_start + ): + out = _layout_topology_view( + { + "view_id": "full", + "action": "orbit_sweep", + "mode": "apply", + "params": {"background": True}, + } + ) + assert out.get("ok") is True, out + assert out.get("status") == "running" + assert out.get("job_id") == "abc123job" + assert out.get("applied") is False + + +def test_job_status_missing() -> None: + out = _layout_topology_view({"action": "job_status", "params": {}}) + assert out.get("ok") is False + assert out.get("error") == "job_id_required" + + +def test_job_status_unknown() -> None: + out = _layout_topology_view( + {"action": "job_status", "params": {"job_id": "does-not-exist"}} + ) + assert out.get("ok") is False + assert out.get("error") == "job_not_found" + + +def test_cancel_missing() -> None: + out = cancel_job("missing-job") + assert out.get("ok") is False + assert out.get("error") == "job_not_found" + + +def test_soft_stale_keeps_running_and_cancelable() -> None: + """stale is a poll warning only -?must not freeze status or block cancel.""" + from netx_api.mcp_remote.topology import layout_jobs as lj + + def runner() -> dict: + time.sleep(0.3) + return {"ok": True, "applied": False} + + jid = start_job(action="polish_crossings", view_id="v1", runner=runner) + with lj._LOCK: # noqa: SLF001 -?test injects silent heartbeat on disk + job = lj._read_job_disk(jid) or lj._JOBS[jid] + job["heartbeat_at"] = time.time() - (lj._STALE_AFTER_S + 5) + lj._JOBS[jid] = job + lj._write_job_disk(job) + pub = job_public(jid) + assert pub is not None + assert pub["stale"] is True + assert pub["status"] == "running" + cancelled = cancel_job(jid) + assert cancelled.get("ok") is True + assert cancelled.get("status") == "cancelling" + for _ in range(40): + pub2 = job_public(jid) + assert pub2 is not None + if pub2["status"] in {"cancelled", "done", "error"}: + break + time.sleep(0.05) + assert job_public(jid)["status"] == "cancelled" diff --git a/packages/netx-topology-mcp/tests/test_layout_ops.py b/tests/mcp_remote/topology/test_layout_ops.py similarity index 92% rename from packages/netx-topology-mcp/tests/test_layout_ops.py rename to tests/mcp_remote/topology/test_layout_ops.py index 09b30a0..016c4a1 100644 --- a/packages/netx-topology-mcp/tests/test_layout_ops.py +++ b/tests/mcp_remote/topology/test_layout_ops.py @@ -4,14 +4,14 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_metrics import grade_layout -from netx_topology_mcp.layout_ops import ( +from netx_api.mcp_remote.topology.layout_metrics import grade_layout +from netx_api.mcp_remote.topology.layout_ops import ( LayoutParams, build_state_from_nodes_edges, run_recipe, score_state, ) -from netx_topology_mcp.layout_ops.transforms import ( +from netx_api.mcp_remote.topology.layout_ops.transforms import ( resolve_overlaps, scale_region, select_pins, @@ -96,8 +96,8 @@ def test_grade_hard_zero_overlap() -> None: def test_select_scope_limits_moves() -> None: - from netx_topology_mcp.layout_ops.scope import select_scope - from netx_topology_mcp.layout_ops.transforms import scale_region + from netx_api.mcp_remote.topology.layout_ops.scope import select_scope + from netx_api.mcp_remote.topology.layout_ops.transforms import scale_region nodes, edges = _line_graph(6, gap=100.0) st = build_state_from_nodes_edges(nodes, edges) @@ -121,7 +121,7 @@ def test_explode_clusters_breaks_stack() -> None: ] st = build_state_from_nodes_edges(nodes, edges) st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes} - from netx_topology_mcp.layout_ops.transforms import explode_clusters + from netx_api.mcp_remote.topology.layout_ops.transforms import explode_clusters out = explode_clusters(st, LayoutParams(), thr=5.0, gap=50.0) d = math.hypot( @@ -132,7 +132,7 @@ def test_explode_clusters_breaks_stack() -> None: def test_place_on_rect_edge_axis_aligned() -> None: - from netx_topology_mcp.layout_ops.sugiyama import _place_on_rect_edge + from netx_api.mcp_remote.topology.layout_ops.sugiyama import _place_on_rect_edge order = [f"e{i}" for i in range(8)] pos = _place_on_rect_edge(order, 0.0, 0.0, 400.0, 200.0) @@ -144,7 +144,7 @@ def test_place_on_rect_edge_axis_aligned() -> None: def test_extract_dangling_feeder_leaf_to_attach() -> None: - from netx_topology_mcp.layout_ops.sugiyama import _extract_dangling_feeders + from netx_api.mcp_remote.topology.layout_ops.sugiyama import _extract_dangling_feeders # Leaf e4 → … → attach e0 (on ring/hub). Not a ring corridor. ens = [f"e{i}" for i in range(5)] @@ -170,8 +170,8 @@ def test_extract_dangling_feeder_leaf_to_attach() -> None: def test_eject_portal_neighbor_outside_ring() -> None: """PNBR-like stub on a portal must leave the ring interior.""" - from netx_topology_mcp.layout_ops.sugiyama import _eject_intruders_from_rings - from netx_topology_mcp.layout_ops.state import LayoutParams + from netx_api.mcp_remote.topology.layout_ops.sugiyama import _eject_intruders_from_rings + from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams # Trapezoid ring a—b with bottom corridor; stub s attached to a sits inside. groups = [ @@ -196,11 +196,11 @@ def test_eject_portal_neighbor_outside_ring() -> None: def test_triangle_ring_stub_must_not_pierce_chord() -> None: """Minimal triangle ring: portal stub edge must not cross the third side.""" - from netx_topology_mcp.layout_ops.sugiyama import ( + from netx_api.mcp_remote.topology.layout_ops.sugiyama import ( _eject_intruders_from_rings, _stub_crosses_ring, ) - from netx_topology_mcp.layout_ops.state import LayoutParams + from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams # Triangle a-v-b with direct a—b; stub s on b pierces a—v. groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}] @@ -226,8 +226,8 @@ def test_triangle_ring_stub_must_not_pierce_chord() -> None: def test_triangle_apex_flips_off_foreign_chord() -> None: """VOTI-like apex must sit on the side that foreign portal edges do not hit.""" - from netx_topology_mcp.layout_ops.sugiyama import _orient_ring_sides - from netx_topology_mcp.layout_metrics import segments_properly_intersect + from netx_api.mcp_remote.topology.layout_ops.sugiyama import _orient_ring_sides + from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}] # Foreign f—b runs under the chord; apex v below is pierced / crossed. @@ -248,11 +248,11 @@ def test_triangle_apex_flips_off_foreign_chord() -> None: def test_eject_one_ring_must_not_pierce_another() -> None: """Ejecting from a large ring must not park the stub through a triangle.""" - from netx_topology_mcp.layout_ops.sugiyama import ( + from netx_api.mcp_remote.topology.layout_ops.sugiyama import ( _eject_intruders_from_rings, _stub_crosses_ring, ) - from netx_topology_mcp.layout_ops.state import LayoutParams + from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams # Big ring a—b via p1-p2; triangle a-v-b; stub s inside big ring. groups = [ @@ -291,7 +291,7 @@ def test_eject_one_ring_must_not_pierce_another() -> None: def test_an_side_ring_not_mistaken_for_feeder() -> None: """AN—EN ring leg must be a ring unit, not a dangling feeder (no X with chain).""" - from netx_topology_mcp.layout_ops.sugiyama import ( + from netx_api.mcp_remote.topology.layout_ops.sugiyama import ( _extract_dangling_feeders, _find_two_portal_ring_groups, _ring_nodes_from_groups, @@ -336,11 +336,11 @@ def test_an_side_ring_not_mistaken_for_feeder() -> None: def test_two_portal_rings_nest_smallest_inner() -> None: """Shared portals: shortest corridor innermost, longer outward.""" - from netx_topology_mcp.layout_ops.sugiyama import ( + from netx_api.mcp_remote.topology.layout_ops.sugiyama import ( _find_two_portal_ring_groups, _place_two_portal_ring_groups, ) - from netx_topology_mcp.layout_ops.state import LayoutParams + from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams # Portals p1,p2 with three disjoint corridors (sizes 1 / 2 / 3 mids). ens = ["p1", "p2", "a1", "b1", "b2", "c1", "c2", "c3"] diff --git a/packages/netx-topology-mcp/tests/test_layout_sight.py b/tests/mcp_remote/topology/test_layout_sight.py similarity index 94% rename from packages/netx-topology-mcp/tests/test_layout_sight.py rename to tests/mcp_remote/topology/test_layout_sight.py index 8ff3595..9e0cb0e 100644 --- a/packages/netx-topology-mcp/tests/test_layout_sight.py +++ b/tests/mcp_remote/topology/test_layout_sight.py @@ -2,7 +2,7 @@ from __future__ import annotations -from netx_topology_mcp.layout_sight import build_sight, list_crossings +from netx_api.mcp_remote.topology.layout_sight import build_sight, list_crossings def test_list_crossings_and_drag_candidates() -> None: diff --git a/packages/netx-topology-mcp/tests/test_layout_stats.py b/tests/mcp_remote/topology/test_layout_stats.py similarity index 98% rename from packages/netx-topology-mcp/tests/test_layout_stats.py rename to tests/mcp_remote/topology/test_layout_stats.py index e88b8ba..eeb70a4 100644 --- a/packages/netx-topology-mcp/tests/test_layout_stats.py +++ b/tests/mcp_remote/topology/test_layout_stats.py @@ -2,7 +2,7 @@ from __future__ import annotations -from netx_topology_mcp.layout_stats import ( +from netx_api.mcp_remote.topology.layout_stats import ( analyze_layout_stats, build_layout_report, score_layout_components, diff --git a/packages/netx-topology-mcp/tests/test_layout_structure.py b/tests/mcp_remote/topology/test_layout_structure.py similarity index 98% rename from packages/netx-topology-mcp/tests/test_layout_structure.py rename to tests/mcp_remote/topology/test_layout_structure.py index 6f8ea0f..d9d71a3 100644 --- a/packages/netx-topology-mcp/tests/test_layout_structure.py +++ b/tests/mcp_remote/topology/test_layout_structure.py @@ -2,7 +2,7 @@ from __future__ import annotations -from netx_topology_mcp.layout_structure import analyze_graph_structure +from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure def _nodes_edges_core_bar(): diff --git a/packages/netx-topology-mcp/tests/test_layout_tool.py b/tests/mcp_remote/topology/test_layout_tool.py similarity index 92% rename from packages/netx-topology-mcp/tests/test_layout_tool.py rename to tests/mcp_remote/topology/test_layout_tool.py index 0eca842..43e0038 100644 --- a/packages/netx-topology-mcp/tests/test_layout_tool.py +++ b/tests/mcp_remote/topology/test_layout_tool.py @@ -5,8 +5,8 @@ from __future__ import annotations import json from unittest.mock import patch -from netx_topology_mcp.http_tools import call_http_tool -from netx_topology_mcp.layout_tool import build_params, list_layout_catalog, resolve_recipe, run_layout_on_graph +from netx_api.mcp_remote.topology.http_tools import call_http_tool +from netx_api.mcp_remote.topology.layout_tool import build_params, list_layout_catalog, resolve_recipe, run_layout_on_graph def test_catalog_and_aliases() -> None: @@ -81,7 +81,7 @@ def test_layout_topology_view_preview() -> None: {"a_node_id": "b", "b_node_id": "c"}, ], } - with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}): + with patch("netx_api.mcp_remote.topology.http_tools.http_json", return_value={"ok": True, "data": graph}): out = call_http_tool( "layoutTopologyView", {"view_id": "v1", "recipe": "corridor", "preset": "loose", "mode": "preview"}, @@ -125,9 +125,9 @@ def test_local_polish_refuses_crossing_regression() -> None: return {"ok": True, "data": graph} raise AssertionError("PATCH must not run on crossing regression") - with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http): + with patch("netx_api.mcp_remote.topology.http_tools.http_json", side_effect=fake_http): with patch( - "netx_topology_mcp.http_tools.run_layout_on_graph", + "netx_api.mcp_remote.topology.http_tools.run_layout_on_graph", return_value=worse, ): out = call_http_tool( @@ -172,7 +172,7 @@ def test_layout_topology_view_apply_patches() -> None: return {"ok": True, "data": graph} return {"ok": True, "data": {"updated": len((body or {}).get("positions") or [])}} - with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http): + with patch("netx_api.mcp_remote.topology.http_tools.http_json", side_effect=fake_http): out = call_http_tool( "layoutTopologyView", {"view_id": "v1", "recipe": "compact", "preset": "balanced", "mode": "apply"}, diff --git a/packages/netx-topology-mcp/tests/test_mass_merge.py b/tests/mcp_remote/topology/test_mass_merge.py similarity index 96% rename from packages/netx-topology-mcp/tests/test_mass_merge.py rename to tests/mcp_remote/topology/test_mass_merge.py index 58b7a47..23bcfdf 100644 --- a/packages/netx-topology-mcp/tests/test_mass_merge.py +++ b/tests/mcp_remote/topology/test_mass_merge.py @@ -4,16 +4,16 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops.dual_units import DualUnit -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.mass_field import ( +from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit +from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.mass_field import ( annotate_dual_unit, build_mass_field, capture_pass, evolve_chains_to_rings, ) -from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round -from netx_topology_mcp.layout_tool import ACTIONS +from netx_api.mcp_remote.topology.layout_ops.mass_merge import mass_merge_round +from netx_api.mcp_remote.topology.layout_tool import ACTIONS def test_actions_include_mass_merge() -> None: diff --git a/packages/netx-topology-mcp/tests/test_mcp_topology.py b/tests/mcp_remote/topology/test_mcp_topology.py similarity index 86% rename from packages/netx-topology-mcp/tests/test_mcp_topology.py rename to tests/mcp_remote/topology/test_mcp_topology.py index 09547f8..f5ae186 100644 --- a/packages/netx-topology-mcp/tests/test_mcp_topology.py +++ b/tests/mcp_remote/topology/test_mcp_topology.py @@ -1,19 +1,17 @@ -"""Tests for netx topology HTTP MCP server.""" +"""Tests for netx topology MCP tools (in-process handlers + /mcp).""" from __future__ import annotations import json -import subprocess -import sys from unittest.mock import patch -from netx_topology_mcp.http_tools import ( +from netx_api.mcp_remote.registry import tools_for_request +from netx_api.mcp_remote.topology.http_tools import ( HTTP_MCP_TOOLS, call_http_tool, resolve_draw_target, tools_for_scopes, ) -from netx_topology_mcp.server import _fetch_scopes def test_tool_list_has_draw_and_query_tools() -> None: @@ -85,7 +83,7 @@ def test_create_folder_posts_region_and_resolves_view() -> None: return {"ok": True, "data": tree} return {"ok": False, "error": f"unexpected {method} {path}"} - with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http) as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json", side_effect=fake_http) as mock_http: out = call_http_tool( "createTopologyFolder", {"name": "华北", "sort_order": 1, "locale": "zh"}, @@ -152,7 +150,7 @@ def test_add_nodes_rejects_managed_ume_ids() -> None: def test_add_nodes_posts_fabric_ids_only() -> None: - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"ok": True, "added": 2, "return_graph": False}} out = call_http_tool( "addTopologyViewNodes", @@ -168,7 +166,7 @@ def test_add_nodes_posts_fabric_ids_only() -> None: def test_add_nodes_filter_posts_without_ids() -> None: - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": {"ok": True, "added": 10, "matched": 40, "next_offset": 10, "truncated": True}, @@ -187,7 +185,7 @@ def test_add_nodes_filter_posts_without_ids() -> None: def test_update_positions_layout_filter() -> None: - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"ok": True, "updated": 5}} out = call_http_tool( "updateTopologyViewPositions", @@ -210,7 +208,7 @@ def test_create_view_tool_removed() -> None: def test_query_edges_defaults_to_adjacency() -> None: - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": { @@ -272,7 +270,7 @@ def test_tools_for_scopes_filters_write() -> None: def test_classify_topology_fabric_nodes_match_tag() -> None: - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": { @@ -313,7 +311,7 @@ def test_classify_topology_fabric_nodes_match_tag() -> None: def test_query_fabric_nodes_modes() -> None: - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"node_count": 3, "edge_count": 2}} out = call_http_tool("queryTopologyFabricNodes", {"mode": "summary"}) payload = json.loads(out["content"][0]["text"]) @@ -379,7 +377,7 @@ def test_get_topology_tree_compacts_by_default() -> None: ], } } - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": tree} out = call_http_tool("getTopologyTree", {"max_depth": 1}) payload = json.loads(out["content"][0]["text"]) @@ -403,7 +401,7 @@ def test_get_topology_view_defaults_to_summary() -> None: "outside_peers": [{"fabric_node_id": "n3"}], "truncated": False, } - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": graph} out = call_http_tool("getTopologyView", {"view_id": "v1"}) payload = json.loads(out["content"][0]["text"]) @@ -431,7 +429,7 @@ def test_project_neighbors_defaults_to_summary() -> None: "truncated": True, "truncate_reason": "membership_cap", } - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": graph} out = call_http_tool("projectTopologyNeighbors", {"view_id": "v1"}) payload = json.loads(out["content"][0]["text"]) @@ -452,7 +450,7 @@ def test_project_neighbors_passes_region_folder_and_reports_skips() -> None: {"fabric_node_id": "x1", "name": "X", "region_folder_id": "other"} ], } - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": graph} out = call_http_tool( "projectTopologyNeighbors", @@ -467,7 +465,7 @@ def test_project_neighbors_passes_region_folder_and_reports_skips() -> None: def test_neighborhood_returns_links_not_ports() -> None: - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = { "ok": True, "data": { @@ -515,59 +513,28 @@ def test_neighborhood_returns_links_not_ports() -> None: assert "world_x" not in payload["nodes"][0] -def test_fetch_scopes_unwraps_envelope() -> None: - with patch("netx_topology_mcp.server.http_json") as mock_http: - mock_http.return_value = {"ok": True, "data": {"scopes": ["ne:read", "ne:write"]}} - assert _fetch_scopes() == ["ne:read", "ne:write"] - - -def test_stdio_initialize_and_tools_list() -> None: - import os - - # Force scopes fetch to fail so tools/list returns the full catalog. - env = os.environ.copy() - env["NETX_API_URL"] = "http://127.0.0.1:1" - env.pop("NETX_API_TOKEN", None) - proc = subprocess.Popen( - [sys.executable, "-m", "netx_topology_mcp"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - env=env, - ) - assert proc.stdin and proc.stdout - try: - proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n") - proc.stdin.flush() - init_resp = json.loads(proc.stdout.readline()) - assert init_resp["result"]["serverInfo"]["name"] == "netx-topology-mcp" - - proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n") - proc.stdin.flush() - list_resp = json.loads(proc.stdout.readline()) - tools = list_resp["result"]["tools"] - assert len(tools) == 16 - names = {t["name"] for t in tools} - assert "suggestSinkHubs" in names - assert "classifyTopologyFabricNodes" in names - assert "createTopologyView" not in names - assert "listTopologyViews" not in names - assert "queryTopologyFabricNodes" in names - assert "createTopologyFolder" in names - assert "analyzeTopologyViewLayout" in names - assert "layoutTopologyView" in names - assert "sinkTopologyDualUnits" in names - assert "copyTopologyViewNodes" in names - finally: - proc.terminate() - proc.wait(timeout=5) +def test_topology_group_lists_sixteen_tools() -> None: + tools = tools_for_request(None, frozenset({"topology"})) + assert len(tools) == 16 + names = {t["name"] for t in tools} + assert "suggestSinkHubs" in names + assert "classifyTopologyFabricNodes" in names + assert "createTopologyView" not in names + assert "listTopologyViews" not in names + assert "queryTopologyFabricNodes" in names + assert "createTopologyFolder" in names + assert "analyzeTopologyViewLayout" in names + assert "layoutTopologyView" in names + assert "sinkTopologyDualUnits" in names + assert "copyTopologyViewNodes" in names + read_only = tools_for_request(frozenset({"ne:read"}), frozenset({"topology"})) + read_names = {t["name"] for t in read_only} + assert "getTopologyView" in read_names + assert "layoutTopologyView" not in read_names def test_layout_metrics_crossing_and_spacing() -> None: - from netx_topology_mcp.layout_metrics import analyze_positions, grade_layout, segments_properly_intersect + from netx_api.mcp_remote.topology.layout_metrics import analyze_positions, grade_layout, segments_properly_intersect assert segments_properly_intersect((0, 0), (2, 2), (0, 2), (2, 0)) assert not segments_properly_intersect((0, 0), (1, 0), (1, 0), (2, 0)) # shared endpoint @@ -607,7 +574,7 @@ def test_analyze_topology_view_layout_single() -> None: ], } - with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}): + with patch("netx_api.mcp_remote.topology.http_tools.http_json", return_value={"ok": True, "data": graph}): out = call_http_tool("analyzeTopologyViewLayout", {"view_id": "v1"}) assert out.get("isError") is not True payload = json.loads(out["content"][0]["text"]) @@ -621,9 +588,9 @@ def test_analyze_topology_view_layout_single() -> None: def test_update_positions_omits_label_and_locked_unless_given() -> None: - from netx_topology_mcp.http_tools import _update_topology_view_positions + from netx_api.mcp_remote.topology.http_tools import _update_topology_view_positions - with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http: mock_http.return_value = {"ok": True, "data": {"ok": True, "updated": 3}} _update_topology_view_positions( { diff --git a/packages/netx-topology-mcp/tests/test_move_nodes.py b/tests/mcp_remote/topology/test_move_nodes.py similarity index 86% rename from packages/netx-topology-mcp/tests/test_move_nodes.py rename to tests/mcp_remote/topology/test_move_nodes.py index 00ded2d..5dfde12 100644 --- a/packages/netx-topology-mcp/tests/test_move_nodes.py +++ b/tests/mcp_remote/topology/test_move_nodes.py @@ -2,8 +2,8 @@ from __future__ import annotations -from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, _move_topology_view_nodes -from netx_topology_mcp.layout_tool import list_layout_catalog +from netx_api.mcp_remote.topology.http_tools import HTTP_MCP_TOOLS, _move_topology_view_nodes +from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog def test_move_nodes_in_layout_schema_and_catalog() -> None: diff --git a/packages/netx-topology-mcp/tests/test_orbit_sweep.py b/tests/mcp_remote/topology/test_orbit_sweep.py similarity index 93% rename from packages/netx-topology-mcp/tests/test_orbit_sweep.py rename to tests/mcp_remote/topology/test_orbit_sweep.py index c569862..de04e00 100644 --- a/packages/netx-topology-mcp/tests/test_orbit_sweep.py +++ b/tests/mcp_remote/topology/test_orbit_sweep.py @@ -2,14 +2,14 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.orbit_sweep import ( +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +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 ( apply_orbit_pick, orbit_sweep_node, orbit_sweep_round, ) -from netx_topology_mcp.layout_tool import run_layout_on_graph +from netx_api.mcp_remote.topology.layout_tool import run_layout_on_graph def _crossed_pair(): @@ -151,14 +151,14 @@ def test_run_layout_orbit_sweep_pick_applies() -> None: def test_catalog_lists_orbit_sweep() -> None: - from netx_topology_mcp.layout_tool import list_layout_catalog + from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog cat = list_layout_catalog() assert "orbit_sweep" in cat["actions"] def test_orbit_default_protect_off_ignores_portal_freeze() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import orbit_params_from_overrides + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_params_from_overrides knobs = orbit_params_from_overrides({"node_id": "d", "portal_ids": ["d"]}) assert knobs["protect_rigid"] == "off" @@ -214,7 +214,7 @@ def test_orbit_objective_total_ranks_clearance_trade() -> None: def test_orbit_until_limit_params_defaults() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import orbit_params_from_overrides + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_params_from_overrides knobs = orbit_params_from_overrides({"until_limit": True}) assert knobs["until_limit"] is True @@ -261,7 +261,7 @@ def test_orbit_far_field_can_cut_long_chord() -> None: def test_orbit_sweep_until_limit_cuts_crossings() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_sweep_until_limit nodes, edges = _crossed_pair() st = build_state_from_nodes_edges(nodes, edges) @@ -322,7 +322,7 @@ def test_run_layout_until_limit_preview_moves_in_result() -> None: def test_select_stretch_pick_prefers_lower_stretch() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import _select_stretch_pick + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _select_stretch_pick sweep = { "improving_n": 2, @@ -353,7 +353,7 @@ def test_select_stretch_pick_prefers_lower_stretch() -> None: def test_until_limit_freezes_portal_ids() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_sweep_until_limit nodes, edges = _crossed_pair() st = build_state_from_nodes_edges(nodes, edges) @@ -373,7 +373,7 @@ def test_until_limit_freezes_portal_ids() -> None: def test_ids_matching_freeze_layers_levels() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import ( + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import ( ids_matching_freeze_layers_levels, ) @@ -399,7 +399,7 @@ def test_ids_matching_freeze_layers_levels() -> None: def test_until_limit_freeze_layers_blocks_core() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_sweep_until_limit nodes = [ {"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0, "level": 3}, @@ -429,7 +429,7 @@ def test_until_limit_freeze_layers_blocks_core() -> None: def test_verdict_partial_weights_match_layout_stats() -> None: - from netx_topology_mcp.layout_ops.orbit_sweep import ( + from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import ( _W_CLR, _W_CROSS, _crossing_part_score, diff --git a/packages/netx-topology-mcp/tests/test_ortho_metro.py b/tests/mcp_remote/topology/test_ortho_metro.py similarity index 92% rename from packages/netx-topology-mcp/tests/test_ortho_metro.py rename to tests/mcp_remote/topology/test_ortho_metro.py index b785775..f5770b3 100644 --- a/packages/netx-topology-mcp/tests/test_ortho_metro.py +++ b/tests/mcp_remote/topology/test_ortho_metro.py @@ -4,12 +4,12 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe -from netx_topology_mcp.layout_ops.ortho_metro import ( +from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe +from netx_api.mcp_remote.topology.layout_ops.ortho_metro import ( _axis_ok, build_ortho_metro_skeleton, ) -from netx_topology_mcp.layout_tool import RECIPE_ALIASES, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_tool import RECIPE_ALIASES, run_layout_on_graph def _core_bar_graph(): @@ -57,7 +57,7 @@ def test_build_ortho_metro_places_all() -> None: def test_ortho_metro_mostly_axis_aligned() -> None: - from netx_topology_mcp.layout_metrics import compute_edge_clearance + from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance nodes, edges = _core_bar_graph() st = build_state_from_nodes_edges(nodes, edges) diff --git a/packages/netx-topology-mcp/tests/test_partition.py b/tests/mcp_remote/topology/test_partition.py similarity index 93% rename from packages/netx-topology-mcp/tests/test_partition.py rename to tests/mcp_remote/topology/test_partition.py index dba55c3..36b9aed 100644 --- a/packages/netx-topology-mcp/tests/test_partition.py +++ b/tests/mcp_remote/topology/test_partition.py @@ -2,14 +2,14 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops import ( +from netx_api.mcp_remote.topology.layout_ops import ( build_state_from_nodes_edges, igraph_available, list_blocks, pack_block_centers, partition_soft_blocks, ) -from netx_topology_mcp.layout_structure import analyze_graph_structure +from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure def _star_graph(): @@ -90,7 +90,7 @@ def test_leiden_mode_falls_back_without_crash() -> None: def test_resolve_auto_small_graph_stays_component() -> None: - from netx_topology_mcp.layout_ops import resolve_block_mode + from netx_api.mcp_remote.topology.layout_ops import resolve_block_mode nodes, edges = _star_graph() st = build_state_from_nodes_edges(nodes, edges) @@ -99,7 +99,7 @@ def test_resolve_auto_small_graph_stays_component() -> None: def test_pack_soft_blocks_moves_territories_apart() -> None: - from netx_topology_mcp.layout_ops import pack_soft_blocks + from netx_api.mcp_remote.topology.layout_ops import pack_soft_blocks nodes, edges = _star_graph() # Stack both hubs on top of each other so pack must separate @@ -133,7 +133,7 @@ def test_pack_soft_blocks_moves_territories_apart() -> None: def test_pack_soft_blocks_preserves_spread_constellation() -> None: - from netx_topology_mcp.layout_ops import pack_soft_blocks + from netx_api.mcp_remote.topology.layout_ops import pack_soft_blocks nodes, edges = _star_graph() # Already-spread hubs: preserve strategy densifies but keeps left/right order @@ -157,7 +157,7 @@ def test_pack_soft_blocks_preserves_spread_constellation() -> None: def test_compact_soft_recipe_unpublished() -> None: - from netx_topology_mcp.layout_tool import resolve_recipe + from netx_api.mcp_remote.topology.layout_tool import resolve_recipe try: resolve_recipe("compact_soft") diff --git a/packages/netx-topology-mcp/tests/test_pin_beam_soft_petals.py b/tests/mcp_remote/topology/test_pin_beam_soft_petals.py similarity index 90% rename from packages/netx-topology-mcp/tests/test_pin_beam_soft_petals.py rename to tests/mcp_remote/topology/test_pin_beam_soft_petals.py index 515f440..40d78a5 100644 --- a/packages/netx-topology-mcp/tests/test_pin_beam_soft_petals.py +++ b/tests/mcp_remote/topology/test_pin_beam_soft_petals.py @@ -2,10 +2,10 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid -from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.pin_beam import pin_beam_rigid +from netx_api.mcp_remote.topology.layout_ops.soft_petals import soft_petals_greedy +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def _starish(): diff --git a/packages/netx-topology-mcp/tests/test_press_crossings.py b/tests/mcp_remote/topology/test_press_crossings.py similarity index 94% rename from packages/netx-topology-mcp/tests/test_press_crossings.py rename to tests/mcp_remote/topology/test_press_crossings.py index 9f9c34a..92a1126 100644 --- a/packages/netx-topology-mcp/tests/test_press_crossings.py +++ b/tests/mcp_remote/topology/test_press_crossings.py @@ -2,17 +2,17 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import ( +from netx_api.mcp_remote.topology.layout_metrics import ( count_edge_crossings, crossings_after_node_move, ) -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.press_crossings import ( +from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.press_crossings import ( _large_graph_budget, park_phantom_nodes, press_hot_edges, ) -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def test_actions_include_press_polish() -> None: diff --git a/packages/netx-topology-mcp/tests/test_pull_far_chains.py b/tests/mcp_remote/topology/test_pull_far_chains.py similarity index 89% rename from packages/netx-topology-mcp/tests/test_pull_far_chains.py rename to tests/mcp_remote/topology/test_pull_far_chains.py index 9a3ef68..b21449c 100644 --- a/packages/netx-topology-mcp/tests/test_pull_far_chains.py +++ b/tests/mcp_remote/topology/test_pull_far_chains.py @@ -2,10 +2,10 @@ from __future__ import annotations -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap -from netx_topology_mcp.layout_ops.pull_far_chains import pull_far_chains +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +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 _has_any_footprint_overlap +from netx_api.mcp_remote.topology.layout_ops.pull_far_chains import pull_far_chains def test_pull_far_chains_shortens_corridor_without_raising_crossings(): diff --git a/packages/netx-topology-mcp/tests/test_rigid_orbit.py b/tests/mcp_remote/topology/test_rigid_orbit.py similarity index 92% rename from packages/netx-topology-mcp/tests/test_rigid_orbit.py rename to tests/mcp_remote/topology/test_rigid_orbit.py index c697b18..4a01b64 100644 --- a/packages/netx-topology-mcp/tests/test_rigid_orbit.py +++ b/tests/mcp_remote/topology/test_rigid_orbit.py @@ -4,14 +4,14 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.rigid_orbit import ( +from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.rigid_orbit import ( rigid_orbit_candidates_for_group, rigid_orbit_round, ) -from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership -from netx_topology_mcp.layout_ops.score import score_state -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership +from netx_api.mcp_remote.topology.layout_ops.score import score_state +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def test_actions_include_rigid_orbit() -> None: diff --git a/packages/netx-topology-mcp/tests/test_rigid_units.py b/tests/mcp_remote/topology/test_rigid_units.py similarity index 88% rename from packages/netx-topology-mcp/tests/test_rigid_units.py rename to tests/mcp_remote/topology/test_rigid_units.py index be09e4a..12fc7d1 100644 --- a/packages/netx-topology-mcp/tests/test_rigid_units.py +++ b/tests/mcp_remote/topology/test_rigid_units.py @@ -2,14 +2,14 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, strip_pack_blocks -from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.rigid_units import ( +from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, strip_pack_blocks +from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.rigid_units import ( groups_from_membership, rigid_untangle_groups, ) -from netx_topology_mcp.layout_ops.state import LayoutParams -from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph +from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams +from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph def test_actions_include_rigid_untangle() -> None: @@ -17,8 +17,8 @@ def test_actions_include_rigid_untangle() -> None: def test_protect_portals_freezes_shared_only() -> None: - from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect - from netx_topology_mcp.layout_ops.state import LayoutState + from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect + from netx_api.mcp_remote.topology.layout_ops.state import LayoutState st = LayoutState( positions={"p": (0, 0), "a": (1, 0), "b": (2, 0)}, @@ -99,7 +99,7 @@ def test_rigid_untangle_preserves_relative_geometry() -> None: def test_rigid_fan_out_reduces_bbox_stack() -> None: """Two eyes glued on one portal should prefer a non-stacked angle.""" - from netx_topology_mcp.layout_ops.rigid_units import rigid_fan_out_portals + from netx_api.mcp_remote.topology.layout_ops.rigid_units import rigid_fan_out_portals # Same portal p; unit A along +x, unit B also along +x (stacked). pos = { diff --git a/packages/netx-topology-mcp/tests/test_sink_dual_units.py b/tests/mcp_remote/topology/test_sink_dual_units.py similarity index 90% rename from packages/netx-topology-mcp/tests/test_sink_dual_units.py rename to tests/mcp_remote/topology/test_sink_dual_units.py index 2f724fe..8be0ecd 100644 --- a/packages/netx-topology-mcp/tests/test_sink_dual_units.py +++ b/tests/mcp_remote/topology/test_sink_dual_units.py @@ -2,11 +2,11 @@ from __future__ import annotations -from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS -from netx_topology_mcp.layout_metrics import count_edge_crossings -from netx_topology_mcp.layout_ops import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units -from netx_topology_mcp.layout_ops.sink_dual_units import ( +from netx_api.mcp_remote.topology.http_tools import HTTP_MCP_TOOLS +from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings +from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, find_dual_portal_units +from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import ( batch_node_ids, layout_and_pack_batch, leftover_batch_ids, diff --git a/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py b/tests/mcp_remote/topology/test_suggest_sink_hubs.py similarity index 97% rename from packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py rename to tests/mcp_remote/topology/test_suggest_sink_hubs.py index e089909..acce0cf 100644 --- a/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py +++ b/tests/mcp_remote/topology/test_suggest_sink_hubs.py @@ -2,11 +2,11 @@ from __future__ import annotations -from netx_topology_mcp.layout_ops.suggest_sink_hubs import ( +from netx_api.mcp_remote.topology.layout_ops.suggest_sink_hubs import ( pick_batch, suggest_sink_hub_batches, ) -from netx_topology_mcp.layout_structure import analyze_graph_structure +from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure def _star_graph(): diff --git a/packages/netx-topology-mcp/tests/test_topology_contract.py b/tests/mcp_remote/topology/test_topology_contract.py similarity index 97% rename from packages/netx-topology-mcp/tests/test_topology_contract.py rename to tests/mcp_remote/topology/test_topology_contract.py index 0de9d6c..ac56ea0 100644 --- a/packages/netx-topology-mcp/tests/test_topology_contract.py +++ b/tests/mcp_remote/topology/test_topology_contract.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest -from netx_topology_mcp import http_tools as tools +from netx_api.mcp_remote.topology import http_tools as tools def payload(name, args): @@ -184,7 +184,7 @@ def test_explicit_background_layout_starts_worker(): def test_background_spawn_error_is_immediately_reported(): - from netx_topology_mcp import layout_jobs as jobs + from netx_api.mcp_remote.topology import layout_jobs as jobs canvas = graph() canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200}) with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object( @@ -200,8 +200,8 @@ def test_background_spawn_error_is_immediately_reported(): def test_background_apply_poll_and_persistent_view_readback(): """Execute worker orchestration inline against an in-memory HTTP backend.""" from types import SimpleNamespace - from netx_topology_mcp import layout_jobs as jobs - from netx_topology_mcp.layout_job_worker import main as worker_main + from netx_api.mcp_remote.topology import layout_jobs as jobs + from netx_api.mcp_remote.topology.layout_job_worker import main as worker_main canvas = {"nodes": [ {"fabric_node_id": "n", "x": 0, "y": 0}, diff --git a/packages/netx-topology-mcp/tests/test_topology_quality.py b/tests/mcp_remote/topology/test_topology_quality.py similarity index 97% rename from packages/netx-topology-mcp/tests/test_topology_quality.py rename to tests/mcp_remote/topology/test_topology_quality.py index 39515f4..c74b2eb 100644 --- a/packages/netx-topology-mcp/tests/test_topology_quality.py +++ b/tests/mcp_remote/topology/test_topology_quality.py @@ -2,8 +2,8 @@ from __future__ import annotations -from netx_topology_mcp.layout_stats import analyze_layout_stats, score_layout_components -from netx_topology_mcp.layout_topology_quality import ( +from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats, score_layout_components +from netx_api.mcp_remote.topology.layout_topology_quality import ( compute_chain_cohesion, compute_ring_integrity, extract_chain_paths, diff --git a/packages/netx-topology-mcp/tests/test_untangle.py b/tests/mcp_remote/topology/test_untangle.py similarity index 90% rename from packages/netx-topology-mcp/tests/test_untangle.py rename to tests/mcp_remote/topology/test_untangle.py index 4fdedb8..ea54226 100644 --- a/packages/netx-topology-mcp/tests/test_untangle.py +++ b/tests/mcp_remote/topology/test_untangle.py @@ -4,8 +4,8 @@ from __future__ import annotations import math -from netx_topology_mcp.layout_ops import build_state_from_nodes_edges -from netx_topology_mcp.layout_ops.untangle import _MAX_JUMP, _candidates, untangle_crossings +from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges +from netx_api.mcp_remote.topology.layout_ops.untangle import _MAX_JUMP, _candidates, untangle_crossings def test_candidates_stay_near_node() -> None: diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py deleted file mode 100644 index c509072..0000000 --- a/tests/test_mcp_http.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Tests for netx HTTP MCP (via netx-mcp package).""" - -from __future__ import annotations - -import json -import subprocess -import sys -from unittest.mock import patch - -import pytest - -from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool - - -def test_http_mcp_tool_list_has_fifteen_tools() -> None: - names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS] - assert len(names) == 15 - assert "queryNmsAlarms" in names - assert "queryNmsAlarmsRaw" in names - assert "execManagedNe" in names - assert "listCliTargets" in names - - -def test_call_query_nms_alarms_forwards_http() -> None: - with patch("netx_mcp.http_tools.http_json") as mock_http: - mock_http.return_value = {"ok": True, "data": {"total": 0, "items": []}} - out = call_http_tool("queryNmsAlarms", {"severity": "critical", "page": 1, "page_size": 10}) - mock_http.assert_called_once() - assert mock_http.call_args[0][0] == "GET" - assert mock_http.call_args[0][1] == "/v1/ume/alarms" - params = mock_http.call_args[1]["params"] - assert params["severity"] == "critical" - text = out["content"][0]["text"] - payload = json.loads(text) - assert payload["ok"] is True - - -def test_call_get_nms_ne_requires_id() -> None: - out = call_http_tool("getNmsNe", {}) - assert out.get("isError") is True - text = out["content"][0]["text"] - payload = json.loads(text) - assert payload["error"] == "ne_id_required" - - -def test_call_exec_managed_ne_posts_body() -> None: - with patch("netx_mcp.http_tools.http_post_json") as mock_post: - mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "ok"}} - out = call_http_tool( - "execManagedNe", - {"ne_id": "abc", "commands": ["show version"]}, - ) - mock_post.assert_called_once() - assert mock_post.call_args[0][0] == "/v1/managed-ne/exec" - body = mock_post.call_args[0][1] - assert body["ne_id"] == "abc" - assert body["commands"] == ["show version"] - text = out["content"][0]["text"] - payload = json.loads(text) - assert payload["ok"] is True - - -def test_stdio_initialize_and_tools_list() -> None: - import os - - env = os.environ.copy() - env["NETX_API_URL"] = "http://127.0.0.1:1" - env.pop("NETX_API_TOKEN", None) - proc = subprocess.Popen( - [sys.executable, "-m", "netx_mcp"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - env=env, - ) - assert proc.stdin and proc.stdout - init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n" - proc.stdin.write(init_req) - proc.stdin.flush() - init_line = proc.stdout.readline() - init_resp = json.loads(init_line) - assert init_resp["result"]["serverInfo"]["mode"] == "http" - - list_req = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n" - proc.stdin.write(list_req) - proc.stdin.flush() - list_line = proc.stdout.readline() - list_resp = json.loads(list_line) - assert "error" not in list_resp, list_resp - tools = list_resp["result"]["tools"] - names = {t["name"] for t in tools} - assert len(tools) == 15 - assert "queryNmsAlarms" in names - assert "findTopologyPaths" in names - - proc.terminate() - proc.wait(timeout=5) - - - -def test_only_standalone_mcp_implementation_is_kept() -> None: - from pathlib import Path - - root = Path(__file__).resolve().parents[1] - assert not (root / "netx_api/mcp_server.py").exists() - assert not list((root / "netx_api/mcp").glob("*.py"))