F Networking for AI: Build an MCP Server to Manage Cisco Devices - The Network DNA: Networking, Cloud, and Security Technology Blog

Networking for AI: Build an MCP Server to Manage Cisco Devices

Networking for AI: Build an MCP Server to Manage Cisco Devices

Network automation has been “the future” for about fifteen years. Ansible playbooks, Python scripts, RESTCONF calls that almost work — the tooling exists, but the barrier to getting an engineer who mostly lives in the CLI to actually use it has always been annoyingly high. The Model Context Protocol changes that. You write an MCP server once, and suddenly Claude can SSH into a Cisco router, pull interface stats, check BGP neighbors, or push a config change — in plain English, with full context about what it’s doing and why.

 April 2026  |  ⏱ 18 min read  |   Python 3.11+ • Netmiko • MCP SDK • IOS-XE  |  ⚙ Network Engineers / NetDevOps

What Is the Model Context Protocol?

MCP is an open protocol from Anthropic that defines how AI models talk to external tools and data sources. Instead of copy-pasting CLI output into a chat window, you build an MCP server — a small process that exposes “tools” the AI can call. The AI decides when to call them, calls them, reads the output, and reasons about it. Think of it as a standardized interface between an AI assistant and anything you can reach with code.

⚠  What You Need Before Starting

Python 3.11 or later installed locally
Anthropic MCP Python SDK: pip install mcp
Netmiko for SSH to Cisco devices: pip install netmiko
Claude Desktop (or Claude API access) to connect the MCP server to an AI client
At least one Cisco IOS or IOS-XE device reachable over SSH from your machine (physical, GNS3, or CML)
SSH enabled on target devices with a local or TACACS+ account that has at minimum privilege level 1 for read-only or level 15 for config changes

Steps in This Guide

1.  How MCP Works With Cisco Devices — The Architecture
2.  Project Setup and File Structure
3.  Build the Netmiko Connection Layer
4.  Write Your First MCP Tool: show_interfaces
5.  Add More Tools: show_bgp, show_route, show_log
6.  Add a Config-Push Tool (with Safety Gates)
7.  Add Device Inventory as an MCP Resource
8.  Connect the Server to Claude Desktop
9.  Test the Full Flow
10. Security Considerations
11. FAQ

1. How MCP Works With Cisco Devices — The Architecture

The MCP client (Claude Desktop or any compatible AI host) starts your MCP server as a subprocess. The two communicate over stdio using JSON-RPC. Your server tells the client which tools it offers. The client — meaning the AI — decides when to call those tools based on what the user asks.

When a tool is called, your Python code runs. Netmiko opens an SSH session to the target Cisco device, sends the command, collects the output, closes or reuses the connection, and returns the result as text. The AI reads that text and responds. The user never sees the SSH session directly.

MCP + Cisco Data Flow

User (you)
Claude Desktop

stdio
JSON-RPC

MCP Server
Your Python code

SSH
TCP 22

Netmiko
SSH client library

SSH
IOS/XE

Cisco Router
/ Switch

The AI never gets direct network access. Your MCP server is the only thing touching Cisco SSH.

Why this design matters: The MCP server is your control point. You decide which commands are allowed, which devices are reachable, and what the AI can and cannot do. The AI reasons; your code acts. That separation is intentional and it’s where you put your guardrails.

2. Project Setup and File Structure

Create a clean directory and set up a virtual environment. Keeping this isolated matters — Netmiko has dependencies that can conflict with other tools if you install globally.

# Create project directory and virtual environment

mkdir cisco-mcp-server cd cisco-mcp-server python3 -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate # Install dependencies pip install mcp netmiko python-dotenv # Create required files touch server.py devices.json .env

The project uses four files:

File Purpose
server.py MCP server entry point — defines all tools and resources
devices.json Device inventory: hostnames, IPs, device types
.env SSH credentials (never committed to version control)
requirements.txt Dependency pins for reproducible deploys

Populate devices.json with your device inventory:

// devices.json

{ "devices": [ { "name": "core-rtr-01", "host": "10.0.0.1", "device_type": "cisco_ios_xe", "port": 22, "description": "Core router, AS 65001, POP-NYC" }, { "name": "dist-sw-01", "host": "10.0.0.10", "device_type": "cisco_ios", "port": 22, "description": "Distribution switch, Building A" }, { "name": "edge-rtr-01", "host": "10.0.0.2", "device_type": "cisco_ios_xe", "port": 22, "description": "Edge router, internet-facing, AS 65001" } ] }

Add SSH credentials to .env. Do not hardcode them in server.py:

# .env — add this to .gitignore immediately

SSH_USERNAME=netadmin SSH_PASSWORD=your-secure-password-here SSH_SECRET=your-enable-secret-here # enable password if required

Before anything else: Add .env to your .gitignore. If you push credentials to GitHub, rotate them immediately — GitHub’s secret scanning catches it but not before it’s been indexed.

3. Build the Netmiko Connection Layer

The connection layer handles SSH sessions. It reads credentials from environment variables, looks up a device by name in the inventory, and returns a Netmiko connection object. Every MCP tool function calls this layer rather than building its own connection.

One decision worth making early: pooling or per-call connections. Persistent connection pooling is faster but more complex to manage. Per-call connections are slower (SSH handshake on every tool call) but stateless and simpler. For a first build, per-call is fine. You can add pooling later once you know the access patterns.

# server.py — Part 1: imports and connection layer

import json import os import logging from pathlib import Path from typing import Any from dotenv import load_dotenv from netmiko import ConnectHandler, NetmikoAuthenticationException, NetmikoTimeoutException from mcp.server import Server from mcp.server.stdio import stdio_server import mcp.types as types # Load .env credentials load_dotenv() # Set up logging — write to file so stdout stays clean for MCP JSON-RPC logging.basicConfig( filename="cisco_mcp.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) logger = logging.getLogger(__name__) # Load device inventory DEVICES_FILE = Path(__file__).parent / "devices.json" with open(DEVICES_FILE) as f: INVENTORY = {d["name"]: d for d in json.load(f)["devices"]} SSH_USERNAME = os.environ["SSH_USERNAME"] SSH_PASSWORD = os.environ["SSH_PASSWORD"] SSH_SECRET = os.environ.get("SSH_SECRET", "") # MCP server instance server = Server("cisco-network-manager") def get_connection(device_name: str) -> ConnectHandler: """Look up a device by name and open an SSH session.""" if device_name not in INVENTORY: raise ValueError( f"Device '{device_name}' not in inventory. " f"Available: {', '.join(INVENTORY.keys())}" ) device = INVENTORY[device_name] return ConnectHandler( device_type=device["device_type"], host=device["host"], port=device.get("port", 22), username=SSH_USERNAME, password=SSH_PASSWORD, secret=SSH_SECRET, conn_timeout=15, auth_timeout=15, )

Logging to file matters here. MCP uses stdout for JSON-RPC communication with the client. If anything other than valid JSON hits stdout — a print statement, a logging message, an uncaught exception traceback — the client will crash or misbehave. Route all logging to a file.

4. Write Your First MCP Tool: show_interfaces

MCP tools are Python functions decorated with @server.list_tools() for registration and called via @server.call_tool() for execution. The tool definition tells the AI what the tool does and what parameters it takes. The implementation does the actual work.

# server.py — Part 2: tool registration

@server.list_tools() async def list_tools() -> list[types.Tool]: return [ types.Tool( name="show_interfaces", description="Run 'show interfaces' on a Cisco device and return the full output. Use this to check interface status, errors, traffic counters, and line protocol state.", inputSchema={ "type": "object", "properties": { "device_name": { "type": "string", "description": "Device name from inventory, e.g. 'core-rtr-01'" }, "interface": { "type": "string", "description": "Optional: specific interface name, e.g. 'GigabitEthernet0/0/1'. Omit for all interfaces." } }, "required": ["device_name"] } ), # more tools added below... ]

# server.py — Part 3: tool execution handler

@server.call_tool() async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: try: if name == "show_interfaces": return [types.TextContent(type="text", text=_show_interfaces(**arguments))] # other tool dispatches added below... else: return [types.TextContent(type="text", text=f"Unknown tool: {name}")] except ValueError as e: return [types.TextContent(type="text", text=f"Error: {e}")] except NetmikoAuthenticationException: return [types.TextContent(type="text", text="SSH authentication failed. Check credentials in .env.")] except NetmikoTimeoutException: return [types.TextContent(type="text", text="Connection timed out. Device unreachable or SSH not enabled.")] except Exception as e: logger.exception("Unexpected error in tool %s", name) return [types.TextContent(type="text", text=f"Unexpected error: {type(e).__name__}: {e}")] def _show_interfaces(device_name: str, interface: str = "") -> str: """Connect to device and run show interfaces [interface].""" logger.info("show_interfaces: device=%s interface=%s", device_name, interface) conn = get_connection(device_name) try: cmd = "show interfaces" if interface: cmd += f" {interface}" output = conn.send_command(cmd, read_timeout=30) return f"[{device_name}] {cmd}\n\n{output}" finally: conn.disconnect()

The finally block on disconnect is not optional. Netmiko holds an SSH session open until you explicitly close it. Without that block, a crash in send_command leaves a dangling session. Cisco devices have a limited vty line count. You will run out faster than you expect.

5. Add More Tools: show_bgp, show_route, show_log, list_devices

Each tool follows the same pattern: register it in list_tools(), dispatch it in call_tool(), implement it as a private function. Here are the remaining read-only tools:

# server.py — Implementation functions for remaining read-only tools

def _show_bgp_summary(device_name: str) -> str: """Return BGP neighbor summary from target device.""" logger.info("show_bgp_summary: device=%s", device_name) conn = get_connection(device_name) try: output = conn.send_command("show bgp summary", read_timeout=20) return f"[{device_name}] show bgp summary\n\n{output}" finally: conn.disconnect() def _show_ip_route(device_name: str, prefix: str = "") -> str: """Return routing table, optionally filtered to a specific prefix.""" logger.info("show_ip_route: device=%s prefix=%s", device_name, prefix) conn = get_connection(device_name) try: cmd = "show ip route" if prefix: cmd += f" {prefix}" output = conn.send_command(cmd, read_timeout=30) return f"[{device_name}] {cmd}\n\n{output}" finally: conn.disconnect() def _show_log(device_name: str, lines: int = 50) -> str: """Return last N lines of syslog buffer.""" logger.info("show_log: device=%s lines=%d", device_name, lines) conn = get_connection(device_name) try: output = conn.send_command(f"show log | tail lines {lines}", read_timeout=15) return f"[{device_name}] show log (last {lines} lines)\n\n{output}" finally: conn.disconnect() def _list_devices() -> str: """Return inventory of all managed devices.""" lines = ["Managed Devices:\n"] for name, info in INVENTORY.items(): lines.append( f" {name} | {info['host']} | {info['device_type']} | {info.get('description', '')}" ) return "\n".join(lines)

Add these to your list_tools() return list and dispatch them in call_tool():

# Dispatch additions in call_tool()

elif name == "show_bgp_summary": return [types.TextContent(type="text", text=_show_bgp_summary(**arguments))] elif name == "show_ip_route": return [types.TextContent(type="text", text=_show_ip_route(**arguments))] elif name == "show_log": return [types.TextContent(type="text", text=_show_log(**arguments))] elif name == "list_devices": return [types.TextContent(type="text", text=_list_devices())]

Tool Name Cisco Command Optional Param Risk Level
list_devices Local only None None
show_interfaces show interfaces interface Read-only
show_bgp_summary show bgp summary None Read-only
show_ip_route show ip route prefix Read-only
show_log show log | tail lines (int) Read-only

6. Add a Config-Push Tool (with Safety Gates)

This is where people get nervous. Giving an AI the ability to push config changes to a router is not the same as giving it the ability to read interface stats. The stakes are different. A hallucinated show command returns garbage. A hallucinated config command can take down a BGP session.

The approach here: implement the tool, but add an explicit allowlist of command prefixes that are permitted. Anything not on the list gets rejected before Netmiko touches the device. The AI can still be asked to generate commands — it just can’t push things that don’t match the allowlist.

  Commands Blocked by the Allowlist (examples)

no ip route (deletes static routes) shutdown (shuts down interfaces)
no router bgp (nukes BGP config) crypto key zeroize (deletes SSH keys)

# server.py — Config push with allowlist validation

# Allowlist: only commands starting with these prefixes are permitted ALLOWED_CONFIG_PREFIXES: list[str] = [ "interface", "description", "ip address", "ip helper-address", "ntp server", "logging host", "snmp-server", "banner motd", "spanning-tree", "switchport", ] def _validate_config_commands(commands: list[str]) -> tuple[bool, str]: """ Check every command against the allowlist. Returns (True, "") if all pass, (False, reason) if any fail. """ for cmd in commands: cmd_clean = cmd.strip().lower() # Skip blank lines and comments if not cmd_clean or cmd_clean.startswith("!"): continue # Explicitly block 'no' prefix commands from the allowlist if cmd_clean.startswith("no "): return False, f"'no' prefix commands are blocked: '{cmd}'" # Check against allowlist if not any(cmd_clean.startswith(prefix) for prefix in ALLOWED_CONFIG_PREFIXES): return False, f"Command not in allowlist: '{cmd}'" return True, "" def _push_config(device_name: str, commands: list[str], dry_run: bool = True) -> str: """ Push configuration commands to a Cisco device. dry_run=True (default) validates and returns what WOULD be sent without applying. dry_run=False actually pushes to the device. """ logger.info( "push_config: device=%s dry_run=%s commands=%s", device_name, dry_run, commands ) # Validate before any SSH connection is made allowed, reason = _validate_config_commands(commands) if not allowed: return f"BLOCKED: {reason}\nNo changes were made." if dry_run: cmd_list = "\n".join(f" {c}" for c in commands) return ( f"DRY RUN — Commands validated, not applied to {device_name}:\n\n{cmd_list}\n\n" f"All commands passed allowlist check. Set dry_run=false to apply." ) # Live push conn = get_connection(device_name) try: output = conn.send_config_set(commands) conn.save_config() # write memory return ( f"Config applied to {device_name}. 'write memory' executed.\n\n" f"Output:\n{output}" ) finally: conn.disconnect()

The dry_run parameter defaults to True for a reason. The AI will almost always ask “would you like me to apply this?” before setting dry_run=false — but you want that to be a conscious user decision, not a default behavior. Make the safe path the default path.

7. Add Device Inventory as an MCP Resource

MCP has two mechanisms for giving information to the AI: tools (functions it calls) and resources (data it can read). The device inventory works well as a resource — the AI can look at it to understand what devices exist before deciding which tool to call. Resources are read-only and don’t require argument handling.

# server.py — MCP resource: device inventory

from mcp.server import Resource import mcp.types as types @server.list_resources() async def list_resources() -> list[types.Resource]: return [ types.Resource( uri="cisco://inventory", name="Device Inventory", description="List of all managed Cisco devices with hostname, IP, type, and description.", mimeType="application/json", ) ] @server.read_resource() async def read_resource(uri: str) -> str: if uri == "cisco://inventory": return json.dumps( {name: {k: v for k, v in info.items() if k != "host"} for name, info in INVENTORY.items()}, indent=2 ) raise ValueError(f"Unknown resource URI: {uri}")

Notice the resource strips the "host" field before returning. There’s no reason to expose internal IP addresses to the AI context. It knows the device name; the MCP server resolves the IP internally. This also means changing an IP in devices.json doesn’t require any AI prompt updates.

8. Connect the Server to Claude Desktop

First, add the MCP server entry point at the bottom of server.py:

# server.py — bottom of file: entry point

import asyncio async def main(): async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, server.create_initialization_options() ) if __name__ == "__main__": asyncio.run(main())

Now register the server in Claude Desktop. On macOS, the config file is at ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows it’s %APPDATA%\Claude\claude_desktop_config.json. Add the server entry:

// claude_desktop_config.json

{ "mcpServers": { "cisco-network-manager": { "command": "/path/to/cisco-mcp-server/.venv/bin/python", "args": ["/path/to/cisco-mcp-server/server.py"], "env": { "SSH_USERNAME": "netadmin", "SSH_PASSWORD": "your-password-here", "SSH_SECRET": "your-enable-secret" } } } }

Use absolute paths everywhere. Claude Desktop starts the MCP server in its own working directory, not yours. Relative paths for the Python executable, the script, or devices.json will silently fail. After saving the config, restart Claude Desktop and look for the server name in the tools panel at the bottom of the chat window. A green indicator means it connected successfully.

9. Test the Full Flow

With the server running and connected, try these prompts in Claude Desktop. They test progressively more complex behavior:

Prompt What It Tests
“What devices do you have access to?” Resource read + inventory display
“Check if GigabitEthernet0/1 on core-rtr-01 is up” show_interfaces with specific interface arg
“How many BGP peers does edge-rtr-01 have and which ones are down?” show_bgp_summary + AI reasoning on output
“Is there a route to 8.8.8.8 on core-rtr-01?” show_ip_route with prefix parameter
“Show me the last 20 syslog lines from dist-sw-01 and tell me if anything looks wrong” show_log + AI analysis of log content
“Add description ‘Uplink to ISP’ to GigabitEthernet0/0 on edge-rtr-01” push_config dry run + allowlist check
“Shut down interface GigabitEthernet0/0 on core-rtr-01” push_config blocked by allowlist (“shutdown” prefix not allowed)

The last two prompts are the important ones. The description change should result in Claude proposing the config, showing you the dry-run output, and asking for confirmation before setting dry_run=false. The shutdown request should be blocked and Claude should tell you exactly why.

Debugging Tips

If the tool shows as connected but calls fail silently, check cisco_mcp.log in the server directory. If the server doesn’t appear in Claude Desktop at all, run python server.py directly from the terminal — import errors and syntax problems surface immediately. If a tool call hangs, the conn_timeout value in get_connection() controls how long Netmiko waits before giving up.

10. Security Considerations

The MCP server runs locally and only you control which AI client connects to it. That’s a reasonable starting point. But a few things are worth getting right before this touches production devices.

Risk Mitigation
Credentials in config file Use a secrets manager (HashiCorp Vault, AWS Secrets Manager) or at minimum a well-permissioned .env file with chmod 600
Shared SSH account Create a dedicated device user (e.g., mcp-agent) with the minimum privilege level needed. Read-only operations: priv 1. Config push: priv 15 with TACACS command authorization.
No audit trail The cisco_mcp.log file captures every tool call with device name and command. Consider shipping this to your SIEM or at least rotating it with logrotate.
AI prompt injection The allowlist in _validate_config_commands() is your primary defense. Even if someone crafts a prompt that tricks the AI into calling push_config with dangerous commands, the server rejects them before SSH is used.
Unencrypted SSH host keys Enable SSH host key verification in Netmiko by passing use_keys=True and pre-populating known_hosts. Skipping this is acceptable in a lab; it’s not acceptable in production.

On the Cisco side: Configure SSH ACLs to restrict which source IPs can reach vty lines. The MCP server’s IP should be the only one permitted for the mcp-agent account. TACACS+ command authorization on top of that gives you a network-side record of every command that ran, independent of the MCP log file.

11. Frequently Asked Questions

Does this work with Cisco NX-OS and ASA, or only IOS/IOS-XE?

Netmiko supports over 80 device types. For NX-OS, use cisco_nxos as the device_type in devices.json. For ASA, use cisco_asa. The SSH handling and command dispatch in the MCP server doesn’t change — only the device_type and the specific commands you expose as tools need to match the target OS. Note that NX-OS and ASA have different command syntax, so your tool descriptions should reflect that.

Can I use NETCONF or RESTCONF instead of SSH/Netmiko?

Yes. Replace the Netmiko connection layer with the ncclient library for NETCONF or plain requests for RESTCONF. The MCP tool structure stays the same. NETCONF is worth considering for IOS-XE 16.8+ because it returns structured XML/YANG data rather than raw CLI output, which the AI can parse more reliably than scraping text. The trade-off is that NETCONF configuration is more verbose to write.

Is this the same as just giving Claude a Python interpreter?

No, and the difference matters. A Python interpreter gives the AI arbitrary code execution. This MCP server gives it a fixed set of named tools with defined inputs and validated outputs. The AI can only do what the tools permit. It can’t write new code, install libraries, or access anything not wired through an MCP tool. That constraint is the security model.

Can multiple engineers share one MCP server?

Not directly with the stdio transport. The stdio model is one server per client session. For a shared deployment, you’d need to run the MCP server over HTTP with SSE transport (the MCP SDK supports this) and deploy it as a service behind authentication. That’s a more complex setup. For individual use or small teams each running their own instance, stdio is fine and simpler to manage.

What happens if a Cisco device has a slow response and times out?

Netmiko’s read_timeout parameter in send_command() controls how long it waits for output before raising a ReadTimeout exception. The call_tool() handler catches that as a generic Exception and returns an error message to the AI. The AI will report the timeout to you. Increase read_timeout for commands that return large outputs, like show tech or full route tables on large routers.

Where do I go from here?

A few natural extensions: add a show_version tool and have Claude summarize the software version across all devices; add a diff_running_startup tool to detect unsaved config changes; integrate with a CMDB so the AI can cross-reference device roles with operational state; or swap Netmiko for NAPALM if you want structured getters that work consistently across vendors. The MCP server pattern scales as far as you’re willing to take it.

What You Built

MCP Server A Python process that exposes Cisco device access as typed, named tools the AI can call
Netmiko Layer SSH connection manager that talks to IOS, IOS-XE, NX-OS, or any Netmiko-supported platform
Read-only Tools show_interfaces, show_bgp_summary, show_ip_route, show_log, list_devices
Config Tool push_config with allowlist validation, “no” prefix blocking, and dry_run-by-default safety gate
MCP Resource Device inventory readable by the AI for context before tool selection
Claude Integration Claude Desktop config wiring so the AI can use all tools in natural language conversations
Tags: MCP Cisco IOS-XE Netmiko Network Automation Claude Desktop Python AI for NetOps NetDevOps