Skip to content

Commit 01cc5d9

Browse files
committed
feat: auto-backup edited entities before write/destructive tool calls
Closes #1288. Captures a per-entity snapshot of every wrapped write or destructive MCP tool call (automation, script, scene, helper, dashboard, dashboard resource, label, category, group, zone, area/floor, todo item, calendar event, entity, integration enable/disable + matching remove / delete tools — 28 wrapped points). Captures land as YAML files in a local directory (default /data/ha_mcp_backups on the addon, ${XDG_DATA_HOME:-~/.local/share}/ha_mcp/backups elsewhere; override via HAMCP_BACKUP_DIR) and are listable, viewable, diffable, restorable, and deletable via: - Polymorphic MCP tool ha_manage_backup(scope, action, ...). Replaces the previous ha_backup_create + ha_backup_restore pair under scope="snapshot" (existing full-HA-tarball behavior, no semantic change) and adds the new scope="edits" actions list/view/restore/ delete. Strong gating against accidental wrong-mode usage: invalid (scope, action) combos return VALIDATION_INVALID_PARAMETER with the legal set in suggestions; required params validated per cell so "restore my automation" can never route through the heavy HA-restart restore path. - New "Backups" tab in the existing /settings web UI with filter, view, diff, restore, per-row delete, and bulk-delete-by-filter actions — list / view / diff / restore / delete REST endpoints at /api/settings/backups[/<name>[/diff|/restore]]. Capture is gated by the new enable_auto_backup setting (default off). Throttle window is configurable via auto_backup_throttle_minutes (default 0 = capture every write); retention is configurable via auto_backup_retain_per_entity (default 20). All three plumb through the addon config + dev mirror + dev translation + start.py env mapping. Implementation lives in src/ha_mcp/backup_manager.py (BackupManager + DomainHandler registry) and src/ha_mcp/tools/auto_backup.py (@with_auto_backup decorator). Captures are best-effort: any failure in the pre-write hook logs a WARNING and the wrapped tool proceeds. The existing ha_config_set_yaml backup precedent in custom_components/ha_mcp_tools is untouched — this PR adds a parallel system that works without the custom component installed.
1 parent d128bf1 commit 01cc5d9

31 files changed

Lines changed: 3011 additions & 174 deletions

docs/superpowers/specs/2026-05-21-1288-auto-backup-design.md

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,78 @@ User-facing entry points (both call `BackupManager.restore`):
121121

122122
Restore is upsert: re-creates entities that were deleted between backup and restore. If HA rejects (validation error, etc.), error propagates with structured suggestions.
123123

124-
## List / view / diff / delete flow
124+
## Polymorphic `ha_manage_backup` tool
125125

126-
Each operation is a thin method on `BackupManager`, exposed via:
126+
`ha_backup_create` and `ha_backup_restore` are **merged** into a single
127+
polymorphic tool `ha_manage_backup` that handles BOTH the existing full-HA-
128+
snapshot functionality AND the new per-edit auto-backup operations. Net tool
129+
count change: **-1** (remove 2 tools, add 1).
127130

128-
| MCP tool | UI endpoint | UI element |
131+
```python
132+
ha_manage_backup(
133+
scope: Literal["snapshot", "edits"],
134+
action: Literal["create", "restore", "list", "view", "delete"],
135+
# snapshot scope params
136+
name: str | None = None, # snapshot.create: tarball name
137+
backup_id: str | None = None, # snapshot.restore: tarball ID
138+
restore_database: bool = False, # snapshot.restore: include DB
139+
# edits scope params
140+
domain: str | None = None, # edits.list / edits.delete: filter
141+
entity_id: str | None = None, # edits.list / edits.delete: filter
142+
backup_name: str | None = None, # edits.view / edits.restore / edits.delete
143+
older_than_days: int | None = None, # edits.delete: bulk-by-age
144+
)
145+
```
146+
147+
### Routing matrix
148+
149+
| scope | action | Behavior |
150+
|---|---|---|
151+
| `snapshot` | `create` | Existing `ha_backup_create` behavior — full HA tarball via HA's native backup integration |
152+
| `snapshot` | `restore` | Existing `ha_backup_restore` behavior — HA restarts, pre-restore safety tarball created automatically |
153+
| `edits` | `list` | List auto-backup files filterable by `domain` and/or `entity_id` |
154+
| `edits` | `view` | Return one auto-backup's YAML content + parsed `config` |
155+
| `edits` | `restore` | Re-apply one auto-backup (creates fresh safety snapshot first); no HA restart |
156+
| `edits` | `delete` | Delete one auto-backup by name OR bulk-delete by filter (domain/entity_id/older_than_days) |
157+
158+
### Gating against accidental wrong-mode usage
159+
160+
This is the main design risk — without strong gates, the LLM could route
161+
"restore my automation" through `(scope="snapshot", action="restore")`,
162+
which would restart HA. Layered defenses:
163+
164+
1. **Type validation**: `scope` and `action` are `Literal`-typed so Pydantic
165+
rejects unknown values before the tool body runs.
166+
2. **Scope+action matrix validation**: invalid combinations
167+
(`(snapshot, list)`, `(snapshot, view)`, `(snapshot, delete)`,
168+
`(edits, create)`) raise `VALIDATION_INVALID_PARAMETER` with the valid
169+
combinations listed in `suggestions`.
170+
3. **Required-param checks per cell**:
171+
- `(snapshot, restore)` requires `backup_id`; if `backup_name` or
172+
`entity_id` is passed instead, structured error explains the param
173+
belongs to the other scope.
174+
- `(edits, restore/view/delete-single)` requires `backup_name` (which
175+
follows the `<domain>.<entity_id>.<timestamp>.yaml` shape — clearly
176+
not a tarball ID).
177+
4. **Annotation differentiation**:
178+
- `(snapshot, restore)` keeps the existing `destructiveHint: True` AND
179+
surfaces the "LAST RESORT — HA will restart" warning in the response.
180+
- `(edits, restore)` is also destructive but explicitly safer; response
181+
includes `restart_required: false` and the path of the safety backup
182+
created.
183+
5. **Docstring**: the tool's docstring leads with a routing table so the
184+
LLM picks the right cell. Per-scope sections clearly state what each
185+
does and how they differ.
186+
187+
### UI mapping
188+
189+
| Tool call | UI endpoint | UI element |
129190
|---|---|---|
130-
| `ha_manage_auto_backup(action="list", domain?, entity_id?, since?, limit?)` | `GET /api/settings/backups?...` | Backup list table with filters |
131-
| `ha_manage_auto_backup(action="view", name=...)` | `GET /api/settings/backups/<name>` | "View" button → modal showing YAML |
132-
| `ha_manage_auto_backup(action="diff", name=...)` | `GET /api/settings/backups/<name>/diff` | "Diff" button → modal with unified diff vs current state |
133-
| `ha_manage_auto_backup(action="restore", name=...)` | `POST /api/settings/backups/<name>/restore` | "Restore" button (confirmation modal) |
134-
| `ha_manage_auto_backup(action="delete", name=...)` *or* `(action="delete_bulk", domain?, entity_id?, older_than?)` | `DELETE /api/settings/backups/<name>`, `DELETE /api/settings/backups?...` | Per-row "Delete" + bulk "Delete all matching filters" |
191+
| `(edits, list)` | `GET /api/settings/backups?...` | Backups tab list table with filters |
192+
| `(edits, view)` | `GET /api/settings/backups/<name>` | "View" button → modal showing YAML |
193+
| (no tool — UI-only diff) | `GET /api/settings/backups/<name>/diff` | "Diff" button → modal with unified diff vs current state |
194+
| `(edits, restore)` | `POST /api/settings/backups/<name>/restore` | "Restore" button (confirmation modal) |
195+
| `(edits, delete)` | `DELETE /api/settings/backups/<name>` and `DELETE /api/settings/backups?...` | Per-row "Delete" + bulk "Delete matching filters" |
135196

136197
**List item shape:**
137198
```json
@@ -208,12 +269,12 @@ One test file per backed-up domain (`test_automation.py`, `test_script.py`, ...,
208269
**New:**
209270
- `src/ha_mcp/backup_manager.py`
210271
- `src/ha_mcp/tools/auto_backup.py` (decorator)
211-
- `src/ha_mcp/tools/tools_auto_backup.py` (`ha_manage_auto_backup` tool)
212272
- `tests/src/unit/test_backup_manager.py`
213273
- `tests/src/e2e/workflows/auto_backup/` (one test file per domain)
214274

215275
**Modified:**
216276
- `src/ha_mcp/config.py` (4 settings)
277+
- `src/ha_mcp/tools/backup.py` — merge `ha_backup_create`+`ha_backup_restore` into a single `ha_manage_backup` polymorphic tool; add the `edits` scope handlers
217278
- `src/ha_mcp/settings_ui.py` (5 routes + Backups tab)
218279
- `homeassistant-addon/config.yaml` + `homeassistant-addon-dev/config.yaml` (4 options + schema)
219280
- `homeassistant-addon-dev/translations/en.yaml` (4 translations)

homeassistant-addon-dev/config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ options:
2727
enable_yaml_config_editing: false
2828
enable_code_mode: false
2929
enable_lite_docstrings: false
30+
enable_auto_backup: false
31+
auto_backup_throttle_minutes: 0
32+
auto_backup_retain_per_entity: 20
3033
tool_search_max_results: 5
3134
disabled_tools: ""
3235
pinned_tools: ""
@@ -40,6 +43,9 @@ schema:
4043
enable_lite_docstrings: bool?
4144
enable_filesystem_tools: bool?
4245
enable_custom_component_integration: bool?
46+
enable_auto_backup: bool?
47+
auto_backup_throttle_minutes: int(0,1440)?
48+
auto_backup_retain_per_entity: int(1,10000)?
4349
tool_search_max_results: int(2,10)?
4450
disabled_tools: str?
4551
pinned_tools: str?

homeassistant-addon-dev/translations/en.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,30 @@ configuration:
4545
Saved tools persist to /data/saved_tools.json by default so they
4646
survive add-on restarts. See docs/beta.md for known limitations.
4747
Requires restart to take effect.
48+
enable_auto_backup:
49+
name: Enable auto-backup of edits
50+
description: >-
51+
Captures a per-entity snapshot before every wrapped write/destructive
52+
MCP tool call (automation, script, scene, helper, dashboard, label,
53+
category, group, zone, area, calendar, todo, entity, integration, and
54+
sibling remove/delete tools). Snapshots are saved as YAML files under
55+
/data/ha_mcp_backups/ (override via HAMCP_BACKUP_DIR) and listed,
56+
restored, or deleted via the Backups tab in the web settings UI or
57+
via ha_manage_backup(scope='edits', ...). Best-effort — failures log
58+
a WARNING but never block the underlying write. Off by default. No
59+
restart required to take effect.
60+
auto_backup_throttle_minutes:
61+
name: Auto-backup throttle (minutes)
62+
description: >-
63+
Per-entity throttle window. 0 (default) captures a snapshot on every
64+
wrapped write. N>0 captures at most one snapshot per N minutes per
65+
entity. Range 0–1440.
66+
auto_backup_retain_per_entity:
67+
name: Auto-backup retention (per entity)
68+
description: >-
69+
Maximum number of snapshots kept per entity. Older snapshots beyond
70+
this cap are rotated out on each successful capture. Default 20,
71+
range 1–10000.
4872
enable_lite_docstrings:
4973
name: Enable lite tool docstrings (beta)
5074
description: >-

homeassistant-addon/config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@ image: "ghcr.io/homeassistant-ai/ha-mcp-addon-{arch}"
3232
options:
3333
backup_hint: "normal"
3434
enable_tool_search: false
35+
enable_auto_backup: false
36+
auto_backup_throttle_minutes: 0
37+
auto_backup_retain_per_entity: 20
3538
verify_ssl: true
3639
schema:
3740
backup_hint: list(strong|normal|weak|auto)
3841
secret_path: str?
3942
enable_tool_search: bool?
43+
enable_auto_backup: bool?
44+
auto_backup_throttle_minutes: int(0,1440)?
45+
auto_backup_retain_per_entity: int(1,10000)?
4046
verify_ssl: bool?
4147
advanced_debug_logging: bool?
4248
# Add-on exposes HTTP port for MCP communication (fixed internal port)

homeassistant-addon/start.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ def main() -> int:
218218
enable_custom_component_integration = False # default
219219
enable_code_mode = False # default
220220
enable_lite_docstrings = False # default
221+
enable_auto_backup = False # default (#1288)
222+
auto_backup_throttle_minutes = 0 # default — every write
223+
auto_backup_retain_per_entity = 20 # default
221224
tool_search_max_results = 5 # default
222225
disabled_tools_raw = "" # default
223226
pinned_tools_raw = "" # default
@@ -242,6 +245,12 @@ def main() -> int:
242245
enable_code_mode = raw_code_mode if isinstance(raw_code_mode, bool) else False
243246
raw_lite_docstrings = config.get("enable_lite_docstrings", False)
244247
enable_lite_docstrings = raw_lite_docstrings if isinstance(raw_lite_docstrings, bool) else False
248+
raw_auto_backup = config.get("enable_auto_backup", False)
249+
enable_auto_backup = raw_auto_backup if isinstance(raw_auto_backup, bool) else False
250+
raw_throttle = config.get("auto_backup_throttle_minutes", 0)
251+
auto_backup_throttle_minutes = raw_throttle if isinstance(raw_throttle, int) else 0
252+
raw_retain = config.get("auto_backup_retain_per_entity", 20)
253+
auto_backup_retain_per_entity = raw_retain if isinstance(raw_retain, int) else 20
245254
raw_max_results = config.get("tool_search_max_results", 5)
246255
tool_search_max_results = raw_max_results if isinstance(raw_max_results, int) else 5
247256
raw_disabled = config.get("disabled_tools", "")
@@ -282,6 +291,9 @@ def main() -> int:
282291
os.environ["HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"] = str(enable_custom_component_integration).lower()
283292
os.environ["ENABLE_CODE_MODE"] = str(enable_code_mode).lower()
284293
os.environ["ENABLE_LITE_DOCSTRINGS"] = str(enable_lite_docstrings).lower()
294+
os.environ["ENABLE_AUTO_BACKUP"] = str(enable_auto_backup).lower()
295+
os.environ["AUTO_BACKUP_THROTTLE_MINUTES"] = str(auto_backup_throttle_minutes)
296+
os.environ["AUTO_BACKUP_RETAIN_PER_ENTITY"] = str(auto_backup_retain_per_entity)
285297
# Persist saved custom tools across addon restarts. /data is the
286298
# per-addon writable directory mapped by Supervisor and survives
287299
# add-on updates (but not uninstall/reinstall — users should copy

0 commit comments

Comments
 (0)