skip to content
BitsAndBytes
Table of Contents

I’ve wanted my AI assistant to actually do things in Home Assistant for a while — not just answer questions about my homelab, but read entity states, edit automations, pull logs, and fix things in place. The Model Context Protocol (MCP) is the plumbing that makes that possible, and there’s a genuinely capable HA MCP server out there. This post is the write-up of getting it running in Docker on Proxmox, hardened, secret-managed, and locked down to localhost.

If you want Claude (or any MCP client) to manage your Home Assistant and you care about not leaving a full-access token flapping in the breeze, this is for you.

What MCP is, and why bridge it to Home Assistant

MCP is a small open protocol that lets an AI client call tools exposed by a server. The client (Claude Code, in my case) discovers the server’s tools, then calls them with structured arguments and gets structured results back. It’s the difference between an assistant that can talk about your house and one that can operate it.

Home Assistant is the perfect thing to put behind MCP: it already has a complete API over every entity, service, automation, and dashboard. Wrap that in MCP and your agent can do everything from “turn off the upstairs lights” to “show me the trace for the motion automation that keeps misfiring” without you ever opening the UI.

”Is it even needed?” — ha-mcp vs HA’s built-in MCP Server

Fair question, because Home Assistant ships an official MCP Server integration. So why run a third-party one?

Because they’re not the same class of tool:

  • HA’s built-in MCP Server integration exposes your Assist intents — basically the voice-assistant action surface over exposed entities. Great for “turn on the lamp,” thin for anything else. You can’t edit an automation with it.
  • homeassistant-ai/ha-mcp exposes roughly 90 tools: entity state and history, service calls, bulk control, automation/script/scene CRUD, dashboard management, HACS search and install, area/floor/label management, system health (including ZHA and Z-Wave network diagnostics), logs, traces, backups, template evaluation. It’s a full admin surface, not just an intent runner.

Given how I actually use HA — hand-tuning automations, running HACS forks, poking at Zigbee diagnostics — the built-in one would’ve been a toy. So yes, for this use case the third-party server earns its place. If all you want is “Siri but local,” the built-in integration is lighter and you can stop reading.

Where to host it

I run everything on a single Proxmox host (an MS-01), with a deliberate separation of concerns across VMs and LXCs. The candidates for hosting the MCP server:

HostWhat it isVerdict
haos VMHome Assistant OS itselfCleanest if you want zero maintenance — install it as a supervised add-on. But it’s the most locked-down environment to customize.
ubuntu-ai VMMy general AI/Docker box — already runs n8n, Postgres, Ollama, and Claude Code itselfWinner. Docker is already here, the compose pattern is established, and the MCP client lives on this same box.
An agent LXCA small container that consumes MCPWrong layer — that’s the client side. Running the server here blurs the boundary.

The thing that made the decision easy: this box already has direct reachability to HAOS. My Home Assistant runs on the IoT VLAN (VLAN 100), and the AI VM has a route onto that VLAN, so it can hit HAOS directly with no extra firewall plumbing:

Terminal window
# from the AI VM
curl -sS -o /dev/null -w "HTTP %{http_code} in %{time_total}s\n" \
--max-time 3 http://192.168.100.187:8123/
# HTTP 200 in 0.003524s

3.5 milliseconds to HAOS. Same host as the MCP client. Docker already running. Decision made: host it on the AI VM.

The shape of it

Here’s the whole thing on one host. The key detail — and the security crux of this whole post — is the dashed boundary: the MCP server is published on loopback only, so the client reaches it but the LAN cannot.

flowchart TB
subgraph host["🖥️ Proxmox Host · MS-01"]
direction TB

subgraph aivm["ubuntu-ai VM · 192.168.1.41 · main LAN"]
direction TB
CC["Claude Code<br/><span style='font-size:0.8em'>MCP client</span>"]
OP["🔐 1Password · op CLI<br/><span style='font-size:0.8em'>op://AI Assistant/…</span>"]

subgraph lo["loopback only — LAN cannot reach this"]
HM["ha-mcp container · Docker<br/><span style='font-size:0.8em'>read-only · cap_drop ALL · 256M</span><br/><span style='font-size:0.8em'>127.0.0.1:8086</span>"]
end
end

subgraph haosvm["haos VM · VLAN 100"]
HA["🏠 Home Assistant<br/><span style='font-size:0.8em'>192.168.100.187:8123</span>"]
end
end

CC -->|"POST /mcp · 127.0.0.1:8086"| HM
OP -.->|"token injected at startup<br/>op run --env-file"| HM
HM ==>|"REST + WebSocket · authed<br/>routed onto VLAN 100 · ~3.5 ms"| HA

classDef boundary stroke:#e06c75,stroke-width:2px,stroke-dasharray:6 4,fill:transparent;
classDef vm fill:transparent,stroke:#5c6370,stroke-width:1px;
class lo boundary;
class aivm,haosvm,host vm;

Three flows worth tracing on that diagram: Claude Code → ha-mcp stays entirely on loopback inside the VM; ha-mcp → Home Assistant crosses to the IoT VLAN over authenticated REST and websocket; and the token flows up from 1Password into the container only at startup, never to disk.

Docker or uvx?

The upstream docs lead with uvx (run it as a subprocess) and also support Docker and a HAOS add-on. I went back and forth here, and it’s worth being honest about the trade:

  • uvx + a systemd unit is arguably cleaner for a single Python tool — no container layer, logs straight to journald, uvx ... upgrade instead of pull-and-recreate.
  • Docker wins when you already have a ~/docker/ directory full of compose files with a consistent restart/logging/upgrade surface — which I do. It also makes the security hardening (read-only rootfs, dropped capabilities, memory caps) declarative and obvious.

I chose Docker specifically because the hardening story is so much nicer in compose, and because a new service slotting into the existing ~/docker/<service>/ convention is one less thing to remember. If you don’t already live in Docker, uvx is a perfectly good answer.

The compose file

Here’s the service, matching the per-directory convention (~/docker/ha-mcp/docker-compose.yml). I’m showing the hardened version up front — I’ll explain each directive after:

services:
ha-mcp:
container_name: ha-mcp
image: ghcr.io/homeassistant-ai/ha-mcp:latest
command: ha-mcp-web
ports:
- "127.0.0.1:8086:8086"
environment:
- HOMEASSISTANT_URL=${HOMEASSISTANT_URL}
- HOMEASSISTANT_TOKEN=${HOMEASSISTANT_TOKEN}
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 256M

The hardening, directive by directive:

  • ports: "127.0.0.1:8086:8086" — bind to loopback only. The container is not reachable from the LAN. More on why this matters below; it’s the single most important line in the file.
  • read_only: true — the root filesystem is mounted read-only. The app can’t be modified at runtime, and neither can anything that compromises it.
  • tmpfs: /tmp — the Python runtime needs a writable scratch dir, so we give it an in-memory /tmp and nothing else. Read-only rootfs without this will crash on first write.
  • cap_drop: ALL — drop every Linux kernel capability the container would otherwise inherit. An HTTP-serving Python app needs none of them.
  • no-new-privileges:true — blocks privilege escalation via setuid binaries. Defense in depth on top of cap_drop.
  • memory: 256M — a hard cap. The server idles well under this; the cap means a runaway can’t eat into the rest of the box.
  • restart: unless-stopped — survives reboots, but respects a manual stop.

Secrets: never let the token touch disk

This is where my homelab has a hard rule: secrets live in 1Password and are injected at runtime via op:// references — they never appear in a file, a log, or shell history. The HA long-lived access token is a full-access credential, so this matters more than usual here.

The .env file contains references, not values:

~/docker/ha-mcp/.env
HOMEASSISTANT_URL=http://192.168.100.187:8123
HOMEASSISTANT_TOKEN=op://AI Assistant/HOAS - Claude code/password

A note on the URL: locally I reach Home Assistant at the friendly https://hass.huislab.app, which my Nginx Proxy Manager routes to 192.168.100.187:8123 (the same split-horizon setup I described in Routing blog.huislab.app through Nginx Proxy Manager). For the container’s HOMEASSISTANT_URL I deliberately use the direct IP instead — it skips the reverse proxy and TLS hop entirely, so there’s one less moving part between the MCP server and HA, and no dependency on DNS or NPM being up for my smart home’s control plane to work. The pretty hostname is for humans; the container talks to the box directly.

That op://... string is a pointer into a 1Password vault, not a secret. The actual token only ever exists in memory, resolved at the moment the container starts, by wrapping docker compose in op run:

Terminal window
cd ~/docker/ha-mcp
op run --env-file=.env -- docker compose up -d

op run reads the .env, resolves every op:// reference against 1Password, and injects the real values as environment variables into the docker compose subprocess — and only that subprocess. Grep the filesystem all you like; the token isn’t there.

If you forget the op run wrapper and just run docker compose up, the container starts with HOMEASSISTANT_TOKEN set to the literal string op://AI Assistant/... and HA auth fails. That failure mode is actually a feature — it means a plaintext token can never silently end up working.

Bringing it up

Terminal window
cd ~/docker/ha-mcp
op run --env-file=.env -- docker compose up -d

First boot pulls the image and starts FastMCP (the server is built on it):

INFO Starting MCP server 'ha-mcp' with transport 'http' (stateless) on
http://0.0.0.0:8086/mcp
INFO Uvicorn running on http://0.0.0.0:8086 (Press CTRL+C to quit)

A quick liveness check — note that a GET returns 405, which is correct, because MCP wants POST:

Terminal window
curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8086/mcp
# HTTP 405

405 here is success: the server is up and speaking MCP, it just won’t answer a bare GET.

The localhost lockdown (and why)

By default the container published on 0.0.0.0:8086 — reachable from anything on the LAN. Pair that with a token that can do anything in Home Assistant, served over plain HTTP with no auth in front of it, and you’ve built an unauthenticated full-control endpoint for your house. Anything on the subnet could drive it.

The fix is the loopback bind you already saw:

ports:
- "127.0.0.1:8086:8086" # was "8086:8086"

The crucial enabling fact: the MCP client (Claude Code) runs on this same VM. So localhost-only costs me nothing — the client reaches 127.0.0.1:8086, and the rest of the network can’t reach it at all. After recreating the container, I verified both halves:

Terminal window
# LAN address — should now FAIL
curl --max-time 3 http://192.168.1.41:8086/mcp
# curl: (7) Failed to connect to 192.168.1.41 port 8086: Couldn't connect to server
# loopback — should work
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8086/mcp
# 405

LAN access dead, loopback alive. The full-access token is now only reachable from a process running on the box itself.

The trade-off to be aware of: if you ever need another host to use this server, localhost-only blocks it. At that point you’d put a reverse proxy with authentication in front, or bind back to the LAN behind a firewall policy that only allows specific clients. For a single co-located client, loopback is the right default.

Registering it with Claude Code

Terminal window
claude mcp add --scope user --transport http home-assistant http://127.0.0.1:8086/mcp

I used --scope user so the server is available from any project, not just the current directory — this is shared home infrastructure, not a per-repo tool. Then:

Terminal window
claude mcp list
# home-assistant: http://127.0.0.1:8086/mcp (HTTP) - ✓ Connected

What you can actually do with it

Once connected, the tool surface is broad. A few examples of the kinds of calls now available:

  • ha_get_overview — a categorized snapshot of the whole system (entity counts by domain, areas, active notifications). Great first call for orienting the agent.
  • ha_search_entities / ha_get_state — find entities and read their current state and attributes.
  • ha_call_service — the workhorse: call any HA service (light.turn_off, climate.set_temperature, …) with full data payloads.
  • ha_config_set_automation / ha_get_automation_traces — create and edit automations, and pull the execution trace when one misbehaves. This is the one I wanted most: “why did the motion light not fire?” answerable without leaving the chat.
  • ha_get_system_health — system diagnostics including ZHA Zigbee signal quality and Z-Wave network status.
  • ha_get_logs — pull HA logs for whatever you’re chasing.

The shift in workflow is real: instead of describing a symptom and getting suggestions I then go execute by hand, the agent reads the live state, makes the change, and confirms it — end to end.

Updating the server

No auto-update is wired in, which is fine for a single home tool. When you want a newer image:

Terminal window
cd ~/docker/ha-mcp
docker compose pull # grab the newest :latest
op run --env-file=.env -- docker compose up -d # recreate from it
docker image prune -f # optional cleanup

One thing that’ll trip you up: the container logs a notice like “Update available: pip install —upgrade fastmcp.” Ignore the pip suggestion — FastMCP is bundled inside the image, so docker compose pull is how you actually pick up a newer version. You never pip into this container.

Things I learned the hard way

  • 405 on a GET is healthy. MCP is POST-only. Don’t read a 405 as a broken server.
  • docker exec ha-mcp curl ... won’t work — the image has no curl. Use python -c for in-container connectivity tests; it’s a Python image, so Python is always there.
  • Forgetting op run fails closed, not open. The token resolves to a literal op:// string and auth fails loudly, rather than a real secret leaking into the container env. Design your secret flow so the failure mode is safe.

What I’d do differently next time

  1. Lead with the hardened compose file, not the minimal one. There’s no reason to run the wide-open version even briefly.

Closing thought

The actual engineering here is small — one hardened container, a loopback bind, a 1Password reference, and a client registration. What makes it worth writing up is the shift it represents: my assistant went from advisor to operator for the entire smart home.

If you run Home Assistant and you’re already using an MCP-capable agent, this is a high-leverage afternoon. Just bind it to localhost if the client lives on the same box, keep the token in a vault, and you’re good to go.