Skip to content

Commit 997658b

Browse files
feat: add overwrite option to ha_import_blueprint for blueprint re-import (#1897)
* feat: add overwrite option to ha_import_blueprint for re-import ha_import_blueprint failed with "File already exists" when the blueprint was already installed, because blueprint/save was called without allow_override. Add an overwrite parameter (default false) that passes allow_override to blueprint/save, giving parity with the UI's "Re-import blueprint" action including the automatic reload of automations/scripts that use the blueprint. When the blueprint exists and overwrite is not set, fail early with a structured RESOURCE_ALREADY_EXISTS error pointing at overwrite=true, using the exists flag already returned by blueprint/import. Closes #1894 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jyfbxjiBfZ97BLUDseDzW * fix: surface blueprint validation errors and harden re-import error paths Review findings on the overwrite feature: - blueprint/import validation_errors (min-version check) were silently discarded and blueprint/save never re-runs them, so an unsupported blueprint saved cleanly and reported success - with overwrite=true it would replace a working blueprint and reload its consumers. Fail the import with VALIDATION_FAILED instead. - The save-time "already exists" fallback (import/save race, or an installed file that fails to load) now raises RESOURCE_ALREADY_EXISTS like the early check, so agents see one code for one condition. - allow_override is only sent when overwrite=true; the WS schema on HA < 2023.12 rejects unknown keys. New e2e coverage: re-import with changed served content verifies the new content actually lands (the issue #1894 user story), overwrite=true on a fresh import installs without reporting an override, and an impossible min_version blueprint is rejected at import. Both blueprint fixtures now expose their writable served directory to make that possible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jyfbxjiBfZ97BLUDseDzW --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 86b94f4 commit 997658b

3 files changed

Lines changed: 330 additions & 34 deletions

File tree

src/ha_mcp/tools/tools_blueprints.py

Lines changed: 119 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,68 @@ async def ha_get_blueprint(
222222
)
223223
return None # unreachable: exception_to_structured_error always raises
224224

225+
async def _save_blueprint(
226+
self,
227+
url: str,
228+
domain: str,
229+
path: str,
230+
yaml_data: str,
231+
overwrite: bool,
232+
) -> dict[str, Any]:
233+
"""Persist a validated blueprint via blueprint/save, raising on failure.
234+
235+
Returns the blueprint/save result payload (contains overrides_existing).
236+
"""
237+
save_message: dict[str, Any] = {
238+
"type": "blueprint/save",
239+
"domain": domain,
240+
"path": path,
241+
"yaml": yaml_data,
242+
"source_url": url,
243+
}
244+
# allow_override only exists on HA >= 2023.12 and the WS schema
245+
# rejects unknown keys - only send it when actually overwriting
246+
if overwrite:
247+
save_message["allow_override"] = True
248+
249+
save_response = await self._client.send_websocket_message(save_message)
250+
251+
if not save_response.get("success"):
252+
error = save_response.get("error", {})
253+
save_error = (
254+
error.get("message", str(error))
255+
if isinstance(error, dict)
256+
else str(error)
257+
)
258+
259+
suggestions = [
260+
"The blueprint was validated but could not be saved to disk",
261+
"Use ha_get_blueprint() to check if it already exists",
262+
]
263+
264+
# Reachable despite the early exists check: a race between
265+
# import and save, or an installed file that failed to load
266+
# (core reports exists=false for those)
267+
already_exists = "already exists" in save_error.lower()
268+
if already_exists:
269+
suggestions.insert(
270+
0,
271+
"A blueprint with this path already exists - pass overwrite=true to re-import it",
272+
)
273+
274+
raise_tool_error(
275+
create_error_response(
276+
ErrorCode.RESOURCE_ALREADY_EXISTS
277+
if already_exists
278+
else ErrorCode.SERVICE_CALL_FAILED,
279+
save_error,
280+
context={"url": url, "path": path},
281+
suggestions=suggestions,
282+
)
283+
)
284+
285+
return save_response.get("result") or {}
286+
225287
@tool(
226288
name="ha_import_blueprint",
227289
tags={"Blueprints"},
@@ -240,17 +302,29 @@ async def ha_import_blueprint(
240302
description="URL to import blueprint from (GitHub, Home Assistant Community, or direct YAML URL)"
241303
),
242304
],
305+
overwrite: Annotated[
306+
bool,
307+
Field(
308+
description="Overwrite the blueprint if it is already installed (re-import). "
309+
"Home Assistant reloads all automations/scripts using the blueprint.",
310+
default=False,
311+
),
312+
] = False,
243313
) -> dict[str, Any]:
244314
"""
245315
Import a blueprint from a URL.
246316
247317
Imports a blueprint from GitHub, Home Assistant Community forums,
248-
or any direct URL to a blueprint YAML file.
318+
or any direct URL to a blueprint YAML file. Set overwrite=true to
319+
re-import a blueprint that is already installed (equivalent to the
320+
UI's "Re-import blueprint" action) - Home Assistant then reloads all
321+
automations/scripts that use it.
249322
250323
EXAMPLES:
251324
- Import from GitHub: ha_import_blueprint("https://github.com/user/repo/blob/main/blueprint.yaml")
252325
- Import from HA Community: ha_import_blueprint("https://community.home-assistant.io/t/motion-light/123456")
253326
- Import direct YAML: ha_import_blueprint("https://example.com/my-blueprint.yaml")
327+
- Re-import an installed blueprint: ha_import_blueprint("https://example.com/my-blueprint.yaml", overwrite=True)
254328
255329
SUPPORTED SOURCES:
256330
- GitHub repository URLs (will be converted to raw URLs)
@@ -260,6 +334,7 @@ async def ha_import_blueprint(
260334
RETURNS:
261335
- Import result with the blueprint path where it was saved
262336
- Blueprint metadata (name, domain, description)
337+
- overrides_existing: true when an installed blueprint was overwritten
263338
- Error details if import fails
264339
"""
265340
try:
@@ -328,43 +403,50 @@ async def ha_import_blueprint(
328403
if not suggested_filename.endswith((".yaml", ".yml")):
329404
suggested_filename = suggested_filename + ".yaml"
330405

331-
# Save the blueprint to disk (blueprint/import only validates)
332-
save_response = await self._client.send_websocket_message(
333-
{
334-
"type": "blueprint/save",
335-
"domain": domain,
336-
"path": suggested_filename,
337-
"yaml": raw_data,
338-
"source_url": url,
339-
}
340-
)
341-
342-
if not save_response.get("success"):
343-
error = save_response.get("error", {})
344-
save_error = (
345-
error.get("message", str(error))
346-
if isinstance(error, dict)
347-
else str(error)
406+
# blueprint/save does not re-run these checks (currently the
407+
# blueprint's min Home Assistant version) - without this gate an
408+
# unsupported blueprint saves cleanly and reports success
409+
validation_errors = result_data.get("validation_errors")
410+
if validation_errors:
411+
raise_tool_error(
412+
create_error_response(
413+
ErrorCode.VALIDATION_FAILED,
414+
"Blueprint failed validation: "
415+
+ "; ".join(str(e) for e in validation_errors),
416+
context={"url": url, "validation_errors": validation_errors},
417+
suggestions=[
418+
"The blueprint is not compatible with this Home Assistant installation",
419+
"Update Home Assistant to satisfy the blueprint's minimum version requirement",
420+
],
421+
)
348422
)
349423

350-
suggestions = [
351-
"The blueprint was validated but could not be saved to disk",
352-
"Use ha_get_blueprint() to check if it already exists",
353-
]
354-
355-
if "already exists" in save_error.lower():
356-
suggestions.insert(0, "A blueprint with this path already exists")
357-
424+
# blueprint/import reports whether the target path is already
425+
# installed - fail early with a re-import hint instead of letting
426+
# blueprint/save reject the write
427+
if result_data.get("exists") and not overwrite:
358428
raise_tool_error(
359429
create_error_response(
360-
ErrorCode.SERVICE_CALL_FAILED,
361-
save_error,
362-
context={"url": url, "path": suggested_filename},
363-
suggestions=suggestions,
430+
ErrorCode.RESOURCE_ALREADY_EXISTS,
431+
f"Blueprint already exists at '{suggested_filename}'. "
432+
"Pass overwrite=true to re-import it.",
433+
context={
434+
"url": url,
435+
"path": suggested_filename,
436+
"domain": domain,
437+
},
438+
suggestions=[
439+
"Call ha_import_blueprint with overwrite=true to update the installed blueprint",
440+
"Use ha_get_blueprint() to inspect the currently installed version",
441+
],
364442
)
365443
)
366444

367-
save_result = save_response.get("result") or {}
445+
# Save the blueprint to disk (blueprint/import only validates)
446+
save_result = await self._save_blueprint(
447+
url, domain, suggested_filename, raw_data, overwrite
448+
)
449+
overrides_existing = save_result.get("overrides_existing", False)
368450

369451
return {
370452
"success": True,
@@ -375,8 +457,12 @@ async def ha_import_blueprint(
375457
"name": blueprint_meta.get("name"),
376458
"description": blueprint_meta.get("description"),
377459
},
378-
"overrides_existing": save_result.get("overrides_existing", False),
379-
"message": "Blueprint imported successfully. Use ha_get_blueprint() to see all installed blueprints.",
460+
"overrides_existing": overrides_existing,
461+
"message": (
462+
"Blueprint re-imported successfully. Automations/scripts using it were reloaded."
463+
if overrides_existing
464+
else "Blueprint imported successfully. Use ha_get_blueprint() to see all installed blueprints."
465+
),
380466
}
381467

382468
except ToolError:

tests/src/e2e/conftest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1450,7 +1450,12 @@ def _blueprint_http_server():
14501450
logger.info(f"🌐 Blueprint HTTP server on :{port}, container URL: {base_url}")
14511451

14521452
try:
1453-
yield {"base_url": base_url, "port": port, "extra_hosts": env["extra_hosts"]}
1453+
yield {
1454+
"base_url": base_url,
1455+
"port": port,
1456+
"extra_hosts": env["extra_hosts"],
1457+
"local_dir": str(assets_dir),
1458+
}
14541459
finally:
14551460
srv.shutdown()
14561461

@@ -1471,6 +1476,7 @@ def _copy_local_blueprint_to_www(config_path: Path) -> dict[str, str]:
14711476
return {
14721477
"base_url": "http://localhost:8123/local",
14731478
"filename": blueprint_name,
1479+
"local_dir": str(www_dir),
14741480
}
14751481

14761482

0 commit comments

Comments
 (0)