ha-mcp-dev 7.6.0.dev622__tar.gz → 7.6.0.dev624__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 (126) hide show
  1. {ha_mcp_dev-7.6.0.dev622/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.6.0.dev624}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/config.py +55 -8
  4. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/server.py +6 -0
  5. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/settings_ui.py +162 -0
  6. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_filesystem.py +1 -1
  7. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_yaml_config.py +85 -0
  8. ha_mcp_dev-7.6.0.dev624/src/ha_mcp/tools/validation_middleware.py +57 -0
  9. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  10. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  11. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/LICENSE +0 -0
  12. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/MANIFEST.in +0 -0
  13. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/README.md +0 -0
  14. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/setup.cfg +0 -0
  15. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/__init__.py +0 -0
  16. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/__main__.py +0 -0
  17. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/_pypi_marker +0 -0
  18. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/_version.py +0 -0
  19. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/auth/__init__.py +0 -0
  20. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/auth/consent_form.py +0 -0
  21. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/auth/provider.py +0 -0
  22. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/backup_manager.py +0 -0
  23. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/client/__init__.py +0 -0
  24. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/client/rest_client.py +0 -0
  25. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/client/supervisor_client.py +0 -0
  26. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/client/websocket_client.py +0 -0
  27. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/client/websocket_listener.py +0 -0
  28. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/errors.py +0 -0
  29. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/__init__.py +0 -0
  30. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/approval_queue.py +0 -0
  31. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/evaluator.py +0 -0
  32. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/handlers.py +0 -0
  33. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/middleware.py +0 -0
  34. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/model.py +0 -0
  35. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/persistence.py +0 -0
  36. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/policy/value_sources.py +0 -0
  37. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/py.typed +0 -0
  38. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  39. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  40. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  41. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  42. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  43. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  44. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  45. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  46. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  47. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  48. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  49. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  50. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  51. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  52. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  53. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  54. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  55. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  56. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  57. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  58. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  59. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  60. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/smoke_test.py +0 -0
  61. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  62. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/__init__.py +0 -0
  63. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/auto_backup.py +0 -0
  64. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/backup.py +0 -0
  65. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  66. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/device_control.py +0 -0
  67. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/enhanced.py +0 -0
  68. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/helpers.py +0 -0
  69. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/reference_validator.py +0 -0
  70. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/registry.py +0 -0
  71. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/smart_search.py +0 -0
  72. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_addons.py +0 -0
  73. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_areas.py +0 -0
  74. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  75. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  76. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_calendar.py +0 -0
  77. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_camera.py +0 -0
  78. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_categories.py +0 -0
  79. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_code.py +0 -0
  80. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  81. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  82. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  83. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  84. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  85. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  86. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_energy.py +0 -0
  87. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_entities.py +0 -0
  88. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_groups.py +0 -0
  89. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_hacs.py +0 -0
  90. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_history.py +0 -0
  91. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_integrations.py +0 -0
  92. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_labels.py +0 -0
  93. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  94. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_registry.py +0 -0
  95. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_resources.py +0 -0
  96. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_search.py +0 -0
  97. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_service.py +0 -0
  98. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_services.py +0 -0
  99. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_system.py +0 -0
  100. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_todo.py +0 -0
  101. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_traces.py +0 -0
  102. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_updates.py +0 -0
  103. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_utility.py +0 -0
  104. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  105. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/tools_zones.py +0 -0
  106. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/tools/util_helpers.py +0 -0
  107. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/transforms/__init__.py +0 -0
  108. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/transforms/categorized_search.py +0 -0
  109. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  110. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/__init__.py +0 -0
  111. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/config_hash.py +0 -0
  112. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/data_paths.py +0 -0
  113. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/domain_handlers.py +0 -0
  114. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  115. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  116. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/operation_manager.py +0 -0
  117. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/python_sandbox.py +0 -0
  118. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/skill_loader.py +0 -0
  119. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp/utils/usage_logger.py +0 -0
  120. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  121. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  122. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  123. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  124. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/tests/__init__.py +0 -0
  125. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/tests/test_constants.py +0 -0
  126. {ha_mcp_dev-7.6.0.dev622 → ha_mcp_dev-7.6.0.dev624}/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.6.0.dev622
3
+ Version: 7.6.0.dev624
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.6.0.dev622"
7
+ version = "7.6.0.dev624"
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"
@@ -110,7 +110,7 @@ class Settings(BaseSettings):
110
110
 
111
111
  # Master beta-features toggle. UI-only — intentionally not in any
112
112
  # addon config.yaml schema. Consumed by the master gate in
113
- # ``_apply_feature_flag_overrides``, which force-sets the five
113
+ # ``_apply_feature_flag_overrides``, which force-sets the
114
114
  # ``BETA_FEATURE_FIELDS`` sub-flags to False whenever this master is
115
115
  # off. Dev addon ``start.py`` auto-writes ``ENABLE_BETA_FEATURES=true``
116
116
  # whenever any beta sub-flag key is present in ``/data/options.json``
@@ -122,6 +122,27 @@ class Settings(BaseSettings):
122
122
  # files. Disabled by default; only for YAML-only features with no UI/API path.
123
123
  enable_yaml_config_editing: bool = Field(False, alias="ENABLE_YAML_CONFIG_EDITING")
124
124
 
125
+ # Per-key gates for ``automation`` / ``script`` / ``scene`` under
126
+ # ``packages/*.yaml``. The custom component accepts these three
127
+ # PACKAGES_ONLY_YAML_KEYS unconditionally; ha-mcp's UI exposes a
128
+ # toggle per key so an operator who wants YAML-managed
129
+ # automations/scripts/scenes in packages but not the others can
130
+ # narrow the surface. ha_config_set_yaml rejects packages/*.yaml
131
+ # writes for a disabled key client-side, and passes the disabled set
132
+ # to the custom component so the underlying service rejects too
133
+ # (writes of these keys to configuration.yaml are rejected
134
+ # independently of these flags). Each
135
+ # toggle is meaningful only when ``enable_yaml_config_editing`` is
136
+ # on; the UI nests these rows under that parent and dims them when
137
+ # the parent is off.
138
+ enable_yaml_packages_automation: bool = Field(
139
+ False, alias="ENABLE_YAML_PACKAGES_AUTOMATION"
140
+ )
141
+ enable_yaml_packages_script: bool = Field(
142
+ False, alias="ENABLE_YAML_PACKAGES_SCRIPT"
143
+ )
144
+ enable_yaml_packages_scene: bool = Field(False, alias="ENABLE_YAML_PACKAGES_SCENE")
145
+
125
146
  # Seed values for tool visibility (comma-separated tool names).
126
147
  # Used as initial config when no tool_config.json exists.
127
148
  # The web settings UI (/settings) is the primary interface for managing these.
@@ -431,6 +452,21 @@ FEATURE_FLAG_FIELDS: tuple[FeatureFlagField, ...] = (
431
452
  # are name-disjoint per _validate_registries()).
432
453
  FeatureFlagField("enable_mandatory_bps", "ENABLE_MANDATORY_BPS", bool),
433
454
  FeatureFlagField("enable_yaml_config_editing", "ENABLE_YAML_CONFIG_EDITING", bool),
455
+ # Per-key sub-gates beneath enable_yaml_config_editing. Nested in
456
+ # the UI, dimmed when the parent is off. Also listed in
457
+ # BETA_FEATURE_FIELDS so they follow the same master-gate +
458
+ # addon-mode override path as the other beta flags — that is what
459
+ # makes the web-UI toggle take effect on the stable add-on (where
460
+ # they are not in config.yaml). See that tuple for the rationale.
461
+ FeatureFlagField(
462
+ "enable_yaml_packages_automation",
463
+ "ENABLE_YAML_PACKAGES_AUTOMATION",
464
+ bool,
465
+ ),
466
+ FeatureFlagField(
467
+ "enable_yaml_packages_script", "ENABLE_YAML_PACKAGES_SCRIPT", bool
468
+ ),
469
+ FeatureFlagField("enable_yaml_packages_scene", "ENABLE_YAML_PACKAGES_SCENE", bool),
434
470
  FeatureFlagField("enable_lite_docstrings", "ENABLE_LITE_DOCSTRINGS", bool),
435
471
  FeatureFlagField("enable_filesystem_tools", "HAMCP_ENABLE_FILESYSTEM_TOOLS", bool),
436
472
  FeatureFlagField(
@@ -467,6 +503,16 @@ _FEATURE_FLAG_INT_BOUNDS: dict[str, tuple[int, int]] = {
467
503
  # gate, never by the per-field iteration.
468
504
  BETA_FEATURE_FIELDS: tuple[str, ...] = (
469
505
  "enable_yaml_config_editing",
506
+ # Per-key sub-gates of enable_yaml_config_editing. Included here so
507
+ # they ride the same master gate + addon-mode override path as the
508
+ # other beta flags. Without this, the addon-mode short-circuit in
509
+ # ``_apply_feature_flag_overrides`` (and the ``get_feature_flag_origin``
510
+ # logic) would leave them dead on the stable add-on — reachable only
511
+ # via the dev add-on's config.yaml options. They still render NESTED
512
+ # under their parent in the web UI (not as separate beta-sub rows).
513
+ "enable_yaml_packages_automation",
514
+ "enable_yaml_packages_script",
515
+ "enable_yaml_packages_scene",
470
516
  "enable_filesystem_tools",
471
517
  "enable_custom_component_integration",
472
518
  "enable_code_mode",
@@ -479,8 +525,9 @@ BETA_FEATURE_FIELDS: tuple[str, ...] = (
479
525
  #
480
526
  # - ``section`` groups fields in the Advanced section of the Server Settings
481
527
  # tab: "connection", "search", "operations", "diagnostics", "tools_surface".
482
- # The 5 beta sub-flags + the master live in a separate "beta" section that
483
- # the UI renders below the Advanced section.
528
+ # The beta sub-flags + the master live in a separate "beta" section that
529
+ # the UI renders below the Advanced section (the per-key yaml-packages
530
+ # sub-flags render nested under enable_yaml_config_editing within it).
484
531
  # - ``editable=False`` marks display-only rows. Connection fields are
485
532
  # non-editable from the running server (chicken-and-egg footgun);
486
533
  # ``MCP_SERVER_VERSION`` is editable (it has an env alias) but the UI
@@ -631,7 +678,7 @@ def get_feature_flag_origin(env_name: str) -> str:
631
678
  schema, ``start.py`` doesn't write the env var, and the master
632
679
  falls through to env / file / default precedence so the
633
680
  standalone web UI master path remains the gate.
634
- - The five ``BETA_FEATURE_FIELDS`` (sub-flags) follow the same
681
+ - The ``BETA_FEATURE_FIELDS`` (sub-flags) follow the same
635
682
  shape — present in dev addon schema, absent from stable. Same
636
683
  env-var-presence signal distinguishes them at runtime.
637
684
  """
@@ -650,8 +697,8 @@ def get_feature_flag_origin(env_name: str) -> str:
650
697
  # Stable addon: env var never written → fall through to
651
698
  # file/default. The master moved from "never schema-bound"
652
699
  # to "schema-bound on dev only"; the same env-var-presence
653
- # signal now distinguishes both for the master and the 5
654
- # sub-flags.
700
+ # signal now distinguishes both for the master and the
701
+ # beta sub-flags.
655
702
  if os.environ.get(env_name) is not None:
656
703
  return "addon"
657
704
  # else: stable / legacy-dev-no-master-key, fall through.
@@ -748,7 +795,7 @@ def _apply_feature_flag_overrides(settings: "Settings") -> None:
748
795
 
749
796
  EXCEPTION: the beta-master + beta-sub-flag fields skip the
750
797
  addon-mode short-circuit. The master isn't in any addon schema;
751
- the five sub-flags are in the dev-addon schema (where ``start.py``
798
+ the sub-flags are in the dev-addon schema (where ``start.py``
752
799
  writes the env var from options.json — env-var-wins skips the
753
800
  file read here, leaving Supervisor authoritative) but NOT in the
754
801
  stable schema (where the env var is never written, so the file
@@ -756,7 +803,7 @@ def _apply_feature_flag_overrides(settings: "Settings") -> None:
756
803
 
757
804
  2. **Beta master gate**: after the per-field pass, if
758
805
  ``enable_beta_features`` is False on the resolved Settings,
759
- force-set the five BETA_FEATURE_FIELDS to False regardless of
806
+ force-set the BETA_FEATURE_FIELDS to False regardless of
760
807
  how they currently look. This is the "master toggle" semantics —
761
808
  even a power user who sets ENABLE_YAML_CONFIG_EDITING=true via
762
809
  env var still needs to flip the master before the flag takes
@@ -190,6 +190,12 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
190
190
  # the skill guide tool are registered so it can wrap everything)
191
191
  self._apply_tool_search()
192
192
 
193
+ # Convert Pydantic type-validation errors to structured ToolErrors so
194
+ # models get actionable guidance instead of raw Pydantic messages.
195
+ from .tools.validation_middleware import ValidationErrorMiddleware
196
+
197
+ self.mcp.add_middleware(ValidationErrorMiddleware())
198
+
193
199
  # Wire tool security policies middleware (#966) — opt-in via
194
200
  # ENABLE_TOOL_SECURITY_POLICIES. Must come last so the middleware
195
201
  # wraps the final tool surface (including the search proxies).
@@ -814,6 +814,15 @@ _SETTINGS_HTML = (
814
814
  left: 36px; top: 0; bottom: 0; width: 2px; background: var(--border); }
815
815
  .feature-row.codemode-sub.dimmed { opacity: 0.55; }
816
816
  .feature-row.codemode-sub.dimmed input { cursor: not-allowed; }
817
+ /* YAML packages per-key sub-rows — second-level nested under
818
+ enable_yaml_config_editing. Same dimming logic as codemode-sub
819
+ but gated by enable_yaml_config_editing (the parent flag) rather
820
+ than enable_code_mode. */
821
+ .feature-row.yaml-packages-sub { padding-left: 56px; position: relative; }
822
+ .feature-row.yaml-packages-sub::before { content: ""; position: absolute;
823
+ left: 36px; top: 0; bottom: 0; width: 2px; background: var(--border); }
824
+ .feature-row.yaml-packages-sub.dimmed { opacity: 0.55; }
825
+ .feature-row.yaml-packages-sub.dimmed input { cursor: not-allowed; }
817
826
  /* Advanced settings sections — one row per
818
827
  ADVANCED_SETTINGS_FIELDS entry, grouped by section. Visually
819
828
  matches the .feature-row treatment so the Server Settings tab
@@ -2180,6 +2189,18 @@ const FEATURE_META = {
2180
2189
  label: "Enable YAML config editing (beta)",
2181
2190
  help: "Beta feature — disabled by default. Allows AI assistants to add, replace, or remove top-level keys in configuration.yaml and packages/*.yaml. Only whitelisted keys are allowed (e.g., template, sensor, command_line, mqtt, knx); core keys like homeassistant, http, and recorder are blocked. Each edit validates YAML syntax, runs a config check, and creates an automatic backup. Changes to most keys require a full HA restart to take effect. See docs/beta.md for known limitations. Dedicated tools (automations, scripts, scenes, helpers, template sensors) should be preferred when available.",
2182
2191
  },
2192
+ enable_yaml_packages_automation: {
2193
+ label: "Allow automation in packages/*.yaml",
2194
+ help: "Sub-toggle of YAML config editing. When on, ha_config_set_yaml accepts yaml_path='automation' inside packages/*.yaml. When off, the wrapper rejects the call client-side AND the custom component rejects it server-side. Storage-mode tools (ha_config_set_automation) cover the UI-managed path and are unaffected. Disabled by default.",
2195
+ },
2196
+ enable_yaml_packages_script: {
2197
+ label: "Allow script in packages/*.yaml",
2198
+ help: "Sub-toggle of YAML config editing. When on, ha_config_set_yaml accepts yaml_path='script' inside packages/*.yaml. When off, the wrapper rejects the call client-side AND the custom component rejects it server-side. Storage-mode tools (ha_config_set_script) cover the UI-managed path and are unaffected. Disabled by default.",
2199
+ },
2200
+ enable_yaml_packages_scene: {
2201
+ label: "Allow scene in packages/*.yaml",
2202
+ help: "Sub-toggle of YAML config editing. When on, ha_config_set_yaml accepts yaml_path='scene' inside packages/*.yaml. When off, the wrapper rejects the call client-side AND the custom component rejects it server-side. Storage-mode tools (ha_config_set_scene) cover the UI-managed path and are unaffected. Disabled by default.",
2203
+ },
2183
2204
  enable_filesystem_tools: {
2184
2205
  label: "Enable filesystem tools (beta)",
2185
2206
  help: "Sets HAMCP_ENABLE_FILESYSTEM_TOOLS=true. Enables direct file read/write access to your Home Assistant filesystem. WARNING: This gives the MCP server sensitive direct file access to your system. Only enable if you trust the AI assistant with file operations. Requires restart to take effect.",
@@ -2204,6 +2225,16 @@ const FEATURE_META = {
2204
2225
  // ``config.BETA_FEATURE_FIELDS`` without duplicating the name list here.
2205
2226
  let BETA_SUB_FLAGS = new Set();
2206
2227
 
2228
+ // Sub-flags of ``enable_yaml_config_editing``. Rendered nested beneath
2229
+ // the parent in renderFeatureFlags so the dependency is visually
2230
+ // obvious. They are NOT in BETA_SUB_FLAGS — the parent is the gate,
2231
+ // and the master-off → parent-off cascade transitively covers them.
2232
+ const YAML_PACKAGES_SUB_FLAGS = [
2233
+ 'enable_yaml_packages_automation',
2234
+ 'enable_yaml_packages_script',
2235
+ 'enable_yaml_packages_scene',
2236
+ ];
2237
+
2207
2238
  // Cached add-on flag. Each settings endpoint (/api/settings/features,
2208
2239
  // /api/settings/advanced, /api/settings/backup-config) returns
2209
2240
  // ``is_addon`` so the env-locked banner copy can adapt — the addon
@@ -2313,6 +2344,10 @@ function renderFeatureFlags(flags) {
2313
2344
  Object.keys(FEATURE_META).forEach(fieldName => {
2314
2345
  const f = flags[fieldName];
2315
2346
  if (!f) return;
2347
+ // Skip yaml-packages sub-rows in the main pass — they're rendered
2348
+ // by renderYamlPackagesSubRows below right after their parent so
2349
+ // the nesting reads in source order.
2350
+ if (YAML_PACKAGES_SUB_FLAGS.includes(fieldName)) return;
2316
2351
  const meta = FEATURE_META[fieldName];
2317
2352
  const isMaster = fieldName === 'enable_beta_features';
2318
2353
  const isBetaSub = BETA_SUB_FLAGS.has(fieldName);
@@ -2381,6 +2416,20 @@ function renderFeatureFlags(flags) {
2381
2416
  renderFeatureFlags(_lastFeatureFlags);
2382
2417
  }
2383
2418
  }
2419
+ // Re-render on enable_yaml_config_editing flip so the 3
2420
+ // packages sub-rows dim/undim immediately. Same pattern as the
2421
+ // master flip above — value is mutated in the live cache and
2422
+ // the panel re-renders synchronously while the save POST runs
2423
+ // in the background.
2424
+ if (fieldName === 'enable_yaml_config_editing') {
2425
+ if (_lastFeatureFlags[fieldName]) {
2426
+ _lastFeatureFlags[fieldName] = {
2427
+ ..._lastFeatureFlags[fieldName],
2428
+ value: input.checked,
2429
+ };
2430
+ renderFeatureFlags(_lastFeatureFlags);
2431
+ }
2432
+ }
2384
2433
  saveFeatureFlag(fieldName, input.checked);
2385
2434
  });
2386
2435
  const slider = document.createElement('span');
@@ -2417,6 +2466,74 @@ function renderFeatureFlags(flags) {
2417
2466
  const codeModeOn = !!f.value;
2418
2467
  renderCodeModeSubRows(targetBody, masterOn, codeModeOn);
2419
2468
  }
2469
+ // After rendering the enable_yaml_config_editing parent, inject
2470
+ // its 3 per-key sub-rows (automation/script/scene). Dimmed when
2471
+ // either the master beta is off (parent forced off) or the parent
2472
+ // itself is off.
2473
+ if (fieldName === 'enable_yaml_config_editing') {
2474
+ const parentOn = !!f.value;
2475
+ renderYamlPackagesSubRows(flags, targetBody, masterOn, parentOn);
2476
+ }
2477
+ });
2478
+ }
2479
+
2480
+ function renderYamlPackagesSubRows(flags, parentEl, masterOn, parentOn) {
2481
+ YAML_PACKAGES_SUB_FLAGS.forEach(fieldName => {
2482
+ const f = flags[fieldName];
2483
+ if (!f) return;
2484
+ const meta = FEATURE_META[fieldName] || { label: fieldName, help: '' };
2485
+ const lockedByGate = !masterOn || !parentOn;
2486
+ const row = document.createElement('div');
2487
+ row.className = 'feature-row yaml-packages-sub' + (lockedByGate ? ' dimmed' : '');
2488
+
2489
+ const info = document.createElement('div');
2490
+ info.className = 'feature-info';
2491
+ const lockedNote = !f.editable
2492
+ ? `<div class="feature-locked-note">` +
2493
+ (f.origin === 'env'
2494
+ ? envLockedNoteHtml(f.env_var, fieldName)
2495
+ : escapeHtml(ORIGIN_LOCKED_NOTE[f.origin] || '')) +
2496
+ `</div>`
2497
+ : '';
2498
+ const infoNote = f.editable && ORIGIN_INFO_NOTE[f.origin]
2499
+ ? `<div class="feature-locked-note">` +
2500
+ `${escapeHtml(ORIGIN_INFO_NOTE[f.origin])}</div>`
2501
+ : '';
2502
+ info.innerHTML =
2503
+ `<div class="feature-name">${escapeHtml(meta.label)}</div>` +
2504
+ `<div class="feature-help">${escapeHtml(meta.help)}</div>` +
2505
+ lockedNote + infoNote;
2506
+
2507
+ const control = document.createElement('div');
2508
+ control.className = 'feature-control';
2509
+ const label = document.createElement('label');
2510
+ label.className = 'switch';
2511
+ const input = document.createElement('input');
2512
+ input.type = 'checkbox';
2513
+ input.checked = !!f.value;
2514
+ input.disabled = !f.editable || lockedByGate;
2515
+ input.addEventListener('change', () => {
2516
+ // Keep the cached flag value in sync (parity with the parent/master
2517
+ // row handlers) so a later parent flip — which re-renders from
2518
+ // _lastFeatureFlags — reflects this sub-row's current state rather
2519
+ // than a stale value.
2520
+ if (_lastFeatureFlags[fieldName]) {
2521
+ _lastFeatureFlags[fieldName] = {
2522
+ ..._lastFeatureFlags[fieldName],
2523
+ value: input.checked,
2524
+ };
2525
+ }
2526
+ saveFeatureFlag(fieldName, input.checked);
2527
+ });
2528
+ const slider = document.createElement('span');
2529
+ slider.className = 'slider';
2530
+ label.appendChild(input);
2531
+ label.appendChild(slider);
2532
+ control.appendChild(label);
2533
+
2534
+ row.appendChild(info);
2535
+ row.appendChild(control);
2536
+ parentEl.appendChild(row);
2420
2537
  });
2421
2538
  }
2422
2539
 
@@ -4190,6 +4307,11 @@ def build_settings_handlers(
4190
4307
  ErrorCode.VALIDATION_INVALID_PARAMETER,
4191
4308
  f"Refusing to flip env-pinned tools: {', '.join(rejected)}. "
4192
4309
  "Unset DISABLED_TOOLS / PINNED_TOOLS first.",
4310
+ suggestions=[
4311
+ "Unset the DISABLED_TOOLS / PINNED_TOOLS environment "
4312
+ "variables (or remove them from your addon/Docker "
4313
+ "config), then restart to edit these tools from the UI.",
4314
+ ],
4193
4315
  context={"rejected": rejected},
4194
4316
  ),
4195
4317
  status_code=409,
@@ -4543,6 +4665,12 @@ def build_settings_handlers(
4543
4665
  "enable_beta_features=true in the same save, or "
4544
4666
  "flip the master on first."
4545
4667
  ),
4668
+ suggestions=[
4669
+ "Include enable_beta_features=true in the same save "
4670
+ "payload as the sub-flag(s).",
4671
+ "Or turn on the master 'Enable beta features' toggle "
4672
+ "first, then enable the sub-flag(s).",
4673
+ ],
4546
4674
  context={"rejected": beta_sub_writes},
4547
4675
  ),
4548
4676
  status_code=409,
@@ -4708,6 +4836,13 @@ def build_settings_handlers(
4708
4836
  create_error_response(
4709
4837
  ErrorCode.INTERNAL_ERROR,
4710
4838
  "Supervisor helper returned ok=False with no error",
4839
+ suggestions=[
4840
+ "Check the Home Assistant Supervisor logs and "
4841
+ "the add-on logs for the underlying failure.",
4842
+ "Report this at "
4843
+ "https://github.com/homeassistant-ai/ha-mcp/issues "
4844
+ "if it persists — this indicates an internal bug.",
4845
+ ],
4711
4846
  ),
4712
4847
  status_code=500,
4713
4848
  )
@@ -5243,6 +5378,11 @@ def build_settings_handlers(
5243
5378
  ErrorCode.VALIDATION_INVALID_PARAMETER,
5244
5379
  f"{fname!r} is set via {env_name} env var — "
5245
5380
  "unset it to edit here.",
5381
+ suggestions=[
5382
+ f"Unset the {env_name} environment variable (or "
5383
+ "remove it from your addon/Docker config), then "
5384
+ "restart to edit this setting from the UI.",
5385
+ ],
5246
5386
  context={"env_var": env_name},
5247
5387
  ),
5248
5388
  status_code=409,
@@ -5283,6 +5423,10 @@ def build_settings_handlers(
5283
5423
  ErrorCode.VALIDATION_INVALID_PARAMETER,
5284
5424
  f"{fname!r} must be between {bounds[0]} and "
5285
5425
  f"{bounds[1]} (got {coerced}).",
5426
+ suggestions=[
5427
+ f"Provide a value for {fname} within the range "
5428
+ f"{bounds[0]}–{bounds[1]}.",
5429
+ ],
5286
5430
  ),
5287
5431
  status_code=400,
5288
5432
  )
@@ -5292,6 +5436,9 @@ def build_settings_handlers(
5292
5436
  create_error_response(
5293
5437
  ErrorCode.VALIDATION_INVALID_PARAMETER,
5294
5438
  f"{fname!r} must be one of {list(choices)} (got {coerced!r}).",
5439
+ suggestions=[
5440
+ f"Set {fname} to one of: {', '.join(map(str, choices))}.",
5441
+ ],
5295
5442
  ),
5296
5443
  status_code=400,
5297
5444
  )
@@ -5342,6 +5489,11 @@ def build_settings_handlers(
5342
5489
  "writes in one batch; the UI should split these "
5343
5490
  f"into separate POSTs ({sorted(file_only)})."
5344
5491
  ),
5492
+ suggestions=[
5493
+ "Submit addon-synced fields (e.g. backup_hint, "
5494
+ "verify_ssl) and override-file fields in separate "
5495
+ "save requests.",
5496
+ ],
5345
5497
  ),
5346
5498
  status_code=500,
5347
5499
  )
@@ -5367,6 +5519,13 @@ def build_settings_handlers(
5367
5519
  create_error_response(
5368
5520
  ErrorCode.INTERNAL_ERROR,
5369
5521
  "Supervisor helper returned ok=False with no error",
5522
+ suggestions=[
5523
+ "Check the Home Assistant Supervisor logs and "
5524
+ "the add-on logs for the underlying failure.",
5525
+ "Report this at "
5526
+ "https://github.com/homeassistant-ai/ha-mcp/issues "
5527
+ "if it persists — this indicates an internal bug.",
5528
+ ],
5370
5529
  ),
5371
5530
  status_code=500,
5372
5531
  )
@@ -5466,6 +5625,9 @@ def build_settings_handlers(
5466
5625
  create_error_response(
5467
5626
  ErrorCode.VALIDATION_INVALID_PARAMETER,
5468
5627
  f"{fname!r} expects {ftype.__name__}, got {type(raw).__name__}.",
5628
+ suggestions=[
5629
+ f"Send {fname} as a {ftype.__name__} value.",
5630
+ ],
5469
5631
  ),
5470
5632
  status_code=400,
5471
5633
  )
@@ -53,7 +53,7 @@ CALLER_TOKEN_BOOTSTRAP_SERVICE = "get_caller_token"
53
53
  # server-side behavior change requires it. Older components (no
54
54
  # ``version`` in the get_caller_token response, or a version below this)
55
55
  # get an actionable "update via HACS" error.
56
- MIN_COMPONENT_VERSION = "0.5.1"
56
+ MIN_COMPONENT_VERSION = "0.5.2"
57
57
 
58
58
 
59
59
  def _version_tuple(version: str) -> tuple[int, ...]:
@@ -11,7 +11,9 @@ The tools will gracefully fail with installation instructions if the component i
11
11
  Feature Flag: Set ENABLE_YAML_CONFIG_EDITING=true to enable.
12
12
  """
13
13
 
14
+ import fnmatch
14
15
  import logging
16
+ import os
15
17
  from typing import Annotated, Any
16
18
 
17
19
  from fastmcp.exceptions import ToolError
@@ -47,6 +49,38 @@ logger = logging.getLogger(__name__)
47
49
 
48
50
  _LOVELACE_DASHBOARD_PREFIX = "lovelace.dashboards."
49
51
 
52
+ # Maps a per-key Settings flag onto the yaml_path top-level segment it
53
+ # gates. Keep in lockstep with the custom component's
54
+ # PACKAGES_ONLY_YAML_KEYS — every key in that frozenset must appear
55
+ # here, otherwise a key would silently be unreachable through the
56
+ # wrapper. The parity invariant (keys == PACKAGES_ONLY_YAML_KEYS, and
57
+ # every value is a real Settings field) is enforced by
58
+ # test_yaml_config_tool.py::test_flag_map_matches_packages_only_keys.
59
+ _YAML_PACKAGES_FLAG_BY_KEY = {
60
+ "automation": "enable_yaml_packages_automation",
61
+ "script": "enable_yaml_packages_script",
62
+ "scene": "enable_yaml_packages_scene",
63
+ }
64
+
65
+
66
+ def _disabled_packages_keys(settings: Any) -> list[str]:
67
+ """Return the sorted list of PACKAGES_ONLY_YAML_KEYS whose Settings
68
+ flag is currently False.
69
+
70
+ Sorted so the value is deterministic in service payloads and test
71
+ assertions; the custom component treats it as a set so order is
72
+ irrelevant on the wire.
73
+
74
+ Uses ``getattr`` without a default so a future rename that breaks the
75
+ ``_YAML_PACKAGES_FLAG_BY_KEY`` → ``Settings`` mapping raises loudly
76
+ instead of silently treating the key as disabled (a dead toggle).
77
+ """
78
+ return sorted(
79
+ key
80
+ for key, flag in _YAML_PACKAGES_FLAG_BY_KEY.items()
81
+ if not getattr(settings, flag)
82
+ )
83
+
50
84
 
51
85
  async def _check_storage_mode_dashboard_collision(client: Any, yaml_path: str) -> None:
52
86
  """Raise a ToolError if a storage-mode dashboard already owns the requested
@@ -237,6 +271,56 @@ class YamlConfigTools:
237
271
  )
238
272
  )
239
273
 
274
+ # Per-key gate: reject before the custom-component round
275
+ # trip when the yaml_path top-level segment matches a
276
+ # disabled PACKAGES_ONLY key AND the target file is under
277
+ # packages/. The keys (automation / script / scene) are
278
+ # only ACCEPTED in packages/*.yaml in the first place, so
279
+ # writes to configuration.yaml must fall through here and
280
+ # let the component-side reject with its own message that
281
+ # lists the storage-mode tools to use instead.
282
+ settings = get_global_settings()
283
+ disabled_keys = _disabled_packages_keys(settings)
284
+ top_key = yaml_path.split(".", 1)[0] if yaml_path else ""
285
+ # Classify the target exactly like the custom component does
286
+ # (os.path.normpath + fnmatch against "packages/*.yaml") so the
287
+ # wrapper's early reject fires for precisely the paths the
288
+ # component treats as a package — e.g. "./packages/x.yaml" and
289
+ # "packages/sub/x.yaml" both normalise/match. Any other target
290
+ # (configuration.yaml, a non-package path) falls through to the
291
+ # component, which rejects these keys with its own storage-mode-
292
+ # tools advisory.
293
+ # os.path.normpath is a pure string transform (no I/O), so the
294
+ # ASYNC240 blocking-call lint doesn't apply — same suppression the
295
+ # component uses on its identical normpath classification.
296
+ normalized_target = os.path.normpath(file) # noqa: ASYNC240
297
+ is_packages_target = fnmatch.fnmatch(normalized_target, "packages/*.yaml")
298
+ if is_packages_target and top_key in disabled_keys:
299
+ raise_tool_error(
300
+ create_error_response(
301
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
302
+ (
303
+ f"yaml_path key {top_key!r} is disabled. Enable "
304
+ f"'Allow {top_key} in packages/*.yaml' under "
305
+ f"YAML config editing in Server Settings to use "
306
+ f"this key, or use the storage-mode tool "
307
+ f"(ha_config_set_{top_key})."
308
+ ),
309
+ suggestions=[
310
+ f"Enable 'Allow {top_key} in packages/*.yaml' under "
311
+ "YAML config editing in the ha-mcp Server Settings "
312
+ "panel, then retry.",
313
+ f"Or use the storage-mode tool ha_config_set_{top_key} "
314
+ "instead of editing packages YAML directly.",
315
+ ],
316
+ context={
317
+ "yaml_path": yaml_path,
318
+ "disabled_key": top_key,
319
+ "file": file,
320
+ },
321
+ )
322
+ )
323
+
240
324
  # Storage-mode dashboard collision check (only for lovelace.dashboards.*).
241
325
  # Skip on `remove` so users can clean up YAML entries that conflict
242
326
  # with a storage-mode dashboard (e.g., during a migration).
@@ -252,6 +336,7 @@ class YamlConfigTools:
252
336
  "action": action,
253
337
  "yaml_path": yaml_path,
254
338
  "backup": backup,
339
+ "disabled_packages_keys": disabled_keys,
255
340
  }
256
341
  if content is not None:
257
342
  service_data["content"] = content
@@ -0,0 +1,57 @@
1
+ """FastMCP middleware that converts Pydantic validation errors to structured ToolErrors.
2
+
3
+ When a model passes the wrong type for a tool parameter (e.g. a JSON string where
4
+ a dict is required), FastMCP raises a PydanticValidationError with a raw message
5
+ like "Input should be a valid dictionary". This middleware intercepts those errors
6
+ and converts them to ha-mcp's structured format with actionable guidance.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Any
13
+
14
+ from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
15
+ from pydantic import ValidationError as PydanticValidationError
16
+
17
+ from ..errors import create_validation_error
18
+ from .helpers import raise_tool_error
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Maps Pydantic error types to model-readable fix hints.
23
+ # FastMCP uses non-strict Pydantic: scalar mismatches (bool, int) are coerced
24
+ # rather than rejected, so only dict_type and list_type fire in practice.
25
+ _TYPE_HINTS: dict[str, str] = {
26
+ "dict_type": (
27
+ "expected a JSON object. "
28
+ 'Pass {"key": "value"} directly, not a JSON-encoded string.'
29
+ ),
30
+ "list_type": (
31
+ "expected a JSON array. Pass [...] directly, not a JSON-encoded string."
32
+ ),
33
+ }
34
+
35
+
36
+ class ValidationErrorMiddleware(Middleware):
37
+ """Convert PydanticValidationError from argument validation into ToolErrors."""
38
+
39
+ async def on_call_tool(
40
+ self, context: MiddlewareContext, call_next: CallNext
41
+ ) -> Any:
42
+ try:
43
+ return await call_next(context)
44
+ except PydanticValidationError as exc:
45
+ errors = exc.errors(include_url=False)
46
+ parts: list[str] = []
47
+ for err in errors:
48
+ loc = err.get("loc", ())
49
+ param = ".".join(str(p) for p in loc if p != "__root__")
50
+ hint = _TYPE_HINTS.get(err["type"], err["msg"])
51
+ parts.append(f"`{param}`: {hint}" if param else hint)
52
+ raise_tool_error(
53
+ create_validation_error(
54
+ "; ".join(parts) if parts else "Invalid argument types.",
55
+ details=", ".join(err["type"] for err in errors),
56
+ )
57
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.6.0.dev622
3
+ Version: 7.6.0.dev624
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
@@ -99,6 +99,7 @@ src/ha_mcp/tools/tools_voice_assistant.py
99
99
  src/ha_mcp/tools/tools_yaml_config.py
100
100
  src/ha_mcp/tools/tools_zones.py
101
101
  src/ha_mcp/tools/util_helpers.py
102
+ src/ha_mcp/tools/validation_middleware.py
102
103
  src/ha_mcp/transforms/__init__.py
103
104
  src/ha_mcp/transforms/categorized_search.py
104
105
  src/ha_mcp/transforms/lite_docstrings.py