← All Posts POST #07 · · 7 min read

MCP: Giving AI Agents the Keys to the (Carefully Segmented) Kingdom

Building four custom MCP servers so Claude can query your Juniper switch, check OPNsense firewall rules, monitor Aruba APs, and manage the Synology NAS. What could go wrong.

#mcp#juniper#opnsense#aruba#synology#claude-code

The Model Context Protocol (MCP) is Anthropic’s answer to a practical question: how do you give an AI agent structured, safe access to real systems without hardcoding a thousand brittle wrappers or handing it raw root credentials?

Each MCP server exposes a set of typed tools over a standard JSON-RPC pipe. The AI client calls those tools, and you get clear boundaries, auditability, and the ability to cut off access by stopping a process.

I built four of them - one for each major piece of lab gear.

Why Custom MCP Servers

Off-the-shelf MCP servers exist for GitHub, Slack, and Postgres. Off-the-shelf MCP servers for a Juniper switch, an OPNsense firewall, an ArubaOS 8 wireless controller, and a Synology NAS didn’t exist in any form I wanted to run. So I wrote my own.

All four use stdio transport instead of SSE (Server-Sent Events). SSE has annoying edge cases behind Docker networks and reverse proxies, whereas stdio is just stdin and stdout over a local Python process - simple, fast, and rock solid.

Juniper MCP: Network Switch Management

The Juniper server wraps junos-eznc (PyEZ, Juniper’s Python NETCONF library) and exposes eleven tools:

  • execute_junos_command - Run CLI commands (show interfaces, show route, etc.)
  • get_junos_config - Fetch the device configuration
  • junos_config_diff - Compare rollback versions
  • gather_device_facts - Pull model, Junos version, and uptime
  • get_router_list - List configured switches from devices.json
  • load_and_commit_config - Push config changes (set, text, or XML)
  • render_and_apply_j2_template - Render and commit Jinja2 templates
  • add_device / reload_devices - Update the target list at runtime

The devices.json file maps friendly names to connection details:

{
"core-switch": {
"ip": "172.16.100.254",
"port": 22,
"username": "<ai-service-account>",
"auth": { "type": "ssh_key", "private_key_path": "~/.ssh/id_ed25519" }
}
}

It uses SSH keys only - no passwords. The switch service account is scoped to a read-only login class by default, so querying state never carries the risk of an accidental commit.

OPNsense MCP: Firewall Management

The OPNsense server uses FastMCP on top of OPNsense’s REST API:

  • Query firewall rules and aliases by interface and direction
  • Inspect DHCP leases and match them against discovery scans
  • Check routing table entries and gateway health (handy for debugging WireGuard routes)
  • Pull interface counters, tunnel status, and active state counts

At launch, a small wrapper script pulled the API key from the vault and started the server:

Terminal window
source <(vault-get-secret.sh "MCP - OPNsense API Key" notes)
export OPNSENSE_URL="https://opnsense.sdx.local"
exec /usr/bin/python3 -m src.main

Aruba MCP: Wireless Controller

The Aruba server talks to the ArubaOS 8 REST API and was built strictly read-only (show commands only). Letting an AI agent push live radio config changes to the WiFi controller is a great idea right up until it causes a broadcast storm and you spend your Saturday afternoon with a serial console cable.

Available tools:

  • List access points and their status (up/down, client count, channel utilisation)
  • Show associated clients (MAC, IP, SSID, SNR)
  • Run read-only show commands and return the parsed text
  • Check controller health and radio counters

In practice, this server gets used the most for answering “why is the WiFi slow in the back room?” - Claude checks which AP the phone is on, what channel it’s using, and whether the noise floor is high without me logging into the controller UI.

Synology MCP: NAS Management

The Synology server covers five DSM API areas:

  • FileStation - Browse shares, create/delete folders, move files
  • DownloadStation - Check and manage download tasks
  • NFS - Inspect NFS exports
  • Health - Storage pool status, volume usage, disk temperatures, and SMART attributes
  • User Management - List NAS users and groups

The health tools get called constantly - one quick prompt confirms all pools are healthy, disk temps are normal, and SMART error counters are sitting at zero.

Claude Code Integration

The servers register in Claude Code’s config so Windows spawns them inside WSL2 over stdio:

{
"mcpServers": {
"juniper": {
"command": "wsl",
"args": ["-d", "Ubuntu", "--", "bash", "-c",
"cd ~/mcp-servers/juniper && python3 jmcp.py -f devices.json -t stdio"],
"type": "stdio"
},
"opnsense": {
"command": "wsl",
"args": ["-d", "Ubuntu", "--", "bash", "-c",
"cd ~/mcp-servers/opnsense && python3 -m src.main"],
"type": "stdio"
}
}
}

When agents run natively inside WSL2, they call the exact same Python entrypoints directly without the wsl wrapper.

The Use Case: Conversational Infrastructure Triage

Before MCP, checking a firewall rule meant opening a browser, logging into OPNsense, picking the interface tab, and scanning a table. Now it’s one prompt:

“Show me the firewall rules allowing traffic from the lab VLAN to the server VLAN.”

Better still is cross-device debugging. When a new VM couldn’t mount a share on the NAS, Claude queried the OPNsense firewall rules, the Juniper switch port VLAN config, and the Synology NFS export permissions in a single turn and spotted the mismatch immediately.


Update - August 2026

All four servers are still running on stdio, and the core pattern has held up well. A few practical details around them changed:

  • The Juniper server now targets one logical switch instead of two - When the two-member EX3400 virtual chassis replaced the old EX3300s on 6 June 2026, devices.json collapsed to a single entry at 172.16.100.254. Junos jumped from 12.3 to 23.4 (which meant rewriting the switch config for ELS syntax), while the MCP server kept working without a single code change.
  • MCP servers live in .claude.json, not settings.json - I had the filename wrong in the original post. settings.json holds the proxy URL and model env vars that get rewritten by claude-mode.ps1, so putting MCP definitions there would wipe them out every time I switched backends.
  • Windows env vars don’t cross the wsl -e boundary - When a Windows process spawns a server inside WSL2, environment variables set on the Windows side don’t automatically pass through unless they’re listed in WSLENV. And SSH private keys have to sit on the native Linux ext4 filesystem (~/.ssh), not on /mnt/f, because DrvFS mounts show up as 0777 and OpenSSH refuses to touch a world-readable key.

What each server needed in practice

  • Juniper - Junos’s built-in read-only class doesn’t actually include view-configuration, so the MCP account uses a custom read-only class that can read the full config while still refusing commit. When I needed to roll back a firewall filter in July, the MCP account rightly refused and forced me to use the break-glass account.
  • OPNsense - Grew to 28 tools and uncovered a quirk in how OPNsense stores rules: legacy rules live in config.xml under Firewall -> Rules, while new automation rules live in the MVC store and are the only ones returned by the standard REST API. Until I updated the MCP server to pull and merge both, querying the API only showed half the ruleset.
  • Aruba - Ten read-only tools, and every call in August was paying a 20-second delay because the controller’s broken RADIUS config timed out three times before falling back to local auth.
  • Synology - Moved to HTTPS on nas.sdx.local:5001. I briefly gave the MCP user full admin rights so the SMART and storage health endpoints would work, then dropped it back to non-admin after a security audit flagged it.

Claude Code isn’t the only client any more. Homer (the Hermes Agent on UbuntuClaw) uses these exact same MCP servers over stdio alongside the Home Assistant server. Write once, use from every agent.


Update - October 2026

  • Every mutable infrastructure server is now split into read-only and -write instances - Once the total tool count passed 110 across the lab, loading write tools into every coding session was both a context-window tax and an unnecessary risk. Default sessions now load five read-only infrastructure servers (Juniper, OPNsense, Aruba, Synology, plus a fifth server for camera and alarm telemetry) using read-only accounts. Mutating tools live in separate -write server configs that only load when I start a session with --admin.
  • Three of the August annoyances are gone:
    • Aruba’s 20-second RADIUS delay is now zero - Adding the Aruba-Admin-Role vendor attribute in Authentik fixed RADIUS on the controller so logins succeed on the first packet. I also added an opt-in aruba-write server that runs config commands over SSH CLI where the REST API falls short.
    • OPNsense’s two rule stores are now one - I migrated all the legacy XML rules into the MVC store in September (legacy_count = 0), so there’s only one source of truth again.
    • Plaintext .env files on the Linux filesystem were replaced by the vault CLI - Credentials now get injected into MCP server subprocesses at launch from an OS-cached age session, leaving zero .env files on disk.