ha-mcp-dev 7.4.1.dev413__tar.gz → 7.4.1.dev415__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {ha_mcp_dev-7.4.1.dev413/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev415}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_config_dashboards.py +273 -63
  4. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_search.py +5 -0
  5. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  6. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/LICENSE +0 -0
  7. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/MANIFEST.in +0 -0
  8. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/README.md +0 -0
  9. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/setup.cfg +0 -0
  10. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/__init__.py +0 -0
  11. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/__main__.py +0 -0
  12. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/_pypi_marker +0 -0
  13. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/_version.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/auth/__init__.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/auth/consent_form.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/auth/provider.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/client/__init__.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/client/rest_client.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/client/websocket_client.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/py.typed +0 -0
  24. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  25. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  26. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  27. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  28. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  29. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  30. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  31. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  32. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  35. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  41. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/server.py +0 -0
  45. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/settings_ui.py +0 -0
  46. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/smoke_test.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/__init__.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/backup.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/device_control.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/enhanced.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/helpers.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/reference_validator.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/registry.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/smart_search.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_addons.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_areas.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_calendar.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_camera.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_categories.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_energy.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_entities.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_groups.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_hacs.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_history.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_integrations.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_labels.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_registry.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_resources.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_service.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_services.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_system.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_todo.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_traces.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_updates.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_utility.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/tools_zones.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/tools/util_helpers.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/transforms/__init__.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/transforms/categorized_search.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/utils/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/utils/config_hash.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/utils/domain_handlers.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/utils/operation_manager.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/utils/python_sandbox.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp/utils/usage_logger.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  99. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  100. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  101. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/tests/__init__.py +0 -0
  104. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/tests/test_constants.py +0 -0
  105. {ha_mcp_dev-7.4.1.dev413 → ha_mcp_dev-7.4.1.dev415}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev413
3
+ Version: 7.4.1.dev415
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.4.1.dev413"
7
+ version = "7.4.1.dev415"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -7,7 +7,7 @@ This module provides tools for managing dashboard metadata and content.
7
7
  import json
8
8
  import logging
9
9
  import re
10
- from typing import Annotated, Any, cast
10
+ from typing import Annotated, Any, cast, overload
11
11
 
12
12
  from fastmcp.exceptions import ToolError
13
13
  from pydantic import Field
@@ -25,7 +25,6 @@ from .util_helpers import parse_json_param
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
27
 
28
-
29
28
  async def _verify_config_unchanged(
30
29
  client: Any,
31
30
  url_path: str,
@@ -247,6 +246,160 @@ def _card_matches(
247
246
  return True
248
247
 
249
248
 
249
+ # Substring in WS error message that signals the dashboard identifier was not
250
+ # accepted by lovelace/config (e.g., caller passed an internal id where url_path
251
+ # is expected). Used to gate the lazy resolver fallback in get/set tools.
252
+ #
253
+ # Source: homeassistant/components/lovelace/websocket.py, _handle_errors —
254
+ # emits f"Unknown config specified: {url_path}" paired with structured
255
+ # error.code "config_not_found". The websocket client currently surfaces only
256
+ # the message string, so substring matching is the only signal available at
257
+ # the tool layer. If HA reformats this string, the lazy fallback regresses
258
+ # silently to never firing — re-verify with major HA upgrades.
259
+ _LAZY_RESOLVE_TRIGGER = "Unknown config specified"
260
+
261
+
262
+ def _should_lazy_resolve(error_msg: str) -> bool:
263
+ """Return True if a WS error message indicates the identifier needs resolving."""
264
+ return _LAZY_RESOLVE_TRIGGER in error_msg
265
+
266
+
267
+ async def _resolve_dashboard(client: Any, identifier: str) -> dict[str, str] | None:
268
+ """Resolve a dashboard identifier (url_path or internal id) to both forms.
269
+
270
+ Calls ``lovelace/dashboards/list`` and returns
271
+ ``{"url_path": ..., "id": ...}`` when the identifier matches either field
272
+ on a registry entry that has both fields populated; otherwise returns
273
+ ``None``. Always pays the round-trip when called.
274
+
275
+ Two call sites:
276
+ - **Lazy fallback** (``_lazy_resolve_and_retry``): only invoked after
277
+ ``lovelace/config`` rejected the identifier with
278
+ ``_LAZY_RESOLVE_TRIGGER`` — the round-trip is gated by the caller.
279
+ - **Eager pre-resolve** (``ha_config_set_dashboard``): invoked before
280
+ hyphen validation so callers may pass either form; gated on a
281
+ cheap heuristic ("no hyphen, not 'lovelace'") rather than an error
282
+ from HA.
283
+ """
284
+ result = await client.send_websocket_message({"type": "lovelace/dashboards/list"})
285
+ if isinstance(result, dict) and "result" in result:
286
+ dashboards = result["result"]
287
+ elif isinstance(result, list):
288
+ dashboards = result
289
+ else:
290
+ # Neither dict-with-result nor list — either HA returned an error
291
+ # envelope (unknown shape) or the response format changed.
292
+ # Surface a warning so the next response-shape change isn't a
293
+ # silent "always no match" regression.
294
+ logger.warning(
295
+ "lovelace/dashboards/list returned an unexpected shape (type=%s); "
296
+ "treating as no-match",
297
+ type(result).__name__,
298
+ )
299
+ return None
300
+
301
+ for d in dashboards:
302
+ if d.get("id") == identifier or d.get("url_path") == identifier:
303
+ url_path = d.get("url_path") or ""
304
+ entry_id = d.get("id") or ""
305
+ if not url_path or not entry_id:
306
+ # Malformed registry entry — neither form is safe to
307
+ # forward. Skip rather than return empty strings that
308
+ # would be silently used by callers (e.g.
309
+ # ``delete_dashboard`` would forward ``resolved_id=""``).
310
+ continue
311
+ return {"url_path": url_path, "id": entry_id}
312
+ return None
313
+
314
+
315
+ @overload
316
+ async def _lazy_resolve_and_retry(
317
+ client: Any,
318
+ url_path: str,
319
+ ws_data: dict[str, Any],
320
+ response: Any,
321
+ ) -> tuple[str, Any]: ...
322
+
323
+
324
+ @overload
325
+ async def _lazy_resolve_and_retry(
326
+ client: Any,
327
+ url_path: None,
328
+ ws_data: dict[str, Any],
329
+ response: Any,
330
+ ) -> tuple[None, Any]: ...
331
+
332
+
333
+ async def _lazy_resolve_and_retry(
334
+ client: Any,
335
+ url_path: str | None,
336
+ ws_data: dict[str, Any],
337
+ response: Any,
338
+ ) -> tuple[str | None, Any]:
339
+ """Trigger-gated lazy resolve + single retry of a lovelace/config call.
340
+
341
+ If `response` indicates HA rejected the identifier with the
342
+ _LAZY_RESOLVE_TRIGGER substring, resolves `url_path` via
343
+ lovelace/dashboards/list and retries the WS call with the canonical
344
+ url_path. Returns the (possibly updated) url_path and the
345
+ (possibly retried) response so the caller can chain naturally:
346
+
347
+ url_path, response = await _lazy_resolve_and_retry(
348
+ client, url_path, ws_data, response
349
+ )
350
+
351
+ No-op when:
352
+ - the response is not a failure (success=True or non-dict),
353
+ - ``url_path`` is empty,
354
+ - the error message does not contain ``_LAZY_RESOLVE_TRIGGER``
355
+ (the substring miss),
356
+ - the resolver finds no match,
357
+ - or the resolver itself raises (logged at WARNING).
358
+
359
+ In every no-op case the original ``response`` is returned unchanged
360
+ so the caller's existing error-handling path runs against the real
361
+ HA error rather than a synthetic "resolver failed" one.
362
+
363
+ The caller's `ws_data` dict is never mutated: when a retry is needed,
364
+ a shallow copy is made and the canonical `url_path` written into the
365
+ copy before the retry call.
366
+ """
367
+ if not (isinstance(response, dict) and not response.get("success", True)):
368
+ return url_path, response
369
+ if not url_path:
370
+ return url_path, response
371
+
372
+ err = response.get("error", {})
373
+ err_msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
374
+ if not _should_lazy_resolve(err_msg):
375
+ return url_path, response
376
+
377
+ try:
378
+ resolved = await _resolve_dashboard(client, url_path)
379
+ except Exception as resolver_exc:
380
+ # Resolver itself raised (timeout, network blip, etc.). Don't let
381
+ # this exception escape and replace the original HA error with
382
+ # one about the resolver — fall through with the original
383
+ # response so the caller surfaces the actual "Unknown config
384
+ # specified" error.
385
+ logger.warning(
386
+ "Lazy resolver failed for url_path=%r: %s; "
387
+ "falling through to original error",
388
+ url_path,
389
+ resolver_exc,
390
+ )
391
+ return url_path, response
392
+
393
+ if resolved is None or not resolved["url_path"]:
394
+ return url_path, response
395
+
396
+ url_path = resolved["url_path"]
397
+ retry_data = dict(ws_data)
398
+ retry_data["url_path"] = url_path
399
+ response = await client.send_websocket_message(retry_data)
400
+ return url_path, response
401
+
402
+
250
403
  def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
251
404
  """Register Home Assistant dashboard configuration tools."""
252
405
 
@@ -255,8 +408,8 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
255
408
  annotations={
256
409
  "idempotentHint": True,
257
410
  "readOnlyHint": True,
258
- "title": "Get Dashboard"
259
- }
411
+ "title": "Get Dashboard",
412
+ },
260
413
  )
261
414
  @log_tool_usage
262
415
  async def ha_config_get_dashboard(
@@ -276,7 +429,10 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
276
429
  ),
277
430
  ] = False,
278
431
  force_reload: Annotated[
279
- bool, Field(description="Force reload from storage (bypass cache). Not applicable in search mode (search always uses force=True for fresh results).")
432
+ bool,
433
+ Field(
434
+ description="Force reload from storage (bypass cache). Not applicable in search mode (search always uses force=True for fresh results)."
435
+ ),
280
436
  ] = False,
281
437
  entity_id: Annotated[
282
438
  str | None,
@@ -347,7 +503,9 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
347
503
 
348
504
  Note: YAML-mode dashboards (defined in configuration.yaml) are not included in list.
349
505
  """
350
- search_mode = entity_id is not None or card_type is not None or heading is not None
506
+ search_mode = (
507
+ entity_id is not None or card_type is not None or heading is not None
508
+ )
351
509
  try:
352
510
  # List mode
353
511
  if list_only:
@@ -371,11 +529,31 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
371
529
  # Search mode — find cards, badges, or header cards
372
530
  if search_mode:
373
531
  get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
374
- if url_path and url_path != "default":
375
- get_data["url_path"] = url_path
532
+ effective_url_path: str | None = (
533
+ url_path if url_path and url_path != "default" else None
534
+ )
535
+ if effective_url_path is not None:
536
+ get_data["url_path"] = effective_url_path
376
537
 
377
538
  response = await client.send_websocket_message(get_data)
378
539
 
540
+ # Lazy resolver fallback: same gate as get-mode. If the
541
+ # caller passed an internal id where url_path is expected,
542
+ # HA rejects with the trigger substring; resolve and retry
543
+ # once. (set_dashboard handles this via an eager pre-resolver
544
+ # before the hyphen check, so it has no equivalent fallback
545
+ # here.)
546
+ search_resolved_from: str | None = None
547
+ if effective_url_path is not None:
548
+ new_url_path, response = await _lazy_resolve_and_retry(
549
+ client, effective_url_path, get_data, response
550
+ )
551
+ if new_url_path != effective_url_path:
552
+ # Surface the original caller-passed identifier so
553
+ # the caller can see their input was canonicalized.
554
+ search_resolved_from = url_path
555
+ url_path = new_url_path
556
+
379
557
  if isinstance(response, dict) and not response.get("success", True):
380
558
  error_msg = response.get("error", {})
381
559
  if isinstance(error_msg, dict):
@@ -428,7 +606,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
428
606
 
429
607
  config_hash: str | None = compute_config_hash(config)
430
608
 
431
- return {
609
+ search_result: dict[str, Any] = {
432
610
  "success": True,
433
611
  "action": "find_card",
434
612
  "url_path": url_path,
@@ -447,6 +625,9 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
447
625
  else "No matches found. Try broader search criteria."
448
626
  ),
449
627
  }
628
+ if search_resolved_from is not None:
629
+ search_result["resolved_from"] = search_resolved_from
630
+ return search_result
450
631
 
451
632
  # Get mode - build WebSocket message
452
633
  data: dict[str, Any] = {"type": "lovelace/config", "force": force_reload}
@@ -456,7 +637,16 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
456
637
 
457
638
  response = await client.send_websocket_message(data)
458
639
 
459
- # Check if request failed
640
+ # Lazy resolver fallback: if HA rejects the identifier as unknown,
641
+ # resolve it via lovelace/dashboards/list and retry once. The
642
+ # round-trip is only paid when the caller passed an internal
643
+ # dashboard id (or another non-url_path form) HA does not accept.
644
+ original_url_path = url_path
645
+ url_path, response = await _lazy_resolve_and_retry(
646
+ client, url_path, data, response
647
+ )
648
+
649
+ # Check if request failed (after potential retry)
460
650
  if isinstance(response, dict) and not response.get("success", True):
461
651
  error_msg = response.get("error", {})
462
652
  if isinstance(error_msg, dict):
@@ -485,7 +675,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
485
675
  # Calculate config size for progressive disclosure hint
486
676
  config_size = len(json.dumps(config)) if isinstance(config, dict) else 0
487
677
 
488
- result = {
678
+ get_result: dict[str, Any] = {
489
679
  "success": True,
490
680
  "action": "get",
491
681
  "url_path": url_path,
@@ -493,17 +683,23 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
493
683
  "config_hash": config_hash,
494
684
  "config_size_bytes": config_size,
495
685
  }
686
+ # Surface the original caller-passed identifier when the lazy
687
+ # resolver canonicalised it (parity with delete_dashboard's
688
+ # resolved_id field). Caller can use this to detect that their
689
+ # input was an internal id rather than a url_path.
690
+ if original_url_path is not None and original_url_path != url_path:
691
+ get_result["resolved_from"] = original_url_path
496
692
 
497
693
  # Add hint for large configs (progressive disclosure) - 10KB ≈ 2-3k tokens
498
694
  if config_size >= 10000:
499
- result["hint"] = (
695
+ get_result["hint"] = (
500
696
  f"Large config ({config_size:,} bytes). For edits, use "
501
697
  "ha_config_get_dashboard(entity_id=...) to find card positions, "
502
698
  "then ha_config_set_dashboard(python_transform=...) "
503
699
  "instead of full config replacement."
504
700
  )
505
701
 
506
- return result
702
+ return get_result
507
703
  except ToolError:
508
704
  raise
509
705
  except Exception as e:
@@ -544,10 +740,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
544
740
 
545
741
  @mcp.tool(
546
742
  tags={"Dashboards"},
547
- annotations={
548
- "destructiveHint": True,
549
- "title": "Create or Update Dashboard"
550
- }
743
+ annotations={"destructiveHint": True, "title": "Create or Update Dashboard"},
551
744
  )
552
745
  @log_tool_usage
553
746
  async def ha_config_set_dashboard(
@@ -729,6 +922,34 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
729
922
  if url_path == "default":
730
923
  url_path = "lovelace"
731
924
 
925
+ # Pre-resolve internal dashboard ID to url_path form before the
926
+ # hyphen check below, so callers may pass either form. Only fires
927
+ # when the identifier looks like an internal id (no hyphen, not
928
+ # the built-in "lovelace") and matches a known dashboard.
929
+ #
930
+ # Caveat: if a caller passes a hyphenless identifier intending
931
+ # to *create* a new dashboard, but it happens to match an
932
+ # existing dashboard's id, the rewrite silently re-targets the
933
+ # operation onto that existing dashboard. Pre-PR they'd have
934
+ # hit the hyphen-validation error and known their input was
935
+ # invalid; now the create-vs-update distinction depends on
936
+ # whether the registry happens to contain a matching id.
937
+ # We log the rewrite and surface the original identifier as
938
+ # ``resolved_from`` on the success response so callers can
939
+ # detect this redirect.
940
+ pre_resolved_from: str | None = None
941
+ if "-" not in url_path and url_path != "lovelace":
942
+ resolved = await _resolve_dashboard(client, url_path)
943
+ if resolved is not None and resolved["url_path"]:
944
+ original_url_path = url_path
945
+ url_path = resolved["url_path"]
946
+ pre_resolved_from = original_url_path
947
+ logger.info(
948
+ "ha_config_set_dashboard pre-resolver mapped %r -> %r",
949
+ original_url_path,
950
+ url_path,
951
+ )
952
+
732
953
  # Validate url_path contains hyphen for new dashboards
733
954
  # The built-in "lovelace" dashboard is exempt since it already exists
734
955
  if "-" not in url_path and url_path != "lovelace":
@@ -897,7 +1118,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
897
1118
  # Compute new hash for potential chaining
898
1119
  new_config_hash = compute_config_hash(transformed_config)
899
1120
 
900
- return {
1121
+ transform_result: dict[str, Any] = {
901
1122
  "success": True,
902
1123
  "action": "python_transform",
903
1124
  "url_path": url_path,
@@ -905,6 +1126,9 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
905
1126
  "python_expression": python_transform,
906
1127
  "message": f"Dashboard {url_path} updated via Python transform",
907
1128
  }
1129
+ if pre_resolved_from is not None:
1130
+ transform_result["resolved_from"] = pre_resolved_from
1131
+ return transform_result
908
1132
 
909
1133
  # Check if dashboard exists
910
1134
  result = await client.send_websocket_message(
@@ -1130,6 +1354,12 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1130
1354
 
1131
1355
  if hint:
1132
1356
  result_dict["hint"] = hint
1357
+ if pre_resolved_from is not None:
1358
+ # Caller passed an internal id; pre-resolver mapped it to
1359
+ # the canonical url_path. Surface the original so a caller
1360
+ # who *intended* to create a new dashboard can detect that
1361
+ # an existing dashboard was updated instead.
1362
+ result_dict["resolved_from"] = pre_resolved_from
1133
1363
 
1134
1364
  return result_dict
1135
1365
 
@@ -1150,17 +1380,15 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1150
1380
 
1151
1381
  @mcp.tool(
1152
1382
  tags={"Dashboards"},
1153
- annotations={
1154
- "destructiveHint": True,
1155
- "title": "Delete Dashboard"
1156
- }
1383
+ annotations={"destructiveHint": True, "title": "Delete Dashboard"},
1157
1384
  )
1158
1385
  @log_tool_usage
1159
1386
  async def ha_config_delete_dashboard(
1160
- dashboard_id: Annotated[
1387
+ url_path: Annotated[
1161
1388
  str,
1162
1389
  Field(
1163
- description="Dashboard ID or URL path to delete (e.g., 'my-dashboard' or 'my_dashboard')"
1390
+ description="Dashboard URL path or internal ID to delete "
1391
+ "(e.g., 'my-dashboard' or 'my_dashboard'). Both forms are accepted."
1164
1392
  ),
1165
1393
  ],
1166
1394
  ) -> dict[str, Any]:
@@ -1170,8 +1398,9 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1170
1398
  WARNING: This permanently deletes the dashboard and all its configuration.
1171
1399
  Cannot be undone. Does not work on YAML-mode dashboards.
1172
1400
 
1173
- Accepts either the internal dashboard ID or the URL path.
1174
- The tool resolves url_path to internal ID automatically.
1401
+ Accepts either the URL path or the internal dashboard ID. HA internal IDs
1402
+ may differ from url_path (e.g. hyphens underscores); the tool resolves
1403
+ either form to the actual registry ID before deletion.
1175
1404
 
1176
1405
  EXAMPLES:
1177
1406
  - Delete dashboard: ha_config_delete_dashboard("mobile-dashboard")
@@ -1179,37 +1408,19 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1179
1408
  Note: The default dashboard cannot be deleted via this method.
1180
1409
  """
1181
1410
  try:
1182
- # Fetch dashboard list to resolve the provided identifier.
1183
- # HA internal IDs may differ from url_path (e.g. hyphens → underscores),
1184
- # so we accept either and resolve to the actual registry ID.
1185
- list_result = await client.send_websocket_message(
1186
- {"type": "lovelace/dashboards/list"}
1187
- )
1188
- if isinstance(list_result, dict) and "result" in list_result:
1189
- dashboards = list_result["result"]
1190
- elif isinstance(list_result, list):
1191
- dashboards = list_result
1192
- else:
1193
- dashboards = []
1194
-
1195
- resolved_id = None
1196
- for d in dashboards:
1197
- if d.get("id") == dashboard_id:
1198
- resolved_id = d["id"]
1199
- break
1200
- if d.get("url_path") == dashboard_id:
1201
- resolved_id = d["id"]
1202
- break
1203
-
1204
- if resolved_id is None:
1205
- raise_tool_error(create_resource_not_found_error(
1206
- "Dashboard",
1207
- dashboard_id,
1208
- details=(
1209
- f"No dashboard found with ID or URL path '{dashboard_id}'. "
1210
- "Use ha_config_get_dashboard(list_only=True) to see available dashboards."
1211
- ),
1212
- ))
1411
+ resolved = await _resolve_dashboard(client, url_path)
1412
+ if resolved is None:
1413
+ raise_tool_error(
1414
+ create_resource_not_found_error(
1415
+ "Dashboard",
1416
+ url_path,
1417
+ details=(
1418
+ f"No dashboard found with URL path or internal ID '{url_path}'. "
1419
+ "Use ha_config_get_dashboard(list_only=True) to see available dashboards."
1420
+ ),
1421
+ )
1422
+ )
1423
+ resolved_id = resolved["id"]
1213
1424
 
1214
1425
  response = await client.send_websocket_message(
1215
1426
  {"type": "lovelace/dashboards/delete", "dashboard_id": resolved_id}
@@ -1233,7 +1444,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1233
1444
  return {
1234
1445
  "success": True,
1235
1446
  "action": "delete",
1236
- "dashboard_id": dashboard_id,
1447
+ "url_path": url_path,
1237
1448
  "message": "Dashboard already deleted or does not exist",
1238
1449
  }
1239
1450
 
@@ -1248,7 +1459,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1248
1459
  "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
1249
1460
  "Cannot delete YAML-mode or default dashboard",
1250
1461
  ],
1251
- context={"action": "delete", "dashboard_id": dashboard_id},
1462
+ context={"action": "delete", "url_path": url_path},
1252
1463
  )
1253
1464
  )
1254
1465
 
@@ -1256,10 +1467,10 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1256
1467
  result: dict[str, Any] = {
1257
1468
  "success": True,
1258
1469
  "action": "delete",
1259
- "dashboard_id": dashboard_id,
1470
+ "url_path": url_path,
1260
1471
  "message": "Dashboard deleted successfully",
1261
1472
  }
1262
- if resolved_id != dashboard_id:
1473
+ if resolved_id != url_path:
1263
1474
  result["resolved_id"] = resolved_id
1264
1475
  return result
1265
1476
  except ToolError:
@@ -1268,7 +1479,7 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1268
1479
  logger.error(f"Error deleting dashboard: {e}")
1269
1480
  exception_to_structured_error(
1270
1481
  e,
1271
- context={"action": "delete", "dashboard_id": dashboard_id},
1482
+ context={"action": "delete", "url_path": url_path},
1272
1483
  suggestions=[
1273
1484
  "Verify dashboard exists and is storage-mode",
1274
1485
  "Check that you have admin permissions",
@@ -1286,4 +1497,3 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1286
1497
  # - ha_config_set_dashboard_resource: Create/update resources (inline code or URL)
1287
1498
  # - ha_config_delete_dashboard_resource: Delete resources
1288
1499
  # =========================================================================
1289
-
@@ -666,6 +666,11 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
666
666
  "safe_mode": config.get("safe_mode", False),
667
667
  "internal_url": config.get("internal_url"),
668
668
  "external_url": config.get("external_url"),
669
+ # No default: distinguish HA-not-exposing-the-key (None)
670
+ # from empty-allowlist ([]) — security-relevant for agents.
671
+ "allowlist_external_dirs": config.get(
672
+ "allowlist_external_dirs"
673
+ ),
669
674
  }
670
675
  )
671
676
  result["system_info"] = system_info
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev413
3
+ Version: 7.4.1.dev415
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT