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.
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.jsregisters all tools. Each integration lives in its own module undertools/—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
zodfor 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]:3001with 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
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.
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.
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.
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.
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.jsonunder"mcp.servers" - Cursor:
.cursor/mcp.jsonin project root - Gemini CLI:
~/.gemini/settings.jsonunder"mcpServers"
The server runs over stdio — the SSH connection is the transport layer. No HTTP port exposed, no extra auth surface.