homelab-codex-ws/services/ha-mcp/README.md
oskar 0650eb857a feat(ha-mcp): read-only MCP server (faza 2a)
Own minimal MCP server exposing the live state of the HA instances in
services/home-assistant/instances.yaml to Claude Code over stdio — the
phase-2 "MCP read-only" gate in services/home-assistant/DESIGN.md.
Operator decision 2026-07-30: build our own rather than adopt hass-mcp,
so the tools reuse scripts/ha/lib/{ha_api,ha_ws}.py (one token-handling
story for the whole HA toolchain) and can answer from the repo and from
instances.yaml, which a generic server cannot.

Seven tools, all read-only, default instance `ken`: list_entities,
get_state, get_areas, find_entities_by_description, read_automation,
list_automations, instance_status.

Read-only by construction, not by policy: REST goes through ha_api.Client
(get/get_raw_text only — no POST method exists), WebSocket commands are
checked against a three-entry *_list allowlist before being sent, and
read_automation reads services/home-assistant/config/<instance>/ rather
than /api/config. Tests assert all three, including a grep guard that
fails if requests.post/call_service ever appears in the package. The
write path stays repo + scripts/ha/deploy.sh.

Details that follow from how this instance actually behaves:

- unavailable is never silent — every entity view carries unavailable +
  unavailable_since, every list a count. The 2026-07-23 audit traced ~15
  silently dead automations to conditions sitting on dead sensors.
- chelsty-ha (status: offline in instances.yaml) is answered from the
  file, never dialed — no 5s timeout for a known-offline LTE site.
- areas come from the WS registries (entity area_id > device area_id) with
  a storage-export fallback; area_source/area_note say which was used and
  what the offline export cannot resolve.
- PL->EN fuzzy matching, since the house is Polish and the entity_ids are
  transliterated English: "czujnik temperatury salon" ->
  sensor.thsalon_temperature, each hit explaining why it matched.
- 5s timeouts and errors returned as {"error": ...} inside a normal tool
  result — a missing token or an unreachable instance never crashes the
  server or hangs the agent.

Registered for Claude Code in the repo-root .mcp.json (new file) as `ha`,
via services/ha-mcp/run.sh (prefers the venv, falls back to system
python3). The mcp SDK lives in services/ha-mcp/.venv — rationale for venv
over --break-system-packages is in the README.

Tests: 42 offline (no network, no HA, no token) + a live read-only smoke
against ken — HA 2026.7.2, 1647 entities, 377 unavailable, 115
automations, 13 areas.
2026-07-30 16:47:27 +02:00

260 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<instance>/` in the repo, then
`scripts/ha/deploy.sh <instance>` (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__<tool>`.
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.21.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/<instance>/`, 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/<instance>.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.