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.
136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
"""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()
|