An MCP server for Home Assistant that runs inside Home Assistant itself - it gives your AI coding assistant (Claude Code, Cursor, Codex, or any other MCP client) hands-on access to your instance, the same way it already helps you with code, but for automations, entities, dashboards, and helpers.
It runs inside Home Assistant's own mcp_server
integration, so there's
no separate server to install, host, or keep running - just a Bearer token
and an MCP client. And because handing write access to an AI assistant is a
big ask, every write is proposed and previewed before anything happens, an
optional dry-run mode can keep it that way permanently, and optional git
mirroring gives you a private, before/after history to roll back from by
hand. See The safety model below for exactly what that
does and doesn't guarantee - it's worth reading before you turn this loose on
a live instance.
- "Add an automation that turns on the hallway light when the hallway motion sensor trips after sunset."
- "Find every automation that references an entity that's now unavailable."
- "The porch light didn't turn on last night - check the logs and logbook and tell me why."
- "Clean up the 40 leftover entities from the Zigbee device I just removed."
⚠️ Use at your own risk. This is a community project, built and maintained in spare time - no SLA, no guaranteed support, and no guarantee that dry-run, the confirm step, or git mirroring catch every mistake before it reaches your live instance. AI can be wrong, and this is all still a work in progress. Provided as-is, with no warranty - see LICENSE.
Needs Home Assistant 2026.8.2+ (see Requirements below for the full list, including client-specific versions).
-
Install via HACS or manually - see Installation below.
-
Settings → Devices & Services → Add Integration → HA Dev Tools.
-
Settings → Devices & Services → Add Integration → Model Context Protocol Server (Home Assistant's own built-in integration) - in its setup, add
dev_toolsto the exposed APIs. This step is easy to miss and is the single most common way to end up stuck: skip it and HA Dev Tools' card still shows healthy, but an MCP client just gets a bare 404 (a Settings → System → Repairs issue will flag it if you do). -
Arm it. Every tool except a diagnostic ping refuses to run until you prove real filesystem access - the same kind SSH or the Terminal add-on already requires:
date +%s > /config/.storage/ha_dev_tools.armedThis enables
dev_toolsfor up to 4 hours (extended by 30 minutes each time a tool is used, and it expires on its own after 30 minutes idle). Since it expires, a shell alias saves retyping it:echo 'alias dev-tools-arm="date +%s > /config/.storage/ha_dev_tools.armed && echo armed"' >> ~/.bashrc && source ~/.bashrc
-
Connect your MCP client to
https://<your-ha-instance>/api/mcp/dev_tools, authenticated with a normal Home Assistant admin long-lived access token as a Bearer token. For Claude Code:claude mcp add --transport http ha-dev-tools \ https://<your-ha-instance>/api/mcp/dev_tools \ --header "Authorization: Bearer <your-long-lived-access-token>" \ --scope user
See Connecting an MCP client below for other clients, and why Claude Desktop/claude.ai generally can't be pointed at a home LAN instance directly.
That's it - ask it something.
Every write tool (write_automation, delete_automation, write_script,
the helper/derived-sensor/template-entity CRUD tools, delete_entity/
delete_entities, write_dashboard/patch_dashboard, write_energy_config) always requires
two calls: propose
(no arguments changed, no side effects - returns a preview of what would be
applied plus a short-lived confirm_token) and confirm (the identical
call, plus that token - only this one can actually do anything). That's
always on and can't be turned off. Two optional layers sit on top of it.
Here's exactly what each one actually guarantees:
| Layer | Guarantee | What it's for |
|---|---|---|
| Confirm step (always on) | UX friction, not an enforced human check - nothing stops an agent from calling propose then confirm back to back with no one actually reading the preview in between | Catches accidental first-call writes and stops a token from silently being reused for a different call |
| Dry-run mode (optional) | Hard guarantee - a confirmed write's content is resolved but never reaches disk while it's on | Lets you review an agent's proposed changes for as long as you want, with zero risk of any of them going live |
| Git mirroring (optional) | Audit trail only - it never gates a write, live or dry-run | A private, before/after history of every change, so you have something to manually restore from (copy the old YAML back) if something goes wrong |
In short: dry-run is the actual safety net. The confirm step and git mirroring make mistakes easier to catch and easier to undo, but neither one stops a bad write from happening. If you want a real guarantee that nothing touches your live config while you're still trusting an agent, turn dry-run on and read what it proposes.
See Setup below for how to enable each, and docs/SECURITY.md for the full threat model - including what these gates do not protect against (this integration isn't sandboxed; a confirmed, non-dry-run write has the same power as your admin account, full stop).
- Home Assistant 2026.8.2 or newer - the release that shipped the native
mcp_serverintegration this depends on. That release itself requires Python 3.14.2+. - The
mcp_serverintegration, configured to expose thedev_toolsAPI (see Quick start above). - If connecting with Claude Code, Claude Code 2.1 or newer - it's the
release line that correctly prioritizes a configured Bearer token over
mcp_server's OAuth discovery metadata. Runclaude updateif unsure.
- Open HACS and search for HA Dev Tools MCP (it's in the default HACS list, so no custom repository is needed).
- Download it.
- Restart Home Assistant.
- Download the latest release.
- Copy the
ha_dev_toolsfolder into<config>/custom_components/. - Restart Home Assistant.
This integration is a remote MCP server - Home Assistant's own mcp_server
integration serves it over Streamable HTTP at
https://<your-ha-instance>/api/mcp/dev_tools, authenticated with a Bearer
token (a normal Home Assistant admin long-lived access token, created under
your HA profile's Security tab). It is not a local/stdio server, so it
doesn't go through claude_desktop_config.json-style local server setup - and
the token, not OAuth, is the auth mechanism actually meant to be used here,
even though mcp_server also advertises OAuth discovery metadata (RFC 9728)
alongside it, for web-based clients that require it. That advertisement
matters for setup, covered below.
-
Claude Code (CLI, 2.1+ - see Requirements) supports remote HTTP servers with custom headers directly, so a Bearer token works as-is (see the command in Quick start above). Run
claude mcp listafterward to confirm it shows as connected. -
Claude Desktop and claude.ai connect to remote servers only through Settings → Connectors → Add custom connector, which has two problems for a typical home HA setup, not just one:
- That flow is built around OAuth - there's no field in it for a static
Bearer token or API key for an individual account. (An org-admin
static_headersconnector option exists on some plans and can carry a fixedAuthorizationheader instead, but that's not available to a regular Pro account.) - Separately, the connector doesn't connect from your Desktop app's own
network the way a local/stdio server would - it's opened from Anthropic's
cloud infrastructure. So the URL also has to be reachable on the public
internet over HTTPS with a valid (non-self-signed) certificate. A plain
LAN address or
homeassistant.localwon't work even with auth solved; it would need to already be exposed publicly, e.g. via Nabu Casa's remote UI or your own reverse proxy with a real certificate.
Between those two, Claude Desktop generally can't be pointed at this integration directly - use Claude Code instead.
- That flow is built around OAuth - there's no field in it for a static
Bearer token or API key for an individual account. (An org-admin
-
Any other MCP client that supports Streamable HTTP with custom request headers (not just OAuth) can connect the same way as Claude Code: point it at the URL above and set an
Authorization: Bearer <token>header.
Author & iterate
| Tool | What it does |
|---|---|
find_entities |
Area/domain/name-scoped entity lookup - avoids dumping hundreds of entities |
render_template |
Render a Jinja2 template against live state, never raising on error |
validate_template |
Check template syntax and flag referenced entities that don't exist |
get_automation |
Layout-aware read: resolves whether an automation lives in automations.yaml or a packages/*.yaml file, and reports whether it's currently enabled - that's runtime-only state the YAML itself never shows |
write_automation |
Layout-aware, package-safe write - never silently duplicates a package-defined automation, always reloads afterward |
delete_automation |
Layout-aware, package-safe delete - resolves which file actually defines it first, refuses to guess if the id isn't found or is defined in more than one file |
list_scripts / get_script / write_script |
Same layout-aware, package-safe pattern as get_automation/write_automation, for script: - resolves whether a script lives in scripts.yaml or a packages/*.yaml file, and writes through the correct one |
list_rest_commands / get_rest_command |
Same layout-aware pattern, for rest_command: - resolves whether it lives in configuration.yaml or a packages/*.yaml file. Read-only - no write_rest_command yet |
check_config |
Home Assistant's own full config validation. With git mirroring on, a passing check also snapshots your hand-edited config (below) |
get_config_file |
Raw text of a hand-edited config file - configuration.yaml, anything it !includes, packages/*.yaml, custom_templates/*.jinja - with !secret/!include as written, live or (source: mirror) its last good snapshot. Reads a file that no longer parses, where the structured tools can't. key (e.g. irrigation_unlimited) returns just that top-level block, wherever it lives - an integration's own YAML config. Withholds a file (or with key, a block) with a literal credential in it; never returns secrets.yaml |
reload_domain |
Reload a domain's config (e.g. automation) without restarting |
Configure
| Tool | What it does |
|---|---|
list_helpers / create_helper / update_helper / delete_helper |
CRUD for storage-defined helpers (input_boolean, counter, timer, ...), and with domain: area / domain: person / domain: label / domain: category for rooms, persons, labels and the automation/script/scene/helpers pages' categories (a category's id is <scope>/<category_id>) - e.g. link device trackers to a person |
delete_entity |
Soft-delete an entity from the entity registry - Home Assistant reconnects it automatically if the same integration re-registers it later; only truly orphaned entries are purged for good, after 30 days. That 30-day window is Home Assistant's own behavior, not something this tool adds - a backup past it only happens if git mirroring (below) is also turned on |
delete_entities |
Same as delete_entity, for a list of entity_ids in one propose/confirm pair - for bulk cleanup, avoids one round trip (and one mirror commit pair) per entity |
update_entities |
What the entity and device settings dialogs do, for several at once: rename an entity_id, set name, room, device_class, icon, disabled or hidden, and "Show as" a switch as a light/fan/cover/lock/siren/valve, labels (labels sets the whole set, add_labels/remove_labels change it) and categories ({scope: category}); for devices, name, room, disabled and labels. Rooms, labels and categories are matched by exact name or id. Every item is checked first - an unknown room or a taken entity_id refuses the whole batch. A rename onto an id that already has statistics is flagged in the preview, since HA won't move the entity's history onto it. A rename lists every place the old id is used (YAML, dashboards, persons' trackers, helpers); update_references: true rewrites the ones it can |
list_derived_sensors / get_derived_sensor / create_derived_sensor / update_derived_sensor / delete_derived_sensor / reload_derived_sensor |
CRUD for calculated/derived sensor helpers (Min/Max, Utility Meter, Integration [Riemann sum], Statistics, Threshold, Derivative, Filter) plus the general-purpose Template helper (any entity domain - light, switch, sensor, ...) - a second helper family implemented as config entries rather than storage items; create/update discover each step's fields interactively since some of these flows are multi-step or menu-driven (Template's first step picks which entity domain to create); update_derived_sensor also takes a flat options patch of just the fields to change, and leaves every other field as it was |
list_template_entities / get_template_entity / create_template_entity / update_template_entity / delete_template_entity |
Layout-aware, package-safe CRUD for YAML template: entities (sensor, binary_sensor, number, switch, ...) - resolves whether an entity lives in configuration.yaml or a packages/*.yaml file, same pattern as get_automation/write_automation. New entities always go into an existing package (configuration.yaml itself is read-only here); every write requires the entity to have its own unique_id. update_template_entity can also change a trigger-based block's triggers / conditions / variables / actions in place - the preview lists every entity in the block, and a reload keeps their state (unlike delete + create). For the config-entry Template helper instead, see the row above |
list_dashboards |
List every configured Lovelace dashboard (title, url_path, icon, mode, require_admin, show_in_sidebar) - storage- and YAML-mode alike. Use before get_dashboard when checking every dashboard for something, since a non-default dashboard's url_path otherwise has to already be known |
get_dashboard / write_dashboard |
Read/write a Lovelace dashboard (storage mode; YAML-mode dashboards are read-only here, matching HA's own restriction). get_dashboard also returns the config's config_hash |
patch_dashboard |
Changes part of a dashboard without re-sending the whole config: add/replace/remove a view, section or card, or set a value at a JSON pointer. Everything else is saved exactly as read. Ops are planned against get_dashboard's config_hash (required), and a dashboard changed since is refused. Ambiguous selectors (e.g. two sections with the same heading) are refused. The preview lists each touched node before/after; the result has the new hash for the next patch |
get_energy_config / write_energy_config |
Read/write the Energy dashboard's own source config (grid/solar/battery/gas/water entities, cost settings) - a separate HA subsystem from Lovelace dashboards, not reachable through get_dashboard. Each field in a write wholesale-replaces that section; omitting a field leaves it untouched |
Act
| Tool | What it does |
|---|---|
trigger_automation |
Run an existing automation immediately by its config id, the same as the UI's "Run actions" button - for one-shot testing without a throwaway automation edit cycle. Like the button, the run is left to finish on its own - a client timeout never cuts it off; it returns at once with the run's context_id (wait_seconds waits for a short run's outcome). Only ever triggers an automation already in reviewed config; never accepts an arbitrary entity_id or service |
set_number_value |
Set a number or input_number entity's value directly (e.g. an EMHASS battery-schedule slot, a Modbus-backed inverter setting) - scoped to exactly these two domains, not a generic service-call tool |
set_boolean_value |
Turn an input_boolean helper on or off directly - scoped to just this virtual helper domain, never switch or any other domain that could be a real-world actuator |
Diagnose
| Tool | What it does |
|---|---|
get_logs |
Tail/filter/search the core Home Assistant log |
get_entity_history |
Recorder-backed state history for one or more entities over a time range |
get_logbook |
Recorder-backed, humanized logbook entries (automations/scripts triggering, notable state changes) over a time range |
list_traces / get_trace |
Automation and script traces - the automation editor's Traces view. list_traces shows the stored runs (Home Assistant keeps the last 5 per automation/script by default), newest first, with the trigger, how each run ended and any error; get_trace reads one run (the latest by default) step by step: every trigger, condition and action with its result, error and changed variables, and links to the scripts it ran. Accepts an automation's or script's entity_id. Read-only |
list_statistics / get_statistics |
The recorder's long-term statistics - what the Energy dashboard runs on, kept even for long-deleted entities and for external sources (e.g. Tibber). list_statistics shows each one's source, unit, whether its entity still exists (orphans), its first/last period (is it still fed?) and HA's own validation issues; get_statistics reads the rows, oldest first and paged. Read-only |
clear_statistics / migrate_statistics |
For history migrations. clear_statistics deletes stray or orphaned series, e.g. the hours a replacement entity collected; it refuses series still recorded by an existing entity or used by the Energy dashboard unless told otherwise. migrate_statistics moves a dead entity's history onto its replacement, which keeps its name and continues the old series, then reports the gap and state jump |
merge_statistics |
Combines one or more series into a target's: several old meters that became one (hours added), or a replacement that ran in parallel before the old device was retired. Meters are rebuilt from per-hour changes, so the sum stays continuous across seams, and a live target keeps recording from the merged sum. Overlapping hours are refused unless you pick target_wins, source_wins or add. Units are converted within a class (Wh/kWh). The preview shows every overlap, every seam (gap, state jump) and the inputs' total vs the result's; the result's next_compile checks the target's newest 5-minute sum lines up with its last hourly one. A long merge answers still_running after 40 s and posts a Home Assistant notification when done |
derive_statistics |
Builds a meter statistic from another one, hour by hour: each hour's change times a fixed rate, rate periods, or a price statistic's hourly mean (missing hours carry the last price, or missing_price: refuse stops the write). For the Energy dashboard's cost/compensation series, which HA only fills from when a source is configured: rebuild the EUR history of a meter, corrections included. Into a new ha_dev_tools: statistic, or replacing a range of an existing one - a live cost sensor goes on from the new sum. The preview shows totals by month and what the range held before, month by month |
redistribute_statistics |
Spreads the energy a meter booked into one catch-up hour after an outage (reader offline, recorder down) back over the silent hours it belongs to - without changing any total, so later hours, the live state and the next compile are untouched. Windows are given or detected (a run of silent hours followed by a spike, optionally on one component of a merged meter). The shape comes from a reference meter that kept recording (an inverter's own counter: its hourly changes, scaled to the moved amount), the meter's own hour-of-week profile, or the caller's own hourly values (5-minute values where the meter still has 5-minute rows), for one window or each of several in one call - one backup, all or nothing; 5-minute rows in the window are rewritten too. The preview shows each reference's total vs the catch-up, its fit to the meter around the window, and before/after by day and month. Backed up first |
restore_statistics |
Every statistics write above first copies each series it changes into a backup statistic in HA's own database (ha_dev_tools:backup_<id>_<time>, visible in Developer Tools > Statistics and in HA's regular backups; list_statistics source=ha_dev_tools lists them with what they back up, when, and stale after 90 days). It also pushes a copy to the mirror repo; with mirroring off, a write needs allow_no_backup, which skips only the mirror copy. restore_statistics puts a backup back, after backing up what it overwrites. Every write is read back from the recorder before it's reported done (an error names what didn't land), and only one runs at a time per statistic |
list_addons / get_addon_logs |
Supervisor add-on info and logs (Home Assistant OS/Supervised only) |
list_mqtt_topics |
Read-only, time-bounded snapshot of a topic filter - the only way to discover a retained MQTT message's existence, since MQTT itself has no "list retained" query. Useful for tracing a "ghost" entity (live state, no registry entry - delete_entity can't touch these) back to the topic keeping it alive. Never publishes anything; requires the mqtt integration to be configured |
Audit
| Tool | What it does |
|---|---|
entity_health_report |
Per-integration counts of disabled/hidden/unavailable/missing entities, scannable instead of a wall of text |
audit_automations |
Flags duplicate automation IDs across packages and references to currently-unavailable entities (tagged with whether that automation is actually enabled), plus a list of currently-disabled automations |
dev_tools_ping also exists as a zero-dependency smoke test for the
integration/MCP wiring itself - it's the one tool that isn't gated (see
Security).
Both of these are optional and off by default. Neither requires a restart to take effect.
Dry-run mode: from this integration's card in Settings → Devices & Services, click Configure and enable dry-run. Every write tool's confirm step still returns its preview, but the underlying write never actually happens - see The safety model above for what that does and doesn't guarantee.
Git mirroring: from the same Configure dialog, enable it, set
Mirror repository to a dedicated private GitHub repo (owner/repo -
separate from your HA config repo, and never the repo any deploy mechanism
pulls from), and paste a Mirror repository access token. To create the
token: GitHub → your avatar → Settings → Developer settings → Personal
access tokens → Fine-grained tokens → Generate new token → restrict
Repository access to that one repo → set Contents permission to
Read and write (nothing else) → set an Expiration date → generate
and paste it in.
Every confirmed write then pushes the touched file's before/after content
to that repo's default branch (in dry-run, to its own proposed/<kind>-<id>
branch instead, so you can review the diff on GitHub before turning dry-run
off). Content that looks like a literal credential (not routed through
!secret) is never pushed. Rollback is manual: find the old version in the
mirror repo's git history and paste it back in - there's no automated
revert. Helper and derived-sensor writes are reconstructed in memory rather
than re-read from disk, to dodge Home Assistant's own storage-save debounce;
delete_entity/delete_entities also back up the entity's full registry
data first, since HA's own registry-purge safety net only lasts 30 days
either way. Supported today: write_automation, delete_automation,
write_script, the template-entity tools, write_dashboard/patch_dashboard and
write_energy_config (live mode only for these - neither has a
compute-without-writing path to mirror in dry-run), the helper tools, the
derived-sensor tools, and delete_entity/delete_entities.
Config you edit by hand is mirrored too: configuration.yaml, every file it
(or those files) !includes, packages/, and custom_templates/ are
committed as-is, one commit per changed file, at Home Assistant start, once
a day, and on every check_config - but only while Home Assistant's own
config check passes, so the mirror's latest copy is always a last known
good one. An edit made over SSH then shows up as a diff, and
get_config_file with source: mirror returns that last good copy to
recover a broken block from. A file with a literal credential anywhere in it
is skipped, and secrets.yaml never leaves the machine.
Every tool above requires two things: proof of recent out-of-band filesystem access, and a genuine Home Assistant admin account. Neither is optional, and neither is enforced by Home Assistant on our behalf - both live in this integration's own code, on purpose. These gates control when and by whom a tool can be invoked - they do not sandbox what it's capable of once invoked, and a confirmed, non-dry-run write has the same power as the admin account behind it. Read docs/SECURITY.md before exposing this to anything beyond your own local network - it explains the actual threat this design defends against, exactly what it doesn't defend against, and why a simpler "just require admin" gate isn't enough on its own.
File access from write_automation and other file-touching tools is
additionally bounded by a path allowlist/denylist - not currently
customizable, defaults only (see docs/SECURITY.md
for exactly what's allowed).
For how this is built - why tools instead of a REST API, how automation package-safety works, what Home Assistant's storage layer will and won't let a custom integration do safely - see docs/ARCHITECTURE.md.
Git mirroring of writes made through this integration is built - see
Setup - optional hardening above to turn it
on. Offline automation testing (the two-tier lint/CI concept) is still
design-stage, not built - see
docs/AUTOMATION_TESTING_DESIGN.md.
scripts/config-repo-setup/ has standalone
scripts for bootstrapping the security hygiene (.gitignore, secret
scanning, config-validation CI) that design assumes onto your own HA
config repo - a different repo from the dedicated mirror repo mirroring
actually pushes to, independent of whether the rest of that design ever
gets built.
See CONTRIBUTING.md.
MIT - see LICENSE.