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

@ -5,6 +5,9 @@ from __future__ import annotations
import unittest
import re
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from netmiko.base_connection import BaseConnection
from netx_api.ne_netmiko import drain_read_channel, send_show_command
@ -24,6 +27,85 @@ class DrainReadChannelTests(unittest.TestCase):
class SendShowCommandTests(unittest.TestCase):
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_real_netmiko_cisco_reader_uses_full_prompt_after_drain(self, drain: MagicMock) -> None:
conn = BaseConnection(host="127.0.0.1", device_type="cisco_ios", auto_connect=False)
conn.base_prompt = "R2"
# Drain clears leftovers; reader then sees echo + body + full prompt.
chunks = iter(["show version\nCisco IOS Software\nR2 uptime is 15 hours\nR2#", "", ""])
conn.channel = SimpleNamespace(read_channel=lambda: next(chunks, ""))
conn.write_channel = MagicMock()
conn.find_prompt = MagicMock()
out = send_show_command(conn, "show version", read_timeout=2)
self.assertIn("Cisco IOS Software", out)
self.assertIn("R2 uptime is 15 hours", out)
conn.write_channel.assert_called_once()
conn.find_prompt.assert_not_called()
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 6)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_does_not_probe_prompt_or_match_hostname_inside_output(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_ssh"
conn.base_prompt = "R2"
body = "Cisco IOS Software\nR2 uptime is 15 hours\nSystem image file is flash:ios.bin"
def delayed_ios_output(**kwargs: object) -> str:
if kwargs.get("auto_find_prompt", True):
return ""
if kwargs.get("cmd_verify", True):
return ""
pattern = str(kwargs.get("expect_string", ""))
if re.search(pattern, "R2 uptime is 15 hours"):
return "Cisco IOS Software"
return body
conn.send_command.side_effect = delayed_ios_output
self.assertEqual(send_show_command(conn, "show version", read_timeout=60), body)
conn.send_command.assert_called_once()
conn.find_prompt.assert_not_called()
conn.send_command_timing.assert_not_called()
self.assertFalse(conn.send_command.call_args.kwargs["cmd_verify"])
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 6)
pattern = conn.send_command.call_args.kwargs["expect_string"]
for prompt in ("R2#", "R2>", "R2# "):
self.assertIsNotNone(re.search(pattern, prompt))
for content in ("R2", "R2 uptime is 15 hours", "R2#show version", "prefix R2#"):
self.assertIsNone(re.search(pattern, content))
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_prompt_hostname_is_literal(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_telnet"
conn.base_prompt = "edge-1.lab"
conn.send_command.return_value = "interfaces"
self.assertEqual(send_show_command(conn, "show ip interface brief"), "interfaces")
pattern = conn.send_command.call_args.kwargs["expect_string"]
self.assertIsNotNone(re.search(pattern, "edge-1.lab#"))
self.assertIsNone(re.search(pattern, "edge-1Xlab#"))
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_timeout_does_not_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios"
conn.base_prompt = "R2"
conn.send_command.side_effect = TimeoutError("late show output")
with self.assertRaises(TimeoutError):
send_show_command(conn, "show version")
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_missing_cached_prompt_fails_before_sending(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios"
conn.base_prompt = ""
with self.assertRaisesRegex(ValueError, "cisco_base_prompt_missing"):
send_show_command(conn, "show version")
conn.send_command.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_mikrotik_does_not_probe_prompt_after_drain(self, drain: MagicMock) -> None:
conn = MagicMock()
@ -88,6 +170,17 @@ class SendShowCommandTests(unittest.TestCase):
conn.send_command_timing.assert_not_called()
drain.assert_called_once_with(conn)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_empty_output_raises_without_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_ssh"
conn.base_prompt = "R2"
conn.send_command.return_value = "\n"
with self.assertRaisesRegex(ValueError, "cisco_empty_show_output"):
send_show_command(conn, "show version", read_timeout=30)
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
if __name__ == "__main__":
unittest.main()