ha-mcp-dev 7.8.0.dev703__tar.gz → 7.8.0.dev706__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 (143) hide show
  1. {ha_mcp_dev-7.8.0.dev703/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.8.0.dev706}/PKG-INFO +2 -1
  2. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/pyproject.toml +2 -1
  3. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/__main__.py +22 -11
  4. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_system.py +364 -22
  5. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/util_helpers.py +75 -16
  6. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706/src/ha_mcp_dev.egg-info}/PKG-INFO +2 -1
  7. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp_dev.egg-info/requires.txt +1 -0
  8. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/LICENSE +0 -0
  9. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/MANIFEST.in +0 -0
  10. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/README.md +0 -0
  11. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/setup.cfg +0 -0
  12. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/__init__.py +0 -0
  13. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/_pypi_marker +0 -0
  14. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/_version.py +0 -0
  15. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/auth/__init__.py +0 -0
  16. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/auth/consent_form.py +0 -0
  17. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/auth/provider.py +0 -0
  18. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/backup_manager.py +0 -0
  19. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/client/rest_client.py +0 -0
  21. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/client/supervisor_client.py +0 -0
  22. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/config.py +0 -0
  25. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  26. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  27. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  28. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/errors.py +0 -0
  29. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/__init__.py +0 -0
  30. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/approval_queue.py +0 -0
  31. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/evaluator.py +0 -0
  32. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/handlers.py +0 -0
  33. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/middleware.py +0 -0
  34. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/model.py +0 -0
  35. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/persistence.py +0 -0
  36. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/policy/value_sources.py +0 -0
  37. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/py.typed +0 -0
  38. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/read_only.py +0 -0
  39. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  40. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  41. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  42. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  43. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  44. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  45. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  46. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  47. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  48. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  49. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  50. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  51. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  52. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  53. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  54. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  55. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  56. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  57. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  58. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  59. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  60. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  61. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/server.py +0 -0
  62. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/settings.css +0 -0
  63. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/settings.js +0 -0
  64. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/settings_ui.py +0 -0
  65. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/smoke_test.py +0 -0
  66. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  67. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/__init__.py +0 -0
  68. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/auto_backup.py +0 -0
  69. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/backup.py +0 -0
  70. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  71. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  72. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/device_control.py +0 -0
  73. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/enhanced.py +0 -0
  74. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/helpers.py +0 -0
  75. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/reference_validator.py +0 -0
  76. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/registry.py +0 -0
  77. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  78. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  79. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  80. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  81. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  82. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  83. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  84. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  85. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  86. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  87. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_addons.py +0 -0
  88. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_areas.py +0 -0
  89. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  90. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  91. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_calendar.py +0 -0
  92. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_camera.py +0 -0
  93. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_categories.py +0 -0
  94. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_code.py +0 -0
  95. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  96. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  97. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  98. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  99. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  100. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  101. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_energy.py +0 -0
  102. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_entities.py +0 -0
  103. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  104. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_groups.py +0 -0
  105. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_hacs.py +0 -0
  106. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_history.py +0 -0
  107. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_integrations.py +0 -0
  108. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_labels.py +0 -0
  109. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  110. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_registry.py +0 -0
  111. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_resources.py +0 -0
  112. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_search.py +0 -0
  113. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_service.py +0 -0
  114. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_services.py +0 -0
  115. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_themes.py +0 -0
  116. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_todo.py +0 -0
  117. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_traces.py +0 -0
  118. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_updates.py +0 -0
  119. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_utility.py +0 -0
  120. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  121. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  122. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/tools_zones.py +0 -0
  123. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/tools/validation_middleware.py +0 -0
  124. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/transforms/__init__.py +0 -0
  125. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/transforms/categorized_search.py +0 -0
  126. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  127. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/__init__.py +0 -0
  128. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/config_hash.py +0 -0
  129. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/data_paths.py +0 -0
  130. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/domain_handlers.py +0 -0
  131. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  132. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  133. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/operation_manager.py +0 -0
  134. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/python_sandbox.py +0 -0
  135. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/skill_loader.py +0 -0
  136. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp/utils/usage_logger.py +0 -0
  137. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  138. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  139. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  140. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  141. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/tests/__init__.py +0 -0
  142. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/tests/test_constants.py +0 -0
  143. {ha_mcp_dev-7.8.0.dev703 → ha_mcp_dev-7.8.0.dev706}/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.8.0.dev703
3
+ Version: 7.8.0.dev706
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
@@ -26,6 +26,7 @@ Requires-Dist: truststore==0.10.4
26
26
  Requires-Dist: websockets==16.0
27
27
  Requires-Dist: cryptography==49.0.0
28
28
  Requires-Dist: pydantic-monty==0.0.18
29
+ Requires-Dist: tzdata>=2024.1
29
30
  Dynamic: license-file
30
31
 
31
32
  > **Breaking change (v7.3.0):** `ha_config_set_yaml` has been moved to [beta](docs/beta.md).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.8.0.dev703"
7
+ version = "7.8.0.dev706"
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"
@@ -32,6 +32,7 @@ dependencies = [
32
32
  "websockets==16.0",
33
33
  "cryptography==49.0.0",
34
34
  "pydantic-monty==0.0.18",
35
+ "tzdata>=2024.1",
35
36
  ]
36
37
 
37
38
  [project.urls]
@@ -363,24 +363,35 @@ _LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
363
363
 
364
364
 
365
365
  class StatelessSessionLogFilter(logging.Filter):
366
- """Downgrade 'Terminating session: None' to DEBUG to reduce user confusion.
366
+ """Suppress the routine 'Terminating session: None' log from the MCP SDK.
367
367
 
368
368
  In stateless HTTP mode every request creates and tears down a temporary
369
- session, producing an INFO log that looks alarming but is routine.
370
- This filter lowers the level to DEBUG so the message only appears with
371
- verbose logging enabled.
369
+ session whose id is ``None``, so the SDK emits an INFO
370
+ ``Terminating session: None`` (mcp/server/streamable_http.py) on *every*
371
+ request. The line is routine but looks alarming and has repeatedly
372
+ confused users into thinking the connection is broken.
373
+
374
+ Returning ``False`` drops the record at this logger before it reaches any
375
+ handler. (Merely downgrading the level to DEBUG did not work: the level
376
+ gate is applied before the filter runs, so the record was already admitted
377
+ and still emitted -- just relabelled.) Real session terminations carry an
378
+ actual id and are not matched, so they still log.
372
379
 
373
380
  # TODO: remove when modelcontextprotocol/python-sdk#2329 is resolved
374
381
  """
375
382
 
376
383
  def filter(self, record: logging.LogRecord) -> bool:
377
- if (
378
- record.name == "mcp.server.streamable_http"
379
- and "Terminating session: None" in record.getMessage()
380
- ):
381
- record.levelno = logging.DEBUG
382
- record.levelname = "DEBUG"
383
- return True
384
+ if record.name != "mcp.server.streamable_http":
385
+ return True
386
+ try:
387
+ message = record.getMessage()
388
+ except (ValueError, TypeError):
389
+ # A malformed %-format record on this logger is not our target, and
390
+ # a filter must not raise: filters run in Logger.handle() with no
391
+ # exception handling, so a raise would crash the logging call.
392
+ return True
393
+ # Drop the stateless teardown noise; keep everything else.
394
+ return "Terminating session: None" not in message
384
395
 
385
396
 
386
397
  class ToolValidationLogFilter(logging.Filter):
@@ -34,6 +34,43 @@ from .util_helpers import (
34
34
 
35
35
  logger = logging.getLogger(__name__)
36
36
 
37
+
38
+ def _reraise_if_fatal(exc: BaseException) -> None:
39
+ """Re-raise exceptions that must unwind rather than be demoted to an
40
+ embedded section error:
41
+
42
+ - ``CancelledError`` / ``KeyboardInterrupt`` / ``SystemExit`` (all
43
+ ``BaseException`` but not ``Exception``) — task cancellation and
44
+ interpreter exit.
45
+ - ``ToolError`` — carries the MCP ``isError`` contract.
46
+ - ``HomeAssistantConnectionError`` — once the HA transport is dead the
47
+ remaining section fetches will fail anyway, so propagate the root
48
+ cause as ``isError=true`` rather than embed N per-section connection
49
+ errors. The codebase's ``rest_client._request`` already wraps
50
+ ``OSError`` / timeout / transport failures into this class, so it is
51
+ the single fatal class to gate on.
52
+
53
+ This implements the cross-section policy proposed in #1624: a dead
54
+ connection fails loud rather than degrade per-section. Every section
55
+ helper in ``ha_get_system_health`` routes its ``except Exception`` block
56
+ through this gate as its first line, so the policy is consistent across
57
+ the ws ``sections`` gather pre-pass and every inline section. Recoverable
58
+ ``Exception``-level failures still fall through to the caller's
59
+ embed-as-error handling.
60
+ """
61
+ # Local import: ``rest_client`` imports from tool helpers transitively,
62
+ # so a module-level import here would risk a circular import in the
63
+ # tools package.
64
+ from ..client.rest_client import HomeAssistantConnectionError
65
+
66
+ if (
67
+ isinstance(exc, ToolError)
68
+ or not isinstance(exc, Exception)
69
+ or isinstance(exc, HomeAssistantConnectionError)
70
+ ):
71
+ raise exc
72
+
73
+
37
74
  # Mapping of reload targets to their service domains and services
38
75
  RELOAD_TARGETS = {
39
76
  "all": None, # Special case - reload all
@@ -357,6 +394,15 @@ class SystemTools:
357
394
  - "config_check": Validate HA configuration via POST /config/core/check_config
358
395
  (the pre-restart safety check; ha_restart runs it automatically). Returns
359
396
  {result: valid|invalid, is_valid, errors}; read-only/idempotent, takes no args.
397
+ - "dead_entities": Surface orphaned/stale entity-registry entries by diffing
398
+ the registry against the state machine and the live config-entries set.
399
+ Returns confidence-tiered buckets — ``config_entry_orphans`` (owning
400
+ integration instance gone; definitively dead) and ``stale_restored`` (HA
401
+ restored the entity from the registry on startup but the loaded integration
402
+ no longer provides it). Each item carries entity_id + platform so a client
403
+ can propose cleanup with ha_remove_entity. Deliberately excludes
404
+ ``unknown``-state entities and merely-offline devices to keep false positives
405
+ low. Read-only; takes no args.
360
406
  - Example: include="repairs,zha_network,zwave_network,config_check"
361
407
  - Example: include="diagnostics", config_entry_id="abc123..."
362
408
  - include_dismissed_repairs: Include user-dismissed/ignored repairs (default: False). Only meaningful when "repairs" is in `include`.
@@ -419,16 +465,19 @@ class SystemTools:
419
465
  # catch cannot swallow an unrelated ToolError.
420
466
  #
421
467
  # Degrade gracefully ONLY when the caller asked for a REST-based
422
- # section (config_check / diagnostics) that can still be served
423
- # without the WebSocket. config_check is the pure-REST
424
- # replacement for the removed ha_check_config tool, so it must
425
- # not depend on the health WebSocket (the system_health/info
426
- # command carries its own 10s timeout and can hang/be absent on
427
- # some installs). If the caller asked for nothing (the health
428
- # baseline itself) or only WS-backed sections, the baseline WAS
429
- # the deliverable: re-raise so the failure surfaces as
430
- # isError=true, exactly as before this change.
431
- if not (includes & {"config_check", "diagnostics"}):
468
+ # section (config_check / diagnostics / dead_entities) that can
469
+ # still be served without the WebSocket. config_check is the
470
+ # pure-REST replacement for the removed ha_check_config tool, so
471
+ # it must not depend on the health WebSocket (the
472
+ # system_health/info command carries its own 10s timeout and can
473
+ # hang/be absent on some installs). dead_entities uses the REST
474
+ # client's own per-client WebSocket bridge for the registry +
475
+ # config-entries (not this health ws_client), so it is likewise
476
+ # independent of the baseline. If the caller asked for nothing
477
+ # (the health baseline itself) or only WS-backed sections, the
478
+ # baseline WAS the deliverable: re-raise so the failure surfaces
479
+ # as isError=true, exactly as before this change.
480
+ if not (includes & {"config_check", "diagnostics", "dead_entities"}):
432
481
  raise
433
482
  logger.warning("system_health baseline unavailable: %s", health_err)
434
483
  ws_client = None
@@ -453,6 +502,7 @@ class SystemTools:
453
502
  "diagnostics",
454
503
  "config_check",
455
504
  "themes",
505
+ "dead_entities",
456
506
  }
457
507
  unknown = includes - VALID_INCLUDES
458
508
  if unknown:
@@ -531,17 +581,13 @@ class SystemTools:
531
581
  # inside a helper would otherwise be silently demoted to
532
582
  # ``{"error": "ToolError: …"}`` and break the MCP
533
583
  # ``isError=true`` contract for the whole tool.
584
+ # ``_reraise_if_fatal`` encapsulates the policy (cancellation,
585
+ # interpreter-exit, ``ToolError``, and the codebase's transport
586
+ # ``HomeAssistantConnectionError``) — the single source of
587
+ # truth shared with each section helper's ``except`` chain.
534
588
  for section_result in gathered:
535
- if isinstance(section_result, asyncio.CancelledError):
536
- raise section_result
537
- if isinstance(section_result, ToolError):
538
- raise section_result
539
- if isinstance(section_result, BaseException) and not isinstance(
540
- section_result, Exception
541
- ):
542
- # ``KeyboardInterrupt`` / ``SystemExit`` — never demote
543
- # these to a section-level error string.
544
- raise section_result
589
+ if isinstance(section_result, BaseException):
590
+ _reraise_if_fatal(section_result)
545
591
  for (section_name, _), section_result in zip(
546
592
  sections, gathered, strict=True
547
593
  ):
@@ -638,6 +684,22 @@ class SystemTools:
638
684
  # in one call.
639
685
  result["config_check"] = await self._fetch_config_check()
640
686
 
687
+ if "dead_entities" in includes:
688
+ # REST + the REST client's own per-client WebSocket bridge
689
+ # (states via /api/states, registry + config-entries via the
690
+ # bridge), not the health ws_client — so it runs inline like
691
+ # config_check and survives a baseline-WS-down install.
692
+ dead_section = await self._fetch_dead_entities()
693
+ # Pop the ``_warnings`` sentinel and bubble it to the top-level
694
+ # ``result["warnings"]`` (the documented contract location).
695
+ # The section helper uses this sentinel so its return signature
696
+ # stays uniform with every other section (a plain dict) while
697
+ # avoiding a collision with the reserved ``warnings`` term.
698
+ section_warnings = dead_section.pop("_warnings", None)
699
+ if section_warnings:
700
+ result.setdefault("warnings", []).extend(section_warnings)
701
+ result["dead_entities"] = dead_section
702
+
641
703
  return result
642
704
 
643
705
  except ToolError:
@@ -765,6 +827,7 @@ class SystemTools:
765
827
  )
766
828
  repairs["error"] = f"Repairs data not available: {err_msg}"
767
829
  except Exception as e:
830
+ _reraise_if_fatal(e)
768
831
  logger.warning("Failed to fetch repairs: %s", e)
769
832
  repairs["error"] = f"Repairs data not available: {e}"
770
833
  return repairs
@@ -810,6 +873,7 @@ class SystemTools:
810
873
  "Use ha_get_device(integration='zha') for full device list."
811
874
  )
812
875
  except Exception as e:
876
+ _reraise_if_fatal(e)
813
877
  logger.warning("Failed to fetch ZHA network data: %s", e)
814
878
  zha_network["error"] = f"ZHA integration not available or error: {e}"
815
879
  return zha_network
@@ -825,8 +889,11 @@ class SystemTools:
825
889
  "total_count": 0,
826
890
  }
827
891
  try:
828
- # Get all zwave_js config entries to find entry_id
829
- entries_result = await ws_client.send_command("config/entries/get")
892
+ # Get all zwave_js config entries to find entry_id. The HA command
893
+ # is ``config_entries/get`` (underscore); the slash form is rejected
894
+ # as "Unknown command", which the outer except would mask as
895
+ # "Z-Wave JS integration not available".
896
+ entries_result = await ws_client.send_command("config_entries/get")
830
897
  zwave_entry_id = None
831
898
  if entries_result.get("success"):
832
899
  for entry in entries_result.get("result", []):
@@ -870,6 +937,7 @@ class SystemTools:
870
937
  "Use ha_get_device(integration='zwave_js') for full device list."
871
938
  )
872
939
  except Exception as e:
940
+ _reraise_if_fatal(e)
873
941
  logger.warning("Failed to fetch Z-Wave network data: %s", e)
874
942
  zwave_network["error"] = (
875
943
  f"Z-Wave JS integration not available or error: {e}"
@@ -904,10 +972,283 @@ class SystemTools:
904
972
  )
905
973
  themes_data["error"] = f"Themes data not available: {err_msg}"
906
974
  except Exception as e:
975
+ _reraise_if_fatal(e)
907
976
  logger.warning("Failed to fetch themes: %s", e)
908
977
  themes_data["error"] = f"Themes data not available: {e}"
909
978
  return themes_data
910
979
 
980
+ @staticmethod
981
+ def _ws_result_list(
982
+ resp: Any,
983
+ ) -> tuple[list[dict[str, Any]] | None, str | None]:
984
+ """Unwrap a ``send_websocket_message`` response into ``(list, None)``
985
+ on success or ``(None, error_str)`` on failure.
986
+
987
+ ``send_websocket_message`` returns the HA WebSocket envelope
988
+ (``{"success": bool, "result": [...]}``) on success and
989
+ ``{"success": False, "error": ...}`` on failure; ``return_exceptions``
990
+ in the caller's ``gather`` can also hand back a raw exception.
991
+ Preserves the underlying cause string (envelope error message,
992
+ exception type, or wrong-shape description) so the caller can
993
+ attribute the failure rather than substitute a fixed "unavailable"
994
+ message that hides the root cause (auth vs command error vs
995
+ malformed envelope). Fatal exceptions (per ``_reraise_if_fatal``)
996
+ unwind instead of being returned as an error string.
997
+ """
998
+ if isinstance(resp, BaseException):
999
+ # gather(return_exceptions=True) hands back the raw exception; let
1000
+ # truly-fatal ones unwind instead of masking them as "unavailable".
1001
+ _reraise_if_fatal(resp)
1002
+ return None, f"{type(resp).__name__}: {resp}"
1003
+ if not isinstance(resp, dict):
1004
+ return None, f"unexpected response type: {type(resp).__name__}"
1005
+ # Require success truthy before trusting ``result`` — matches the
1006
+ # ``if result.get("success")`` convention used by the other WS handlers
1007
+ # in this file (and treats a malformed envelope missing the key as a
1008
+ # failure rather than reading a half-built result).
1009
+ if not resp.get("success"):
1010
+ err = resp.get("error")
1011
+ if isinstance(err, dict):
1012
+ err_msg = err.get("message") or err.get("code") or str(err)
1013
+ elif err:
1014
+ err_msg = str(err)
1015
+ else:
1016
+ err_msg = "unknown error"
1017
+ return None, str(err_msg)
1018
+ result = resp.get("result")
1019
+ if isinstance(result, list):
1020
+ return result, None
1021
+ return None, f"unexpected result shape: {type(result).__name__}"
1022
+
1023
+ async def _fetch_dead_entities(self) -> dict[str, Any]:
1024
+ """Surface orphaned/stale entity-registry entries.
1025
+
1026
+ Diffs the entity registry against the state machine and the live
1027
+ config-entries set, classifying findings into confidence tiers:
1028
+
1029
+ - ``config_entry_orphans`` (definitive): registry entries whose
1030
+ ``config_entry_id`` is no longer present in ``config_entries/get`` —
1031
+ the owning integration instance was removed, leaving the registry
1032
+ entry behind.
1033
+ - ``stale_restored`` (likely): entries HA recreated from the registry on
1034
+ startup — state ``unavailable`` with ``restored: true`` — whose owning
1035
+ config entry still exists. The integration is loaded but no longer
1036
+ provides the entity (renamed/removed device, re-paired Zigbee).
1037
+
1038
+ Deliberately NEVER flagged, to keep false positives low: ``unknown``
1039
+ state (alive, just no current value — e.g. weather/disaster-alert
1040
+ sensors), bare ``unavailable`` without ``restored`` (a loaded
1041
+ integration reporting a device merely offline right now), and entries
1042
+ disabled via ``disabled_by`` (intentional, unless their config entry is
1043
+ also gone — those still surface as orphans). The ``restored`` flag is
1044
+ what HA sets when it rebuilds a state object from the registry's cached
1045
+ last state because no live platform currently provides the entity; it is
1046
+ the discriminator between "dead" and "temporarily offline". (This tracks
1047
+ HA Core's state-restoration behaviour; re-verify if classification drifts
1048
+ after an HA upgrade.)
1049
+
1050
+ Entities can appear under ``stale_restored`` transiently right after a
1051
+ restart, before integrations finish loading; ``note`` flags this.
1052
+
1053
+ Instance method (not @staticmethod): uses the REST client
1054
+ (``self._client``) for states plus its per-client WebSocket bridge for
1055
+ the registry + config entries, so it needs no system_health ws_client
1056
+ and runs even when the health baseline is unavailable.
1057
+ """
1058
+ DEAD_ENTITIES_LIMIT = 50
1059
+ dead: dict[str, Any] = {
1060
+ "config_entry_orphans": {"items": [], "count": 0, "total_count": 0},
1061
+ "stale_restored": {"items": [], "count": 0, "total_count": 0},
1062
+ "summary": {"candidate_total": 0, "registry_total": 0},
1063
+ }
1064
+ # Warnings collected here are bubbled to the top-level
1065
+ # ``result["warnings"]`` by the aggregator. The section returns a
1066
+ # plain dict (like every other section helper, keeping the return
1067
+ # signature uniform) with a ``_warnings`` sentinel that
1068
+ # ``ha_get_system_health`` pops and extends onto ``result["warnings"]``
1069
+ # — the documented contract location, which a section-local
1070
+ # ``warnings`` key would collide with.
1071
+ bubble_warnings: list[str] = []
1072
+ try:
1073
+ # Index the gather result (rather than tuple-unpack) so mypy can
1074
+ # type each element through the return_exceptions=True overload;
1075
+ # mirrors smart_search/_entities.py::_fetch_search_entities.
1076
+ results = await asyncio.gather(
1077
+ self._client.get_states(),
1078
+ self._client.send_websocket_message(
1079
+ {"type": "config/entity_registry/list"}
1080
+ ),
1081
+ self._client.send_websocket_message({"type": "config_entries/get"}),
1082
+ return_exceptions=True,
1083
+ )
1084
+ states = results[0]
1085
+
1086
+ if isinstance(states, BaseException):
1087
+ # Truly-fatal errors must propagate, not demote to a section
1088
+ # error string (mirrors the ws sections gather pre-pass).
1089
+ _reraise_if_fatal(states)
1090
+ dead["error"] = f"Could not fetch entity states: {states}"
1091
+ return dead
1092
+ if not isinstance(states, list):
1093
+ dead["error"] = (
1094
+ "Could not fetch entity states: expected list, got "
1095
+ f"{type(states).__name__}"
1096
+ )
1097
+ return dead
1098
+ registry, registry_err = self._ws_result_list(results[1])
1099
+ if registry is None:
1100
+ # Preserve the underlying cause (envelope error message,
1101
+ # exception type, or wrong-shape description) so the client
1102
+ # can distinguish auth vs command vs malformed envelope
1103
+ # rather than see a fixed "unavailable" substitute.
1104
+ dead["error"] = (
1105
+ f"Could not fetch entity registry "
1106
+ f"(config/entity_registry/list: {registry_err})"
1107
+ )
1108
+ return dead
1109
+
1110
+ # config-entries is the only optional source: without it the
1111
+ # definitive orphan tier can't be computed, but stale_restored still
1112
+ # can — so degrade rather than fail the whole section.
1113
+ entries, entries_err = self._ws_result_list(results[2])
1114
+ live_entry_ids: set[str] | None = None
1115
+ if entries is None:
1116
+ # Genuine fetch failure — preserve the cause so a backend
1117
+ # failure isn't reported as "no entries".
1118
+ dead["config_entries_checked"] = False
1119
+ bubble_warnings.append(
1120
+ f"config_entries/get failed ({entries_err}); "
1121
+ "config_entry_orphans tier skipped (cannot distinguish a "
1122
+ "removed integration from a failed fetch). stale_restored "
1123
+ "still computed."
1124
+ )
1125
+ elif not entries:
1126
+ # Real empty list — HA reports no config entries configured.
1127
+ # Distinct from a fetch failure: the message names the actual
1128
+ # state. The orphan tier still skips since there is no live
1129
+ # set to diff against; stale_restored still computed.
1130
+ dead["config_entries_checked"] = False
1131
+ bubble_warnings.append(
1132
+ "config_entries/get returned an empty list (no "
1133
+ "integrations configured); config_entry_orphans tier "
1134
+ "skipped. stale_restored still computed."
1135
+ )
1136
+ else:
1137
+ live_entry_ids = {
1138
+ e["entry_id"]
1139
+ for e in entries
1140
+ if isinstance(e, dict) and e.get("entry_id")
1141
+ }
1142
+ dead["config_entries_checked"] = True
1143
+
1144
+ state_by_id = {
1145
+ s["entity_id"]: s
1146
+ for s in states
1147
+ if isinstance(s, dict) and s.get("entity_id")
1148
+ }
1149
+
1150
+ orphans: list[dict[str, Any]] = []
1151
+ stale: list[dict[str, Any]] = []
1152
+ for entry in registry:
1153
+ if not isinstance(entry, dict):
1154
+ continue
1155
+ eid = entry.get("entity_id")
1156
+ if not eid:
1157
+ continue
1158
+ cfg = entry.get("config_entry_id")
1159
+ disabled_by = entry.get("disabled_by")
1160
+
1161
+ # Tier 1 — config-entry orphan (only when the live set loaded).
1162
+ # Covers disabled leftovers too: a disabled entity whose config
1163
+ # entry is gone is still dead cruft (disabled_by is surfaced on
1164
+ # the item so the client sees why it lingered).
1165
+ if live_entry_ids is not None and cfg and cfg not in live_entry_ids:
1166
+ orphans.append(
1167
+ {
1168
+ "entity_id": eid,
1169
+ "platform": entry.get("platform"),
1170
+ "config_entry_id": cfg,
1171
+ "disabled_by": disabled_by,
1172
+ "has_state": eid in state_by_id,
1173
+ }
1174
+ )
1175
+ continue
1176
+
1177
+ # Tier 2 — stale restored. Skip intentionally-disabled entries
1178
+ # (they normally have no state object anyway).
1179
+ if disabled_by is not None:
1180
+ continue
1181
+ state_obj = state_by_id.get(eid)
1182
+ if state_obj is None:
1183
+ continue
1184
+ attrs = state_obj.get("attributes")
1185
+ if (
1186
+ state_obj.get("state") == "unavailable"
1187
+ and isinstance(attrs, dict)
1188
+ and attrs.get("restored")
1189
+ ):
1190
+ stale.append(
1191
+ {
1192
+ "entity_id": eid,
1193
+ "platform": entry.get("platform"),
1194
+ "config_entry_id": cfg,
1195
+ }
1196
+ )
1197
+
1198
+ def _bucket(items: list[dict[str, Any]]) -> dict[str, Any]:
1199
+ total = len(items)
1200
+ capped = items[:DEAD_ENTITIES_LIMIT]
1201
+ bucket: dict[str, Any] = {
1202
+ "items": capped,
1203
+ "count": len(capped),
1204
+ "total_count": total,
1205
+ }
1206
+ if total > DEAD_ENTITIES_LIMIT:
1207
+ bucket["truncated"] = True
1208
+ bucket["hint"] = (
1209
+ f"Showing {DEAD_ENTITIES_LIMIT} of {total}; "
1210
+ "remove cleanup candidates in batches and re-run."
1211
+ )
1212
+ return bucket
1213
+
1214
+ candidate_total = len(orphans) + len(stale)
1215
+ dead["config_entry_orphans"] = _bucket(orphans)
1216
+ dead["stale_restored"] = _bucket(stale)
1217
+ dead["summary"] = {
1218
+ "candidate_total": candidate_total,
1219
+ "registry_total": len(registry),
1220
+ }
1221
+ # Only attach the guidance note when there is something to act on —
1222
+ # no point spending tokens on cleanup advice for an empty result.
1223
+ if candidate_total:
1224
+ dead["note"] = (
1225
+ "Excludes 'unknown'-state entities and merely-offline "
1226
+ "devices (bare 'unavailable' without 'restored'). Entries "
1227
+ "can appear under stale_restored transiently right after a "
1228
+ "restart; re-run if HA restarted recently. Remove a "
1229
+ "confirmed-dead entity with ha_remove_entity(entity_id)."
1230
+ )
1231
+ except ToolError:
1232
+ # A ToolError (incl. one re-raised by _reraise_if_fatal) carries the
1233
+ # MCP isError contract — let it reach ha_get_system_health's own
1234
+ # ``except ToolError: raise`` instead of being demoted to a section
1235
+ # error string here (AGENTS.md error-handling guard pattern).
1236
+ raise
1237
+ except Exception as e:
1238
+ _reraise_if_fatal(e)
1239
+ # ``logger.exception`` so an unexpected diff bug gets a full
1240
+ # traceback rather than a one-line warning that hides the
1241
+ # site of the regression.
1242
+ logger.exception("Failed to compute dead entities")
1243
+ dead["error"] = f"Dead-entities diff not available: {e}"
1244
+ # ``_warnings`` is a sentinel that ``ha_get_system_health`` pops to
1245
+ # ``result["warnings"]``; it never reaches the client. Attaching it
1246
+ # outside the try/except keeps it correct on both happy and
1247
+ # embed-as-error paths.
1248
+ if bubble_warnings:
1249
+ dead["_warnings"] = bubble_warnings
1250
+ return dead
1251
+
911
1252
  async def _fetch_config_check(self) -> dict[str, Any]:
912
1253
  """Validate HA configuration via POST /config/core/check_config.
913
1254
 
@@ -936,6 +1277,7 @@ class SystemTools:
936
1277
  "errors": errors,
937
1278
  }
938
1279
  except Exception as e:
1280
+ _reraise_if_fatal(e)
939
1281
  logger.warning("Failed to check config: %s", e)
940
1282
  config_check["error"] = f"Config check not available: {e}"
941
1283
  return config_check