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 — 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
cp devices.example.yaml devices.yaml # then edit for your fabric
cp .env.example .env # set passwords referenced as ${VAR}
docker compose up --buildThe 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.
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:
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.
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):
python scripts/probe.py --inventory devices.yaml
python scripts/probe.py --inventory devices.yaml --test-write # LAB switches ONLYIt 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.
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 testsModel-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):
{
"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"
}
}
}
}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 |
Three independent rails stand between an LLM and a live switch:
- Read-only refusal.
SONIC_MCP_READ_ONLY=truedisables every write server-wide; any device flaggedread_onlyrefuses writes regardless. The guard trips before a SetRequest is built. - 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=truemakes preview the default. - Prior-state + backup. The client captures the targeted subtree before each
write (inline undo); with
SONIC_MCP_AUTO_BACKUP=truea 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).
- Paths are OpenConfig xpaths. List keys use
[key=value]; the leadingmodule: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) andcreate_vlan(VLAN) use SONiC YANG paths whose resolution is build-specific. Validate withget_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 setSONIC_MCP_SAVE_PATHif your build maps one. - Rollback is experimental. A
get --type configsnapshot is a list of notifications, not a set-ready tree, so replaying it as a Setreplacemay not round-trip. Prefer targetedgnmi_setreverts.
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> |
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)