# ha-mcp — read-only MCP server for Home Assistant **Status: phase 2a** of `services/home-assistant/DESIGN.md` — own minimal MCP server (the alternative, adopting `hass-mcp`, was the other option in that document's Open questions; operator decision 2026-07-30: build our own). Exposes the live state of the HA instances defined in `services/home-assistant/instances.yaml` to Claude Code over stdio, so an agent can reason about the real house — which entities exist, which are dead, what an automation actually contains — without going near the import/deploy paths. ## Read-only by construction This is not a policy, a flag, or a prompt instruction — there is no code path in this server that changes anything in Home Assistant: - REST goes through `scripts/ha/lib/ha_api.py`, whose `Client` exposes exactly `get` and `get_raw_text`. There is no `post`/`put`/`delete` method to call. - WebSocket goes through `scripts/ha/lib/ha_ws.py` and every command is checked against `READ_ONLY_WS_COMMANDS` (three `*_list` registry reads) before it is sent. HA's mutating registry commands are not in the allowlist. - `read_automation` reads the repo, not the API. - Both facts are asserted by the test suite (`tests/test_backend_offline.py`), including a grep-level guard that fails if `requests.post` / `call_service` ever appears in this package. **The write path back into Home Assistant is unchanged and lives elsewhere:** edit `services/home-assistant/config//` in the repo, then `scripts/ha/deploy.sh ` (drift-abort → `check_config` → write → verify). See `services/home-assistant/DESIGN.md`, "Sync model" and "Validation gate". Nothing in this server bypasses that, and nothing in this server should ever learn to. ## Install The `mcp` SDK (and its pydantic/anyio/httpx dependency tree) is not packaged for Debian and is not needed by anything else in this repo, so it goes into a venv rather than into the system Python: ```bash python3 -m venv --system-site-packages services/ha-mcp/.venv services/ha-mcp/.venv/bin/pip install mcp pytest ``` `--system-site-packages` is deliberate: `requests`, `PyYAML` and `websocket-client` are already installed system-wide and used by `scripts/ha/lib/*`; the venv reuses those exact versions instead of shadowing them with a second copy. `.venv/` is already covered by the repo `.gitignore`. `pip install --break-system-packages mcp` also works and is one line shorter, but it writes into the system interpreter that runs every deploy script on this workstation — a venv keeps a 40-package dependency tree out of that blast radius for a tool only Claude Code uses. Use the venv. `run.sh` prefers `services/ha-mcp/.venv/bin/python` and falls back to the system `python3`; if neither can import `mcp` it exits with one actionable line on stderr rather than a traceback. ## Registration in Claude Code `.mcp.json` in the repo root (project scope — shared with anyone who checks out this repo): ```json { "mcpServers": { "ha": { "command": "./services/ha-mcp/run.sh", "args": [], "env": {} } } } ``` The command path is relative, so it resolves in any checkout (main or task worktree) as long as Claude Code is started from the repo root. Start CC there; on first run it asks whether to trust the project's MCP servers. Check with `/mcp` — the server appears as `ha`, its tools as `mcp__ha__`. Which repo the server reads is derived from its own location; override with the `HA_MCP_REPO` environment variable if you ever need to point one checkout at another's config. ## Standalone ```bash ./services/ha-mcp/run.sh # stdio server — speaks JSON-RPC on stdout services/ha-mcp/tests/run.sh # offline test suite (no HA, no token) services/ha-mcp/.venv/bin/python services/ha-mcp/tests/smoke_live.py [instance] [entity_id] ``` `smoke_live.py` is the only thing here that touches the network (GET only). A one-off tool call without a client is easiest through the package: ```bash PYTHONPATH=services/ha-mcp/src services/ha-mcp/.venv/bin/python -c " from ha_mcp import tools; from ha_mcp.backend import LiveBackend from ha_mcp.config import get_instance, repo_root n, c = get_instance('ken', repo_root()); print(tools.instance_status(LiveBackend(n, c, repo_root())))" ``` ## Tools All seven are read-only, take an optional `instance` (default `ken` — the canonical home instance), and return a JSON object. Failures come back as `{"error": "..."}` inside a normal result: a missing token, an unreachable instance or a typo'd entity_id never crashes the server or leaves a tool call hanging (timeouts are 5 s). | Tool | Arguments | Returns | |---|---|---| | `list_entities` | `domain?`, `area?`, `limit?` (200) | entity_id, friendly_name, state, area; `unavailable` flag | | `get_state` | `entity_id` | full state incl. every attribute and area | | `get_areas` | — | areas with entity counts and dead-entity counts | | `find_entities_by_description` | `text` | ≤20 fuzzy hits, each with the reason it matched | | `read_automation` | `id_or_alias` | automation YAML **from the repo** + live `last_triggered` | | `list_automations` | `filter?` | id, alias, state, last_triggered, repo path | | `instance_status` | — | reachability, HA version, entity/automation counts, dead count | ### Examples `instance_status()` — on the live `ken`: ```json { "instance": "ken", "status": "active", "reachable": true, "version": "2026.7.2", "location_name": "KEN", "entity_count": 1647, "unavailable_count": 377, "automation_count": 115, "repo_automations": 115, "area_count": 13, "area_source": "websocket", "unavailable_note": "377 entities are unavailable/unknown — the 2026-07-23 audit traced ~15 silently dead automations to exactly this (…)" } ``` `instance_status(instance="chelsty-ha")` — no socket is opened for an instance `instances.yaml` marks offline; you get the recorded status instead of a timeout: ```json { "instance": "chelsty-ha", "status": "offline", "reachable": false, "reason": "status: offline in instances.yaml — not queried. This instance sits behind an intermittent LTE uplink (site chelsty) …" } ``` `find_entities_by_description(text="czujnik temperatury salon")` — Polish description against transliterated English entity_ids: ```json { "entity_id": "sensor.thsalon_temperature", "friendly_name": "thSalon Temperature", "state": "24.5", "area": null, "score": 15, "matched_tokens": "3/3", "why": "'czujnik'->'sensor' in domain=sensor+entity_id; 'temperatury'->'temperature' in name+entity_id; 'salon' in name+entity_id" } ``` Matching is substring + prefix over a diacritic-normalized haystack (entity_id, friendly_name, area) plus a small PL→EN synonym table in `src/ha_mcp/match.py` (`czujnik`→`sensor`, `swiatlo`→`light`, `ruch`→ `motion`/`occupancy`, …). Every hit carries `why`, so a wrong hit is diagnosable instead of mysterious. `list_entities(area="Salon", domain="light")` — area accepts a name, an `area_id` or a registry alias ("Wejście" → Hall), with or without diacritics: ```json { "count": 1, "unavailable_count": 1, "area_source": "websocket", "entities": [{ "entity_id": "light.ledtv", "friendly_name": "LED za TV", "state": "unavailable", "area": "Salon", "unavailable": true, "unavailable_since": "2026-07-29T18:38:39.830594+00:00" }] } ``` `read_automation(id_or_alias="Klima salon: wyłącz")` — content from the repo, state from the instance: ```json { "id": "1784804668795", "alias": "Klima salon: wyłącz chłodzenie i osusz parownik", "path": "services/home-assistant/config/ken/automations/1784804668795.yaml", "source": "repo (services/home-assistant/config/) — not /api/config", "automation": {"…": "parsed YAML"}, "yaml": "actions:\n- choose:\n…", "live": {"entity_id": "automation.klima_salon_wylacz_chlodzenie_i_osusz_parownik", "state": "on", "last_triggered": "2026-07-29T19:41:21.554395+00:00"} } ``` An id or a unique alias substring both work; an ambiguous substring returns the candidate list rather than guessing. The repo read works with the instance down — you then get `live_error` instead of `live`. ## Why `unavailable` is in every result The 2026-07-23 audit (`services/home-assistant/docs/audyt-automatyzacji-2026-07-23.md`, 1.2–1.4) traced ~15 silently broken automations to dead sensors: a condition on an `unavailable` entity is simply never true, and HA reports no error. So every entity view here carries an explicit `unavailable: true` plus `unavailable_since`, every list reports `unavailable_count`, and `get_areas` counts dead entities per room. An agent reading this instance through these tools cannot conclude "the automation looks fine" without seeing that its trigger is dead. ## Areas Area assignment is not in `/api/states`; it comes from the registries over WebSocket (`area_registry` + `entity_registry` + `device_registry`, an entity's own `area_id` winning over its device's — the same precedence HA uses). Every result says where its areas came from in `area_source`: - `websocket` — live registries (~1470 of ~1650 entities resolve; the rest genuinely have no area assigned in HA, e.g. the thSalon device). - `storage-export` — offline fallback from `services/home-assistant/storage-export//`, used when the WebSocket is unreachable or `websocket-client` is missing. Incomplete by nature: that curated export has no device registry, so only entities with an explicit `area_id` resolve. `area_note` says so in the result. ## Tokens Same rule as the rest of `scripts/ha/`: the token is read from the `token_path` in `instances.yaml` (`~/.config/ha-deploy/.token`, `chmod 600`) by `ha_api.read_token`, in-process. It never appears in argv, in a log line, in a tool result, or in this repo. A missing token is a reported tool error naming the path it looked at — not a crash, and not a silent empty result. ## Tests ```bash services/ha-mcp/tests/run.sh # 42 tests, offline ``` Offline in the same sense as `scripts/ha/tests/*`: no network, no HA instance, no token. Small hand-made fixtures under `tests/fixtures/` (shaped like real `/api/states` and WebSocket registry payloads) cover the tool logic; the repo's own `config/ken/automations/` and newest `fixtures/ken-states-*.yaml` cover the repo-backed and at-scale paths (ranking over ~1650 real entities behaves differently from ranking over ten). Live smoke (read-only, run against `ken` on 2026-07-30): `instance_status` → HA 2026.7.2, 1647 entities, 377 unavailable, 115 automations, 13 areas; `get_state("sensor.thsalon_temperature")` → `24.5 °C`. ## Not a deployed service No `docker-compose.yml`, no `service.yaml`, no `healthcheck.sh`: this is dev-station tooling that Claude Code spawns over stdio for the duration of a session, not a container that runs on a node. It has no `owner_node`, no exposure, and nothing in `hosts/*/services.yaml` refers to it — the same way `scripts/ha/` is repo tooling rather than a service. Phase 3 of DESIGN.md (agents proposing changes) is where a long-running process may appear; it is not this.