Initial commit: Cisco Switch MCP with fixes C3, C4, C5, C6, S2

This commit is contained in:
root
2026-07-04 20:15:11 +00:00
commit 68e87cf6a5
30 changed files with 727 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
"""SSH client for Cisco IOS-XE switches via Netmiko."""
from __future__ import annotations
import json
from contextlib import contextmanager
from typing import Iterator
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
from cisco_switch_mcp.config import settings
from cisco_switch_mcp.guards import GuardError, require_credentials
class CiscoSwitchError(Exception):
def __init__(self, message: str, *, cause: Exception | None = None):
super().__init__(message)
self.cause = cause
def _device_params() -> dict:
require_credentials()
secret = settings.enable_password or settings.secret or settings.password
params = {
"device_type": settings.device_type,
"host": settings.host,
"port": settings.port,
"username": settings.username,
"password": settings.password,
"secret": secret,
"timeout": settings.timeout,
"conn_timeout": settings.timeout,
"fast_cli": False,
}
if settings.session_log_path:
params["session_log"] = settings.session_log_path
return params
@contextmanager
def connect() -> Iterator:
conn = None
try:
conn = ConnectHandler(**_device_params())
try:
conn.enable()
except Exception:
pass
yield conn
except NetmikoAuthenticationException as exc:
raise CiscoSwitchError("SSH authentication failed", cause=exc) from exc
except NetmikoTimeoutException as exc:
raise CiscoSwitchError(
f"SSH connection timed out to {settings.host}:{settings.port}", cause=exc
) from exc
except Exception as exc:
raise CiscoSwitchError(f"SSH connection failed: {exc}", cause=exc) from exc
finally:
if conn is not None:
try:
conn.disconnect()
except Exception:
pass
def run_command(command: str, *, use_textfsm: bool = False) -> str:
"""Execute a privileged exec-mode show command."""
cmd = command.strip()
if not cmd:
raise GuardError("command is empty")
with connect() as conn:
if use_textfsm:
try:
result = conn.send_command(cmd, use_textfsm=True)
if isinstance(result, list):
import json
return json.dumps(result, indent=2, default=str)
except Exception:
pass
output = conn.send_command(cmd, read_timeout=settings.timeout)
lines = output.splitlines()
if len(lines) > settings.max_output_lines:
trimmed = lines[: settings.max_output_lines]
trimmed.append(
f"... truncated ({len(lines) - settings.max_output_lines} more lines)"
)
return "\n".join(trimmed)
return output
def run_show_command(command: str) -> str:
"""Execute a show command with error wrapping.
Wraps run_command in a try/except and returns a JSON error
payload on failure instead of raising an exception, so that
MCP tools always return a string even when the switch is
unreachable or authentication fails.
"""
try:
return run_command(command)
except Exception as exc:
return json.dumps({"error": str(exc)}, indent=2)
def run_config_commands(commands: list[str]) -> str:
"""Apply configuration commands in config mode."""
from cisco_switch_mcp.guards import validate_config_commands
validate_config_commands(commands)
cleaned = [c.strip() for c in commands if c.strip()]
if not cleaned:
raise GuardError("no configuration commands provided")
with connect() as conn:
output = conn.send_config_set(cleaned)
return output
def test_connection() -> dict:
require_credentials()
with connect() as conn:
hostname = conn.find_prompt()
version = conn.send_command("show version | include Cisco IOS XE|Model Number|System serial|uptime")
return {
"status": "ok",
"host": settings.host,
"prompt": hostname,
"version_summary": version.strip(),
"device_type": settings.device_type,
}