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.
69 lines
2.3 KiB
Python
69 lines
2.3 KiB
Python
"""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 {})
|