---
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:
[](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).