Giving Claude hands on Home Assistant: an MCP server
/ 12 min read
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-mcpexposes 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:
| Host | What it is | Verdict |
|---|---|---|
haos VM | Home Assistant OS itself | Cleanest if you want zero maintenance — install it as a supervised add-on. But it’s the most locked-down environment to customize. |
ubuntu-ai VM | My general AI/Docker box — already runs n8n, Postgres, Ollama, and Claude Code itself | Winner. Docker is already here, the compose pattern is established, and the MCP client lives on this same box. |
| An agent LXC | A small container that consumes MCP | Wrong 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:
# from the AI VMcurl -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.003524s3.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 ... upgradeinstead 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: 256MThe 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/tmpand 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 ofcap_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:
HOMEASSISTANT_URL=http://192.168.100.187:8123HOMEASSISTANT_TOKEN=op://AI Assistant/HOAS - Claude code/passwordA 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:
cd ~/docker/ha-mcpop run --env-file=.env -- docker compose up -dop 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 runwrapper and just rundocker compose up, the container starts withHOMEASSISTANT_TOKENset to the literal stringop://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
cd ~/docker/ha-mcpop run --env-file=.env -- docker compose up -dFirst 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/mcpINFO 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:
curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8086/mcp# HTTP 405405 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:
# LAN address — should now FAILcurl --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 workcurl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8086/mcp# 405LAN 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
claude mcp add --scope user --transport http home-assistant http://127.0.0.1:8086/mcpI 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:
claude mcp list# home-assistant: http://127.0.0.1:8086/mcp (HTTP) - ✓ ConnectedWhat 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:
cd ~/docker/ha-mcpdocker compose pull # grab the newest :latestop run --env-file=.env -- docker compose up -d # recreate from itdocker image prune -f # optional cleanupOne 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
405on 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. Usepython -cfor in-container connectivity tests; it’s a Python image, so Python is always there.- Forgetting
op runfails closed, not open. The token resolves to a literalop://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
- 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.