Writeup | AI Automation & Infrastructure

Self-Hosted MCP Server for AI Homelab Control

Built a custom Model Context Protocol server running on a dedicated Proxmox LXC. It exposes 55 validated tools for OPNsense, Proxmox, AdGuard, NPMplus, Mailcow, Wazuh, Jellyfin, and other lab services so MCP-compatible clients can run approved checks and service actions over SSH.

MCP Node.js OPNsense API Proxmox Claude Code Wazuh Gemini CLI stdio transport

The Problem

When an assistant needs to interact with homelab infrastructure — check DHCP leases, restart a service, add a DNS rewrite — the normal workflow is slow: open a terminal, SSH to the right host, find the right API endpoint, format the JSON body, parse the response, and bring the output back into the working context.

MCP (Model Context Protocol) solves this at the protocol level. An MCP server exposes tools as structured functions. MCP-compatible clients — Claude Code, Gemini CLI, VS Code Copilot, Cursor — call those tools natively and receive structured results. That removes copy-pasting, repeated terminal switching, and manually formatted API requests.

The goal: one server, one SSH connection, the AI calls what it needs.

Architecture

  • Transport: stdio over SSH — the AI client SSHes into LXC 130 and pipes MCP protocol through the connection. No exposed HTTP port, no auth surface beyond SSH key.
  • Runtime: Node.js 20 LTS on a minimal Debian 12 LXC. 64 MB RAM idle — lighter than running a Docker container.
  • Structure: index.js registers all tools. Each integration lives in its own module under tools/adguard.js, opnsense.js, proxmox.js, etc.
  • Auth: Every tool module reads credentials from local runtime configuration at startup. API keys, passwords, and SSH keys are never passed over the MCP connection itself.
  • Schema: Tools use zod for input validation. The MCP SDK generates the JSON Schema exposed to the client automatically.

How a Tool Call Works

  • AI client sends: {"tool": "adguard_add_rewrite", "input": {"domain": "test.lab", "answer": "[internal-ip]"}}
  • MCP server calls the handler, which POSTs to AdGuard's REST API at http://[adguard-internal-ip]:3001 with Basic auth.
  • Handler returns a plain-text result: "Added rewrite: test.lab → [internal-ip]"
  • AI client gets structured content: [{"type": "text", "text": "..."}] back and reasons on the result.
  • From the client perspective: call the tool, receive the result, and keep the workflow in one place.

Tools Exposed — 55 Total

Grouped by service. Each tool is callable by MCP-compatible clients over SSH.

AdGuard Home

  • List / add / delete DNS rewrites
  • Get / set custom filtering rules
  • Query log (recent DNS lookups)
  • Status (version, stats)

Proxmox

  • List all LXCs and status
  • Execute command inside any LXC
  • Start / stop / status LXC
  • List all cluster nodes
  • Node CPU / RAM / uptime
  • Create LXC, backup LXC
  • List backups

OPNsense

  • Raw API call passthrough
  • DHCP leases (dnsmasq)
  • Firmware version / update info
  • Restart any service
  • List firewall aliases
  • List firewall rules
  • Add DHCP reservation
  • List interfaces
  • ARP table
  • Add Unbound DNS override

NPMplus

  • List all proxy hosts
  • Add / delete / update proxy host
  • List SSL certificates
  • Enable / disable proxy host
  • Trigger cert renewal
  • Find proxy host by domain
  • Get access/error logs

Mailcow

  • List mailboxes
  • Add / delete mailbox
  • List aliases
  • Add alias

Monitoring

  • Uptime Kuma — all monitor statuses
  • Wazuh — recent security alerts
  • Wazuh — agent connection status
  • Proxmox backup job results

Services

  • Authentik — list users / applications
  • Headscale — list Tailscale nodes
  • Jellyfin — server status, recent media
  • Home Assistant — entity states, service calls

Non-Obvious Engineering Problems

AdGuard binding issue

Service bound to old IP

AdGuard Home was still bound to a legacy management address from before the VLAN migration. After migration, AdGuard's management interface was unreachable from the MCP LXC. Fix: edited AdGuardHome.yaml via SSH to bind to all local interfaces and restarted. Also updated the NPMplus proxy host that was still pointing at the old address.

NPMplus auth

Cookie-based auth, not Bearer token

NPMplus uses a session cookie (__Host-Http-token) not a Bearer JWT like the original Nginx Proxy Manager. The login endpoint sets the cookie via Set-Cookie header. The initial implementation tried Bearer auth and got 401s. Also, NPMplus only serves HTTPS — hitting the plain HTTP port gets a 308 redirect. Fixed by using node-fetch with a custom https.Agent({ rejectUnauthorized: false }) and extracting the session cookie.

OPNsense API

GET requests rejecting JSON bodies

OPNsense returns 400 Invalid JSON syntax if you send a body on a GET request. Several endpoints (firmware info, alias list) were implemented as GET but needed POST per the actual OPNsense PHP controllers. Fixed by auditing each endpoint against the controller source and adding a method !== 'GET' guard before attaching the request body.

DHCP leases

No API endpoint for dnsmasq leases

OPNsense's dnsmasq plugin has no REST endpoint that returns current leases. Tried /dhcpv4/leases/searchLease and /dnsmasq/leases/searchLease — both 404. dnsmasq writes leases to a flat file at /var/db/dnsmasq.leases. Fix: SSH to OPNsense via firstmoon and read the file directly with sshpass.

Firewall inter-VLAN

NPMplus blocked from reaching Mailcow

NPMplus (VLAN 7/DMZ) proxying to Mailcow (VLAN 6/Internal_Services) was returning 502. The DMZ VLAN has a default-deny RFC1918 block rule. The fix — adding a pass rule for [npmplus-ip] → [mailcow-ip]:443 — had to be inserted before the block rule. New rules added via API land at the end of the list. Solved by reading the block rule's sequence number (2200) and re-adding the pass rule with sequence 2199.

Real Tasks Completed With the MCP Server

Operational tasks completed through controlled MCP tool calls.

NIC Swap Recovery

After a hardware swap broke all VLANs by changing firewall interface names, used OPNsense API tools to migrate VLAN parent interfaces and fix scrambled DHCP ranges in a single session.

Proxy Host Audit

Called npmplus_list_proxyhosts, tested all 28 backends, identified two broken ones (headplane 404, firstwing 401), diagnosed root causes, and corrected both through the MCP workflow.

Firewall Debugging

Diagnosed a 502 on webmail by calling opnsense_list_firewall_rules, identifying the blocking rule, and adding the correct pass rule with the right sequence number via the API.

DNS + Service Ops

Added DNS rewrites, checked DHCP leases, restarted services, queried Wazuh for recent alerts, and checked Uptime Kuma monitor statuses through the MCP server.

Connecting an AI Client

Works with any MCP-compatible client over stdio transport via SSH.

Claude Code

{"mcpServers": {"homelab": {"command": "ssh","args": ["-i", "~/.ssh/id_ai_assistant","-o", "StrictHostKeyChecking=no","aiassistant@[mcp-lxc-ip]","node /opt/homelab-mcp/index.js"]}}}

VS Code / Cursor / Gemini CLI

Same SSH stdio config — just placed in the right settings file for each client:

  • VS Code: user settings.json under "mcp.servers"
  • Cursor: .cursor/mcp.json in project root
  • Gemini CLI: ~/.gemini/settings.json under "mcpServers"

The server runs over stdio — the SSH connection is the transport layer. No HTTP port exposed, no extra auth surface.