MCP: add bizMonitor server, align ops/topology tools, harden CLI exec

- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools
  mirroring the netxops bizMonitor group.
- Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP
  contracts; surface partial batch failures as tool errors.
- CLI hard gate: single-line show/display only; exec job IDs, path
  containment, owner and ne:exec checks.
- Cisco IOSv collection: stable prompt/echo handling over CR.
- Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json;
  sync skills and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 18:52:05 +08:00
parent d8da7acb7d
commit 9085cac742
63 changed files with 3251 additions and 1092 deletions

View file

@ -0,0 +1,8 @@
"""Keep layout-job persistence inside pytest temporary directories."""
import pytest
@pytest.fixture(autouse=True)
def isolated_layout_job_dir(tmp_path, monkeypatch):
monkeypatch.setenv("NETX_LAYOUT_JOB_DIR", str(tmp_path / "layout-jobs"))

View file

@ -16,6 +16,60 @@ from netx_topology_mcp.layout_jobs import (
)
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)

View file

@ -10,10 +10,10 @@ def test_move_nodes_in_layout_schema_and_catalog() -> None:
tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")
enum = tool["inputSchema"]["properties"]["action"]["enum"]
assert "move_nodes" in enum
assert "sink_nodes" in enum
assert "sink_nodes" not in enum
cat = list_layout_catalog()
assert "move_nodes" in cat["actions"]
assert "sink_nodes" in cat["actions"]
assert "sink_nodes" not in cat["actions"]
def test_move_nodes_requires_ids_and_views() -> None:

View file

@ -0,0 +1,263 @@
"""HTTP contract and partial-failure regression tests; no backend required."""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from netx_topology_mcp import http_tools as tools
def payload(name, args):
result = tools.call_http_tool(name, args)
return result, json.loads(result["content"][0]["text"])
@pytest.mark.parametrize("name,args", [
("getTopologyTree", {}),
("getTopologyView", {"view_id": "v"}),
("createTopologyFolder", {"name": "测试"}),
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("updateTopologyViewPositions", {"view_id": "v", "layout": "grid"}),
("projectTopologyNeighbors", {"view_id": "v"}),
("queryTopologyFabricNodes", {"mode": "list"}),
("classifyTopologyFabricNodes", {"action": "apply_rules"}),
("queryTopologyNeighborhood", {"node_id": "n"}),
("queryTopologyEdges", {}),
("layoutTopologyView", {"view_id": "v"}),
("suggestSinkHubs", {"source_view_id": "s"}),
("analyzeTopologyViewLayout", {"view_id": "v"}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t"}),
])
def test_business_failure_is_mcp_error(name, args):
with patch.object(tools, "http_json", return_value={
"ok": True, "data": {"ok": False, "error": "backend_rejected"},
}):
result, data = payload(name, args)
assert result.get("isError") is True, (name, data)
assert data["ok"] is False
assert data["error"] == "backend_rejected"
@pytest.mark.parametrize("name,args,method", [
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
("updateTopologyViewPositions", {"view_id": "v", "positions": [
{"fabric_node_id": f"n{i}", "x": i, "y": 0} for i in range(501)
]}, "PATCH"),
])
def test_chunk_failure_stops_later_mutations(name, args, method):
with patch.object(tools, "http_json", side_effect=[
{"ok": True, "data": {"ok": True, "added": 200, "updated": 200, "removed": 200}},
{"ok": True, "data": {"ok": False, "error": "write_failed"}},
]) as call:
result, data = payload(name, args)
assert result.get("isError") is True
assert call.call_count == 2
assert call.call_args.args[0] == method
assert data["error"] == "write_failed"
def graph(node="n"):
return {"ok": True, "nodes": [{"fabric_node_id": node, "x": 1, "y": 2}], "edges": []}
@pytest.mark.parametrize("name,args", [
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
])
def test_position_failure_preserves_source(name, args):
def http(method, path, **kwargs):
if method == "GET":
return {"ok": True, "data": graph() if path.endswith("/s") else {"nodes": [], "edges": []}}
raise AssertionError(f"Unexpected mutation {method} {path}")
with patch.object(tools, "http_json", side_effect=http), patch.object(
tools, "_ensure_view_max_nodes", return_value={"ok": True}
), patch.object(tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}), patch.object(
tools, "_patch_positions_chunked", return_value={"ok": False, "error": "patch_denied", "updated": 0}
), patch.object(tools, "_fabric_bridges_into", return_value=[]), patch.object(
tools, "_remove_topology_view_nodes"
) as remove:
result, data = payload(name, args)
assert result.get("isError") is True, data
assert data["error"] == "patch_denied"
assert data["added"] == 1
remove.assert_not_called()
def test_move_capacity_error_stays_error():
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}), patch.object(
tools, "_ensure_view_max_nodes", return_value={"ok": False, "error": "cap_denied"}
), patch.object(tools, "_add_topology_view_nodes") as add:
result, data = payload("layoutTopologyView", {
"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply",
"params": {"fabric_node_ids": ["n"]},
})
assert result.get("isError") is True
assert data["error"] == "cap_denied"
add.assert_not_called()
def test_invalid_add_does_not_change_capacity():
with patch.object(tools, "http_json") as http:
result, _ = payload("addTopologyViewNodes", {"view_id": "v", "max_nodes": 2000})
assert result.get("isError") is True
http.assert_not_called()
@pytest.mark.parametrize("name,args", [
("getTopologyView", {"view_id": "v", "sample": 0}),
("projectTopologyNeighbors", {"view_id": "v", "sample": 0, "dry_run": True}),
])
def test_zero_sample_and_projection_preview(name, args):
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}) as http:
result, data = payload(name, args)
assert result.get("isError") is not True
assert data["sample_nodes"] == []
if name == "projectTopologyNeighbors":
assert http.call_args.kwargs["body"]["dry_run"] is True
assert data["dry_run"] is True
def test_invalid_projection_detail_does_not_write():
with patch.object(tools, "http_json") as http:
result, data = payload("projectTopologyNeighbors", {"view_id": "v", "detail": "raw"})
assert result.get("isError") is True
assert data["error"] == "detail_invalid"
http.assert_not_called()
def test_zero_updated_is_not_reported_as_successful_count():
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"ok": True, "updated": 0, "skipped_locked": 1}}):
assert tools._patch_positions_chunked("v", [{"fabric_node_id": "n", "x": 1, "y": 2}])["updated"] == 0
def test_apply_rules_uses_query_flags():
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"updated": 1}}) as http:
payload("classifyTopologyFabricNodes", {"action": "apply_rules", "skip_manual": False, "fill_empty_only": True})
assert http.call_args.args == ("POST", "/v1/topology/classify/apply")
assert http.call_args.kwargs["params"] == {"skip_manual": False, "fill_empty_only": True}
@pytest.mark.parametrize("action", ["resolve_overlaps", "sink_nodes", "align_to_reference", "compose_views", "compose_orbit"])
def test_removed_layout_actions_are_not_advertised_or_called(action):
with patch.object(tools, "http_json") as http:
result, data = payload("layoutTopologyView", {"view_id": "v", "action": action})
assert result.get("isError") is True
assert data["error"] == f"unknown_action:{action}"
assert action not in data["actions"]
schema = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")["inputSchema"]
assert action not in schema["properties"]["action"]["enum"]
http.assert_not_called()
@pytest.mark.parametrize("name,args", [
("queryTopologyFabricNodes", {"mode": "find"}),
("classifyTopologyFabricNodes", {"action": "apply"}),
("queryTopologyEdges", {"detail": "raw"}),
])
def test_removed_query_aliases_do_not_call_backend(name, args):
with patch.object(tools, "http_json") as http:
result, _ = payload(name, args)
assert result.get("isError") is True
http.assert_not_called()
def test_explicit_background_layout_starts_worker():
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(
tools, "start_job", return_value="test-job"
) as start, patch.object(tools, "job_public", return_value=None), patch.object(tools, "run_layout_on_graph") as compute:
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
assert result.get("isError") is not True
assert data["job_id"] == "test-job"
assert data["applied"] is False
assert start.call_args.kwargs["tool_args"]["params"]["_force_sync"] is True
compute.assert_not_called()
def test_background_spawn_error_is_immediately_reported():
from netx_topology_mcp 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(
jobs.subprocess, "Popen", side_effect=OSError("worker unavailable")
):
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
assert result.get("isError") is True
assert data["status"] == "error"
assert data["error"].startswith("worker_start_failed:")
assert data["job_id"]
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
canvas = {"nodes": [
{"fabric_node_id": "n", "x": 0, "y": 0},
{"fabric_node_id": "n2", "x": 100, "y": 200},
], "edges": []}
positions = [
{"fabric_node_id": "n", "x": 20, "y": 30},
{"fabric_node_id": "n2", "x": 220, "y": 330},
]
def http(method, path, **kwargs):
if method == "GET":
return {"ok": True, "data": canvas}
assert method == "PATCH" and path == "/v1/topology/views/v/positions"
canvas["nodes"] = kwargs["body"]["positions"]
return {"ok": True, "data": {"ok": True, "updated": 2}}
def spawn(argv, **kwargs):
assert worker_main(["--job-id", argv[-1]]) == 0
return SimpleNamespace(pid=123)
with patch.object(tools, "http_json", side_effect=http), patch.object(
jobs.subprocess, "Popen", side_effect=spawn
), patch.object(tools, "run_layout_on_graph", return_value={"ok": True, "positions": positions, "overlap": {}}):
_, started = payload("layoutTopologyView", {
"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True},
})
jobs._JOBS.clear()
_, polled = payload("layoutTopologyView", {"action": "job_status", "params": {"job_id": started["job_id"]}})
_, view = payload("getTopologyView", {"view_id": "v", "detail": "full"})
assert polled["status"] == "done"
assert polled["result"]["applied"] is True
assert polled["result"]["updated"] == 2
assert view["nodes"] == positions
def test_hub_schema_matches_default_exclusion_behavior():
tool = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "suggestSinkHubs")
assert tool["inputSchema"]["properties"]["use_source_dual_portals"]["default"] is False
@pytest.mark.parametrize("name,args", [
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
])
def test_failed_final_readback_reports_completed_writes(name, args):
with patch.object(tools, "http_json", side_effect=[
{"ok": True, "data": graph()}, {"ok": True, "data": {"nodes": [], "edges": []}},
{"ok": False, "error": "read_timeout"}, {"ok": False, "error": "read_timeout"},
]), patch.object(tools, "_ensure_view_max_nodes", return_value={"ok": True}), patch.object(
tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}
), patch.object(tools, "_patch_positions_chunked", return_value={"ok": True, "updated": 1}), patch.object(
tools, "_remove_topology_view_nodes", return_value={"ok": True, "removed": 1}
), patch.object(tools, "_fabric_bridges_into", return_value=[]):
result, data = payload(name, args)
assert result.get("isError") is True
assert data["error"] == "view_readback_failed"
assert data["write_completed"] is True