From 68e87cf6a5b2239b7803c49b32f6d5e28c80fabe Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 20:15:11 +0000 Subject: [PATCH] Initial commit: Cisco Switch MCP with fixes C3, C4, C5, C6, S2 --- .env.example | 12 ++ README.md | 58 ++++++++ pyproject.toml | 23 +++ src/cisco_switch_mcp/__init__.py | 3 + src/cisco_switch_mcp/__main__.py | 4 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 258 bytes .../__pycache__/audit.cpython-311.pyc | Bin 0 -> 1707 bytes .../__pycache__/client.cpython-311.pyc | Bin 0 -> 7405 bytes .../__pycache__/config.cpython-311.pyc | Bin 0 -> 1493 bytes .../__pycache__/guards.cpython-311.pyc | Bin 0 -> 3004 bytes .../__pycache__/server.cpython-311.pyc | Bin 0 -> 1896 bytes src/cisco_switch_mcp/audit.py | 23 +++ src/cisco_switch_mcp/client.py | 131 ++++++++++++++++++ src/cisco_switch_mcp/config.py | 27 ++++ src/cisco_switch_mcp/guards.py | 55 ++++++++ src/cisco_switch_mcp/server.py | 27 ++++ src/cisco_switch_mcp/tools/__init__.py | 14 ++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1005 bytes .../__pycache__/diagnostics.cpython-311.pyc | Bin 0 -> 4443 bytes .../__pycache__/interfaces.cpython-311.pyc | Bin 0 -> 3148 bytes .../tools/__pycache__/l2.cpython-311.pyc | Bin 0 -> 3109 bytes .../tools/__pycache__/system.cpython-311.pyc | Bin 0 -> 4771 bytes .../tools/__pycache__/vlan.cpython-311.pyc | Bin 0 -> 2447 bytes .../tools/__pycache__/write.cpython-311.pyc | Bin 0 -> 5656 bytes src/cisco_switch_mcp/tools/diagnostics.py | 75 ++++++++++ src/cisco_switch_mcp/tools/interfaces.py | 41 ++++++ src/cisco_switch_mcp/tools/l2.py | 41 ++++++ src/cisco_switch_mcp/tools/system.py | 70 ++++++++++ src/cisco_switch_mcp/tools/vlan.py | 34 +++++ src/cisco_switch_mcp/tools/write.py | 89 ++++++++++++ 30 files changed, 727 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/cisco_switch_mcp/__init__.py create mode 100644 src/cisco_switch_mcp/__main__.py create mode 100644 src/cisco_switch_mcp/__pycache__/__init__.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/__pycache__/audit.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/__pycache__/client.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/__pycache__/config.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/__pycache__/guards.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/__pycache__/server.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/audit.py create mode 100644 src/cisco_switch_mcp/client.py create mode 100644 src/cisco_switch_mcp/config.py create mode 100644 src/cisco_switch_mcp/guards.py create mode 100644 src/cisco_switch_mcp/server.py create mode 100644 src/cisco_switch_mcp/tools/__init__.py create mode 100644 src/cisco_switch_mcp/tools/__pycache__/__init__.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/tools/__pycache__/diagnostics.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/tools/__pycache__/interfaces.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/tools/__pycache__/l2.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/tools/__pycache__/system.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/tools/__pycache__/vlan.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/tools/__pycache__/write.cpython-311.pyc create mode 100644 src/cisco_switch_mcp/tools/diagnostics.py create mode 100644 src/cisco_switch_mcp/tools/interfaces.py create mode 100644 src/cisco_switch_mcp/tools/l2.py create mode 100644 src/cisco_switch_mcp/tools/system.py create mode 100644 src/cisco_switch_mcp/tools/vlan.py create mode 100644 src/cisco_switch_mcp/tools/write.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ddf1f23 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Catalyst 3650 / IOS-XE SSH credentials +CISCO_SWITCH_HOST=10.77.30.40 +CISCO_SWITCH_PORT=22 +CISCO_SWITCH_USERNAME=REPLACE_WITH_YOUR_USERNAME +CISCO_SWITCH_PASSWORD=REPLACE_WITH_YOUR_PASSWORD +CISCO_SWITCH_ENABLE_PASSWORD= +CISCO_SWITCH_DEVICE_TYPE=cisco_xe + +# Safety +CISCO_SWITCH_ALLOW_WRITES=false +CISCO_SWITCH_DRY_RUN=false +CISCO_SWITCH_AUDIT_LOG_PATH=/root/.hermes/logs/cisco-switch-mcp-audit.jsonl diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d96167 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Cisco Switch MCP + +MCP server for **Cisco Catalyst 3650** and other **IOS-XE** switches. Connects via SSH using [Netmiko](https://github.com/ktbyers/netmiko). + +## Prerequisites on the switch + +1. **SSH enabled**: `ip domain-name local.lan` + `crypto key generate rsa` + `ip ssh version 2` +2. **User account** with privilege 15 (or enable password configured) +3. **Management IP** reachable from the Hermes LXC + +## Install + +```bash +/usr/local/lib/hermes-agent/venv/bin/pip install -e /root/cisco-switch-mcp +``` + +## Hermes config + +Add to `~/.hermes/config.yaml`: + +```yaml +mcp_servers: + cisco-switch: + command: /usr/local/lib/hermes-agent/venv/bin/cisco-switch-mcp + timeout: 120 + enabled: true + env: + CISCO_SWITCH_HOST: "10.77.30.40" + CISCO_SWITCH_USERNAME: "admin" + CISCO_SWITCH_PASSWORD: "your_password" + CISCO_SWITCH_DEVICE_TYPE: "cisco_xe" + CISCO_SWITCH_ALLOW_WRITES: "false" +``` + +Or: `echo Y | hermes mcp add cisco-switch --command /usr/local/lib/hermes-agent/venv/bin/cisco-switch-mcp --env CISCO_SWITCH_HOST=...` + +Restart dashboard/gateway and start a **new chat session**. + +## Tools (40+) + +| Category | Examples | +|----------|----------| +| System | version, inventory, CPU, memory, environment, stack status | +| Interfaces | status, descriptions, counters, port-channels | +| VLAN/STP | vlan brief, spanning-tree | +| L2 | MAC table, CDP/LLDP neighbors, ARP | +| Diagnostics | logging, ping, traceroute, running-config | +| Writes | shutdown port, set access VLAN, description, save config | + +## Safety + +- Writes disabled by default (`CISCO_SWITCH_ALLOW_WRITES=false`) +- Blocks `reload`, `write erase`, `format` +- Audit log at `CISCO_SWITCH_AUDIT_LOG_PATH` + +## device_type + +Catalyst 3650 runs IOS-XE. Use `cisco_xe` (default). If connection fails, try `cisco_ios`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60027cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cisco-switch-mcp" +version = "1.0.0" +description = "Model Context Protocol server for Cisco Catalyst IOS-XE switch management" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +dependencies = [ + "mcp>=1.0.0", + "netmiko>=4.3.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", +] + +[project.scripts] +cisco-switch-mcp = "cisco_switch_mcp.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/cisco_switch_mcp"] diff --git a/src/cisco_switch_mcp/__init__.py b/src/cisco_switch_mcp/__init__.py new file mode 100644 index 0000000..af46bbb --- /dev/null +++ b/src/cisco_switch_mcp/__init__.py @@ -0,0 +1,3 @@ +"""Cisco switch MCP server for IOS-XE Catalyst switches.""" + +__version__ = "1.0.0" diff --git a/src/cisco_switch_mcp/__main__.py b/src/cisco_switch_mcp/__main__.py new file mode 100644 index 0000000..b3a5a95 --- /dev/null +++ b/src/cisco_switch_mcp/__main__.py @@ -0,0 +1,4 @@ +from cisco_switch_mcp.server import main + +if __name__ == "__main__": + main() diff --git a/src/cisco_switch_mcp/__pycache__/__init__.cpython-311.pyc b/src/cisco_switch_mcp/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2925b8fd0ce1c88aad746fb741266563402becc4 GIT binary patch literal 258 zcmZ3^%ge<81WUb?vx0&2V-N=h7@>^Md_cx@h7^V|zbHSyL_Zm1hc3hp-Q45?{oBA4Au|$L>st4 Ju!t2X0RZm4NY(%V literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/__pycache__/audit.cpython-311.pyc b/src/cisco_switch_mcp/__pycache__/audit.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..120f40c0be63bd06ebfa0068fa6d7433a2bf9a7e GIT binary patch literal 1707 zcmah}&u<$=6rSCkUGLg)nxr*tDv(TzNLET>kpgK|gjAIx1R_GTmm1Kr-B~B=?hk8r ztTd4$<G>m&v$-j3gX^WK}8 zH}8Gk$N78~fjs{BY;|2l=vOi6i`E0m?$-bwAcD$>C`1jFNG+?1z*?x4HF)C?N4Sh5 zy{t>S9vV@moRQcF&B!WS5@*6}lq=@|o5XskkmF?Rwox7;+ErwqSUGd4P6Ad8lZ9d> zp~a}qTo%L&#iZs_KuMg={4P?m6`phBI1z3z&f6+CiOYNzL_Rl#UP)pfqVQ)fTZFfE zDPD$`nfgp@n0^S8457;ot;gJOJ+TefQbxh*GdBp+{b3S#>l6*H0F37PVt~-S1%b05M^EA=LxSaNmYd^+kyh+64dBLCnUHn8WBz zfLjOC25P88d8jp%N6Mx?#a+?stTKIG5Xq0dgaDHykNk>T4;fdo_Ax5@#>sJ!a~V zEnVhV?xj{1Q*lyJ%KqCWnk1~`1*w-zr%M6z7N;YxR!XVYGdew^BaAYwnc6b94icmj zu$O31ssTaQ&~Js;?tS@a@`tHbq0}yvo)<1WD_m$5F18C7J7cFh$4_@Ndfwso-;uKYEGhDPq2?U9LAZnB-5T>GGd&D)+-9?Ii{>kSZGke#(W8Ob;@AUeWpUyQ$ORdpTd$jcP8?DiITjpHb zoNJnMT@8Ze3aS1OYyTAL(DqWax3~9FpLC>SP>f%+*~_Z-RK=H#%u`DNZJWKWhz?QN z8c{UaA37}1F`-64*?FFE9FlmB!?TW4sl)fgcN{9d2K05I1dncOvSe*{T>vQN>Ms! zvWX^l(3$4o*g?59`E<}k^S?$1ooWt_4tk|IICjufb8vJG^pWzZ(nVPXI`Y4Y&On}j E1MFRrUH||9 literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/__pycache__/client.cpython-311.pyc b/src/cisco_switch_mcp/__pycache__/client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91a70744cd5e0718837faa49eb4d95fb5521fc05 GIT binary patch literal 7405 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 Q)d}|aDTv-KfbI1E0t|YLCIA2c literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/__pycache__/config.cpython-311.pyc b/src/cisco_switch_mcp/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c75528bf88246a7a061eeab6c767c1039329402 GIT binary patch literal 1493 zcmZuxO>f&a7^ZF7Q5`3CY{!X{<_oPmcthgTfT1XYVJ`ZW!bRW|19TXnNKB$m7Cn;A zN3nwj?BD@Aq$axFtu))e}fwArINei+S-N){5KlR>DriP#ir(UgCS?VH!V6AaoA* zu-Q0{C_V#nKJG$x(n~f`HTAMuXW^c|-{Fq*S-85#S!scVdBXac@B(D%fb_`Y@e-7)V3B8A36KAm7 zbGV1i#vZnlG({)8yZO1AkbaA@j#N28 zg@Dga0=5rjPYCaXFKachpr#GEF1(|Py8&~*CayVdgwuB5xYU0WgDvJ-&M|@Yv^$anen>^5 zI%dNl$`JBR#|efC*N01GK%~K_Fwf|&gpgci330-ZNpr9TA$;0M zekgesGM>{gqoJUos9_dBCHDg6NUq)IbGifXG8XW8&C41VG%N!Ag02A3+fRq?d}v5b zaChhu8M;KkrP|PejrIvo={g$pEihOS{o@)GD!oGGx0TEZdGU;sQTeOwvbTp3k_=Sm-Igy46^wlu0KH9?5ST=?=c?Gw)lrSB3k z`uA}Chcjoun5e94p`ry-<|D}7TK9(MXOn*d6l_M~D}c*HEEXG}S`XDO&}Q%d;xd(w htzM%4LigLhb?=+^(#I%M?0)vc-5>8pmcGpV?*QPOs=@#O literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/__pycache__/guards.cpython-311.pyc b/src/cisco_switch_mcp/__pycache__/guards.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1197a56567f0bf49410d128fc62a4bc0f3b6c9b GIT binary patch literal 3004 zcmai0&2JM&6rWkI?RESS;)KNJqpS)vwFCiFprVB$8d5$Q9E4MXQlZs$XOk>ld(F%m zh-D-TaX<)(94b+XBIQyFrH3B5RO+ALRF-P36bZG5sy8Dzaq62{+hiL`$GgvOXWqWq zdB692Ge0DgeF)m))7vjyj3D$Uf9STbuizI~K)HtqnneT?$;L%#R>E8l*`cC5D+^t= z!^OyK1S1KZKqPz3~YnA4i(V1NQVgCWYNP%(>nP?WnvLu3GD#`0R!RMHu)S>xY$Wt_}q!&$1^ZGQc z=#Yxd^@OHfuIRQu@)*6~9$y{ULmkK6V_3{^cC)LNYb@+88s$BV8h)qdcWS&-tb~n~ z7pVg83K0W^^@FJQel1z4AE(K#g%_cwTcIUS&O0SDuqYm1xbX`clzYep*=?6P3x;Vj zfqtoLK@)*~?n^Y*@A8l8G=d+3rzbscHy~_0WcljJg6XQ`rwZd!T4CnY^!Q2bdeO4JXgfN26(^SAsx?(W z(hDg}%BdqDc?1eT(hD3y525D?+ZzYI>q@#HAH;EEf@g2z2Qd@K1Skyo7Gs&QQ)3YqZwD%q``8ekvQ*PZ_fx%=Fl5Gyx>E6 zm>-6RAm;yzpHxFNOk@)NP6k?aaiE*QQ-!bgI9*f$4fT)uH)_dIjRn0lZ!%Rcmu(AR z$jN?Sd*Sn*U=X2p|T%@83q+lj5&Ac3VkvWGJb+?)%rfmXg z_Q11YR#S#k_Tgi$PF)YH)$O191X2kHk~$TpcEY8Wb=QT`_mSEyh(m&Gn&Fh@ta(7I zZ0m;U$(J0f~9M}O1^`aeadJ%46!dI0S_*XKX2`V(7Q<*EeZ4upt%IQ1q z!bSIjT@G$`4TPwk&G`Fmgv0%UGp*Eg{X{Fd;qHao7nT-QlG~ceZNDdX{+8Uik{oR& zN9&)p(pgTxGuW56BCYtq()mVwGhD0bEsd?8|9TJvTr26xW_q%bm~4koVoN*p`XxVy zVpa)aF$e1nWDZAIRM)eh+=J@44FBufzc7ai3Y*%R>{!34)xio9zZsN3F!hyHmHPRuWsAiLLdC zR(t~w^M|k_HL?d*QU{x{`-zrFLVoAM$gs*%V=5j7gz_g^W8{R7!2lou;rR@h%4+98Z_3uPK8 z^9;S)2wu<7u14@`1s`Fw9l0p-XMmf7%JoTD- RqO6lcG9KXG-zY>j`~yEyv0?xK literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/__pycache__/server.cpython-311.pyc b/src/cisco_switch_mcp/__pycache__/server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4231bee191eebd374d6bdba5571d997b5b8fd7a5 GIT binary patch literal 1896 zcmaJ>&2Jk;6rcU@_r|f4(0l@`7NNQ#HaQ@niVq>eI267}v_9S06WQog)rx*%+RZsO(MoQ>XUfRzX z86l^=te-P-e%{E7amFk7nxP3f>rMDYqbMO6;gpke@=puLGD1(F4~sF0Cm)waqs!l8 z7(5v(oGGVxq$YJn8N3$pOQOtT_KCE>ayJT(XjNi@kYWu_el0k*jVSd*ogF*oQ?kTrrkE@haQmggO4 zHb2?&@FWM%{`Z`|6<8hYyjW+KxbbhPkgd~ z3a8{l5FQ~mPAx*)W0_qLm#^3c>tRV~#})}~>W#MPv9>sp5Rm`plp{Y?l9X+sihA(Q z8fJnQkjA##=@LN&opycSBKy@!DpI#X$|7|qBrGa8c-OVD$@X_}q(NFCAA3CRG|F6X zDw?!UC}&nFlFSeaj8VyCB|xPNxBS z=v2+zU%R(<*gR_XL>-bd`LfbT>ylJ+(Kxb^4gtL*n z1rSZaIjR?bjyC)tU`RMCqf#Q|T+Q)+-UDp!cCiZDcQOa6x8*vU}6%6{U9PS?Yq5gsa@0D1*5RwPLpplTmg&(PI=vYnwq zPt-H?em~jH(Di<@4N#;1rZzwy^pkCXYW-w8OHcGV19d7^<=p3SCX None: + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "action": action, + "dry_run": dry_run, + **details, + } + path = Path(settings.audit_log_path) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, default=str) + "\n") diff --git a/src/cisco_switch_mcp/client.py b/src/cisco_switch_mcp/client.py new file mode 100644 index 0000000..f9ad425 --- /dev/null +++ b/src/cisco_switch_mcp/client.py @@ -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, + } diff --git a/src/cisco_switch_mcp/config.py b/src/cisco_switch_mcp/config.py new file mode 100644 index 0000000..e579e88 --- /dev/null +++ b/src/cisco_switch_mcp/config.py @@ -0,0 +1,27 @@ +"""Configuration from environment variables.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="CISCO_SWITCH_", extra="ignore") + + host: str = "" + port: int = 22 + username: str = "" + password: str = "" + enable_password: str = "" + device_type: str = "cisco_xe" + secret: str = "" # alias: set CISCO_SWITCH_SECRET for enable if enable_password empty + + timeout: float = 30.0 + session_log_path: str = "" + + allow_writes: bool = False + dry_run: bool = False + audit_log_path: str = "/root/.hermes/logs/cisco-switch-mcp-audit.jsonl" + block_config_replace: bool = True + max_output_lines: int = 500 + + +settings = Settings() diff --git a/src/cisco_switch_mcp/guards.py b/src/cisco_switch_mcp/guards.py new file mode 100644 index 0000000..fd88520 --- /dev/null +++ b/src/cisco_switch_mcp/guards.py @@ -0,0 +1,55 @@ +"""Safety guardrails.""" + +from __future__ import annotations + +from cisco_switch_mcp.audit import audit_log +from cisco_switch_mcp.config import settings + + +class GuardError(Exception): + pass + + +def require_credentials() -> None: + if not settings.host or not settings.username or not settings.password: + raise GuardError( + "Set CISCO_SWITCH_HOST, CISCO_SWITCH_USERNAME, and CISCO_SWITCH_PASSWORD." + ) + + +def require_writes(action: str, payload: dict | None = None) -> None: + require_credentials() + if settings.dry_run: + audit_log(action, {"payload": payload or {}}, dry_run=True) + return + if not settings.allow_writes: + raise GuardError( + f"Write operation '{action}' blocked. Set CISCO_SWITCH_ALLOW_WRITES=true." + ) + + +def is_dry_run_response(action: str, payload: dict | None = None) -> dict: + audit_log(action, {"payload": payload or {}}, dry_run=True) + return { + "dry_run": True, + "action": action, + "payload": payload, + "message": "Dry run — no changes applied.", + } + + +def validate_config_commands(commands: list[str]) -> None: + if not settings.block_config_replace: + return + blocked = ("write erase", "reload", "format", "delete /force", "copy run start") + joined = " ".join(commands).lower() + for phrase in blocked: + if phrase in joined: + raise GuardError(f"Blocked dangerous command pattern: {phrase}") + + +def validate_interface(interface: str) -> str: + iface = interface.strip() + if not iface: + raise GuardError("interface name is required") + return iface diff --git a/src/cisco_switch_mcp/server.py b/src/cisco_switch_mcp/server.py new file mode 100644 index 0000000..38d0bac --- /dev/null +++ b/src/cisco_switch_mcp/server.py @@ -0,0 +1,27 @@ +"""Cisco Catalyst IOS-XE switch MCP server.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp import __version__ +from cisco_switch_mcp.tools import register_all + +mcp = FastMCP( + "cisco-switch", + instructions=( + "Cisco Catalyst IOS-XE switch management via SSH. " + "Use show_* tools for monitoring. Write tools require CISCO_SWITCH_ALLOW_WRITES=true. " + "Interface names use full form: GigabitEthernet1/0/1." + ), +) + +register_all(mcp) + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/cisco_switch_mcp/tools/__init__.py b/src/cisco_switch_mcp/tools/__init__.py new file mode 100644 index 0000000..70decf7 --- /dev/null +++ b/src/cisco_switch_mcp/tools/__init__.py @@ -0,0 +1,14 @@ +"""Tool registration.""" + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp.tools import diagnostics, interfaces, l2, system, vlan, write + + +def register_all(mcp: FastMCP) -> None: + system.register(mcp) + interfaces.register(mcp) + vlan.register(mcp) + l2.register(mcp) + diagnostics.register(mcp) + write.register(mcp) diff --git a/src/cisco_switch_mcp/tools/__pycache__/__init__.cpython-311.pyc b/src/cisco_switch_mcp/tools/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e22c05f6f9d65b7a6648b7614a4216fb8b42dae2 GIT binary patch literal 1005 zcmb7CziZn-6uz^bCE2c%lDdut8v-c>4Y46z+b)3&EpZF!P_zgk$*zokgu6?ELxv9i z1H2_;rh&rTUUJCcQN=KCW5r?zTiFx^)1kC43IC%9IBm_i%9?StHRI~*#d%2Rj(es?WT9MNNbeIO`y;vpRf4>3B5O1; zk&l8(Ep~37bxD%2t{YG{*`wzHbKmU=sk@Z83#Yws+S19y-O{dY2XVk`yK^$)_m=5w zCk)A^I8tV$Ljmtm4%P9i$-xwi42%?v95yo8zE-Bt%Al1(D+hgiG})a(GlOOd%^cM6 z%Zb=;WY9>Vk;A6=^ZLOQ+8MM{Xy;HF_a;xKuq_Tsmvb(%A+yFc+x8OIw)vJQwnKe# z=95leJUU?#`9yfL=<`lXouZ|MIE7$^R#M4@bW*UQtt6^hb+0THa<&;S4c literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/tools/__pycache__/diagnostics.cpython-311.pyc b/src/cisco_switch_mcp/tools/__pycache__/diagnostics.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42b3a272a0c4b3717f76e519e9bbf3e8913d6309 GIT binary patch literal 4443 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 M5x{!KKw!-O0*YmTr2qf` literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/tools/__pycache__/vlan.cpython-311.pyc b/src/cisco_switch_mcp/tools/__pycache__/vlan.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42ea2137a3214944a6a526e6ad42355839d2c05b GIT binary patch literal 2447 zcmbtW-D@0G6uQp4V&kQGS_)Qy@nNac+%aBTs%=|1R|2oa9|0p*bFl80SK{^t=)pY%*kh=4}QIH;9-%$L@qX`nGQc z`d8F*>qa%!2250evmTRUkmUL50u94x!S@^X)&)lG%xGpt6OEiD3%Gf8 z>u!392lf1Th>0$fpBq=Hy9v3vw0hkSZOdnC)t9Xwg=82YLH5bt1yY_)rsrE)rL9#G zwL;H+(F59fK{UiM4&p6Xf*f;%Z;*F{PUZjw4%{aX#6ehdYotN1A;*iKx{>a< zk>&YJuZL6zlJgqqH$I-%VQ%?;w+0dQOSe6Dry5`6m(P1y6B-G<%h90YkabpR z4#kyWD0;yrZXLgM7EE#>7Z98r$KGveD{XBhQCIlMX$h$1(z<2mn;5<@oM7vqK&Qnn zOB%wSAP|x4LSxF4W>1VTqWd_~u6Um~@`GV&De?R;WP?QOr<9Qj`tjtp<$I15xn|!N zzy1Pv`(8aRcKK^a>4hAy@LE?ln-+B#eGL+(1rUZr5h`v-6%XNH*r;^9OH(7a1)gl? zfIgMiwf|hDli*8mCi?&cCv)ljL|r>Qj>Kqou94 zwe>_@=fyQd-h(N28hG`voftBI1L-8_r<`(jeO!3TDx@;@KSQBKbl{stUqNvm1t%)eh;2za&y9_pB-@lup*k%3O!! zM1L__$8w8BWfeEgM1LI`@|!BkXF`XZ$o|q3Q|9InZUA8X^4MK7XiA_NYFac+C$vp7 z9Wl*%6aEXhrb&@A=_E3YK^K58K^^`_z%={MFw8^S_uw1GDaa8UXbI2N>y6N9`tBON t3Oi%lWG;w~BnZM&GLevpBQlfZ#}RoW$&VwlkmN_F@T!0?yiD@@{|k8WS_c3C literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/tools/__pycache__/write.cpython-311.pyc b/src/cisco_switch_mcp/tools/__pycache__/write.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3d497ffaecabd38e91621418e0cedc3d247c296 GIT binary patch literal 5656 zcmc&&O>Eo96(&XfP?j9ol7F^t*5M|OZ6&eXIF2`)4bnQXy^EqroMw@D>fHAJPzNyHypir-@JMAU3+^Q1LfH-k1Tz2fMNbcUK%CfG}u>VXne~cCd(ie1r)Xr z$Oc%_<`gc=!851?3!!X?@>!D zHc{xvb`(0ZorPpJ3H$^)@F18?A?_NJ?kFF#J^ODRr&Dt?hRW*b5=$GN0n^c$K5t$KnZAn1!9rYMpH!v*a7bOE}_rT(}!Ba3eI+L$^H??9IXo{E}To24{c$TEXJchTYE@vfB zVQ~PlDj;EnLtG{BC+07K3bz#sfygT{mWlEH^DitkGf^ z6sm+CgT_-=3x(Gse$F+{z|avKZK3W-CpuN#8<*HTZXy$zr!+>Fc>vj z9-Bl3S(U+>fUCQbvMdmr3dE>_h6PC#Z!3}@s)B3!3WH+1OS-1oAyg_9bxgK4 zOe~9u(J@)&bj0)7kW7IY7V~5m!M)ILM~dRIqKOC}gg2e&2~K%^b`)!xF`Ae4yf&iW zla2frBLL}99p@cp&SB;VlkP~PbT`Npm+eDTt0B<(AIm4PvPJ?`NUdXJHMDtX$m~4(udIjb{M>&&c%xA;yoIk*hfIW}btPg(qQ zjh{C8>CHsoMbMjN97pKRo`_<+a*)cnwmuDAI) z09_9>0eDRs09_9_D^2rdPb)O1dH3mV}cd<3Iao923tg_jpDuXb7-Rhb{Md z`1NtgFfQH)LD@lC0s?H#5y=q|{1ejEM+m`Qy3N5iF**e~exJ1Z2{}Z_Ug!=x=%ybL zh!DXvK1>M3GuZ+hIO8MGv=90~)-<7V<{hAHk{^H(^}hjWMPw}b@apeBUj6vdP<85$ znMawe~{G)%q`)*Ke4qIV&|+OU;?_If~igwIPcitMOwdKh}!bBh?!l z{IfxGbkaIHY4I0o`~{Q0KrxGt5=+I9r020*Bs=Agfo#={q%f*m9z)A>paC%dHbC6L zDZ6nNz{^p{I?qZH| zWBSbHmpfn-2ZM1>hYPS1q??8^3NVkz-T<>h5Y*-3z9Ci*p!72We9I2fPc#9|6F42D z4YnZ7{eL;n9Ls0+fw?_3KZLpHe+ANtzC_>m3E}Sv!AcyiB@S1|s~4-|wZ!mh&=VWY zV?Te^1%%YpFPtEWUhrn}D%Af2;OG**jw)~{n1=V$zWOILATxubzRa$$ z2D~-Ol8ZA4TyC)P1~hLtLlFM_K99KdroaS&%X>;~(Et>9&ZMp9W?$F?Z8&!Z-VnF~ z5V*qS>yyPI95h9ITZUsNUKadGU*pV}5iY=y=?I~dePtd#<8{nio=EV?i(ae$YOVjOnVmONH?7poTI!}5zeyv(iM2(GpQ!N@CO^>{ z35M2sH$Hu~V4j+`($f|{Q{!h$er7ikcleY?b`}QIzXU>ksk`sdc{6ovmEX+-W?f&}7-<7`0*M)Zt~;1E|g|JOV>|oNtgu90dW^; zk<1(up09t9aEIR9Ph$}sX;(k!*zc5IE8zJ8YX?b|245oE79JqvI3Z(%Ob~Jwh)#SQ zpbf=AH{Y-`J_kMBMC5xQ_nFNQ|GT-b=YErYklhKyLOnZ-kNlfs!X1sSJ|-O9749Iy z4dTiX#(+<5zvJ;_yasW{BaANJcnuLM?2~h~>FF;WbZkW8h+{#L!lb+GNG^x8d@g6V z<#G!pxYUwzIZXCKdQP{)#&QvWgM+}alaLo@bP3;uBzFO>;@s=79-m7SDSACe^L4wo zr8BR{a1B6H`n`JT#h`O5(5kyg?(uX?bBf*kAxUpM^%Aiom` Uv4I`thmnpcmIc>%8>JTb9~OQv(*OVf literal 0 HcmV?d00001 diff --git a/src/cisco_switch_mcp/tools/diagnostics.py b/src/cisco_switch_mcp/tools/diagnostics.py new file mode 100644 index 0000000..9df6175 --- /dev/null +++ b/src/cisco_switch_mcp/tools/diagnostics.py @@ -0,0 +1,75 @@ +"""Diagnostics, logs, and config read tools.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp.client import run_show_command + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def cisco_switch_show_logging() -> str: + """Get recent syslog messages.""" + return run_show_command("show logging last 80") + + @mcp.tool() + def cisco_switch_ping( + target: str, + count: int = 5, + source: str = "", + ) -> str: + """Ping from the switch (e.g. target=10.77.30.1).""" + target = target.strip() + if not target: + raise ValueError("target is required") + count = max(1, min(count, 10)) + cmd = f"ping {target} repeat {count}" + if source.strip(): + cmd = f"ping {target} source {source.strip()} repeat {count}" + return run_show_command(cmd) + + @mcp.tool() + def cisco_switch_traceroute(target: str) -> str: + """Traceroute from the switch.""" + target = target.strip() + if not target: + raise ValueError("target is required") + return run_show_command(f"traceroute {target}") + + @mcp.tool() + def cisco_switch_show_running_config(section: str = "") -> str: + """Get running configuration. Optional section filter (e.g. 'interface GigabitEthernet1/0/1').""" + if section.strip(): + return run_show_command(f"show running-config | section {section.strip()}") + return run_show_command("show running-config") + + @mcp.tool() + def cisco_switch_show_startup_config() -> str: + """Get startup configuration.""" + return run_show_command("show startup-config") + + @mcp.tool() + def cisco_switch_show_ip_route() -> str: + """Get IP routing table on the switch.""" + return run_show_command("show ip route") + + @mcp.tool() + def cisco_switch_show_users() -> str: + """Get logged-in users.""" + return run_show_command("show users") + + @mcp.tool() + def cisco_switch_show_power_inline() -> str: + """Get PoE status (if PoE ports present).""" + return run_show_command("show power inline") + + @mcp.tool() + def cisco_switch_show_interfaces_transceiver() -> str: + """Get SFP DOM data: temperature, voltage, current, TX/RX power.""" + return run_show_command("show interface transceiver") + + @mcp.tool() + def cisco_switch_show_access_lists() -> str: + """Get ACL configuration with hit counts.""" + return run_show_command("show access-lists") diff --git a/src/cisco_switch_mcp/tools/interfaces.py b/src/cisco_switch_mcp/tools/interfaces.py new file mode 100644 index 0000000..c5e8158 --- /dev/null +++ b/src/cisco_switch_mcp/tools/interfaces.py @@ -0,0 +1,41 @@ +"""Interface monitoring tools.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp.client import run_show_command +from cisco_switch_mcp.guards import validate_interface + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def cisco_switch_show_interfaces_status() -> str: + """Get brief interface status (port, VLAN, speed, duplex, status).""" + return run_show_command("show interfaces status") + + @mcp.tool() + def cisco_switch_show_interfaces_description() -> str: + """Get interface descriptions.""" + return run_show_command("show interfaces description") + + @mcp.tool() + def cisco_switch_show_interface(interface: str) -> str: + """Get detailed statistics for one interface (e.g. GigabitEthernet1/0/1).""" + iface = validate_interface(interface) + return run_show_command(f"show interfaces {iface}") + + @mcp.tool() + def cisco_switch_show_interfaces_counters() -> str: + """Get interface error and packet counters.""" + return run_show_command("show interfaces counters errors") + + @mcp.tool() + def cisco_switch_show_port_channel_summary() -> str: + """Get EtherChannel / port-channel summary.""" + return run_show_command("show etherchannel summary") + + @mcp.tool() + def cisco_switch_show_ip_interface_brief() -> str: + """Get L3 interface IP summary (SVIs and routed ports).""" + return run_show_command("show ip interface brief") diff --git a/src/cisco_switch_mcp/tools/l2.py b/src/cisco_switch_mcp/tools/l2.py new file mode 100644 index 0000000..43cf0c4 --- /dev/null +++ b/src/cisco_switch_mcp/tools/l2.py @@ -0,0 +1,41 @@ +"""Layer-2 discovery and MAC tools.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp.client import run_show_command +from cisco_switch_mcp.guards import validate_interface + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def cisco_switch_show_mac_address_table() -> str: + """Get MAC address table (CAM table).""" + return run_show_command("show mac address-table") + + @mcp.tool() + def cisco_switch_show_mac_address_table_vlan(vlan_id: int) -> str: + """Get MAC address table filtered by VLAN.""" + return run_show_command(f"show mac address-table vlan {vlan_id}") + + @mcp.tool() + def cisco_switch_show_mac_address_table_interface(interface: str) -> str: + """Get MAC addresses learned on one interface.""" + iface = validate_interface(interface) + return run_show_command(f"show mac address-table interface {iface}") + + @mcp.tool() + def cisco_switch_show_cdp_neighbors() -> str: + """Get CDP neighbor discovery table.""" + return run_show_command("show cdp neighbors detail") + + @mcp.tool() + def cisco_switch_show_lldp_neighbors() -> str: + """Get LLDP neighbor discovery table.""" + return run_show_command("show lldp neighbors detail") + + @mcp.tool() + def cisco_switch_show_arp() -> str: + """Get ARP table from the switch.""" + return run_show_command("show ip arp") diff --git a/src/cisco_switch_mcp/tools/system.py b/src/cisco_switch_mcp/tools/system.py new file mode 100644 index 0000000..6a53ca1 --- /dev/null +++ b/src/cisco_switch_mcp/tools/system.py @@ -0,0 +1,70 @@ +"""System and hardware monitoring tools.""" + +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp.client import CiscoSwitchError, run_show_command, test_connection +from cisco_switch_mcp.config import settings + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def cisco_switch_get_config() -> str: + """Get MCP server configuration (no secrets).""" + return json.dumps( + { + "host": settings.host, + "port": settings.port, + "device_type": settings.device_type, + "username_configured": bool(settings.username), + "allow_writes": settings.allow_writes, + "dry_run": settings.dry_run, + }, + indent=2, + ) + + @mcp.tool() + def cisco_switch_test_connection() -> str: + """Test SSH connectivity and authentication to the switch.""" + try: + return json.dumps(test_connection(), indent=2, default=str) + except CiscoSwitchError as exc: + return json.dumps({"status": "error", "message": str(exc)}, indent=2) + + @mcp.tool() + def cisco_switch_show_version() -> str: + """Get IOS-XE version, model, serial number, and uptime.""" + return run_show_command("show version") + + @mcp.tool() + def cisco_switch_show_inventory() -> str: + """Get hardware inventory (chassis, power supplies, modules).""" + return run_show_command("show inventory") + + @mcp.tool() + def cisco_switch_show_environment() -> str: + """Get environmental status: temperature, fans, power supplies.""" + return run_show_command("show environment all") + + @mcp.tool() + def cisco_switch_show_processes_cpu() -> str: + """Get CPU utilization summary.""" + return run_show_command("show processes cpu sorted | exclude 0.00") + + @mcp.tool() + def cisco_switch_show_memory() -> str: + """Get memory utilization.""" + return run_show_command("show memory statistics") + + @mcp.tool() + def cisco_switch_show_clock() -> str: + """Get switch system time.""" + return run_show_command("show clock") + + @mcp.tool() + def cisco_switch_show_redundancy() -> str: + """Get stack/redundancy state (useful for Catalyst 3650 stacks).""" + return run_show_command("show redundancy") diff --git a/src/cisco_switch_mcp/tools/vlan.py b/src/cisco_switch_mcp/tools/vlan.py new file mode 100644 index 0000000..9ed0073 --- /dev/null +++ b/src/cisco_switch_mcp/tools/vlan.py @@ -0,0 +1,34 @@ +"""VLAN tools.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp.client import run_show_command + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def cisco_switch_show_vlan_brief() -> str: + """List VLANs and port assignments (show vlan brief).""" + return run_show_command("show vlan brief") + + @mcp.tool() + def cisco_switch_show_vlan(id: int = 0) -> str: + """Get details for one VLAN ID, or all VLANs if id=0.""" + if id > 0: + return run_show_command(f"show vlan id {id}") + return run_show_command("show vlan") + + @mcp.tool() + def cisco_switch_show_spanning_tree() -> str: + """Get spanning-tree overview.""" + return run_show_command("show spanning-tree summary") + + @mcp.tool() + def cisco_switch_show_spanning_tree_interface(interface: str) -> str: + """Get spanning-tree details for one interface.""" + from cisco_switch_mcp.guards import validate_interface + + iface = validate_interface(interface) + return run_show_command(f"show spanning-tree interface {iface} detail") diff --git a/src/cisco_switch_mcp/tools/write.py b/src/cisco_switch_mcp/tools/write.py new file mode 100644 index 0000000..c3c567d --- /dev/null +++ b/src/cisco_switch_mcp/tools/write.py @@ -0,0 +1,89 @@ +"""Controlled write/configuration tools.""" + +from __future__ import annotations + +import json +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from cisco_switch_mcp.audit import audit_log +from cisco_switch_mcp.client import run_config_commands +from cisco_switch_mcp.config import settings +from cisco_switch_mcp.guards import ( + GuardError, + is_dry_run_response, + require_writes, + validate_interface, +) + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def cisco_switch_interface_shutdown(interface: str, shutdown: bool = True) -> str: + """Administratively shut down or enable an interface.""" + iface = validate_interface(interface) + action = "shutdown" if shutdown else "no shutdown" + commands = [f"interface {iface}", action] + payload = {"interface": iface, "shutdown": shutdown, "commands": commands} + if settings.dry_run: + return json.dumps(is_dry_run_response("interface_shutdown", payload), indent=2) + require_writes("interface_shutdown", payload) + result = run_config_commands(commands) + audit_log("interface_shutdown", payload | {"result": result}) + return result + + @mcp.tool() + def cisco_switch_set_access_vlan(interface: str, vlan_id: int) -> str: + """Set switchport access VLAN on an access port.""" + iface = validate_interface(interface) + if vlan_id < 1 or vlan_id > 4094: + raise GuardError("vlan_id must be 1-4094") + commands = [ + f"interface {iface}", + "switchport mode access", + f"switchport access vlan {vlan_id}", + ] + payload = {"interface": iface, "vlan_id": vlan_id, "commands": commands} + if settings.dry_run: + return json.dumps(is_dry_run_response("set_access_vlan", payload), indent=2) + require_writes("set_access_vlan", payload) + result = run_config_commands(commands) + audit_log("set_access_vlan", payload | {"result": result}) + return result + + @mcp.tool() + def cisco_switch_set_interface_description(interface: str, description: str) -> str: + """Set interface description.""" + iface = validate_interface(interface) + desc = description.strip()[:240] + commands = [f"interface {iface}", f"description {desc}"] + payload = {"interface": iface, "description": desc, "commands": commands} + if settings.dry_run: + return json.dumps(is_dry_run_response("set_description", payload), indent=2) + require_writes("set_description", payload) + result = run_config_commands(commands) + audit_log("set_description", payload | {"result": result}) + return result + + @mcp.tool() + def cisco_switch_run_config_commands(commands: list[str]) -> str: + """Apply arbitrary configuration commands. Blocked patterns: reload, write erase, format.""" + payload: dict[str, Any] = {"commands": commands} + if settings.dry_run: + return json.dumps(is_dry_run_response("run_config_commands", payload), indent=2) + require_writes("run_config_commands", payload) + result = run_config_commands(commands) + audit_log("run_config_commands", payload | {"result": result}) + return result + + @mcp.tool() + def cisco_switch_save_config() -> str: + """Save running config to startup (copy running-config startup-config).""" + commands = ["end", "write memory"] + if settings.dry_run: + return json.dumps(is_dry_run_response("save_config", {}), indent=2) + require_writes("save_config", {}) + result = run_config_commands(commands) + audit_log("save_config", {"result": result}) + return result