SONiC MCP Server
by owroc
README.md
# SONiC MCP Server — gNMI variant / pyGNMI
An MCP server that exposes a SONiC switch's **gNMI** interface as tools, so any
MCP-capable LLM can read switch state and change configuration. It speaks gNMI
natively in Python via [pyGNMI](https://github.com/akarneliuk/pygnmi) — no
external binary required — and serves MCP over Streamable HTTP, packaged as a
**portable Docker container** you can run on any Docker host.
```
┌─────────────┐ Streamable HTTP ┌──────────────────────┐ gNMI/gRPC ┌──────────────┐
│ LLM client │ ─────────────────► │ sonic-mcp container │ ────────────► │ SONiC switch │
│ (Claude, │ POST /mcp │ FastMCP + pyGNMI │ :8080 │ gnmi/telemetry│
│ IDE, etc.) │ ◄───────────────── │ │ ◄──────────── │ container │
└─────────────┘ └──────────┬───────────┘ └──────────────┘
devices.yaml (bind-mounted)
```
> **Security posture is lab-grade by default.** gNMI runs insecure (plaintext),
> MCP auth is off, and writes execute live. An LLM with a write-enabled connection
> to your switches can disrupt the network
## Quick start (Docker)
```bash
cp devices.example.yaml devices.yaml # then edit for your fabric
cp .env.example .env # set passwords referenced as ${VAR}
docker compose up --build
```
The MCP endpoint comes up at `http://127.0.0.1:8000/mcp` and a health probe at
`http://127.0.0.1:8000/health`. The image carries no site data — `devices.yaml`
is bind-mounted and everything else is an env var, so the same image runs in any
environment.
## Configure
`devices.yaml` is a validated list of switches. Credentials may be inlined (lab)
or referenced from the environment as `${VAR}` so secrets stay out of the file:
```yaml
devices:
- name: leaf-01
host: 10.0.0.11
port: 8080 # SONiC gnmi/telemetry default; build-specific
insecure: true # plaintext gRPC (no TLS)
username: admin
password: ${LEAF01_PASSWORD}
tags: [lab, leaf]
- name: spine-01
host: 10.0.0.1
read_only: true # refuses config changes regardless of server setting
tags: [prod, spine]
```
Every tool takes a `device` argument that must match a `name` here — an LLM never
addresses a raw IP, so the `read_only` guard can't be sidestepped. Mark production
switches `read_only: true`.
> **Set the gNMI port.** It's build-specific — commonly **8080**, but 50051 /
> 6030 / 57400 are seen. The probe (below) confirms it.
## Probe first — this is the gate
SONiC's OpenConfig coverage varies by branch, `Set` support is a compile-time
flag, and path semantics are discovered empirically. Before trusting any tool
against real hardware, run the standalone probe (needs only `pip install pygnmi
pyyaml`):
```bash
python scripts/probe.py --inventory devices.yaml
python scripts/probe.py --inventory devices.yaml --test-write # LAB switches ONLY
```
It writes `probe-results.json` enumerating, per device: reachability, gNMI
version, supported models/encodings, which candidate paths return data, whether
Subscribe works, and (with `--test-write`) whether a reversible Set is accepted.
## Local development (without Docker)
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" # runtime + pytest/ruff
SONIC_MCP_INVENTORY_PATH=./devices.yaml python -m sonic_mcp # HTTP on :8000
SONIC_MCP_TRANSPORT=stdio SONIC_MCP_INVENTORY_PATH=./devices.yaml python -m sonic_mcp
pytest # 53 tests, no switch required
ruff check src tests
```
## Connect an LLM
Model-agnostic — anything that speaks MCP works.
**HTTP client / orchestrator / automation** — point your MCP client at
`http://<host>:8000/mcp`.
**Claude Desktop (stdio)** — edit
`~/Library/Application Support/Claude/claude_desktop_config.json`. Use the venv
Python that has the deps, and an **absolute** inventory path (Claude Desktop
launches with a minimal PATH):
```json
{
"mcpServers": {
"sonic-gnmi": {
"command": "/abs/path/.venv/bin/python",
"args": ["-m", "sonic_mcp"],
"env": {
"SONIC_MCP_TRANSPORT": "stdio",
"SONIC_MCP_INVENTORY_PATH": "/abs/path/devices.yaml"
}
}
}
}
```
## Tools (19)
Call `list_devices` first to learn the valid `device` names (credential-free).
| Group | Tools |
|---|---|
| **Discovery** | `list_devices`, `get_capabilities` |
| **Read** | `get_interface_status`, `get_interface_counters`, `get_lldp_neighbors`, `get_bgp_neighbors`, `get_environment`, `get_device_identity`, `get_config` |
| **Telemetry** | `subscribe_telemetry` (bounded by sample count *and* wall-clock) |
| **Write** | `set_interface_admin`, `configure_interface_ip`, `create_vlan` |
| **Ops** | `backup_config`, `diff_config`, `rollback_config`, `save_config` |
| **Escape hatches** | `gnmi_get`, `gnmi_set` |
## Safety model
Three independent rails stand between an LLM and a live switch:
1. **Read-only refusal.** `SONIC_MCP_READ_ONLY=true` disables every write
server-wide; any device flagged `read_only` refuses writes regardless. The
guard trips before a SetRequest is built.
2. **Dry-run.** Every write tool accepts `dry_run`; a dry run returns the exact
SetRequest without touching the Set RPC. `SONIC_MCP_DEFAULT_DRY_RUN=true` makes
preview the default.
3. **Prior-state + backup.** The client captures the targeted subtree before each
write (inline undo); with `SONIC_MCP_AUTO_BACKUP=true` a full-config snapshot is
also written to the backup dir first.
Every mutation attempt — refused, dry-run, applied, or errored — is recorded to
the audit log (`SONIC_MCP_AUDIT_LOG_PATH` for a durable JSON-lines trail).
## gNMI notes & caveats
- **Paths are OpenConfig xpaths.** List keys use `[key=value]`; the leading
`module:` prefix is the gNMI *origin*. OpenConfig (interfaces, platform) is
gNMI's sweet spot.
- **SONiC-native models may need an origin.** `get_device_identity`
(DEVICE_METADATA) and `create_vlan` (VLAN) use SONiC YANG paths whose
resolution is build-specific. Validate with `get_capabilities` / `gnmi_get`.
- **No standard save.** gNMI has no commit/save RPC and SONiC generally maps
none — writes hit *running* config only. Persist with the CLI (`config save`),
or set `SONIC_MCP_SAVE_PATH` if your build maps one.
- **Rollback is experimental.** A `get --type config` snapshot is a list of
notifications, not a set-ready tree, so replaying it as a Set `replace` may not
round-trip. Prefer targeted `gnmi_set` reverts.
## Environment variables
Every field in `src/sonic_mcp/settings.py` maps to a `SONIC_MCP_`-prefixed var;
the full annotated list is in `.env.example`. The most-used:
| Var | Default | Purpose |
|---|---|---|
| `SONIC_MCP_INVENTORY_PATH` | `/config/devices.yaml` | inventory file path |
| `SONIC_MCP_TRANSPORT` | `http` | `http` or `stdio` |
| `SONIC_MCP_HOST` / `SONIC_MCP_PORT` | `0.0.0.0` / `8000` | MCP bind address |
| `SONIC_MCP_READ_ONLY` | `false` | disable all write tools |
| `SONIC_MCP_DEFAULT_DRY_RUN` | `false` | make writes preview by default |
| `SONIC_MCP_AUTO_BACKUP` | `true` | snapshot running config before each write |
| `SONIC_MCP_BACKUP_DIR` | `/config/backups` | snapshot directory |
| `SONIC_MCP_AUDIT_LOG_PATH` | *(unset)* | JSON-lines change trail |
| `SONIC_MCP_GNMI_TIMEOUT` | `15` | per-RPC seconds |
| `SONIC_MCP_ENCODING` | `json_ietf` | gNMI value encoding |
| `SONIC_MCP_SAVE_PATH` | *(unset)* | gNMI Set path that persists running→startup |
| `SONIC_MCP_AUTH_TOKEN` | *(unset)* | require `Authorization: Bearer <token>` |
## Layout
```
src/sonic_mcp/ package (settings, inventory, gnmi, paths, safety, backup, context, server, tools/)
scripts/probe.py standalone pre-flight probe (the Phase 2 gate)
tests/ pytest suite (fake gNMI; no switch needed)
Dockerfile multi-stage, non-root
docker-compose.yml reference deployment
legacy/ the original single-file server.py (superseded; kept for reference)
```
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues