---
okf: "0.1"
type: service
visibility: private
status: active
updated: 2026-07-30
links:
  - ../runbooks/ha-mcp-install.md
---

# 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.

## 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.

## 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.
