Skip to content

Commit f04f741

Browse files
teh-hippoCopilot
andauthored
fix: add blueprint/save step to ha_import_blueprint (#685) (#751)
ha_import_blueprint only called HA's blueprint/import WebSocket command, which downloads and validates but does not persist the blueprint to disk. This caused the tool to report success while the blueprint never appeared. Changes: - Call blueprint/save after blueprint/import to actually write to disk - Fix metadata extraction (blueprint.metadata.name, not blueprint.name) - Ensure suggested_filename always has .yaml extension - Validate that import returns filename and raw data before saving - Add e2e test verifying imported blueprints appear in the list Closes #685 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 69494ed commit f04f741

2 files changed

Lines changed: 127 additions & 6 deletions

File tree

src/ha_mcp/tools/tools_blueprints.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,18 +248,75 @@ async def ha_import_blueprint(
248248
suggestions=suggestions,
249249
))
250250

251-
# Extract import result
251+
# Extract import result (blueprint/import only validates, does not save)
252252
result_data = response.get("result", {})
253+
suggested_filename = result_data.get("suggested_filename", "")
254+
raw_data = result_data.get("raw_data", "")
255+
blueprint_meta = result_data.get("blueprint", {}).get("metadata", {})
256+
domain = blueprint_meta.get("domain", "automation")
257+
258+
if not suggested_filename or not raw_data:
259+
raise_tool_error(create_error_response(
260+
ErrorCode.SERVICE_CALL_FAILED,
261+
"Blueprint validated but no filename or YAML data was returned",
262+
context={"url": url},
263+
suggestions=[
264+
"This may indicate an incompatible blueprint format",
265+
"Try a different blueprint URL",
266+
],
267+
))
268+
269+
# Ensure the path has a .yaml extension — HA's blueprint/import returns
270+
# suggested_filename without the extension (e.g. "user/blueprint_name")
271+
if not suggested_filename.endswith((".yaml", ".yml")):
272+
suggested_filename = suggested_filename + ".yaml"
273+
274+
# Save the blueprint to disk (blueprint/import only validates)
275+
save_response = await client.send_websocket_message(
276+
{
277+
"type": "blueprint/save",
278+
"domain": domain,
279+
"path": suggested_filename,
280+
"yaml": raw_data,
281+
"source_url": url,
282+
}
283+
)
284+
285+
if not save_response.get("success"):
286+
error = save_response.get("error", {})
287+
save_error = (
288+
error.get("message", str(error))
289+
if isinstance(error, dict)
290+
else str(error)
291+
)
292+
293+
suggestions = [
294+
"The blueprint was validated but could not be saved to disk",
295+
"Use ha_get_blueprint() to check if it already exists",
296+
]
297+
298+
if "already exists" in save_error.lower():
299+
suggestions.insert(0, "A blueprint with this path already exists")
300+
301+
raise_tool_error(create_error_response(
302+
ErrorCode.SERVICE_CALL_FAILED,
303+
save_error,
304+
context={"url": url, "path": suggested_filename},
305+
suggestions=suggestions,
306+
))
307+
308+
save_result = save_response.get("result") or {}
253309

254310
return {
255311
"success": True,
256312
"url": url,
257313
"imported_blueprint": {
258-
"path": result_data.get("suggested_filename") or result_data.get("path"),
259-
"domain": result_data.get("blueprint", {}).get("domain", "automation"),
260-
"name": result_data.get("blueprint", {}).get("name"),
261-
"description": result_data.get("blueprint", {}).get("description"),
314+
"path": suggested_filename,
315+
"domain": domain,
316+
"name": blueprint_meta.get("name"),
317+
"description": blueprint_meta.get("description"),
262318
},
319+
"overrides_existing": save_result.get("overrides_existing", False),
263320
"message": "Blueprint imported successfully. Use ha_get_blueprint() to see all installed blueprints.",
264321
}
265322

tests/src/e2e/workflows/blueprints/test_blueprints.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313

1414
import pytest
1515

16-
from ...utilities.assertions import MCPAssertions, safe_call_tool, wait_for_automation
16+
from ...utilities.assertions import (
17+
MCPAssertions,
18+
_extract_error_message,
19+
safe_call_tool,
20+
wait_for_automation,
21+
)
1722

1823
logger = logging.getLogger(__name__)
1924

@@ -231,6 +236,65 @@ async def test_import_blueprint_nonexistent_url(self, mcp_client):
231236
assert "suggestions" in result.get("error", {}), "Error response should include suggestions"
232237
logger.info("ha_import_blueprint properly handles non-existent URL")
233238

239+
@pytest.mark.slow
240+
async def test_import_blueprint_saves_to_disk(self, mcp_client):
241+
"""
242+
Test: Import blueprint actually saves to disk (issue #685)
243+
244+
Validates that ha_import_blueprint calls both blueprint/import (validate)
245+
AND blueprint/save (persist), so the blueprint appears in the list.
246+
Uses a known community blueprint that won't already be installed.
247+
"""
248+
logger.info("Testing ha_import_blueprint saves blueprint to disk...")
249+
250+
# Use a community blueprint unlikely to be pre-installed
251+
test_url = "https://gist.github.com/Blackshome/4010fb83bb8c19b5fa1425526c6ff0e2"
252+
253+
async with MCPAssertions(mcp_client) as mcp:
254+
# List blueprints before import
255+
before = await mcp.call_tool_success(
256+
"ha_get_blueprint",
257+
{"domain": "automation"},
258+
)
259+
before_paths = [bp["path"] for bp in before.get("blueprints", [])]
260+
261+
# Try to import
262+
result = await safe_call_tool(
263+
mcp_client,
264+
"ha_import_blueprint",
265+
{"url": test_url},
266+
)
267+
268+
if result.get("success"):
269+
# Import succeeded - verify metadata is populated
270+
imported = result.get("imported_blueprint", {})
271+
assert imported.get("path", "").endswith(".yaml"), \
272+
f"Blueprint path should end with .yaml, got: {imported.get('path')}"
273+
assert imported.get("domain") in ("automation", "script"), \
274+
f"Blueprint domain should be automation or script, got: {imported.get('domain')}"
275+
assert imported.get("name"), "Blueprint name should not be empty"
276+
assert imported["path"] not in before_paths, \
277+
f"Blueprint {imported['path']} should not have existed before import"
278+
logger.info(f"Blueprint imported: {imported.get('name')} at {imported.get('path')}")
279+
280+
# Verify it appears in the blueprint list
281+
after = await mcp.call_tool_success(
282+
"ha_get_blueprint",
283+
{"domain": imported.get("domain", "automation")},
284+
)
285+
after_paths = [bp["path"] for bp in after.get("blueprints", [])]
286+
assert imported["path"] in after_paths, \
287+
f"Imported blueprint {imported['path']} should appear in blueprint list"
288+
logger.info("Blueprint appears in list after import")
289+
else:
290+
# Only acceptable failure is "already exists"
291+
error_msg = _extract_error_message(result)
292+
assert "already exists" in error_msg.lower(), \
293+
f"Expected 'already exists' error, got: {result}"
294+
logger.info("Blueprint already existed (prior test run), still valid")
295+
296+
logger.info("ha_import_blueprint save-to-disk test completed")
297+
234298

235299
@pytest.mark.blueprint
236300
async def test_blueprint_discovery_workflow(mcp_client):

0 commit comments

Comments
 (0)