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.
This commit is contained in:
oskar 2026-07-30 16:37:38 +02:00
parent cb8a19de83
commit 0650eb857a
18 changed files with 2115 additions and 3 deletions

9
.mcp.json Normal file
View file

@ -0,0 +1,9 @@
{
"mcpServers": {
"ha": {
"command": "./services/ha-mcp/run.sh",
"args": [],
"env": {}
}
}
}

259
services/ha-mcp/README.md Normal file
View file

@ -0,0 +1,259 @@
# 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.

View file

@ -0,0 +1,8 @@
# ha-mcp — dev-station tool, not a deployed service (no image, no compose).
# Install into services/ha-mcp/.venv; see README.md, "Install".
mcp>=2.0
# Also required, and already present system-wide on the dev station /
# control nodes (used by scripts/ha/lib/*): requests, PyYAML,
# websocket-client. The venv is created with --system-site-packages so it
# reuses them instead of shadowing the versions the rest of scripts/ha uses.
pytest # tests only

25
services/ha-mcp/run.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Launch the ha-mcp stdio server. Referenced by the repo-root .mcp.json.
#
# Picks the venv at services/ha-mcp/.venv when it exists (see README.md,
# "Install"), otherwise falls back to the system python3 — which works only
# if the `mcp` SDK is installed there. Never prints to stdout: stdout is the
# JSON-RPC channel and any stray byte breaks the protocol.
set -euo pipefail
SERVICE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_PY="$SERVICE_DIR/.venv/bin/python"
if [[ -x "$VENV_PY" ]]; then
PY="$VENV_PY"
else
PY="$(command -v python3)"
fi
if ! "$PY" -c "import mcp" 2>/dev/null; then
echo "ha-mcp: the 'mcp' SDK is not importable by $PY — see services/ha-mcp/README.md, 'Install'" >&2
exit 1
fi
export PYTHONPATH="$SERVICE_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
exec "$PY" -m ha_mcp.server "$@"

View file

@ -0,0 +1,9 @@
"""Read-only MCP server for Home Assistant instances (DESIGN.md, phase 2a).
Read-only by construction: the backend only ever issues HTTP GET (via
scripts/ha/lib/ha_api.py, which has no POST method) and WebSocket commands
from an explicit allowlist of `*_list`/`*/get` reads. The write path back
into HA stays where DESIGN.md put it: repo + scripts/ha/deploy.sh.
"""
__version__ = "0.1.0"

View file

@ -0,0 +1,280 @@
"""Data sources behind the tools: live HA instance + repo files.
Everything the tools need is behind one small interface so the offline test
suite can substitute a fake (see tests/test_tools_offline.py) without any
network or HA instance:
states() -> list of /api/states dicts
ha_config() -> /api/config dict
area_index() -> AreaIndex (entity_id -> area name, area -> entity count)
automation_files()-> list of (repo-relative path, absolute path)
Read-only by construction:
* REST goes through scripts/ha/lib/ha_api.py, whose Client exposes `get`
and `get_raw_text` and nothing else there is no POST/PUT/DELETE method
to call by accident.
* WebSocket goes through scripts/ha/lib/ha_ws.py, and every command this
module sends must be in READ_ONLY_WS_COMMANDS an allowlist, checked at
call time, so a future edit cannot quietly introduce a mutating command.
"""
import sys
from pathlib import Path
import yaml
from . import config as cfg
#: Every WebSocket command this server is allowed to send. HA's mutating
#: registry commands (`config/*_registry/create|update|delete`) are absent
#: deliberately and a non-listed command raises before it hits the socket.
READ_ONLY_WS_COMMANDS = frozenset(
{
"config/area_registry/list",
"config/entity_registry/list",
"config/device_registry/list",
}
)
#: Short by policy: the MCP server is interactive tooling — a hung tool call
#: blocks the agent, so a fast, clear failure beats a slow success.
DEFAULT_TIMEOUT = 5
class BackendError(RuntimeError):
"""Instance unreachable / token missing / instance not live — reportable."""
def _import_ha_lib(root=None):
"""Import scripts/ha/lib/{ha_api,ha_ws} — reuse, don't reimplement."""
lib_dir = str(cfg.ha_lib_dir(root))
if lib_dir not in sys.path:
sys.path.insert(0, lib_dir)
import ha_api # noqa: E402 (path set up above)
return ha_api
class AreaIndex:
"""entity_id -> area name, plus the area list, plus where it came from."""
def __init__(self, areas, entity_area, source, note=None):
#: [{"area_id":..., "name":..., "aliases": [...]}]
self.areas = areas
#: {entity_id: area name}
self.entity_area = entity_area
#: "websocket" | "storage-export" | "none"
self.source = source
self.note = note
def area_of(self, entity_id):
return self.entity_area.get(entity_id)
@classmethod
def empty(cls, note):
return cls([], {}, "none", note)
def _index_from_registries(area_reg, entity_reg, device_reg, source, note=None):
"""Build an AreaIndex from raw registry lists (WS or storage-export shape).
An entity's area is its own `area_id` when set, else its device's the
same precedence HA itself uses. `device_reg` may be None (the offline
storage-export has no device registry), in which case device-inherited
areas are simply unresolved rather than wrong.
"""
areas = [
{
"area_id": a.get("area_id") or a.get("id"),
"name": a.get("name"),
"aliases": list(a.get("aliases") or []),
}
for a in (area_reg or [])
]
names = {a["area_id"]: a["name"] for a in areas if a["area_id"]}
device_area = {
d.get("id"): d.get("area_id") for d in (device_reg or []) if d.get("id")
}
entity_area = {}
for ent in entity_reg or []:
entity_id = ent.get("entity_id")
if not entity_id:
continue
area_id = ent.get("area_id") or device_area.get(ent.get("device_id"))
name = names.get(area_id)
if name:
entity_area[entity_id] = name
return AreaIndex(areas, entity_area, source, note)
def load_offline_area_index(instance, root=None):
"""Fallback area map from storage-export/<instance>/core.*_registry.yaml.
Used when the WebSocket path is unavailable (dependency missing,
instance unreachable). Incomplete by nature: the curated export has no
`core.device_registry`, so only entities carrying an explicit `area_id`
resolve the note says so rather than pretending the rest are area-less.
"""
export_dir = cfg.ha_service_dir(root) / "storage-export" / instance
def _load(name, key):
path = export_dir / name
if not path.is_file():
return None
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return ((data.get("data") or {}).get(key)) or []
try:
area_reg = _load("core.area_registry.yaml", "areas")
entity_reg = _load("core.entity_registry.yaml", "entities")
except (OSError, yaml.YAMLError) as exc:
return AreaIndex.empty(f"storage-export unreadable: {exc}")
if area_reg is None and entity_reg is None:
return AreaIndex.empty(
f"no storage-export for instance '{instance}' and no live websocket"
)
return _index_from_registries(
area_reg,
entity_reg,
None,
"storage-export",
"offline fallback: storage-export has no device registry, so entities "
"that inherit their area from a device are reported without one",
)
class LiveBackend:
"""One HA instance, read through the REST + WebSocket clients in scripts/ha/lib.
Everything is fetched lazily and cached for the lifetime of the object
(one MCP tool call), so a tool that needs states and areas pays for one
REST GET and one WS session, not several.
"""
def __init__(self, instance, instance_cfg, root=None, timeout=DEFAULT_TIMEOUT):
self.instance = instance
self.cfg = instance_cfg
self.root = root or cfg.repo_root()
self.timeout = timeout
self._states = None
self._config = None
self._area_index = None
self._token = None
# --- plumbing -----------------------------------------------------
@property
def status(self):
return self.cfg.get("status") or "unknown"
def _require_live(self):
"""Refuse network I/O for instances instances.yaml calls offline.
`chelsty-ha` sits behind an intermittent LTE uplink and is marked
`status: offline`; hitting it would burn the timeout on every call
and tell the caller nothing instances.yaml did not already say.
"""
if self.status == "offline":
raise BackendError(
f"instance '{self.instance}' is marked status: offline in "
"instances.yaml — no live query attempted (intermittent LTE "
"uplink; see DESIGN.md). Repo-backed tools (read_automation) "
"still work."
)
if not self.cfg.get("base_url"):
raise BackendError(f"instance '{self.instance}' has no base_url in instances.yaml")
def _client(self):
self._require_live()
ha_api = _import_ha_lib(self.root)
if self._token is None:
try:
self._token = ha_api.read_token(self.cfg.get("token_path"))
except ha_api.HaApiError as exc:
raise BackendError(str(exc)) from exc
return ha_api.Client(self.cfg["base_url"], self._token, timeout=self.timeout)
def _get(self, path):
import requests
client = self._client()
try:
return client.get(path)
except requests.RequestException as exc:
raise BackendError(
f"instance '{self.instance}' unreachable at "
f"{self.cfg.get('base_url')} ({type(exc).__name__}: {exc})"
) from exc
# --- data ---------------------------------------------------------
def states(self):
if self._states is None:
data = self._get("/api/states")
if not isinstance(data, list):
raise BackendError(f"/api/states returned {type(data).__name__}, expected a list")
self._states = data
return self._states
def ha_config(self):
if self._config is None:
data = self._get("/api/config")
if not isinstance(data, dict):
raise BackendError(f"/api/config returned {type(data).__name__}, expected an object")
self._config = data
return self._config
def _ws_registries(self):
"""One WS session, three allowlisted `*_list` reads."""
self._require_live()
ha_api = _import_ha_lib(self.root)
token = self._token or ha_api.read_token(self.cfg.get("token_path"))
self._token = token
import ha_ws # noqa: E402 (sys.path set up by _import_ha_lib)
out = {}
with ha_ws.HaWsClient(self.cfg["base_url"], token, timeout=self.timeout) as conn:
for command in (
"config/area_registry/list",
"config/entity_registry/list",
"config/device_registry/list",
):
if command not in READ_ONLY_WS_COMMANDS: # pragma: no cover - guard
raise BackendError(f"refusing non-allowlisted WS command '{command}'")
out[command] = conn.command(command)
return out
def area_index(self):
"""Live area map via WebSocket, falling back to storage-export."""
if self._area_index is not None:
return self._area_index
try:
reg = self._ws_registries()
self._area_index = _index_from_registries(
reg["config/area_registry/list"],
reg["config/entity_registry/list"],
reg["config/device_registry/list"],
"websocket",
)
except Exception as exc: # noqa: BLE001 — any WS failure degrades, never crashes
index = load_offline_area_index(self.instance, self.root)
reason = f"{type(exc).__name__}: {exc}"
index.note = f"live websocket unavailable ({reason}); {index.note or ''}".strip("; ")
self._area_index = index
return self._area_index
def automation_files(self):
"""[(repo-relative path, absolute path)] for config/<instance>/automations/."""
return automation_files(self.instance, self.root)
def automation_files(instance, root=None):
root = root or cfg.repo_root()
directory = cfg.ha_service_dir(root) / "config" / instance / "automations"
if not directory.is_dir():
return []
return [
(str(Path(p).relative_to(root)), p)
for p in sorted(directory.glob("*.yaml"))
]

View file

@ -0,0 +1,68 @@
"""Repo-root discovery and `instances.yaml` access.
The MCP server is a dev-station tool that reads the repo it lives in: HA
instance definitions come from services/home-assistant/instances.yaml (same
file scripts/ha/import.sh and deploy.sh read), automations come from
services/home-assistant/config/<instance>/automations/.
"""
import os
from pathlib import Path
import yaml
DEFAULT_INSTANCE = "ken"
#: Where scripts/ha/lib lives, so ha_api.py / ha_ws.py can be imported
#: instead of reimplemented (DESIGN.md, "Deploy path: adapter per instance").
_HA_LIB_RELPATH = "scripts/ha/lib"
class ConfigError(RuntimeError):
"""Raised for a missing/broken instances.yaml or an unknown instance."""
def repo_root():
"""Absolute path to the repo checkout this server was started from.
`HA_MCP_REPO` overrides it (useful when the server is launched from a
different worktree than the one it should read); otherwise it is derived
from this file's location: src/ha_mcp/config.py -> services/ha-mcp -> repo.
"""
override = os.environ.get("HA_MCP_REPO")
if override:
return Path(override).expanduser().resolve()
return Path(__file__).resolve().parents[4]
def ha_service_dir(root=None):
return (root or repo_root()) / "services" / "home-assistant"
def ha_lib_dir(root=None):
return (root or repo_root()) / _HA_LIB_RELPATH
def load_instances(root=None):
"""Parse instances.yaml -> {name: dict}. Raises ConfigError if unusable."""
path = ha_service_dir(root) / "instances.yaml"
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
except OSError as exc:
raise ConfigError(f"cannot read {path}: {exc}") from exc
except yaml.YAMLError as exc:
raise ConfigError(f"{path} is not valid YAML: {exc}") from exc
instances = (data or {}).get("instances") or {}
if not instances:
raise ConfigError(f"{path} defines no instances")
return instances
def get_instance(name=None, root=None):
"""Return (name, config-dict) for `name` (default: `ken`)."""
instances = load_instances(root)
name = name or DEFAULT_INSTANCE
if name not in instances:
known = ", ".join(sorted(instances))
raise ConfigError(f"unknown instance '{name}' (known: {known})")
return name, dict(instances[name] or {})

View file

@ -0,0 +1,144 @@
"""Deliberately simple fuzzy matching for find_entities_by_description.
No embeddings, no Levenshtein: substring + prefix matching over a normalized
haystack (entity_id, friendly_name, area), plus a small Polish->English
synonym table, because that is what the actual failure mode needs. The
instance is a Polish household whose entity_ids are English-ish and
transliterated (`sensor.thsalon_temperature`, `binary_sensor.mdwejscie_*`),
so an agent asking for "czujnik temperatury salon" has to cross both the
language boundary and the missing-diacritics boundary. Everything here is
explainable, which is why each hit carries the reason it matched.
"""
import re
_PL = str.maketrans(
{
"ą": "a",
"ć": "c",
"ę": "e",
"ł": "l",
"ń": "n",
"ó": "o",
"ś": "s",
"ż": "z",
"ź": "z",
}
)
#: Polish query word (normalized) -> extra terms to try against the haystack.
#: Values are matched exactly the same way the original token is.
SYNONYMS = {
"czujnik": ["sensor"],
"czujniki": ["sensor"],
"czujnika": ["sensor"],
"temperatura": ["temperature", "temp"],
"temperatury": ["temperature", "temp"],
"temperature": ["temperature"],
"wilgotnosc": ["humidity"],
"wilgotnosci": ["humidity"],
"swiatlo": ["light", "swiatlo"],
"swiatla": ["light", "swiatlo"],
"lampa": ["light", "lamp"],
"lampka": ["light", "lampka"],
"przelacznik": ["switch"],
"gniazdko": ["switch", "gniazdk", "socket", "plug"],
"gniazdka": ["switch", "gniazdk", "socket", "plug"],
"ruch": ["motion", "occupancy", "md"],
"ruchu": ["motion", "occupancy", "md"],
"obecnosc": ["occupancy", "occu", "presence"],
"obecnosci": ["occupancy", "occu", "presence"],
"bateria": ["battery"],
"baterii": ["battery"],
"drzwi": ["door", "contact"],
"okno": ["window"],
"okna": ["window"],
"klima": ["climate", "klima"],
"klimatyzacja": ["climate", "klima"],
"termostat": ["climate", "thermostat", "trv"],
"grzejnik": ["climate", "trv"],
"roleta": ["cover"],
"zaluzje": ["cover"],
"zamek": ["lock"],
"kamera": ["camera"],
"odkurzacz": ["vacuum"],
"glosnik": ["media_player"],
"zalanie": ["leak", "water"],
"wyciek": ["leak", "water"],
"dym": ["smoke"],
"automatyzacja": ["automation"],
"skrypt": ["script"],
"scena": ["scene"],
}
#: Tokens too generic to carry signal — dropped before scoring.
STOPWORDS = frozenset({"w", "we", "na", "do", "z", "ze", "i", "the", "a", "of", "in"})
_PREFIX_LEN = 5
def normalize(text):
"""Lowercase, de-diacritic, collapse punctuation to single spaces."""
if not text:
return ""
lowered = str(text).lower().translate(_PL)
return re.sub(r"[^a-z0-9]+", " ", lowered).strip()
def tokenize(text):
return [t for t in normalize(text).split() if t and t not in STOPWORDS]
def _term_score(term, entity_id_n, name_n, area_n, domain):
"""Score one term against one entity; returns (score, reason-or-None)."""
score = 0
where = []
if term == domain:
score += 3
where.append(f"domain={domain}")
if term in name_n:
score += 3
where.append("name")
elif len(term) >= _PREFIX_LEN and term[:_PREFIX_LEN] in name_n:
score += 2
where.append("name~")
if term in entity_id_n:
score += 2
where.append("entity_id")
elif len(term) >= _PREFIX_LEN and term[:_PREFIX_LEN] in entity_id_n:
score += 1
where.append("entity_id~")
if area_n and term in area_n:
score += 3
where.append("area")
if not score:
return 0, None
return score, "+".join(where)
def score_entity(tokens, entity_id, friendly_name, area):
"""(total score, matched-token count, reason string) for one entity.
Each query token scores at most once its own form and its synonyms
compete, best one wins so a token with three synonyms cannot outweigh
a token with none.
"""
entity_id_n = normalize(entity_id)
name_n = normalize(friendly_name)
area_n = normalize(area)
domain = entity_id.split(".", 1)[0] if "." in entity_id else ""
total = 0
matched = 0
reasons = []
for token in tokens:
best = (0, None, None)
for term in [token] + SYNONYMS.get(token, []):
score, where = _term_score(term, entity_id_n, name_n, area_n, domain)
if score > best[0]:
best = (score, where, term)
if best[0]:
total += best[0]
matched += 1
term_note = f"'{token}'" if best[2] == token else f"'{token}'->'{best[2]}'"
reasons.append(f"{term_note} in {best[1]}")
return total, matched, "; ".join(reasons)

View file

@ -0,0 +1,135 @@
"""MCP server (stdio) exposing the read-only HA tools in tools.py.
Every tool is declared with `read_only_hint=True` / `destructive_hint=False`
but the guarantee is structural, not advisory: this process has no code
path that writes to HA (see backend.py).
Errors never propagate as exceptions: a missing token, an unreachable
instance or an unknown entity comes back as `{"error": "..."}` inside a
normal tool result, so one bad call cannot take the server down mid-session.
Logging goes to stderr only stdout is the JSON-RPC channel.
"""
import logging
import sys
from mcp.server.mcpserver import MCPServer
from mcp.types import ToolAnnotations
from . import tools
from .backend import DEFAULT_TIMEOUT, BackendError, LiveBackend
from .config import DEFAULT_INSTANCE, ConfigError, get_instance, repo_root
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="ha-mcp: %(message)s")
log = logging.getLogger("ha-mcp")
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=True)
mcp = MCPServer(
name="ha-mcp",
version="0.1.0",
instructions=(
"Read-only access to the Home Assistant instances defined in "
"services/home-assistant/instances.yaml. Default instance: 'ken' (the "
"canonical home instance); pass `instance` to target another one. "
"These tools cannot change anything in Home Assistant — to change an "
"automation, edit services/home-assistant/config/<instance>/ in the repo "
"and deploy with scripts/ha/deploy.sh (see services/home-assistant/DESIGN.md). "
"Entities reported with unavailable=true are dead: their automations "
"silently do not fire."
),
)
def _backend(instance):
name, cfg = get_instance(instance, repo_root())
return LiveBackend(name, cfg, repo_root(), timeout=DEFAULT_TIMEOUT)
def _call(fn, instance, **kwargs):
"""Run one tool, turning any failure into a readable result payload."""
try:
backend = _backend(instance)
return fn(backend, **kwargs)
except (BackendError, ConfigError) as exc:
return {"error": str(exc), "instance": instance or DEFAULT_INSTANCE}
except Exception as exc: # noqa: BLE001 — the server must survive any tool
log.exception("unhandled error in %s", getattr(fn, "__name__", fn))
return {
"error": f"{type(exc).__name__}: {exc}",
"instance": instance or DEFAULT_INSTANCE,
}
@mcp.tool(annotations=READ_ONLY)
def list_entities(domain: str | None = None, area: str | None = None,
limit: int = tools.DEFAULT_ENTITY_LIMIT,
instance: str | None = None) -> dict:
"""List Home Assistant entities with state and area.
Filter by `domain` (e.g. "sensor", "light", "binary_sensor") and/or `area`
(area name or area_id, e.g. "Salon"). Entities that are dead are flagged
with unavailable=true and unavailable_since.
"""
return _call(tools.list_entities, instance, domain=domain, area=area, limit=limit)
@mcp.tool(annotations=READ_ONLY)
def get_state(entity_id: str, instance: str | None = None) -> dict:
"""Full state of one entity, including every attribute and its area."""
return _call(tools.get_state, instance, entity_id=entity_id)
@mcp.tool(annotations=READ_ONLY)
def get_areas(instance: str | None = None) -> dict:
"""List areas (rooms) with the number of entities in each, dead ones counted separately."""
return _call(tools.get_areas, instance)
@mcp.tool(annotations=READ_ONLY)
def find_entities_by_description(text: str, instance: str | None = None) -> dict:
"""Find entities from a loose description, in Polish or English.
Matches entity_id, friendly_name and area with substring/prefix matching
plus a PL->EN synonym table (e.g. "czujnik temperatury salon" ->
sensor.thsalon_temperature). Returns up to 20 hits, each with the reason
it matched.
"""
return _call(tools.find_entities_by_description, instance, text=text)
@mcp.tool(annotations=READ_ONLY)
def read_automation(id_or_alias: str, instance: str | None = None) -> dict:
"""Read one automation's YAML from the repo, annotated with its live state.
Accepts a numeric automation id or (part of) its alias. The content comes
from services/home-assistant/config/<instance>/automations/ the
reviewable source of truth not from the HA API; last_triggered and the
on/off state are read from the live instance when it is reachable.
"""
return _call(tools.read_automation, instance, id_or_alias=id_or_alias)
@mcp.tool(annotations=READ_ONLY)
def list_automations(filter: str | None = None, instance: str | None = None) -> dict:
"""List automations (id, alias, state, last_triggered), optional substring filter."""
return _call(tools.list_automations, instance, filter=filter)
@mcp.tool(annotations=READ_ONLY)
def instance_status(instance: str | None = None) -> dict:
"""Reachability, HA version, entity/automation counts and how many entities are dead.
Instances marked `status: offline` in instances.yaml (chelsty-ha, LTE site)
report that status directly instead of burning a network timeout.
"""
return _call(tools.instance_status, instance)
def main():
log.info("starting stdio server (read-only), repo=%s", repo_root())
mcp.run("stdio")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,429 @@
"""Tool implementations. Pure Python, no MCP imports — see server.py for wiring.
Two conventions hold across every function here:
1. **Read-only.** Nothing in this module writes anything, anywhere: not to
HA (the backend has no mutating call), not to the repo. The write path
is repo + scripts/ha/deploy.sh (DESIGN.md, "Sync model").
2. **`unavailable` is never silent.** The 2026-07-23 audit found ~15
automations dead because conditions sat on `unavailable` sensors that
nobody noticed (docs/audyt-automatyzacji-2026-07-23.md, 1.2). Every
entity view carries an explicit `unavailable` flag plus `last_changed`,
and every list reports how many of its results are dead, so an agent
reasoning about this instance cannot miss it.
"""
import difflib
import yaml
from . import match
from .backend import BackendError
#: Cap on list_entities results — the live instance has ~1650 entities and
#: dumping all of them into an agent's context is worse than useless.
DEFAULT_ENTITY_LIMIT = 200
FIND_LIMIT = 20
DEAD_STATES = ("unavailable", "unknown")
def _entity_view(state, area=None):
attrs = state.get("attributes") or {}
value = state.get("state")
view = {
"entity_id": state.get("entity_id"),
"friendly_name": attrs.get("friendly_name"),
"state": value,
"area": area,
}
if value in DEAD_STATES:
view["unavailable"] = True
# How long it has been dead is the actionable part (audit 1.2).
view["unavailable_since"] = state.get("last_changed")
return view
def _domain(entity_id):
return entity_id.split(".", 1)[0] if "." in entity_id else ""
def _resolve_area(index, wanted):
"""Map user input (name, area_id or registry alias) to the canonical area name.
Aliases count: the `hall` area is called "Wejście" by everyone in the
house, and that alias is in the registry refusing it would be pedantry.
Returns None when nothing matches.
"""
needle = match.normalize(wanted)
for area in index.areas:
candidates = [area.get("name"), area.get("area_id")] + list(area.get("aliases") or [])
if any(needle == match.normalize(c) for c in candidates if c):
return area.get("name") or area.get("area_id")
return None
def _count_dead(views):
return sum(1 for v in views if v.get("unavailable"))
def _area_meta(index):
meta = {"area_source": index.source}
if index.note:
meta["area_note"] = index.note
return meta
def list_entities(backend, domain=None, area=None, limit=DEFAULT_ENTITY_LIMIT):
"""Entities with state and area, optionally filtered by domain and/or area."""
states = backend.states()
index = backend.area_index()
wanted_area = None
if area:
wanted_area = _resolve_area(index, area)
if not wanted_area:
known = sorted(a.get("name") or a.get("area_id") for a in index.areas)
raise BackendError(
f"unknown area '{area}' (known: {', '.join(k for k in known if k) or 'none'})"
)
views = []
for state in states:
entity_id = state.get("entity_id") or ""
if domain and _domain(entity_id) != domain.strip().lower():
continue
entity_area = index.area_of(entity_id)
if wanted_area and entity_area != wanted_area:
continue
views.append(_entity_view(state, entity_area))
views.sort(key=lambda v: v["entity_id"])
result = {
"instance": backend.instance,
"filters": {"domain": domain, "area": wanted_area or area},
"count": len(views),
"unavailable_count": _count_dead(views),
"entities": views[:limit],
}
if len(views) > limit:
result["truncated"] = True
result["truncated_note"] = (
f"showing {limit} of {len(views)} — narrow with domain/area or raise limit"
)
result.update(_area_meta(index))
return result
def get_state(backend, entity_id):
"""Full state object (all attributes) for one entity."""
entity_id = (entity_id or "").strip()
if not entity_id:
raise BackendError("entity_id is required")
states = backend.states()
for state in states:
if state.get("entity_id") == entity_id:
index = backend.area_index()
view = _entity_view(state, index.area_of(entity_id))
view["attributes"] = state.get("attributes") or {}
view["last_changed"] = state.get("last_changed")
view["last_updated"] = state.get("last_updated")
view["instance"] = backend.instance
view.update(_area_meta(index))
return view
# Not found: a typo'd entity_id is the common case, so answer with the
# nearest ids by edit distance (difflib, not the fuzzy matcher — this is
# an id comparison, not a description).
close = difflib.get_close_matches(
entity_id, [s.get("entity_id") or "" for s in states], n=5, cutoff=0.7
)
raise BackendError(
f"no entity '{entity_id}' on instance '{backend.instance}'"
+ (
" — closest: " + ", ".join(close)
if close
else " (try find_entities_by_description for a description-based search)"
)
)
def get_areas(backend):
"""Areas from the registry, each with a live entity count."""
index = backend.area_index()
states = backend.states()
counts = {}
dead = {}
unassigned = 0
for state in states:
entity_id = state.get("entity_id") or ""
area = index.area_of(entity_id)
if not area:
unassigned += 1
continue
counts[area] = counts.get(area, 0) + 1
if state.get("state") in DEAD_STATES:
dead[area] = dead.get(area, 0) + 1
areas = [
{
"area_id": a.get("area_id"),
"name": a.get("name"),
"aliases": a.get("aliases") or [],
"entity_count": counts.get(a.get("name"), 0),
"unavailable_count": dead.get(a.get("name"), 0),
}
for a in index.areas
]
areas.sort(key=lambda a: (a["name"] or a["area_id"] or "").lower())
result = {
"instance": backend.instance,
"count": len(areas),
"areas": areas,
"entities_without_area": unassigned,
}
result.update(_area_meta(index))
return result
def find_entities_by_description(backend, text, limit=FIND_LIMIT):
"""Fuzzy lookup over entity_id / friendly_name / area (see match.py)."""
tokens = match.tokenize(text)
if not tokens:
raise BackendError("give me something to search for (text was empty after normalization)")
index = backend.area_index()
scored = []
for state in backend.states():
entity_id = state.get("entity_id") or ""
attrs = state.get("attributes") or {}
area = index.area_of(entity_id)
score, matched, reason = match.score_entity(
tokens, entity_id, attrs.get("friendly_name"), area
)
if not score:
continue
view = _entity_view(state, area)
view["score"] = score
view["matched_tokens"] = f"{matched}/{len(tokens)}"
view["why"] = reason
scored.append((matched, score, entity_id, view))
scored.sort(key=lambda row: (-row[0], -row[1], row[2]))
matches = [row[3] for row in scored[:limit]]
result = {
"instance": backend.instance,
"query": text,
"tokens": tokens,
"total_matches": len(scored),
"returned": len(matches),
"unavailable_count": _count_dead(matches),
"matches": matches,
}
result.update(_area_meta(index))
return result
# --- automations: repo is the source of truth, live state is annotation ---
def _automation_entries(backend):
"""[(id, alias, repo-relative path, absolute path, parsed dict)] from the repo."""
entries = []
for rel_path, abs_path in backend.automation_files():
try:
with open(abs_path, "r", encoding="utf-8") as f:
parsed = yaml.safe_load(f)
except (OSError, yaml.YAMLError):
parsed = None
parsed = parsed if isinstance(parsed, dict) else {}
auto_id = str(parsed.get("id") or abs_path.stem)
entries.append((auto_id, parsed.get("alias"), rel_path, abs_path, parsed))
return entries
def _live_automations(backend):
"""{automation id: live state dict}, or ({}, error message) if unreachable."""
try:
states = backend.states()
except BackendError as exc:
return {}, str(exc)
live = {}
for state in states:
entity_id = state.get("entity_id") or ""
if not entity_id.startswith("automation."):
continue
attrs = state.get("attributes") or {}
auto_id = attrs.get("id")
record = {
"entity_id": entity_id,
"state": state.get("state"),
"last_triggered": attrs.get("last_triggered"),
"friendly_name": attrs.get("friendly_name"),
}
if auto_id:
live[str(auto_id)] = record
return live, None
def read_automation(backend, id_or_alias):
"""Automation YAML straight from the repo, annotated with live state.
Repo, not `/api/config/automation/config/<id>`, on purpose: the repo is
the reviewable source of truth (DESIGN.md, phase 1), and reading it works
even when the instance is unreachable.
"""
needle = (id_or_alias or "").strip()
if not needle:
raise BackendError("id_or_alias is required")
entries = _automation_entries(backend)
if not entries:
raise BackendError(
f"no automations in the repo for instance '{backend.instance}' "
f"(expected services/home-assistant/config/{backend.instance}/automations/)"
)
needle_n = match.normalize(needle)
exact = [e for e in entries if e[0] == needle or match.normalize(e[1]) == needle_n]
if not exact:
exact = [e for e in entries if needle_n and needle_n in match.normalize(e[1])]
if not exact:
exact = [e for e in entries if needle_n and needle_n in match.normalize(e[0])]
if not exact:
raise BackendError(
f"no automation matching '{id_or_alias}' in the repo "
f"(searched id and alias of {len(entries)} files)"
)
if len(exact) > 1:
raise BackendError(
f"'{id_or_alias}' matches {len(exact)} automations — be more specific: "
+ "; ".join(f"{e[0]} ({e[1]})" for e in exact[:10])
)
auto_id, alias, rel_path, abs_path, parsed = exact[0]
with open(abs_path, "r", encoding="utf-8") as f:
raw = f.read()
live, live_error = _live_automations(backend)
result = {
"instance": backend.instance,
"id": auto_id,
"alias": alias,
"path": rel_path,
"automation": parsed,
"yaml": raw,
"source": "repo (services/home-assistant/config/) — not /api/config",
}
record = live.get(auto_id)
if record:
result["live"] = record
elif live_error:
result["live_error"] = live_error
else:
result["live"] = None
result["live_note"] = (
"no automation entity with this id on the live instance — "
"present in the repo but not loaded (deploy pending, or disabled/removed in HA)"
)
return result
def list_automations(backend, filter=None):
"""id / alias / state / last_triggered for every automation, repo-joined."""
entries = _automation_entries(backend)
by_id = {e[0]: e for e in entries}
live, live_error = _live_automations(backend)
rows = []
for auto_id in sorted(set(by_id) | set(live)):
entry = by_id.get(auto_id)
record = live.get(auto_id) or {}
rows.append(
{
"id": auto_id,
"alias": (entry[1] if entry else None) or record.get("friendly_name"),
"state": record.get("state"),
"last_triggered": record.get("last_triggered"),
"entity_id": record.get("entity_id"),
"path": entry[2] if entry else None,
"in_repo": entry is not None,
"on_instance": auto_id in live,
}
)
if filter:
needle = match.normalize(filter)
rows = [
r
for r in rows
if needle
in match.normalize(" ".join(str(v) for v in (r["alias"], r["id"], r["entity_id"]) if v))
]
result = {
"instance": backend.instance,
"filter": filter,
"count": len(rows),
"automations": rows,
}
if live_error:
result["live_error"] = live_error
result["live_note"] = "state/last_triggered unavailable — listing repo files only"
return result
def instance_status(backend):
"""Reachability, HA version, entity counts — or the offline record from instances.yaml."""
cfg = backend.cfg
result = {
"instance": backend.instance,
"status": backend.status,
"adapter": cfg.get("adapter"),
"base_url": cfg.get("base_url"),
"site": cfg.get("site"),
"repo_automations": len(backend.automation_files()),
}
if backend.status == "offline":
# instances.yaml already answers this; a 5s timeout would not add anything.
result["reachable"] = False
result["reason"] = (
"status: offline in instances.yaml — not queried. This instance sits "
"behind an intermittent LTE uplink (site chelsty) and is offline-first "
"by design; see DESIGN.md and CLAUDE.md, 'CHELSTY-Specific Rules'."
)
return result
try:
config = backend.ha_config()
states = backend.states()
except BackendError as exc:
result["reachable"] = False
result["error"] = str(exc)
return result
dead = [s for s in states if s.get("state") in DEAD_STATES]
result.update(
{
"reachable": True,
"version": config.get("version"),
"location_name": config.get("location_name"),
"time_zone": config.get("time_zone"),
"entity_count": len(states),
"unavailable_count": len(dead),
"automation_count": sum(
1 for s in states if (s.get("entity_id") or "").startswith("automation.")
),
}
)
index = backend.area_index()
result["area_count"] = len(index.areas)
result.update(_area_meta(index))
if dead:
result["unavailable_note"] = (
f"{len(dead)} entities are unavailable/unknown — the 2026-07-23 audit "
"traced ~15 silently dead automations to exactly this "
"(docs/audyt-automatyzacji-2026-07-23.md, 1.2)"
)
return result

View file

@ -0,0 +1,116 @@
"""Offline test rig: a fake backend fed from JSON fixtures.
Same idea as scripts/ha/tests/test_import_api_offline.sh no network, no HA
instance, no token. The fixtures under tests/fixtures/ are shaped exactly
like real `/api/states` and WebSocket registry payloads (trimmed down);
read_automation tests read the real repo automations, which is offline too.
"""
import json
import sys
from pathlib import Path
import pytest
SERVICE_DIR = Path(__file__).resolve().parents[1]
REPO_ROOT = SERVICE_DIR.parents[1]
FIXTURES = Path(__file__).resolve().parent / "fixtures"
sys.path.insert(0, str(SERVICE_DIR / "src"))
from ha_mcp import backend as backend_mod # noqa: E402
def load_fixture(name):
with open(FIXTURES / name, encoding="utf-8") as f:
return json.load(f)
class FakeBackend:
"""Stands in for LiveBackend: canned states/config/registries, real repo files."""
def __init__(self, instance="ken", states=None, registries=None, config=None,
area_source="websocket", automations_instance=None):
self.instance = instance
self.cfg = {
"adapter": "api",
"base_url": "http://ha.example:8123",
"status": "active",
"site": "ken",
"token_path": "/nonexistent",
}
self._states = load_fixture("states.json") if states is None else states
reg = load_fixture("registries.json") if registries is None else registries
self._config = config or {
"version": "2026.7.2",
"location_name": "KEN",
"time_zone": "Europe/Warsaw",
}
self._index = backend_mod._index_from_registries(
reg.get("areas"), reg.get("entities"), reg.get("devices"), area_source
)
self._automations_instance = automations_instance or instance
@property
def status(self):
return self.cfg.get("status") or "unknown"
def states(self):
return self._states
def ha_config(self):
return self._config
def area_index(self):
return self._index
def automation_files(self):
return backend_mod.automation_files(self._automations_instance, REPO_ROOT)
class UnreachableBackend(FakeBackend):
"""Live calls fail; repo-backed calls still work (read_automation offline path)."""
def states(self):
raise backend_mod.BackendError("instance 'ken' unreachable at http://ha.example:8123")
def ha_config(self):
raise backend_mod.BackendError("instance 'ken' unreachable at http://ha.example:8123")
@pytest.fixture
def fake_backend():
return FakeBackend()
@pytest.fixture
def unreachable_backend():
return UnreachableBackend()
@pytest.fixture
def repo_root():
return REPO_ROOT
@pytest.fixture(scope="session")
def real_ken_backend():
"""FakeBackend over the newest committed /api/states snapshot of `ken`.
Still offline (the fixture is a file in the repo), but ~1650 real
entities instead of ten ranking behaves differently at that scale.
Areas come from the storage-export fallback, same as a live run with the
WebSocket unavailable.
"""
import yaml
from ha_mcp import backend as bm
snapshots = sorted((REPO_ROOT / "services" / "home-assistant" / "fixtures").glob("ken-states-*.yaml"))
if not snapshots:
pytest.skip("no ken-states-*.yaml fixture in the repo")
with open(snapshots[-1], encoding="utf-8") as f:
states = yaml.safe_load(f)
backend = FakeBackend(states=states)
backend._index = bm.load_offline_area_index("ken", REPO_ROOT)
return backend

View file

@ -0,0 +1,21 @@
{
"areas": [
{"area_id": "salon", "name": "Salon", "aliases": []},
{"area_id": "hall", "name": "Hall", "aliases": ["Wejście"]},
{"area_id": "sypialnia", "name": "Sypialnia", "aliases": []}
],
"devices": [
{"id": "dev_thsalon", "area_id": "salon"},
{"id": "dev_mdwejscie", "area_id": "hall"},
{"id": "dev_thsypialnia", "area_id": "sypialnia"}
],
"entities": [
{"entity_id": "sensor.thsalon_temperature", "device_id": "dev_thsalon", "area_id": null},
{"entity_id": "sensor.thsalon_humidity", "device_id": "dev_thsalon", "area_id": null},
{"entity_id": "select.thsalon_temperature_unit", "device_id": "dev_thsalon", "area_id": null},
{"entity_id": "sensor.thsypialnia_temperature", "device_id": "dev_thsypialnia", "area_id": null},
{"entity_id": "binary_sensor.0x00124b00251472ef_occupancy", "device_id": "dev_mdwejscie", "area_id": null},
{"entity_id": "light.salon_lampa", "device_id": null, "area_id": "salon"},
{"entity_id": "switch.tasmota_14", "device_id": null, "area_id": null}
]
}

View file

@ -0,0 +1,111 @@
[
{
"entity_id": "sensor.thsalon_temperature",
"state": "24.3",
"attributes": {
"friendly_name": "thSalon Temperature",
"device_class": "temperature",
"unit_of_measurement": "°C"
},
"last_changed": "2026-07-27T09:12:03.101000+00:00",
"last_updated": "2026-07-27T09:12:03.101000+00:00"
},
{
"entity_id": "sensor.thsalon_humidity",
"state": "49.5",
"attributes": {
"friendly_name": "thSalon Humidity",
"device_class": "humidity",
"unit_of_measurement": "%"
},
"last_changed": "2026-07-27T09:12:03.102000+00:00",
"last_updated": "2026-07-27T09:12:03.102000+00:00"
},
{
"entity_id": "select.thsalon_temperature_unit",
"state": "celsius",
"attributes": {
"friendly_name": "thSalon Temperature unit",
"options": ["celsius", "fahrenheit"]
},
"last_changed": "2026-07-26T21:14:41.396310+00:00",
"last_updated": "2026-07-26T21:14:41.396310+00:00"
},
{
"entity_id": "sensor.thsypialnia_temperature",
"state": "unavailable",
"attributes": {
"friendly_name": "thSypialnia Temperature",
"device_class": "temperature"
},
"last_changed": "2026-07-17T15:07:11.000000+00:00",
"last_updated": "2026-07-17T15:07:11.000000+00:00"
},
{
"entity_id": "binary_sensor.0x00124b00251472ef_occupancy",
"state": "unavailable",
"attributes": {
"friendly_name": "mdWejscie Occupancy",
"device_class": "occupancy"
},
"last_changed": "2026-07-17T15:07:12.000000+00:00",
"last_updated": "2026-07-17T15:07:12.000000+00:00"
},
{
"entity_id": "light.salon_lampa",
"state": "on",
"attributes": {
"friendly_name": "Lampa salon",
"brightness": 180
},
"last_changed": "2026-07-27T18:40:00.000000+00:00",
"last_updated": "2026-07-27T18:40:00.000000+00:00"
},
{
"entity_id": "switch.tasmota_14",
"state": "on",
"attributes": {
"friendly_name": "Tasmota 14"
},
"last_changed": "2026-03-26T04:00:00.000000+00:00",
"last_updated": "2026-03-26T04:00:00.000000+00:00"
},
{
"entity_id": "automation.klima_salon_off",
"state": "on",
"attributes": {
"id": "1784804668795",
"friendly_name": "Klima salon: wyłącz chłodzenie i osusz parownik",
"last_triggered": "2026-07-27T19:31:00.000000+00:00",
"mode": "single",
"current": 0
},
"last_changed": "2026-07-26T21:15:19.374224+00:00",
"last_updated": "2026-07-27T19:31:00.000000+00:00"
},
{
"entity_id": "automation.mirror_on",
"state": "on",
"attributes": {
"id": "1636927813132",
"friendly_name": "Mirror ON",
"last_triggered": "2026-07-24T04:30:00.102503+00:00",
"mode": "single",
"current": 0
},
"last_changed": "2026-07-26T21:15:19.375401+00:00",
"last_updated": "2026-07-26T21:15:19.375401+00:00"
},
{
"entity_id": "automation.only_on_instance",
"state": "off",
"attributes": {
"id": "9999999999999",
"friendly_name": "Created in the UI, never imported",
"mode": "single",
"current": 0
},
"last_changed": "2026-07-26T21:15:19.376000+00:00",
"last_updated": "2026-07-26T21:15:19.376000+00:00"
}
]

13
services/ha-mcp/tests/run.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Run the ha-mcp test suite (offline: no network, no HA instance, no token).
# Uses services/ha-mcp/.venv when present, else whatever python3 has pytest.
set -euo pipefail
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_DIR="$(dirname "$TESTS_DIR")"
VENV_PY="$SERVICE_DIR/.venv/bin/python"
PY="${VENV_PY}"
[[ -x "$PY" ]] || PY="$(command -v python3)"
exec "$PY" -m pytest "$TESTS_DIR" "$@"

View file

@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Live read-only smoke test against a real instance (default: `ken`).
NOT part of the pytest suite: it needs a reachable instance and a token at
`token_path`, so it is a separate opt-in script. Read-only, like everything
else here it calls GET /api/config, GET /api/states and the WebSocket
`*_list` reads, nothing that writes.
services/ha-mcp/.venv/bin/python services/ha-mcp/tests/smoke_live.py [instance]
"""
import json
import sys
from pathlib import Path
SERVICE_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SERVICE_DIR / "src"))
from ha_mcp import tools # noqa: E402
from ha_mcp.backend import LiveBackend # noqa: E402
from ha_mcp.config import get_instance, repo_root # noqa: E402
def main(argv):
instance = argv[1] if len(argv) > 1 else None
name, cfg = get_instance(instance, repo_root())
status = tools.instance_status(LiveBackend(name, cfg, repo_root()))
print("== instance_status ==")
print(json.dumps(status, indent=2, ensure_ascii=False))
if not status.get("reachable"):
return 0 if status.get("status") == "offline" else 1
entity_id = argv[2] if len(argv) > 2 else "sensor.thsalon_temperature"
print(f"\n== get_state({entity_id}) ==")
state = tools.get_state(LiveBackend(name, cfg, repo_root()), entity_id)
print(json.dumps(state, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View file

@ -0,0 +1,135 @@
"""Backend/config tests, plus the structural read-only guarantees.
No network here either: the token/unreachable paths are exercised with a
bogus token_path and a base_url nothing listens on, and the WebSocket
allowlist is asserted on the constant, not by dialing an instance.
"""
import sys
from pathlib import Path
import pytest
from ha_mcp import backend as backend_mod
from ha_mcp import config as cfg
from ha_mcp.backend import BackendError, LiveBackend
from ha_mcp.config import ConfigError, get_instance
SERVICE_DIR = Path(__file__).resolve().parents[1]
# --- read-only by construction ------------------------------------------
def test_rest_client_has_no_mutating_method(repo_root):
"""scripts/ha/lib/ha_api.Client is GET-only — nothing to call by accident."""
sys.path.insert(0, str(cfg.ha_lib_dir(repo_root)))
import ha_api
public = {n for n in dir(ha_api.Client) if not n.startswith("_")}
assert public == {"get", "get_raw_text"}
for forbidden in ("post", "put", "patch", "delete", "post_config", "call_service"):
assert not hasattr(ha_api.Client, forbidden)
def test_ws_allowlist_contains_only_list_reads():
for command in backend_mod.READ_ONLY_WS_COMMANDS:
assert command.endswith("/list"), command
for mutating in (
"config/area_registry/create",
"config/entity_registry/update",
"config/device_registry/update",
"call_service",
):
assert mutating not in backend_mod.READ_ONLY_WS_COMMANDS
def test_no_write_verbs_anywhere_in_the_package():
"""A grep-level guard: this package must never learn to POST."""
forbidden = ("requests.post", "requests.put", "requests.delete", "call_service", "post_config")
for path in (SERVICE_DIR / "src" / "ha_mcp").glob("*.py"):
text = path.read_text(encoding="utf-8")
for token in forbidden:
# backend.py names the mutating commands only inside comments
code = "\n".join(
line for line in text.splitlines() if not line.strip().startswith("#")
)
assert token not in code, f"{path.name} references {token}"
# --- instances.yaml ------------------------------------------------------
def test_default_instance_is_ken(repo_root):
name, instance = get_instance(None, repo_root)
assert name == "ken"
assert instance["adapter"] == "api"
assert instance["status"] == "active"
def test_unknown_instance_lists_the_known_ones(repo_root):
with pytest.raises(ConfigError) as exc:
get_instance("nope", repo_root)
assert "ken" in str(exc.value) and "chelsty-ha" in str(exc.value)
# --- failure paths: reportable, never a crash ---------------------------
def test_missing_token_is_a_readable_error(repo_root, tmp_path):
backend = LiveBackend(
"ken",
{"base_url": "http://127.0.0.1:1", "status": "active", "token_path": str(tmp_path / "absent")},
repo_root,
)
with pytest.raises(BackendError) as exc:
backend.states()
message = str(exc.value)
assert "no HA token" in message
assert "absent" in message # says which path it looked at
def test_offline_instance_never_opens_a_socket(repo_root):
name, instance = get_instance("chelsty-ha", repo_root)
backend = LiveBackend(name, instance, repo_root)
with pytest.raises(BackendError) as exc:
backend.states()
assert "status: offline" in str(exc.value)
def test_timeout_default_is_short():
assert backend_mod.DEFAULT_TIMEOUT == 5
# --- area index ----------------------------------------------------------
def test_area_index_prefers_entity_area_over_device_area():
index = backend_mod._index_from_registries(
[{"area_id": "salon", "name": "Salon"}, {"area_id": "hall", "name": "Hall"}],
[{"entity_id": "light.x", "area_id": "hall", "device_id": "d1"}],
[{"id": "d1", "area_id": "salon"}],
"websocket",
)
assert index.area_of("light.x") == "Hall"
def test_offline_area_index_falls_back_to_storage_export(repo_root):
index = backend_mod.load_offline_area_index("ken", repo_root)
assert index.source == "storage-export"
assert {a["name"] for a in index.areas} >= {"Salon", "Kuchnia", "Sypialnia"}
# and it says out loud what the offline export cannot resolve
assert "device" in index.note
def test_offline_area_index_for_an_instance_without_an_export(repo_root):
index = backend_mod.load_offline_area_index("chelsty-ha", repo_root)
assert index.source == "none"
assert index.entity_area == {}
def test_automation_files_are_repo_relative(repo_root):
files = backend_mod.automation_files("ken", repo_root)
assert len(files) > 100
rel, absolute = files[0]
assert rel.startswith("services/home-assistant/config/ken/automations/")
assert absolute.is_file()

View file

@ -0,0 +1,304 @@
"""Offline tests for the seven read-only tools."""
import pytest
from ha_mcp import backend as backend_mod
from ha_mcp import tools
from ha_mcp.backend import BackendError
SALON_TEMP = "sensor.thsalon_temperature"
MDWEJSCIE = "binary_sensor.0x00124b00251472ef_occupancy"
KLIMA_OFF_ID = "1784804668795"
# --- list_entities -------------------------------------------------------
def test_list_entities_filters_by_domain(fake_backend):
result = tools.list_entities(fake_backend, domain="sensor")
ids = [e["entity_id"] for e in result["entities"]]
assert ids == [
"sensor.thsalon_humidity",
SALON_TEMP,
"sensor.thsypialnia_temperature",
]
assert result["count"] == 3
# select.thsalon_temperature_unit shares the prefix but not the domain
assert "select.thsalon_temperature_unit" not in ids
def test_list_entities_filters_by_area(fake_backend):
result = tools.list_entities(fake_backend, area="Salon")
ids = {e["entity_id"] for e in result["entities"]}
# three via the device registry, one via its own area_id
assert ids == {
SALON_TEMP,
"sensor.thsalon_humidity",
"select.thsalon_temperature_unit",
"light.salon_lampa",
}
assert all(e["area"] == "Salon" for e in result["entities"])
def test_list_entities_area_accepts_id_alias_and_any_casing(fake_backend):
assert tools.list_entities(fake_backend, area="salon")["count"] == 4 # area_id / lowercase
by_alias = tools.list_entities(fake_backend, area="wejście") # registry alias for Hall
assert by_alias["count"] == 1
assert by_alias["filters"]["area"] == "Hall" # reported under its canonical name
assert tools.list_entities(fake_backend, area="wejscie")["count"] == 1 # no diacritics
def test_list_entities_domain_and_area_combined(fake_backend):
result = tools.list_entities(fake_backend, domain="sensor", area="Salon")
assert [e["entity_id"] for e in result["entities"]] == [
"sensor.thsalon_humidity",
SALON_TEMP,
]
def test_list_entities_unknown_area_is_an_error_not_an_empty_list(fake_backend):
with pytest.raises(BackendError) as exc:
tools.list_entities(fake_backend, area="Piwnica")
assert "unknown area" in str(exc.value)
assert "Salon" in str(exc.value)
def test_list_entities_marks_unavailable(fake_backend):
result = tools.list_entities(fake_backend)
by_id = {e["entity_id"]: e for e in result["entities"]}
dead = by_id[MDWEJSCIE]
assert dead["unavailable"] is True
# how long it has been dead is the actionable part (audit 1.2)
assert dead["unavailable_since"] == "2026-07-17T15:07:12.000000+00:00"
alive = by_id[SALON_TEMP]
assert "unavailable" not in alive
assert result["unavailable_count"] == 2 # mdwejscie + thsypialnia
def test_list_entities_truncates_loudly(fake_backend):
result = tools.list_entities(fake_backend, limit=2)
assert len(result["entities"]) == 2
assert result["truncated"] is True
assert result["count"] == 10 # the full match count is still reported
def test_list_entities_reports_where_areas_came_from(fake_backend):
assert tools.list_entities(fake_backend)["area_source"] == "websocket"
# --- get_state -----------------------------------------------------------
def test_get_state_returns_full_attributes_and_area(fake_backend):
result = tools.get_state(fake_backend, SALON_TEMP)
assert result["state"] == "24.3"
assert result["area"] == "Salon"
assert result["attributes"]["unit_of_measurement"] == "°C"
assert result["attributes"]["device_class"] == "temperature"
assert result["last_changed"] == "2026-07-27T09:12:03.101000+00:00"
def test_get_state_flags_unavailable(fake_backend):
result = tools.get_state(fake_backend, MDWEJSCIE)
assert result["unavailable"] is True
assert result["state"] == "unavailable"
def test_get_state_unknown_entity_suggests_candidates(fake_backend):
with pytest.raises(BackendError) as exc:
tools.get_state(fake_backend, "sensor.thsalon_temperatur")
message = str(exc.value)
assert "no entity" in message
assert SALON_TEMP in message # closest match offered, not just a flat error
# --- get_areas -----------------------------------------------------------
def test_get_areas_counts_entities_and_dead_ones(fake_backend):
result = tools.get_areas(fake_backend)
by_name = {a["name"]: a for a in result["areas"]}
assert by_name["Salon"]["entity_count"] == 4
assert by_name["Salon"]["unavailable_count"] == 0
assert by_name["Hall"]["entity_count"] == 1
assert by_name["Hall"]["unavailable_count"] == 1
assert by_name["Sypialnia"]["unavailable_count"] == 1
assert by_name["Hall"]["aliases"] == ["Wejście"]
# switch.tasmota_14 + the three automations have no area
assert result["entities_without_area"] == 4
# --- find_entities_by_description ---------------------------------------
def test_find_entities_by_description_finds_the_salon_thermometer(fake_backend):
result = tools.find_entities_by_description(fake_backend, "czujnik temperatury salon")
top = result["matches"][0]
assert top["entity_id"] == SALON_TEMP
assert top["matched_tokens"] == "3/3"
# the reason is human-readable and names the PL->EN bridge it crossed
assert "'czujnik'->'sensor'" in top["why"]
assert "'temperatury'->'temperature'" in top["why"]
def test_find_entities_ranks_full_token_matches_first(fake_backend):
result = tools.find_entities_by_description(fake_backend, "czujnik temperatury salon")
ids = [m["entity_id"] for m in result["matches"]]
assert ids.index(SALON_TEMP) < ids.index("sensor.thsalon_humidity")
assert ids.index(SALON_TEMP) < ids.index("light.salon_lampa")
def test_find_entities_matches_by_area(fake_backend):
result = tools.find_entities_by_description(fake_backend, "sypialnia")
assert result["matches"][0]["entity_id"] == "sensor.thsypialnia_temperature"
assert result["matches"][0]["unavailable"] is True
assert result["unavailable_count"] == 1
def test_find_entities_caps_at_20(fake_backend):
states = [
{"entity_id": f"sensor.salon_{i}", "state": "1", "attributes": {"friendly_name": f"Salon {i}"}}
for i in range(40)
]
backend = type(fake_backend)(states=states)
result = tools.find_entities_by_description(backend, "salon")
assert result["total_matches"] == 40
assert result["returned"] == 20
def test_find_entities_empty_query_errors(fake_backend):
with pytest.raises(BackendError):
tools.find_entities_by_description(fake_backend, " ")
def test_find_entities_ranks_first_against_the_real_1600_entity_snapshot(real_ken_backend):
"""Ten hits differently at scale: 672 of ~1650 entities match at least one token."""
result = tools.find_entities_by_description(real_ken_backend, "czujnik temperatury salon")
assert result["total_matches"] > 100
assert result["matches"][0]["entity_id"] == SALON_TEMP
assert result["matches"][0]["state"] # a real reading, not a placeholder
# --- read_automation (repo-backed) --------------------------------------
def test_read_automation_by_alias_returns_the_klima_file(fake_backend):
result = tools.read_automation(fake_backend, "Klima salon: wyłącz")
assert result["id"] == KLIMA_OFF_ID
assert result["path"] == (
f"services/home-assistant/config/ken/automations/{KLIMA_OFF_ID}.yaml"
)
assert result["alias"] == "Klima salon: wyłącz chłodzenie i osusz parownik"
assert "script.klima_salon_dry_off" in result["yaml"]
assert result["automation"]["mode"] == "single"
assert result["source"].startswith("repo")
def test_read_automation_annotates_with_live_state(fake_backend):
result = tools.read_automation(fake_backend, KLIMA_OFF_ID)
assert result["live"]["state"] == "on"
assert result["live"]["last_triggered"] == "2026-07-27T19:31:00.000000+00:00"
assert result["live"]["entity_id"] == "automation.klima_salon_off"
def test_read_automation_works_with_the_instance_down(unreachable_backend):
result = tools.read_automation(unreachable_backend, KLIMA_OFF_ID)
assert "script.klima_salon_dry_off" in result["yaml"] # repo read still succeeds
assert "unreachable" in result["live_error"]
def test_read_automation_ambiguous_alias_lists_candidates(fake_backend):
with pytest.raises(BackendError) as exc:
tools.read_automation(fake_backend, "Klima salon")
message = str(exc.value)
assert "matches 2 automations" in message
assert KLIMA_OFF_ID in message
def test_read_automation_unknown_errors_cleanly(fake_backend):
with pytest.raises(BackendError) as exc:
tools.read_automation(fake_backend, "nie ma takiej automatyzacji")
assert "no automation matching" in str(exc.value)
def test_read_automation_does_not_escape_the_automations_dir(fake_backend):
with pytest.raises(BackendError):
tools.read_automation(fake_backend, "../../../etc/passwd")
# --- list_automations ----------------------------------------------------
def test_list_automations_joins_repo_and_live_state(fake_backend):
rows = {r["id"]: r for r in tools.list_automations(fake_backend)["automations"]}
klima = rows[KLIMA_OFF_ID]
assert klima["alias"] == "Klima salon: wyłącz chłodzenie i osusz parownik"
assert klima["state"] == "on"
assert klima["last_triggered"] == "2026-07-27T19:31:00.000000+00:00"
assert klima["in_repo"] and klima["on_instance"]
# in the repo but not loaded on the instance
repo_only = [r for r in rows.values() if not r["on_instance"]]
assert repo_only and all(r["state"] is None for r in repo_only)
# on the instance but never imported into the repo
ui_only = rows["9999999999999"]
assert ui_only["in_repo"] is False
assert ui_only["alias"] == "Created in the UI, never imported"
def test_list_automations_filter_is_substring_and_diacritic_insensitive(fake_backend):
result = tools.list_automations(fake_backend, filter="klima salon")
assert result["count"] == 2
assert all("Klima salon" in r["alias"] for r in result["automations"])
assert tools.list_automations(fake_backend, filter="wylacz chlodzenie")["count"] == 1
def test_list_automations_degrades_when_instance_is_down(unreachable_backend):
result = tools.list_automations(unreachable_backend)
assert result["count"] > 100 # repo listing still works
assert "unreachable" in result["live_error"]
assert all(r["state"] is None for r in result["automations"])
# --- instance_status -----------------------------------------------------
def test_instance_status_reports_version_and_dead_entities(fake_backend):
result = tools.instance_status(fake_backend)
assert result["reachable"] is True
assert result["version"] == "2026.7.2"
assert result["entity_count"] == 10
assert result["unavailable_count"] == 2
assert result["automation_count"] == 3
assert result["area_count"] == 3
assert "audyt" in result["unavailable_note"]
def test_instance_status_unreachable_is_reported_not_raised(unreachable_backend):
result = tools.instance_status(unreachable_backend)
assert result["reachable"] is False
assert "unreachable" in result["error"]
def test_instance_status_offline_instance_short_circuits(repo_root):
"""chelsty-ha: answer from instances.yaml, never open a socket."""
from ha_mcp.config import get_instance
name, cfg = get_instance("chelsty-ha", repo_root)
backend = backend_mod.LiveBackend(name, cfg, repo_root)
def explode(*_args, **_kwargs): # pragma: no cover - must never run
raise AssertionError("network call attempted against an offline instance")
backend._get = explode
result = tools.instance_status(backend)
assert result["reachable"] is False
assert result["status"] == "offline"
assert "instances.yaml" in result["reason"]
assert "error" not in result

View file

@ -12,7 +12,7 @@ entry.
|---|---| |---|---|
| **0 — Snapshot** | Import-only tooling (this skeleton). Pull `/config` + `.storage` from each instance into the repo, read-only. No deploy, no write path back to HA. | | **0 — Snapshot** | Import-only tooling (this skeleton). Pull `/config` + `.storage` from each instance into the repo, read-only. No deploy, no write path back to HA. |
| **1 — Repo + CC** | Repo is the reviewable source of truth. Changes are authored in the repo (by a human or Claude Code) and pushed manually via the docker-exec / api adapters described below. Deploy has a hard drift-abort (see Sync model). **Partially built**: `scripts/ha/deploy.sh` covers the `api` adapter's automations/scripts/scenes scope; `docker-exec` deploy and dashboards/helpers writes are still open. | | **1 — Repo + CC** | Repo is the reviewable source of truth. Changes are authored in the repo (by a human or Claude Code) and pushed manually via the docker-exec / api adapters described below. Deploy has a hard drift-abort (see Sync model). **Partially built**: `scripts/ha/deploy.sh` covers the `api` adapter's automations/scripts/scenes scope; `docker-exec` deploy and dashboards/helpers writes are still open. |
| **2 — MCP read-only** | Expose HA state (entities, areas, config) to agents via an MCP server in read-only mode — either a self-hosted MCP or `hass-mcp` — to let agents reason about the live instance without touching import/deploy paths. Undecided which (see Open questions). | | **2 — MCP read-only** | Expose HA state (entities, areas, config) to agents via an MCP server in read-only mode, to let agents reason about the live instance without touching import/deploy paths. **2a done (operator decision 2026-07-30: own minimal MCP, not `hass-mcp`)**`services/ha-mcp/`, seven read-only tools over stdio, registered for Claude Code in the repo-root `.mcp.json`. Read-only by construction: GET + allowlisted WS `*_list` reads only; automations are read from the repo, not the API. Open: exposing it to anything other than CC on the dev station (2b). |
| **3 — Agents** | Agents propose changes (automations, scripts, scenes) through the same reviewable repo path used by humans; the human-in-the-loop approval flow from `services/control-plane/` (pending → approved → executed) governs anything destructive. Telegram becomes a first-class interface alongside CC. | | **3 — Agents** | Agents propose changes (automations, scripts, scenes) through the same reviewable repo path used by humans; the human-in-the-loop approval flow from `services/control-plane/` (pending → approved → executed) governs anything destructive. Telegram becomes a first-class interface alongside CC. |
Each phase is a hard gate: no phase-N tooling depends on phase-(N+1) existing. Each phase is a hard gate: no phase-N tooling depends on phase-(N+1) existing.
@ -235,8 +235,13 @@ krzyżowych, to osobny task, nie efekt uboczny porządków).
- What actually drives the phase-3 operational agent (a new agent process - What actually drives the phase-3 operational agent (a new agent process
vs. extending an existing one in `services/`)? vs. extending an existing one in `services/`)?
- Own minimal MCP server vs. adopting `hass-mcp` for phase 2 read-only - ~~Own minimal MCP server vs. adopting `hass-mcp` for phase 2 read-only
access — tradeoffs not yet evaluated. access~~ — decided 2026-07-30: own minimal server (`services/ha-mcp/`).
Reusing `scripts/ha/lib/{ha_api,ha_ws}.py` keeps one token-handling and
one read-only-by-construction story for the whole HA toolchain, and lets
the tools answer from the repo (`read_automation`) and from
`instances.yaml` (offline `chelsty-ha`) — neither of which a generic
server knows about.
- Whether SSH access to `chelsty-ha` itself (not just its HA API) is - Whether SSH access to `chelsty-ha` itself (not just its HA API) is
available/reliable enough to justify a `file` adapter there, which would available/reliable enough to justify a `file` adapter there, which would
let phase-1 tooling treat `chelsty-ha` more like `ken` for drift-checking let phase-1 tooling treat `chelsty-ha` more like `ken` for drift-checking