netmiko-mcp-server
by nagayon-935
README.md
English | [日本語](README.ja.md)
# netmiko-mcp-server
An MCP server for operating network devices through chat. It uses `netmiko` for SSH / Telnet connectivity and supports `enable` passwords.
> **Warning:** This tool sends commands directly to network devices. Configuration-change tools are disabled by default, and even show-style commands are all denied unless a commands file is supplied. Always verify behavior in a lab environment before connecting to production devices.
## Features
- SSH / Telnet support
- `enable` password (`secret`) support
- SSH public-key authentication (`use_keys`, `key_file`) support
- MCP stdio / SSE modes
- Commands are denied by default; only commands explicitly allowed by the allow/deny lists in `commands.toml` can run
- The configuration-change tool (`set_config_commands_and_commit_or_save`) is disabled by default; enable it explicitly with `--enable-config`
- SSE mode requires Bearer token authentication (can be explicitly disabled with `--no-http-auth`, not recommended)
- Every command attempt and connection result is recorded to a JSON audit log (fail-closed: the operation itself fails if the log write fails)
- Large output is automatically saved to a file and can be read back with paging (keeps the LLM's context from being overwhelmed)
- Parallel command execution across multiple devices grouped via `[groups]`
- Structured (JSON) output via ntc-templates with `use_textfsm=True`
- Inventory `password`/`secret` values can be stored encrypted (Fernet symmetric encryption)
- `import_inventory.py` lets you build or append to the inventory interactively (no LLM tokens needed, with per-field validation)
## Usage
### 1. Define devices (TOML)
Edit `network_devices.toml` to define `[default]` and individual devices.
```toml
[default]
username = "netops"
password = "password"
secret = "enablepassword"
[router_telnet]
hostname = "192.0.2.10"
device_type = "cisco_ios_telnet"
[switch_ssh]
hostname = "192.0.2.11"
device_type = "cisco_ios"
use_keys = true
key_file = "/home/user/.ssh/id_rsa"
[c1200coreSW]
hostname = "192.0.2.12"
device_type = "cisco_ios"
pre_commands = ["terminal datadump"]
ansi_escape_codes = true
```
#### Build the inventory interactively (import_inventory.py)
Instead of editing the TOML by hand, you can build or append to the inventory with an interactive script.
```bash
uv run python import_inventory.py # default: network_devices.toml
uv run python import_inventory.py -f my_devices.toml
uv run python import_inventory.py --metadata # also prompt for public search metadata
```
- Prompts for each field (device name, hostname/IP with IPv4/IPv6/FQDN support, device_type from netmiko's platform list, etc.) with validation and re-prompting on invalid input. A wrong `device_type` shows partial-match suggestions. Enter `q` at the device-name prompt to finish and move to the save/confirm step.
- Passwords and the `enable` secret are always entered twice and must match, since neither is echoed.
- If the target file already exists, choose **append / overwrite / abort**. Append mode preserves existing comments, key order, and already-encrypted (`enc:`) values untouched. Whenever the target file already exists — append as well as overwrite — a `<filename>.bak` backup is written first (`*.toml.bak` is gitignored because it can hold credentials).
- Entering group names (comma-separated) per device automatically updates the `[groups]` table. A group may not share a name with a device: the inventory resolves device names first, so a same-named group could never be selected.
- If `NETMIKO_MCP_SERVER_INVENTORY_KEY` is set, `password`/`secret` are encrypted automatically before saving (see [Encrypting credentials](#4-encrypting-credentials-optional)). If it isn't set, you can choose to save in plaintext or abort. This is asked *before* any device is entered, so aborting never discards typed input.
- The file is written atomically with owner-only permissions (0600), and the saved file is round-trip verified through the inventory loader after writing.
- Out of scope: editing/deleting existing devices, creating the `[default]` section, and prompting for `pre_commands` / `ansi_escape_codes` / timeout fields (edit these by hand). Note that in append mode, if an existing `[groups]` table is at the end of the file, new device tables are appended after it — this is still valid TOML and loads correctly.
### 2. Command allowlist (TOML)
Create `commands.toml` to explicitly list the commands the LLM is allowed to run. **If this file is not provided, every command is denied.**
```toml
allowed_commands = [
"show version",
"show ip interface brief",
"show ip interface*", # trailing glob: matches "show ip interface" plus anything after it
"show ip route *", # space + glob: "show ip route" alone is not allowed; an argument is required
]
denied_commands = [
"show running-config", # deny always wins, even if it also matches allowed_commands
]
```
**Allowlist for configuration-change commands (when using `--enable-config`)**
Adding `config_allowed_commands`/`config_denied_commands` to the same `commands.toml` applies allow/deny checks to each line passed to `set_config_commands_and_commit_or_save` (denied entirely if unset). If even one command in the batch is denied, nothing is sent to the device.
All four command lists must be arrays of non-empty strings; malformed lists stop server startup. An empty configuration batch is rejected and audited before connecting to a device.
```toml
config_allowed_commands = [
"interface *",
"description *",
"ip address *",
"no shutdown",
]
config_denied_commands = [
"no ip address*",
]
```
In addition, `shutdown` (bringing an interface down) and `clear*` are **always denied** regardless of the above configuration (a hardcoded baseline protection — see `BASELINE_CONFIG_DENIED_COMMANDS` in `security.py`). Listing them in `config_allowed_commands` cannot override this. `no shutdown` (bringing an interface back up) is not on the dangerous side of the operation, so it is not included in the baseline deny list.
### 3. Device metadata and groups (optional)
#### Metadata
Device tables can include optional public search metadata:
```toml
[tokyo_core]
hostname = "192.0.2.20"
device_type = "cisco_ios"
site = "Tokyo"
role = "core-switch"
environment = "production"
description = "Tokyo core switch"
tags = ["bgp", "critical"]
```
Metadata is exposed to MCP clients, so it must not contain passwords or other secrets. It is not sent to Netmiko, and a device's `name` cannot override its TOML table name.
#### Groups
Add a `[groups]` table to `network_devices.toml` to run commands in parallel across a set of devices with `send_command_to_group`.
```toml
[groups]
core_switches = ["switch_ssh", "c1200coreSW"]
```
- Group members must be arrays of device-name strings. Duplicate members are executed once.
- Use `all` instead of a group name to target every device. `all` is reserved, and a group may not share a name with a device (rejected during discovery).
- Each group command reads devices and groups together once, so inventory changes take effect on the next call without mixing versions during execution.
#### Discovery tools
- `get_network_device_list` returns public metadata and group memberships.
- `get_network_group_list` lists groups and deduplicated member names.
- `find_network_devices(query="", site=None, role=None, environment=None, group=None, tag=None, limit=20)` returns candidates without connecting or executing. Free text uses case-insensitive substrings; other filters use case-insensitive exact matching, and all conditions combine with AND. `limit` is 1-100.
- The response includes `matches`, `total`, `truncated`, `requires_selection`, and `executed=false`.
- Ambiguity is calculated before truncation: present the choices to the user and execute with an exact registered name.
### 4. Encrypting credentials (optional)
If plaintext passwords in the TOML file are a concern, `password`/`secret` can be encrypted.
```bash
# 1. Generate a key and set it as an environment variable (the server needs the same key at startup)
export NETMIKO_MCP_SERVER_INVENTORY_KEY=$(uv run --frozen python main.py --generate-key)
# 2. Encrypt the password and paste the result into the TOML file
uv run --frozen python main.py --encrypt-value "mypassword"
# => enc:gAAAAA...
```
```toml
[router1]
hostname = "192.0.2.10"
device_type = "cisco_ios"
password = "enc:gAAAAA..."
```
If `NETMIKO_MCP_SERVER_INVENTORY_KEY` is not set while an encrypted value is being loaded, the server fails at startup. Keep the key out of the TOML file and manage it only through the environment variable.
### 5. Starting the server
#### Checking setup (`--doctor`)
Check setup before starting the server, using the same options and environment as the intended startup:
```bash
uv run --frozen python main.py network_devices.toml --commands-file commands.toml --doctor
```
- Validates inventory and group references, decrypts encrypted credentials for validation, and checks SSH key paths, command policies, storage accessibility, and numeric limits. With `--sse`, it also checks bind/subnet/port settings and bearer-token presence.
- Does not connect to devices, bind a port, start MCP, or write files. Passing these offline checks does not verify connectivity or guarantee later filesystem writes.
- Add `--doctor-json` for machine-readable checks and remedies.
- Exit status is 1 for errors and 0 otherwise; warnings such as deny-all are reported without failing the check.
#### stdio (local)
```bash
uv run --frozen python main.py /path/to/devices.toml \
--commands-file /path/to/commands.toml
```
Add `--enable-config` if you also want to use the configuration-change tool (disabled by default).
#### SSE (for remote connections)
SSE mode requires Bearer token authentication. First set the token as an environment variable.
```bash
export NETMIKO_MCP_SERVER_BEARER_TOKEN="$(openssl rand -hex 32)"
```
```bash
uv run --frozen python main.py /path/to/devices.toml \
--commands-file /path/to/commands.toml \
--sse --bind 10.70.72.1 --port 10000
```
Example SSE URL: `http://<server-ip>:10000/sse` (the client must send an `Authorization: Bearer <token>` header)
Starting `--sse` without `NETMIKO_MCP_SERVER_BEARER_TOKEN` set stops the server with a startup error. Only pass `--no-http-auth` explicitly if you want to run without authentication (not recommended).
#### Restricting access by subnet
In SSE mode, `--allowed-subnet` lets you specify allowed subnets (comma-separated, e.g. `10.70.72.0/24`). The default is `0.0.0.0/0`. Combining this with Bearer token authentication provides defense in depth.
```bash
uv run --frozen python main.py /path/to/devices.toml \
--commands-file /path/to/commands.toml \
--sse --bind 10.70.72.1 --allowed-subnet 10.70.72.0/24,127.0.0.1/32 --port 10000
```
#### Audit log
By default, entries are recorded in JSON Lines format at `~/.netmiko_mcp_server_audit.log`. Use `--audit-log-file /path/to/audit.log` to change the path.
#### Handling large output
By default, output exceeding 1000 lines is automatically saved under `~/.netmiko_mcp_server_outputs/<device>/` and can be read back with paging via the `list_device_outputs`/`read_device_output` tools. The threshold is configurable with `--output-save-threshold`, and the save location with `--output-dir`.
Saved files are created with owner-only permissions (0600) and unique filenames. Saving, listing, and reading reject symlinks that resolve outside the output directory. For paging, `offset` must be non-negative and `limit` must be positive.
#### Parallelism for group execution
`send_command_to_group` defaults to 10 concurrent connections. Change this with `--max-workers`.
#### Running with Docker
**Use the published image (recommended)**
GitHub Actions automatically builds and publishes an image to the GitHub Container Registry (GHCR) on every push to `main` or on `v*.*.*` tag pushes (the `publish` job in `.github/workflows/ci.yaml`). The image is only published once lint, type-check, and tests all pass.
```bash
docker pull ghcr.io/nagayon-935/netmiko_mcp_server:latest
```
Available tags:
| Tag | Meaning |
|---|---|
| `latest` | latest commit on the `main` branch |
| `sha-<short-sha>` | build for a specific commit (for traceability) |
| `v1.2.3` / `1.2` | semver tags, generated only when a `v*.*.*` git tag is pushed |
Supported architectures: `linux/amd64`, `linux/arm64` (also works with Docker/Podman on a Raspberry Pi or Apple Silicon).
> **Note for first-time publishing:** GHCR packages can default to Private even when the repository is public. If `docker pull` fails with a 403, go to the repository's GitHub page → Packages → the package's Package settings, and change Visibility to Public. Also check that Actions has `packages: write` permission under Settings → Actions → General → Workflow permissions ("Read and write permissions" must be enabled).
**Building it yourself**
```bash
docker build -t netmiko-mcp-server .
```
Then mount the device config file and the command allowlist and start the container (replace `netmiko-mcp-server` with `ghcr.io/nagayon-935/netmiko_mcp_server:latest` if you're using the published image).
```bash
docker run -d -p 10000:10000 \
-v $(pwd)/network_devices.toml:/app/config.toml \
-v $(pwd)/commands.toml:/app/commands.toml \
-e NETMIKO_MCP_SERVER_BEARER_TOKEN="$(openssl rand -hex 32)" \
--name netmiko-mcp netmiko-mcp-server \
--sse --port 10000 --commands-file /app/commands.toml
```
## MCP tools
MCP calls return structured results in `structuredContent` and the same JSON as text. Success is `{"ok": true, "data": ...}`; failure is `{"ok": false, "error": {"code": ..., "message": ..., "next_action": ..., "retryable": false, "execution_state": ...}}` with MCP `isError=true`. This changes the MCP response shape; clients should read `data` instead of assuming raw output. Python helper calls retain their text/parsed-output interface.
Errors distinguish policy denial, missing inventory/devices, authentication failure, connection timeout, output storage, and audit-write failure. Raw connection exceptions are not returned. Failed configuration operations report `execution_state="unknown"` and `retryable=false`: inspect device state before repeating them. Output-save failures report `execution_state="completed"` because the command already ran. Group results contain a `data` map of per-device envelopes plus a `summary` of total/succeeded/failed; partial failure sets `ok=false` without discarding successful output. No automatic retries are performed.
| Tool | Description |
|---|---|
| `get_network_device_list` | Returns the list of all devices in the inventory (no credentials included) |
| `get_network_group_list` | Lists groups and exact registered member names |
| `find_network_devices` | Searches public metadata and returns candidates without executing |
| `send_command_and_get_output` | Sends a command to a single device, with `use_textfsm` and `save_output` options |
| `send_command_to_group` | Runs a command in parallel across a device name, group name, or `all`, with `use_textfsm` and `save_output` options |
| `list_device_outputs` | Lists saved output files |
| `read_device_output` | Reads a saved output file with paging |
| `set_config_commands_and_commit_or_save` | Sends configuration-change commands (requires `--enable-config`) |
## Using it from the Gemini CLI (example)
Register the server in the Gemini CLI's MCP configuration.
```json
{
"mcpServers": {
"netmiko server": {
"url": "http://<server-ip>:10000/sse"
}
}
}
```
If Bearer token authentication is enabled (the default), the client also needs to be configured to send an `Authorization: Bearer <token>` header. How to set headers varies by AI client, so check your client's MCP server configuration documentation. This is not needed if authentication was disabled with `--no-http-auth` (not recommended).
## Notes
- Set `device_type` to a name supported by `netmiko`.
- If `secret` is set, `enable()` is attempted automatically.
- Show-style tools (`send_command_and_get_output`, `send_command_to_group`) use `allowed_commands`/`denied_commands`; `set_config_commands_and_commit_or_save` uses `config_allowed_commands`/`config_denied_commands` and also requires `--enable-config`. Everything is denied when the relevant list is unset (see [Command allowlist](#2-command-allowlist-toml)).
- Only trusted operators should use configuration commands, and only within the scope of `config_allowed_commands`.
## Migrating from older versions
The `--secured` and `--disable-config` flags from earlier versions have been removed.
- `--secured` (prefix-based blocklist) → replaced by the allow/deny list in `--commands-file`
- `--disable-config` (enabled by default, opt-out) → replaced by `--enable-config` (disabled by default, opt-in)
## License
MIT License. See [LICENSE](LICENSE) for details.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues