ha-mcp-dev 7.2.0.dev352__tar.gz → 7.2.0.dev353__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 (100) hide show
  1. {ha_mcp_dev-7.2.0.dev352/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.2.0.dev353}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/pyproject.toml +1 -8
  3. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_config_automations.py +110 -106
  4. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_filesystem.py +55 -33
  5. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_hacs.py +142 -122
  6. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_history.py +96 -67
  7. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_resources.py +205 -184
  8. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_traces.py +122 -96
  9. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_updates.py +292 -285
  10. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  11. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/LICENSE +0 -0
  12. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/MANIFEST.in +0 -0
  13. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/README.md +0 -0
  14. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/setup.cfg +0 -0
  15. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/__init__.py +0 -0
  16. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/__main__.py +0 -0
  17. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/_pypi_marker +0 -0
  18. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/auth/__init__.py +0 -0
  19. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/auth/consent_form.py +0 -0
  20. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/auth/provider.py +0 -0
  21. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/client/__init__.py +0 -0
  22. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/client/rest_client.py +0 -0
  23. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/client/websocket_client.py +0 -0
  24. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/client/websocket_listener.py +0 -0
  25. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/config.py +0 -0
  26. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/errors.py +0 -0
  27. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/py.typed +0 -0
  28. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  29. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  30. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  31. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  32. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  33. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  34. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  35. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  36. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  37. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  38. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  39. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  40. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  41. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  42. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  43. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  44. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  45. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  46. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  47. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  48. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/server.py +0 -0
  49. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/smoke_test.py +0 -0
  50. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/__init__.py +0 -0
  51. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/backup.py +0 -0
  52. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  53. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/device_control.py +0 -0
  54. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/enhanced.py +0 -0
  55. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/helpers.py +0 -0
  56. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/smart_search.py +0 -0
  58. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_addons.py +0 -0
  59. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_areas.py +0 -0
  60. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  61. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  62. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_calendar.py +0 -0
  63. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_camera.py +0 -0
  64. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_categories.py +0 -0
  65. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  66. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  67. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  68. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  69. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_entities.py +0 -0
  70. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_groups.py +0 -0
  71. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_integrations.py +0 -0
  72. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_labels.py +0 -0
  73. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  74. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_registry.py +0 -0
  75. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_search.py +0 -0
  76. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_service.py +0 -0
  77. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_services.py +0 -0
  78. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_system.py +0 -0
  79. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_todo.py +0 -0
  80. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_utility.py +0 -0
  81. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  82. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  83. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/tools_zones.py +0 -0
  84. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/tools/util_helpers.py +0 -0
  85. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/transforms/__init__.py +0 -0
  86. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/transforms/categorized_search.py +0 -0
  87. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/utils/__init__.py +0 -0
  88. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/utils/domain_handlers.py +0 -0
  89. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  90. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/utils/operation_manager.py +0 -0
  91. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/utils/python_sandbox.py +0 -0
  92. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp/utils/usage_logger.py +0 -0
  93. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  94. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  95. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  96. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  97. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  98. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/tests/__init__.py +0 -0
  99. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/tests/test_constants.py +0 -0
  100. {ha_mcp_dev-7.2.0.dev352 → ha_mcp_dev-7.2.0.dev353}/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.2.0.dev352
3
+ Version: 7.2.0.dev353
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.2.0.dev352"
7
+ version = "7.2.0.dev353"
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"
@@ -142,18 +142,11 @@ ignore = [
142
142
  "src/ha_mcp/tools/registry.py" = ["C901"]
143
143
  "src/ha_mcp/tools/smart_search.py" = ["C901"]
144
144
  "src/ha_mcp/tools/tools_addons.py" = ["C901"]
145
- "src/ha_mcp/tools/tools_config_automations.py" = ["C901"]
146
145
  "src/ha_mcp/tools/tools_config_dashboards.py" = ["C901"]
147
146
  "src/ha_mcp/tools/tools_config_helpers.py" = ["C901"]
148
147
  "src/ha_mcp/tools/tools_entities.py" = ["C901"]
149
- "src/ha_mcp/tools/tools_filesystem.py" = ["C901"]
150
- "src/ha_mcp/tools/tools_hacs.py" = ["C901"]
151
- "src/ha_mcp/tools/tools_history.py" = ["C901"]
152
148
  "src/ha_mcp/tools/tools_registry.py" = ["C901"]
153
- "src/ha_mcp/tools/tools_resources.py" = ["C901"]
154
149
  "src/ha_mcp/tools/tools_search.py" = ["C901"]
155
- "src/ha_mcp/tools/tools_traces.py" = ["C901"]
156
- "src/ha_mcp/tools/tools_updates.py" = ["C901"]
157
150
  "src/ha_mcp/tools/tools_utility.py" = ["C901"]
158
151
  "src/ha_mcp/tools/util_helpers.py" = ["C901"]
159
152
 
@@ -9,6 +9,7 @@ import logging
9
9
  from typing import Annotated, Any, cast
10
10
 
11
11
  from fastmcp.exceptions import ToolError
12
+ from fastmcp.tools import tool
12
13
  from pydantic import Field
13
14
 
14
15
  from ..errors import (
@@ -22,7 +23,12 @@ from .best_practice_checker import (
22
23
  from .best_practice_checker import (
23
24
  get_skill_prefix as _get_skill_prefix,
24
25
  )
25
- from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
26
+ from .helpers import (
27
+ exception_to_structured_error,
28
+ log_tool_usage,
29
+ raise_tool_error,
30
+ register_tool_methods,
31
+ )
26
32
  from .util_helpers import (
27
33
  apply_entity_category,
28
34
  coerce_bool_param,
@@ -48,7 +54,7 @@ def _normalize_automation_config(
48
54
  and plural ('triggers', 'actions', 'conditions') field names in YAML,
49
55
  but the API expects singular forms at the root level.
50
56
 
51
- IMPORTANT: 'triggers' 'trigger' and 'actions' 'action' normalization
57
+ IMPORTANT: 'triggers' -> 'trigger' and 'actions' -> 'action' normalization
52
58
  is ONLY applied at the root level. Deeper in the tree these keys are either
53
59
  invalid or semantically different, and normalizing them can produce keys
54
60
  that Home Assistant rejects (e.g., 'action' inside a delay object).
@@ -67,8 +73,8 @@ def _normalize_automation_config(
67
73
  in_choose_or_if: Whether we're inside a choose/if option that requires
68
74
  'conditions' (plural) to remain unchanged
69
75
  is_root: Whether this is the root-level automation config dict.
70
- Only root level gets 'triggers''trigger' and
71
- 'actions''action' normalization.
76
+ Only root level gets 'triggers'->'trigger' and
77
+ 'actions'->'action' normalization.
72
78
 
73
79
  Returns:
74
80
  Normalized configuration with singular field names at root level,
@@ -100,14 +106,14 @@ def _normalize_automation_config(
100
106
  # Build field mappings based on context
101
107
  field_mappings: dict[str, str] = {}
102
108
 
103
- # 'triggers' 'trigger' and 'actions' 'action' ONLY at root level.
109
+ # 'triggers' -> 'trigger' and 'actions' -> 'action' ONLY at root level.
104
110
  # Deeper in the tree these keys are invalid and normalizing them produces
105
- # keys HA rejects (e.g., 'action' inside a delay object see issue #498).
111
+ # keys HA rejects (e.g., 'action' inside a delay object -- see issue #498).
106
112
  if is_root:
107
113
  field_mappings["triggers"] = "trigger"
108
114
  field_mappings["actions"] = "action"
109
115
 
110
- # 'sequences' 'sequence' is safe at any level (only meaningful in choose options)
116
+ # 'sequences' -> 'sequence' is safe at any level (only meaningful in choose options)
111
117
  field_mappings["sequences"] = "sequence"
112
118
 
113
119
  # Only add 'conditions' mapping if NOT inside a choose/if option
@@ -180,34 +186,13 @@ def _normalize_config_for_roundtrip(config: dict[str, Any]) -> dict[str, Any]:
180
186
  return cast(dict[str, Any], normalized)
181
187
 
182
188
 
183
- def _strip_empty_automation_fields(config: dict[str, Any]) -> dict[str, Any]:
184
- """
185
- Strip empty trigger/action/condition arrays from automation config.
186
-
187
- Blueprint-based automations should not have trigger/action/condition fields
188
- since these come from the blueprint itself. If empty arrays are present,
189
- they override the blueprint's configuration and break the automation.
190
-
191
- Args:
192
- config: Automation configuration dict
193
-
194
- Returns:
195
- Configuration with empty trigger/action/condition arrays removed
196
- """
197
- cleaned = config.copy()
198
-
199
- # Remove empty arrays for blueprint automations
200
- for field in ["trigger", "action", "condition"]:
201
- if field in cleaned and cleaned[field] == []:
202
- del cleaned[field]
203
-
204
- return cleaned
205
-
189
+ class AutomationConfigTools:
190
+ """Configuration management tools for Home Assistant automations."""
206
191
 
207
- def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
208
- """Register Home Assistant automation configuration tools."""
192
+ def __init__(self, client: Any) -> None:
193
+ self._client = client
209
194
 
210
- async def _resolve_automation_entity_id(identifier: str) -> str | None:
195
+ async def _resolve_automation_entity_id(self, identifier: str) -> str | None:
211
196
  """Resolve an automation identifier to its entity_id.
212
197
 
213
198
  If identifier is already an entity_id (starts with "automation."),
@@ -217,7 +202,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
217
202
  if identifier.startswith("automation."):
218
203
  return identifier
219
204
  try:
220
- states = await client.get_states()
205
+ states = await self._client.get_states()
221
206
  for state in states:
222
207
  if (
223
208
  state.get("entity_id", "").startswith("automation.")
@@ -228,16 +213,18 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
228
213
  logger.debug(f"Failed to resolve entity_id for automation {identifier}: {e}")
229
214
  return None
230
215
 
231
- @mcp.tool(
216
+ @tool(
217
+ name="ha_config_get_automation",
232
218
  tags={"Automations"},
233
219
  annotations={
234
220
  "idempotentHint": True,
235
221
  "readOnlyHint": True,
236
- "title": "Get Automation Config"
237
- }
222
+ "title": "Get Automation Config",
223
+ },
238
224
  )
239
225
  @log_tool_usage
240
226
  async def ha_config_get_automation(
227
+ self,
241
228
  identifier: Annotated[
242
229
  str,
243
230
  Field(
@@ -257,14 +244,14 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
257
244
  For comprehensive automation documentation, use ha_get_skill_home_assistant_best_practices.
258
245
  """
259
246
  try:
260
- config_result = await client.get_automation_config(identifier)
261
- # Normalize config for round-trip compatibility (GET SET)
247
+ config_result = await self._client.get_automation_config(identifier)
248
+ # Normalize config for round-trip compatibility (GET -> SET)
262
249
  normalized_config = _normalize_config_for_roundtrip(config_result)
263
250
 
264
251
  # Resolve entity_id and fetch category from entity registry
265
- entity_id = await _resolve_automation_entity_id(identifier)
252
+ entity_id = await self._resolve_automation_entity_id(identifier)
266
253
  if entity_id:
267
- cat_id = await fetch_entity_category(client, entity_id, "automation")
254
+ cat_id = await fetch_entity_category(self._client, entity_id, "automation")
268
255
  if cat_id:
269
256
  normalized_config["category"] = cat_id
270
257
 
@@ -305,15 +292,17 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
305
292
  ],
306
293
  )
307
294
 
308
- @mcp.tool(
295
+ @tool(
296
+ name="ha_config_set_automation",
309
297
  tags={"Automations"},
310
298
  annotations={
311
299
  "destructiveHint": True,
312
- "title": "Create or Update Automation"
313
- }
300
+ "title": "Create or Update Automation",
301
+ },
314
302
  )
315
303
  @log_tool_usage
316
304
  async def ha_config_set_automation(
305
+ self,
317
306
  config: Annotated[
318
307
  str | dict[str, Any],
319
308
  Field(
@@ -437,7 +426,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
437
426
  }
438
427
  }
439
428
  }
440
- })
429
+ )
441
430
 
442
431
  PREFER NATIVE SOLUTIONS OVER TEMPLATES:
443
432
  Before using template triggers/conditions/actions, check if a native option exists:
@@ -463,25 +452,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
463
452
  """
464
453
  bp_warnings: list[str] = []
465
454
  try:
466
- # Parse JSON config if provided as string
467
- try:
468
- parsed_config = parse_json_param(config, "config")
469
- except ValueError as e:
470
- raise_tool_error(create_validation_error(
471
- f"Invalid config parameter: {e}",
472
- parameter="config",
473
- invalid_json=True,
474
- ))
475
-
476
- # Ensure config is a dict
477
- if parsed_config is None or not isinstance(parsed_config, dict):
478
- raise_tool_error(create_validation_error(
479
- "Config parameter must be a JSON object",
480
- parameter="config",
481
- details=f"Received type: {type(parsed_config).__name__}",
482
- ))
483
-
484
- config_dict = cast(dict[str, Any], parsed_config)
455
+ config_dict = self._parse_and_validate_config(config)
485
456
 
486
457
  # Extract category before sending to HA REST API (which rejects unknown keys).
487
458
  # Parameter takes precedence over config dict value.
@@ -492,48 +463,20 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
492
463
  config_dict = _normalize_automation_config(config_dict)
493
464
 
494
465
  # Validate required fields based on automation type
495
- # Blueprint automations only need alias, regular automations need trigger and action
496
- if "use_blueprint" in config_dict:
497
- required_fields = ["alias"]
498
- # Strip empty trigger/action/condition arrays that would override blueprint
499
- config_dict = _strip_empty_automation_fields(config_dict)
500
- else:
501
- required_fields = ["alias", "trigger", "action"]
502
-
503
- missing_fields = [f for f in required_fields if f not in config_dict]
504
- if missing_fields:
505
- raise_tool_error(create_config_error(
506
- f"Missing required fields: {', '.join(missing_fields)}",
507
- identifier=identifier,
508
- missing_fields=missing_fields,
509
- ))
510
-
511
- # Prevent duplicate creation when config contains an existing automation id
512
- if identifier is None and "id" in config_dict:
513
- existing_id = config_dict["id"]
514
- raise_tool_error(create_validation_error(
515
- f"Config contains 'id' field ('{existing_id}') but no identifier was provided. "
516
- "This would create a duplicate automation instead of updating the existing one.",
517
- parameter="identifier",
518
- details=f"To update, pass identifier='{existing_id}' (or the automation's entity_id). "
519
- "To create a genuinely new automation, remove the 'id' field from the config.",
520
- ))
521
-
522
- # Pre-check for best-practice issues (used for both success
523
- # warnings and error enrichment if the API call fails).
466
+ self._validate_required_fields(config_dict, identifier)
467
+
524
468
  # Pre-check for best-practice issues.
525
469
  bp_warnings = _check_best_practices(
526
470
  config_dict, skill_prefix=_get_skill_prefix()
527
471
  )
528
472
 
529
- result = await client.upsert_automation_config(config_dict, identifier)
473
+ result = await self._client.upsert_automation_config(config_dict, identifier)
530
474
 
531
475
  # If the client could not verify the entity was registered, warn but don't hard-fail.
532
- # The automation may have been created but not yet visible (slow hardware, reload needed).
533
476
  if result.get("entity_not_verified"):
534
477
  result["warning"] = (
535
478
  "Automation was submitted to Home Assistant but the entity was not found "
536
- "after polling. The automation may still have been created check Home "
479
+ "after polling. The automation may still have been created -- check Home "
537
480
  "Assistant logs and try reloading automations. Common causes: "
538
481
  "automations.yaml vs automation.yaml filename mismatch, invalid config "
539
482
  "that HA accepted but failed to load, or slow hardware."
@@ -543,12 +486,12 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
543
486
  # Wait for automation to be queryable
544
487
  wait_bool = coerce_bool_param(wait, "wait", default=True)
545
488
  entity_id = result.get("entity_id")
546
- # On updates, entity_id may not be in the result derive from identifier
489
+ # On updates, entity_id may not be in the result -- derive from identifier
547
490
  if not entity_id and identifier and identifier.startswith("automation."):
548
491
  entity_id = identifier
549
492
  if wait_bool and entity_id:
550
493
  try:
551
- registered = await wait_for_entity_registered(client, entity_id)
494
+ registered = await wait_for_entity_registered(self._client, entity_id)
552
495
  if not registered:
553
496
  result["warning"] = f"Automation created but {entity_id} not yet queryable. It may take a moment to become available."
554
497
  except Exception as e:
@@ -557,7 +500,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
557
500
  # Apply category to entity registry if provided
558
501
  if effective_category and entity_id:
559
502
  await apply_entity_category(
560
- client, entity_id, effective_category, "automation", result, "automation"
503
+ self._client, entity_id, effective_category, "automation", result, "automation"
561
504
  )
562
505
 
563
506
  if bp_warnings:
@@ -590,16 +533,72 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
590
533
  suggestions=suggestions,
591
534
  )
592
535
 
593
- @mcp.tool(
536
+ @staticmethod
537
+ def _parse_and_validate_config(config: str | dict[str, Any]) -> dict[str, Any]:
538
+ """Parse JSON config and validate it is a dict."""
539
+ try:
540
+ parsed_config = parse_json_param(config, "config")
541
+ except ValueError as e:
542
+ raise_tool_error(create_validation_error(
543
+ f"Invalid config parameter: {e}",
544
+ parameter="config",
545
+ invalid_json=True,
546
+ ))
547
+
548
+ if parsed_config is None or not isinstance(parsed_config, dict):
549
+ raise_tool_error(create_validation_error(
550
+ "Config parameter must be a JSON object",
551
+ parameter="config",
552
+ details=f"Received type: {type(parsed_config).__name__}",
553
+ ))
554
+
555
+ return cast(dict[str, Any], parsed_config)
556
+
557
+ @staticmethod
558
+ def _validate_required_fields(
559
+ config_dict: dict[str, Any], identifier: str | None
560
+ ) -> None:
561
+ """Validate required fields and prevent duplicate creation."""
562
+ if "use_blueprint" in config_dict:
563
+ required_fields = ["alias"]
564
+ # Strip empty trigger/action/condition arrays that would override blueprint
565
+ for field in ["trigger", "action", "condition"]:
566
+ if field in config_dict and config_dict[field] == []:
567
+ del config_dict[field]
568
+ else:
569
+ required_fields = ["alias", "trigger", "action"]
570
+
571
+ missing_fields = [f for f in required_fields if f not in config_dict]
572
+ if missing_fields:
573
+ raise_tool_error(create_config_error(
574
+ f"Missing required fields: {', '.join(missing_fields)}",
575
+ identifier=identifier,
576
+ missing_fields=missing_fields,
577
+ ))
578
+
579
+ # Prevent duplicate creation when config contains an existing automation id
580
+ if identifier is None and "id" in config_dict:
581
+ existing_id = config_dict["id"]
582
+ raise_tool_error(create_validation_error(
583
+ f"Config contains 'id' field ('{existing_id}') but no identifier was provided. "
584
+ "This would create a duplicate automation instead of updating the existing one.",
585
+ parameter="identifier",
586
+ details=f"To update, pass identifier='{existing_id}' (or the automation's entity_id). "
587
+ "To create a genuinely new automation, remove the 'id' field from the config.",
588
+ ))
589
+
590
+ @tool(
591
+ name="ha_config_remove_automation",
594
592
  tags={"Automations"},
595
593
  annotations={
596
594
  "destructiveHint": True,
597
595
  "idempotentHint": True,
598
- "title": "Remove Automation"
599
- }
596
+ "title": "Remove Automation",
597
+ },
600
598
  )
601
599
  @log_tool_usage
602
600
  async def ha_config_remove_automation(
601
+ self,
603
602
  identifier: Annotated[
604
603
  str,
605
604
  Field(
@@ -625,19 +624,19 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
625
624
  """
626
625
  try:
627
626
  # Resolve entity_id for wait verification (identifier may be a unique_id)
628
- entity_id_for_wait = await _resolve_automation_entity_id(identifier)
627
+ entity_id_for_wait = await self._resolve_automation_entity_id(identifier)
629
628
  if not entity_id_for_wait:
630
629
  logger.warning(
631
- f"Could not resolve unique_id '{identifier}' to entity_id wait verification will be skipped"
630
+ f"Could not resolve unique_id '{identifier}' to entity_id -- wait verification will be skipped"
632
631
  )
633
632
 
634
- result = await client.delete_automation_config(identifier)
633
+ result = await self._client.delete_automation_config(identifier)
635
634
 
636
635
  # Wait for entity to be removed
637
636
  wait_bool = coerce_bool_param(wait, "wait", default=True)
638
637
  if wait_bool and entity_id_for_wait:
639
638
  try:
640
- removed = await wait_for_entity_removed(client, entity_id_for_wait)
639
+ removed = await wait_for_entity_removed(self._client, entity_id_for_wait)
641
640
  if not removed:
642
641
  result["warning"] = f"Deletion confirmed by API but {entity_id_for_wait} may still appear briefly."
643
642
  except Exception as e:
@@ -668,3 +667,8 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
668
667
  "Check Home Assistant connection",
669
668
  ]
670
669
  raise_tool_error(error_response)
670
+
671
+
672
+ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
673
+ """Register Home Assistant automation configuration tools."""
674
+ register_tool_methods(mcp, AutomationConfigTools(client))
@@ -19,10 +19,16 @@ import os
19
19
  from typing import Annotated, Any
20
20
 
21
21
  from fastmcp.exceptions import ToolError
22
+ from fastmcp.tools import tool
22
23
  from pydantic import Field
23
24
 
24
25
  from ..errors import ErrorCode, create_error_response
25
- from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
26
+ from .helpers import (
27
+ exception_to_structured_error,
28
+ log_tool_usage,
29
+ raise_tool_error,
30
+ register_tool_methods,
31
+ )
26
32
  from .util_helpers import coerce_bool_param, coerce_int_param, unwrap_service_response
27
33
 
28
34
  logger = logging.getLogger(__name__)
@@ -87,29 +93,23 @@ async def _assert_mcp_tools_available(client: Any) -> None:
87
93
  ))
88
94
 
89
95
 
90
- def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
91
- """Register filesystem access tools with the MCP server.
92
-
93
- This function only registers tools if the feature flag is enabled.
94
- Set HAMCP_ENABLE_FILESYSTEM_TOOLS=true to enable.
95
- """
96
- if not is_filesystem_tools_enabled():
97
- logger.debug(
98
- f"Filesystem tools disabled (set {FEATURE_FLAG}=true to enable)"
99
- )
100
- return
96
+ class FilesystemTools:
97
+ """Filesystem access tools for Home Assistant."""
101
98
 
102
- logger.info("Filesystem tools enabled via feature flag")
99
+ def __init__(self, client: Any) -> None:
100
+ self._client = client
103
101
 
104
- @mcp.tool(
102
+ @tool(
103
+ name="ha_list_files",
105
104
  tags={"Files"},
106
105
  annotations={
107
106
  "readOnlyHint": True,
108
- "title": "List Files"
109
- }
107
+ "title": "List Files",
108
+ },
110
109
  )
111
110
  @log_tool_usage
112
111
  async def ha_list_files(
112
+ self,
113
113
  path: Annotated[
114
114
  str,
115
115
  Field(
@@ -158,7 +158,7 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
158
158
  """
159
159
  try:
160
160
  # Check if custom component is available
161
- await _assert_mcp_tools_available(client)
161
+ await _assert_mcp_tools_available(self._client)
162
162
 
163
163
  # Build service data
164
164
  service_data: dict[str, Any] = {"path": path}
@@ -166,7 +166,7 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
166
166
  service_data["pattern"] = pattern
167
167
 
168
168
  # Call the custom component service
169
- result = await client.call_service(
169
+ result = await self._client.call_service(
170
170
  MCP_TOOLS_DOMAIN,
171
171
  "list_files",
172
172
  service_data,
@@ -193,15 +193,17 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
193
193
  context={"tool": "ha_list_files", "path": path, "pattern": pattern},
194
194
  )
195
195
 
196
- @mcp.tool(
196
+ @tool(
197
+ name="ha_read_file",
197
198
  tags={"Files"},
198
199
  annotations={
199
200
  "readOnlyHint": True,
200
- "title": "Read File"
201
- }
201
+ "title": "Read File",
202
+ },
202
203
  )
203
204
  @log_tool_usage
204
205
  async def ha_read_file(
206
+ self,
205
207
  path: Annotated[
206
208
  str,
207
209
  Field(
@@ -270,7 +272,7 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
270
272
  )
271
273
 
272
274
  # Check if custom component is available
273
- await _assert_mcp_tools_available(client)
275
+ await _assert_mcp_tools_available(self._client)
274
276
 
275
277
  # Build service data
276
278
  service_data: dict[str, Any] = {"path": path}
@@ -278,7 +280,7 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
278
280
  service_data["tail_lines"] = tail_lines_int
279
281
 
280
282
  # Call the custom component service
281
- result = await client.call_service(
283
+ result = await self._client.call_service(
282
284
  MCP_TOOLS_DOMAIN,
283
285
  "read_file",
284
286
  service_data,
@@ -304,15 +306,17 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
304
306
  context={"tool": "ha_read_file", "path": path},
305
307
  )
306
308
 
307
- @mcp.tool(
309
+ @tool(
310
+ name="ha_write_file",
308
311
  tags={"Files"},
309
312
  annotations={
310
313
  "destructiveHint": True,
311
- "title": "Write File"
312
- }
314
+ "title": "Write File",
315
+ },
313
316
  )
314
317
  @log_tool_usage
315
318
  async def ha_write_file(
319
+ self,
316
320
  path: Annotated[
317
321
  str,
318
322
  Field(
@@ -396,7 +400,7 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
396
400
  create_dirs_bool = coerce_bool_param(create_dirs, "create_dirs", default=True)
397
401
 
398
402
  # Check if custom component is available
399
- await _assert_mcp_tools_available(client)
403
+ await _assert_mcp_tools_available(self._client)
400
404
 
401
405
  # Build service data
402
406
  service_data: dict[str, Any] = {
@@ -407,7 +411,7 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
407
411
  }
408
412
 
409
413
  # Call the custom component service
410
- result = await client.call_service(
414
+ result = await self._client.call_service(
411
415
  MCP_TOOLS_DOMAIN,
412
416
  "write_file",
413
417
  service_data,
@@ -433,15 +437,17 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
433
437
  context={"tool": "ha_write_file", "path": path},
434
438
  )
435
439
 
436
- @mcp.tool(
440
+ @tool(
441
+ name="ha_delete_file",
437
442
  tags={"Files"},
438
443
  annotations={
439
444
  "destructiveHint": True,
440
- "title": "Delete File"
441
- }
445
+ "title": "Delete File",
446
+ },
442
447
  )
443
448
  @log_tool_usage
444
449
  async def ha_delete_file(
450
+ self,
445
451
  path: Annotated[
446
452
  str,
447
453
  Field(
@@ -510,13 +516,13 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
510
516
  )
511
517
 
512
518
  # Check if custom component is available
513
- await _assert_mcp_tools_available(client)
519
+ await _assert_mcp_tools_available(self._client)
514
520
 
515
521
  # Build service data
516
522
  service_data: dict[str, Any] = {"path": path}
517
523
 
518
524
  # Call the custom component service
519
- result = await client.call_service(
525
+ result = await self._client.call_service(
520
526
  MCP_TOOLS_DOMAIN,
521
527
  "delete_file",
522
528
  service_data,
@@ -541,3 +547,19 @@ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
541
547
  e,
542
548
  context={"tool": "ha_delete_file", "path": path},
543
549
  )
550
+
551
+
552
+ def register_filesystem_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
553
+ """Register filesystem access tools with the MCP server.
554
+
555
+ This function only registers tools if the feature flag is enabled.
556
+ Set HAMCP_ENABLE_FILESYSTEM_TOOLS=true to enable.
557
+ """
558
+ if not is_filesystem_tools_enabled():
559
+ logger.debug(
560
+ f"Filesystem tools disabled (set {FEATURE_FLAG}=true to enable)"
561
+ )
562
+ return
563
+
564
+ logger.info("Filesystem tools enabled via feature flag")
565
+ register_tool_methods(mcp, FilesystemTools(client))