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.
260 lines
11 KiB
Markdown
260 lines
11 KiB
Markdown
# 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.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/<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.
|