--- url: https://devitek.github.io/mcp-ha/reference/architecture.md --- # Architecture ## Overview The add-on runs a Node 26 server inside a Supervisor-managed container. MCP clients reach it over the LAN; the add-on talks to Home Assistant through the Supervisor's internal proxy, authenticated with the `SUPERVISOR_TOKEN` injected into the container. No user token, no external URL. ```mermaid flowchart LR subgraph lan["Your LAN"] C["MCP client
Claude Code, Claude Desktop, Gemini CLI"] end subgraph haos["Home Assistant OS"] subgraph addon["mcp-ha add-on"] T["Streamable HTTP endpoint /mcp
bearer auth, stateless"] S["MCP server
19 tools + safety layer"] W["WebSocket client"] H["HTTP client"] T --> S S --> W S --> H end CORE["HA Core"] SUP["Supervisor"] W -->|"ws://supervisor/core/websocket"| CORE H -->|"REST http://supervisor/core/api"| CORE H -->|"http://supervisor/addons"| SUP end C -->|"POST /mcp
Authorization: Bearer token"| T ``` ## WebSocket-first The HA **WebSocket API is the primary channel**: states, services, registries (areas, devices, entities), history, statistics, logbook, service calls. One persistent connection, commands correlated by a monotonically increasing `id`. ```mermaid sequenceDiagram participant A as Add-on participant HA as HA Core WebSocket A->>HA: connect HA-->>A: auth_required A->>HA: auth (SUPERVISOR_TOKEN) HA-->>A: auth_ok Note over A,HA: commands queued until auth_ok are flushed A->>HA: get_states (id 1) HA-->>A: result (id 1) loop every 30 s A->>HA: ping HA-->>A: pong end Note over A,HA: on close: pending commands rejected,
reconnect with exponential backoff (1 s to 30 s) ``` Two HTTP leftovers exist because they have no WebSocket equivalent: | Need | Channel | |------|---------| | Add-on list and details | Supervisor API `http://supervisor/addons` | | Automation/script YAML config | REST `GET /api/config/automation/config/` | | Template rendering | REST `POST /api/template` (the WS command is a subscription, unsuited to one-shot stateless calls) | | HA error log | REST `GET /api/error_log` | ## Stateless MCP transport The server implements MCP over **Streamable HTTP** in stateless mode: one MCP server instance and one transport per request, no session. That makes the endpoint trivially compatible with multiple simultaneous clients and with restarts. `GET /mcp` answers 405; `/health` is the only unauthenticated route. ## Context-window discipline Tool responses are designed for LLM consumption: * projection by default: lists return minimal fields, details live in `ha_get_entity`; * standard envelope with `total`, `has_more`, `next_offset`; * unfiltered `ha_list_entities` returns a histogram, not a dump; * bounded time windows, downsampling beyond 250 history points; * global cap around 15 KB per response, with a note explaining how to refine. ## Token bootstrap ```mermaid sequenceDiagram participant U as User participant A as Add-on participant S as Supervisor A->>A: start, api_token option empty A->>A: generate 32 random bytes, write /data/token (0600) A->>S: GET /addons/self/info (current options) A->>S: POST /addons/self/options (merge api_token) Note over A,S: retried a few times, the Supervisor may still be booting A->>A: print a masked prefix in the add-on log U->>A: reads the full token in the Configuration tab ``` ## Registry cache Areas, devices and entity registries change rarely: they are cached for 60 seconds. States are always fetched live (a single WS round-trip). A future version will maintain a live state cache fed by `subscribe_events`. ## Repository layout ``` mcp-ha/ ├── mcp_ha/ # the add-on (self-contained Docker build context) │ ├── config.yaml # manifest (options, schema, ports, permissions) │ ├── Dockerfile # multi-stage: node:26-alpine build, HA base runtime │ ├── run.sh # bashio entrypoint │ └── src/ # TypeScript server (MCP SDK, ws, zod) ├── docs/ # this site (VitePress, en + fr) └── .github/workflows/ # CI, release (multi-arch images), docs deploy ``` --- --- url: https://devitek.github.io/mcp-ha/guide/configuration.md --- # Configuration All options live in the add-on **Configuration** tab. Restart the add-on after changing them. ## Options | Option | Default | Description | |--------|---------|-------------| | `log_level` | `info` | Log verbosity: `trace`, `debug`, `info`, `notice`, `warning`, `error`, `fatal`. See [Logging](/reference/logging). | | `api_token` | empty | Token expected from MCP clients in the `Authorization: Bearer ...` header. Leave empty to have one generated on first start (it is saved back into this option). | | `allow_write` | `false` | Exposes the `ha_call_service` tool. Without it the add-on is strictly read only: no write tool is even visible to the client. | | `filter_reads` | `false` | Also applies `entity_denylist` to reads: denied entities disappear from listings, details, history and logbook. | | `entity_allowlist` | `[]` | Glob patterns of entities allowed for writes. When non-empty, writes are deny-by-default. | | `entity_denylist` | `[]` | Glob patterns of entities always refused for writes. Wins over the allowlist. | | `service_denylist` | see below | Services refused in any context. | | `confirm_domains` | `[lock, alarm_control_panel]` | Writes on these domains require a two-step confirmation: the assistant first gets a preview and a single-use token, and must call again with it to execute. | ## Glob patterns Lists accept simple globs where `*` matches anything and every other character is literal: * `light.*` : every light * `lock.front_door` : one exact entity * `*.kitchen_*` : any domain, entities whose name starts with `kitchen_` Matching is case insensitive. ## Write rules A service call must pass **all** of these checks, in order: 1. `allow_write` is enabled (otherwise the tool is not registered at all). 2. The service is not in `service_denylist`. 3. Every targeted `entity_id` passes the allow/deny lists: allowed when the allowlist is empty or matches, and the denylist does not match. **The denylist always wins.** 4. When any entity restriction is configured, targeting by `area_id` or `device_id` is refused (it would bypass the lists): target explicit `entity_id` values instead. 5. On a domain listed in `confirm_domains`, the call must carry a valid `confirm_token` obtained from a first call (single use, expires after 2 minutes, bound to the exact same call). These rules apply identically to all four write tools (`ha_call_service`, `ha_run_script`, `ha_trigger_automation`, `ha_set_automation`): they share a single guarded write path. Every attempt, allowed or refused, produces a JSON audit line in the add-on log. ## Default service denylist ```yaml service_denylist: - homeassistant.stop - homeassistant.restart - hassio.* - shell_command.* - python_script.* - recorder.purge* - backup.* ``` These block stopping or restarting Home Assistant, arbitrary shell commands, recorder purges and backup manipulation. You can edit the list, but think twice before removing entries. ## Example: cautious write setup Allow the assistant to control lights and media players, nothing else, and hide cameras from reads: ```yaml allow_write: true entity_allowlist: - light.* - media_player.* entity_denylist: - light.baby_room filter_reads: true # entity_denylist also hides these from reads thanks to filter_reads ``` --- --- url: https://devitek.github.io/mcp-ha/guide/clients.md --- # Connecting clients The server speaks MCP over **Streamable HTTP** at `http://HA_IP:9583/mcp`, with a bearer token. Replace `HA_IP` and `YOUR_TOKEN` in the examples below. ::: tip The endpoint only accepts POST (stateless mode). `http://HA_IP:9583/health` answers without authentication and tells you whether the add-on is connected to Home Assistant. ::: ## Claude Code (CLI) ```bash claude mcp add --transport http home-assistant \ http://HA_IP:9583/mcp \ --header "Authorization: Bearer YOUR_TOKEN" ``` Then just ask questions in a session: "which lights are on?", "show me the automations that ran tonight". ## Claude Desktop Claude Desktop launches MCP servers itself, so it needs a small bridge (`mcp-remote`) to reach an HTTP server. In `claude_desktop_config.json`: ```json { "mcpServers": { "home-assistant": { "command": "npx", "args": ["-y", "mcp-remote", "http://HA_IP:9583/mcp", "--header", "Authorization: Bearer YOUR_TOKEN"] } } } ``` Restart Claude Desktop after editing the file. ## Gemini CLI In `~/.gemini/settings.json`: ```json { "mcpServers": { "home-assistant": { "httpUrl": "http://HA_IP:9583/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } ``` ## Any other MCP client Anything that supports MCP over Streamable HTTP works the same way: endpoint `http://HA_IP:9583/mcp`, header `Authorization: Bearer YOUR_TOKEN`. The server is stateless: no session negotiation, each POST is independent. ## First prompts to try * "Which lights are on right now?" * "What is the temperature in the living room and how did it evolve today?" * "List my automations, which ones ran in the last 24 hours?" * "What happened in the house tonight?" (logbook) * With `allow_write` enabled: "Turn off every light in the kitchen" (the assistant will use `ha_call_service`; ask it to use `dry_run` first if you want a preview) --- --- url: https://devitek.github.io/mcp-ha/guide/installation.md --- # Installation ## Requirements * **Home Assistant OS** or **Home Assistant Supervised**. The add-on runs as a Supervisor-managed container; Container and Core installations have no add-on support. * Architecture aarch64 (Raspberry Pi 4/5 and other 64-bit ARM boards) or amd64 (NUC, VM, x86 server). ## Add the repository Click the button: [![Add repository](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2FDevitek%2Fmcp-ha) Or manually: **Settings → Add-ons → Add-on store → ⋮ → Repositories**, then paste: ``` https://github.com/Devitek/mcp-ha ``` ## Install and start 1. Find **MCP Home Assistant** in the store (refresh the page if needed) and click **Install**. The Supervisor pulls a prebuilt image from GitHub Container Registry, this takes a few seconds. 2. Click **Start**. ## Get your API token On first start, the add-on generates a random API token (32 bytes) and saves it into the **Configuration** tab of the add-on, in the `api_token` option. That tab is the only place showing the full value: the **Log** tab only ever shows a masked prefix such as `d370f4f8**********`, because a secret does not belong in logs. Every installation gets its own token: reinstalling the add-on wipes its data and produces a fresh one. You can also set your own value in `api_token` at any time; restart the add-on to apply it. If the option ever looks empty, restart the add-on: saving the token there is retried at every start. Next step: [connect a client](/guide/clients). --- --- url: https://devitek.github.io/mcp-ha/reference/logging.md --- # Logging ## Levels The `log_level` option controls verbosity, from most to least verbose: | Level | What you get | |-------|--------------| | `trace` | raw WebSocket frame metadata, tool arguments | | `debug` | WebSocket commands, HTTP calls to HA, MCP requests, tool invocations | | `info` | startup summary, connection lifecycle (default) | | `notice` | noteworthy events: unauthorized attempts, token written to options | | `warning` | recoverable problems: reconnections, failed tool calls | | `error` | HA connection refusals, configuration read errors | | `fatal` | startup failure | The same value is applied to bashio (the `run.sh` wrapper), so Supervisor-side lines follow the same threshold. ## Format One line per event on stderr, visible in the add-on **Log** tab: ``` [2026-08-20T15:30:12.345Z] INFO mcp-ha x.y.z listening on port 9583 (MCP endpoint /mcp, health /health) [2026-08-20T15:30:12.401Z] DEBUG WS command get_states (id 12) ``` ## Audit trail Write attempts through `ha_call_service` produce one JSON line each, **regardless of the log level**. This is a security record, not debug output: lowering the verbosity never silences it. ```json {"ts":"2026-08-20T15:31:02.000Z","audit":true,"tool":"ha_call_service","domain":"light","service":"turn_on","target":{"entity_id":["light.kitchen"]},"allowed":true,"result":"ok"} ``` Refused attempts carry `"allowed": false` and a `reason`. Secrets never appear in audit lines. ## Secrets No secret is ever logged in full, at any level. The API token only appears as a masked prefix with fixed-length padding (`d370f4f8**********`); the full value lives in the add-on Configuration tab. A unit test guards this invariant. ## Diagnosing * Connection issues: `debug` shows every WS command and reconnection with its backoff delay. * Tool behaviour: `debug` logs each tool invocation, `trace` adds the (truncated) arguments. * Client authentication: unauthorized requests are logged at `notice` with the source address. In dev mode (outside the add-on), set the level with the `LOG_LEVEL` environment variable. --- --- url: https://devitek.github.io/mcp-ha/guide/security.md --- # Security Giving an LLM access to your home automation deserves a real security posture. This page summarizes the model; the authoritative document is [SECURITY.md](https://github.com/Devitek/mcp-ha/blob/main/SECURITY.md) in the repository. ## Design choices * **Read only by default.** With `allow_write: false` (the default), the write tool is not registered: it does not appear in the client's tool list at all. * **LAN only.** Plain HTTP with a static bearer token. Do not expose port 9583 to the internet; for remote access use a VPN (WireGuard, Tailscale...). * **The Supervisor token never leaves the add-on.** MCP clients authenticate with their own API token; no tool returns any HA credential. ## Write path The four write tools (`ha_call_service`, `ha_run_script`, `ha_trigger_automation`, `ha_set_automation`) share one guarded path; every call goes through this gauntlet: ```mermaid flowchart TD A["ha_call_service"] --> B{"allow_write enabled?"} B -- "no" --> R0["Tool not registered:
invisible to the client"] B -- "yes" --> C{"service in
service_denylist?"} C -- "yes" --> R1["Refused + audit line"] C -- "no" --> D{"targeted entities pass
allowlist / denylist?"} D -- "no" --> R1 D -- "yes" --> E{"area_id / device_id target
while restrictions exist?"} E -- "yes" --> R1 E -- "no" --> F{"dry_run?"} F -- "yes" --> P["Preview returned + audit,
nothing executed"] F -- "no" --> G{"domain in
confirm_domains?"} G -- "yes, no token" --> C1["Preview + single-use
confirm_token returned"] G -- "yes, valid token" --> X["call_service executed + audit"] G -- "no" --> X ``` The audit lines are JSON, one per attempt, and are emitted regardless of the configured log level. See [Logging](/reference/logging). ## Token lifecycle * Generated on first start (32 random bytes) when `api_token` is empty. * Persisted in `/data/token` (mode 600) and written back into the add-on options. The log never shows it in full: only a masked prefix with fixed-length padding (`d370f4f8**********`), so neither the value nor its length leaks. * Compared in constant time on every request. * To rotate: clear the `api_token` option, delete `/data/token` (or reinstall), restart, then update your clients. ::: warning Versions before 0.1.4 Add-on versions 0.1.0 to 0.1.3 printed the token in full in the add-on log. If you ever shared logs produced by those versions (issue, forum, screenshot), rotate your token now. ::: ## Other guard rails * After 5 failed authentications, an IP is progressively blocked (up to 60 s, HTTP 429 with `Retry-After`); a user-set token shorter than 16 characters triggers a loud startup warning. * The Node server runs as a dedicated unprivileged user inside the container, confined by a custom AppArmor profile that denies `/etc/shadow`, writes outside `/data`, and privilege escalation. The profile was validated on a real AppArmor-enforcing host. ## Accepted limitations * `ha_render_template` evaluates Jinja server-side and can read **any** entity state: it is therefore disabled entirely when `filter_reads` is enabled. * The token being in the options means it is included in add-on backups, and visible to HA admins. So are the logs. * No TLS: anyone able to sniff your LAN traffic can read the token. That is the LAN-only tradeoff. ## Reporting Found a vulnerability? Please use [private security advisories](https://github.com/Devitek/mcp-ha/security/advisories/new) rather than a public issue. --- --- url: https://devitek.github.io/mcp-ha/reference/tools.md --- # Tool reference 19 tools, prefixed `ha_`. All read tools carry the `readOnlyHint` annotation. Responses are compact JSON with a standard list envelope: ```json { "items": [...], "returned": 50, "total": 734, "has_more": true, "next_offset": 50, "note": "..." } ``` ## Entities ### ha\_search\_entities Fuzzy search by name, entity\_id or area. The natural entry point. | Param | Type | Notes | |-------|------|-------| | `query` | string, required | e.g. `kitchen light` | | `limit` | number | default 20, max 50 | ### ha\_list\_entities Paginated list. **Called without any filter, it returns a histogram** (counts per domain and per area) instead of a dump. | Param | Type | Notes | |-------|------|-------| | `domain` | string | e.g. `light`, `sensor`, `automation` | | `area` | string | area name, case insensitive | | `search` | string | fuzzy filter | | `state` | string | exact state, e.g. `on` | | `limit` / `offset` | number | default 50, max 200 | ### ha\_get\_entity Full state and attributes of one entity (long attribute values truncated). | Param | Type | |-------|------| | `entity_id` | string, required | ### ha\_list\_areas All areas with their entity counts. No parameters. ### ha\_list\_devices Devices with manufacturer, model, area. Params: `area`, `limit`, `offset`. ## Services ### ha\_list\_services Without parameters: domains and their service counts. With `domain`: detailed services and fields. With `search`: cross-domain lookup. ### ha\_call\_service Only registered when `allow_write` is enabled. Subject to the [write rules](/guide/configuration#write-rules). | Param | Type | Notes | |-------|------|-------| | `domain` / `service` | string, required | e.g. `light` / `turn_on` | | `target` | object | `entity_id`, `device_id`, `area_id` (prefer `entity_id`) | | `data` | object | service data, e.g. `{ "brightness_pct": 50 }` | | `dry_run` | boolean | preview without executing | | `confirm_token` | string | token from a `confirmation_required` answer (sensitive domains) | | `return_response` | boolean | for services that return data | On domains listed in `confirm_domains` (locks and alarms by default), the first call answers `confirmation_required` with a single-use `confirm_token` bound to that exact call; execute by calling again with the same arguments plus the token. ### ha\_run\_script Runs a script, optionally with variables. Same guarded path as `ha_call_service`. | Param | Type | Notes | |-------|------|-------| | `entity_id` | string, required | must be a `script.*` entity | | `variables` | object | passed to the script | | `dry_run` / `confirm_token` | | as in `ha_call_service` | ### ha\_trigger\_automation Triggers an automation now. `skip_condition` defaults to `true` (actions run even if conditions do not hold). | Param | Type | Notes | |-------|------|-------| | `entity_id` | string, required | must be an `automation.*` entity | | `skip_condition` | boolean | default `true` | | `dry_run` / `confirm_token` | | as in `ha_call_service` | ### ha\_set\_automation Enables or disables an automation. | Param | Type | Notes | |-------|------|-------| | `entity_id` | string, required | must be an `automation.*` entity | | `enabled` | boolean, required | `true` to enable | | `dry_run` / `confirm_token` | | as in `ha_call_service` | ## Automations and scripts ### ha\_list\_automations entity\_id, name, enabled, last\_triggered. Params: `limit`, `offset`. ### ha\_get\_automation State plus, for UI-created automations, the full configuration (triggers, conditions, actions). YAML-defined automations return their state with a note. ### ha\_list\_scripts entity\_id, name, running, last\_triggered. Params: `limit`, `offset`. ## History ### ha\_get\_history State changes of one entity. Window: `hours` (min 0.25, default 24, max 168) or `start`/`end` ISO 8601. The first point is the state already in effect at window start; more than 250 points are downsampled with a note. ### ha\_get\_statistics Recorder aggregates (mean, min, max, sum) for numeric sensors. `statistic_id` (string or list up to 10), `period` among `5minute`, `hour`, `day`, `week`, `month`, window up to one year. Prefer this over `ha_get_history` for long ranges. ### ha\_get\_logbook Human-readable events, filterable by `entity_id`, window from 0.25 h up to 7 days, capped at 100 events. ## Add-ons and system ### ha\_get\_addons Without `slug`: list of installed add-ons. With `slug`: details of one. Read only. Requires the Supervisor (unavailable in dev mode). ### ha\_render\_template Evaluates a Jinja2 template server-side and returns the rendering. Read only, very powerful for computed queries. Not registered when `filter_reads` is enabled (a template can read any entity): ``` {{ states.light | selectattr('state','eq','on') | list | count }} ``` ### ha\_get\_system `section: "config"`: HA version, name, timezone, units, integration count. `section: "error_log"`: last 100 lines of the HA error log. --- --- url: https://devitek.github.io/mcp-ha/guide/troubleshooting.md --- # Troubleshooting ## Lost API token It is visible in the add-on **Configuration** tab (`api_token` option) and kept in `/data/token`. The log never shows it in full, only a masked prefix. If the option looks empty, restart the add-on: the write-back is retried at every start. To force a new token: clear the option, delete `/data/token`, restart. ## 401 Unauthorized * Check the header: `Authorization: Bearer YOUR_TOKEN`, no stray spaces or quotes. * The token in your client must match the `api_token` option exactly. * Each unauthorized attempt is logged (`Unauthorized MCP request from ...`), which confirms the request reaches the add-on. ## Tools answer "Home Assistant WebSocket is not connected" The add-on maintains a permanent WebSocket to Home Assistant and reconnects with backoff. A short outage right after an HA restart is normal. * Check the add-on log: you should see `Connecting to Home Assistant WebSocket...` then `Authenticated with Home Assistant`. * If it loops on reconnection, raise `log_level` to `debug` and look at the reason. ## `ha_get_addons` fails The Supervisor API only exists when running as a real add-on. In dev mode (outside HA), this tool answers a clear error; everything else works. ## Responses look truncated That is by design: responses are capped (about 15 KB) to protect the LLM context window. The `note` field tells the assistant how to refine (domain/area filters, shorter time window, pagination). It is a feature, not a bug. ## Logs too quiet or too noisy Adjust the `log_level` option: `debug` adds WebSocket commands, HTTP calls and tool invocations; `trace` adds raw frame details and tool arguments. See [Logging](/reference/logging). ## Is the server alive? `http://HA_IP:9583/health` answers without authentication: ```json { "status": "ok", "websocket": true } ``` `websocket: false` means the add-on runs but is not (yet) connected to Home Assistant. After more than 5 minutes of lost connection the endpoint answers 503 with `"status": "degraded"`, which lets the container healthcheck restart the add-on. ## Something else? Open an [issue on GitHub](https://github.com/Devitek/mcp-ha/issues) with the add-on version, your HA version, the client used and a log excerpt (mask your token).