homelab-codex-ws/kb/services/ha-mcp.md
oskar 4658089e21 fix(kb): przepiecie wszystkich odwolan wewnetrznych po migracji
126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.

  15  markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
      odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
      rozwiazywala sie z katalogu, w ktorym lezala)
 200  odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
      -> nowa sciezka repo-root-relative, zgodnie z konwencja repo
   5  linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
      tylko w starym katalogu; przeliczone recznie

Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.

Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.

Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.

Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:46 +02:00

7.9 KiB
Raw Permalink Blame History

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

ha-mcp — read-only MCP server for Home Assistant

Status: phase 2a of kb/decisions/ha-configs-as-code.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 kb/decisions/ha-configs-as-code.md, "Sync model" and "Validation gate". Nothing in this server bypasses that, and nothing in this server should ever learn to.

Tools

All seven are read-only, take an optional instance (default ken — the canonical home instance), and return a JSON object. Failures come back as {"error": "..."} inside a normal result: a missing token, an unreachable instance or a typo'd entity_id never crashes the server or leaves a tool call hanging (timeouts are 5 s).

Tool Arguments Returns
list_entities domain?, area?, limit? (200) entity_id, friendly_name, state, area; unavailable flag
get_state entity_id full state incl. every attribute and area
get_areas areas with entity counts and dead-entity counts
find_entities_by_description text ≤20 fuzzy hits, each with the reason it matched
read_automation id_or_alias automation YAML from the repo + live last_triggered
list_automations filter? id, alias, state, last_triggered, repo path
instance_status reachability, HA version, entity/automation counts, dead count

Examples

instance_status() — on the live ken:

{
  "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:

{
  "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:

{
  "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 (czujniksensor, swiatlolight, ruchmotion/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:

{
  "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:

{
  "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 (kb/audits/ha-automatyzacje-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.

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.