Initial commit: Cisco Switch MCP with fixes C3, C4, C5, C6, S2
This commit is contained in:
131
src/cisco_switch_mcp/client.py
Normal file
131
src/cisco_switch_mcp/client.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user