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.
42 lines
1.4 KiB
Python
42 lines
1.4 KiB
Python
#!/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))
|