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,77 @@
"""无设备/网络副作用地验证 MCP 执行边界与终态失败语义。"""
import json
from unittest.mock import patch
import pytest
from netx_mcp.http_tools import call_http_tool
@pytest.mark.parametrize("command", [
"reboot", "configure terminal", "show version; reboot", "show version && reboot",
"show version | reboot", "show version | redirect flash:config", "show $(reboot)",
"show `reboot`", "display version\nreboot", "show version\u2028reboot",
"show version > config", "show version\x00", "/system reboot",
])
@pytest.mark.parametrize("batch", [False, True])
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:
result = call_http_tool("execManagedNe", args)
assert result.get("isError") is True
post.assert_not_called()
@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:
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"
assert post.call_args.args[1]["commands"] == [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": {
"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"])
assert data["poll_after_ms"] == 5000 and data["job_id"] == "test-job"
assert "end the turn" not in data["hint"]
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}):
result = call_http_tool("getNeExecJob", {"job_id": "test-job"})
assert result.get("isError") is True
data = json.loads(result["content"][0]["text"])
assert data["data"]["result"]["detail"] == "SSH connection failed"
@pytest.mark.parametrize("failed", [1, 2])
@pytest.mark.parametrize("async_job", [False, True])
def test_batch_target_failures_are_tool_errors_even_when_backend_ok(failed, async_job):
result = {"ok": True, "summary": {"total": 2, "ok": 2-failed, "failed": failed},
"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}):
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}):
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
@pytest.mark.parametrize("target_ok", [False, True])
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}):
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
assert bool(out.get("isError")) is not target_ok