ha-mcp-dev 6.3.1.dev137__py3-none-any.whl

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 (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. tests/test_env_manager.py +336 -0
@@ -0,0 +1,191 @@
1
+ """
2
+ Enhanced documentation and domain information for Home Assistant MCP Tools.
3
+
4
+ This module provides domain information and documentation helpers.
5
+ All MCP tools are now consolidated in tools_registry.py to eliminate duplication.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ # Top 25 Most Critical Home Assistant Domains
11
+ TOP_25_DOMAINS = [
12
+ # Critical control domains (8)
13
+ "light",
14
+ "switch",
15
+ "climate",
16
+ "media_player",
17
+ "lock",
18
+ "cover",
19
+ "fan",
20
+ "vacuum",
21
+ # Essential monitoring (4)
22
+ "sensor",
23
+ "binary_sensor",
24
+ "device_tracker",
25
+ "weather",
26
+ # Critical input helpers (5)
27
+ "input_number",
28
+ "input_boolean",
29
+ "input_text",
30
+ "input_datetime",
31
+ "input_select",
32
+ # Automation core (3)
33
+ "automation",
34
+ "script",
35
+ "scene",
36
+ # High importance (5)
37
+ "camera",
38
+ "alarm_control_panel",
39
+ "button",
40
+ "siren",
41
+ "timer",
42
+ ]
43
+
44
+
45
+ class EnhancedToolsMixin:
46
+ """Mixin class to add enhanced documentation and domain information to MCP server."""
47
+
48
+ def register_enhanced_tools(self) -> None:
49
+ """Enhanced tools are now consolidated in tools_registry.py to eliminate duplication."""
50
+ # This mixin now focuses on domain information and documentation helpers
51
+ pass
52
+
53
+ def get_domain_info(self, domain: str) -> dict[str, Any]:
54
+ """Get domain-specific information for enhanced documentation."""
55
+ return self._get_domain_info(domain)
56
+
57
+ def get_domain_insights(
58
+ self, domain: str, entity_state: dict[str, Any]
59
+ ) -> dict[str, Any]:
60
+ """Get domain-specific insights for entity state."""
61
+ return self._get_domain_insights(domain, entity_state)
62
+
63
+ def get_domain_actions(self, domain: str) -> list[str]:
64
+ """Get available actions for domain."""
65
+ return self._get_domain_actions(domain)
66
+
67
+ def get_parameter_guidance(
68
+ self, domain: str, entity_state: dict[str, Any]
69
+ ) -> dict[str, Any]:
70
+ """Get parameter guidance for domain."""
71
+ return self._get_parameter_guidance(domain, entity_state)
72
+
73
+ # Helper methods for enhanced functionality
74
+
75
+ def _get_domain_info(self, domain: str) -> dict[str, Any]:
76
+ """Get domain-specific information."""
77
+ domain_info = {
78
+ "light": {
79
+ "type": "control",
80
+ "complexity": "high",
81
+ "parameters": ["brightness", "color", "temperature"],
82
+ },
83
+ "switch": {
84
+ "type": "control",
85
+ "complexity": "low",
86
+ "parameters": ["basic_on_off"],
87
+ },
88
+ "climate": {
89
+ "type": "control",
90
+ "complexity": "high",
91
+ "parameters": ["temperature", "mode", "preset"],
92
+ },
93
+ "input_boolean": {
94
+ "type": "input",
95
+ "complexity": "low",
96
+ "parameters": ["toggle"],
97
+ },
98
+ "input_number": {
99
+ "type": "input",
100
+ "complexity": "medium",
101
+ "parameters": ["value", "min", "max"],
102
+ },
103
+ "sensor": {
104
+ "type": "monitoring",
105
+ "complexity": "low",
106
+ "parameters": ["read_only"],
107
+ },
108
+ "automation": {
109
+ "type": "system",
110
+ "complexity": "medium",
111
+ "parameters": ["trigger", "enable"],
112
+ },
113
+ }
114
+
115
+ return domain_info.get(
116
+ domain, {"type": "unknown", "complexity": "medium", "parameters": ["basic"]}
117
+ )
118
+
119
+ def _get_domain_insights(
120
+ self, domain: str, entity_state: dict[str, Any]
121
+ ) -> dict[str, Any]:
122
+ """Get domain-specific insights for entity state."""
123
+ insights: dict[str, Any] = {"domain": domain, "recommendations": []}
124
+
125
+ if domain == "light":
126
+ if entity_state.get("state") == "on":
127
+ insights["recommendations"].append(
128
+ "Can adjust brightness, color, or turn off"
129
+ )
130
+ else:
131
+ insights["recommendations"].append(
132
+ "Can turn on with optional brightness/color"
133
+ )
134
+
135
+ elif domain == "input_boolean":
136
+ current_state = entity_state.get("state")
137
+ insights["recommendations"].append(
138
+ f"Can toggle from {current_state} to {'off' if current_state == 'on' else 'on'}"
139
+ )
140
+
141
+ elif domain == "input_number":
142
+ current_value = entity_state.get("state", 0)
143
+ min_val = entity_state.get("attributes", {}).get("min", 0)
144
+ max_val = entity_state.get("attributes", {}).get("max", 100)
145
+ insights["recommendations"].append(
146
+ f"Can set value between {min_val}-{max_val} (current: {current_value})"
147
+ )
148
+
149
+ return insights
150
+
151
+ def _get_domain_actions(self, domain: str) -> list[str]:
152
+ """Get available actions for domain."""
153
+ actions = {
154
+ "light": ["turn_on", "turn_off", "toggle"],
155
+ "switch": ["turn_on", "turn_off", "toggle"],
156
+ "climate": ["set_temperature", "set_hvac_mode", "set_preset_mode"],
157
+ "input_boolean": ["turn_on", "turn_off", "toggle"],
158
+ "input_number": ["set_value", "increment", "decrement"],
159
+ "input_text": ["set_value"],
160
+ "automation": ["trigger", "turn_on", "turn_off"],
161
+ "scene": ["turn_on"],
162
+ }
163
+
164
+ return actions.get(domain, ["turn_on", "turn_off"])
165
+
166
+ def _get_parameter_guidance(
167
+ self, domain: str, entity_state: dict[str, Any]
168
+ ) -> dict[str, Any]:
169
+ """Get parameter guidance for domain."""
170
+ guidance = {}
171
+
172
+ if domain == "light":
173
+ guidance = {
174
+ "brightness_pct": "0-100 percentage (user-friendly)",
175
+ "color_temp_kelvin": "2000-6500K (warm to cool)",
176
+ "rgb_color": "[red, green, blue] values 0-255 each",
177
+ }
178
+ elif domain == "climate":
179
+ attributes = entity_state.get("attributes", {})
180
+ guidance = {
181
+ "temperature": f"Range: {attributes.get('min_temp', 'unknown')}-{attributes.get('max_temp', 'unknown')}",
182
+ "hvac_mode": f"Options: {attributes.get('hvac_modes', 'unknown')}",
183
+ "preset_mode": f"Options: {attributes.get('preset_modes', 'unknown')}",
184
+ }
185
+ elif domain == "input_number":
186
+ attributes = entity_state.get("attributes", {})
187
+ guidance = {
188
+ "value": f"Range: {attributes.get('min', 0)}-{attributes.get('max', 100)}, Step: {attributes.get('step', 1)}"
189
+ }
190
+
191
+ return guidance
@@ -0,0 +1,184 @@
1
+ """
2
+ Reusable helper functions for MCP tools.
3
+
4
+ Centralized utilities that can be shared across multiple tool implementations.
5
+ """
6
+
7
+ import functools
8
+ import logging
9
+ import time
10
+ from typing import Any
11
+
12
+ from ..client.rest_client import (
13
+ HomeAssistantAPIError,
14
+ HomeAssistantAuthError,
15
+ HomeAssistantConnectionError,
16
+ )
17
+ from ..client.websocket_client import HomeAssistantWebSocketClient
18
+ from ..errors import (
19
+ ErrorCode,
20
+ create_auth_error,
21
+ create_connection_error,
22
+ create_entity_not_found_error,
23
+ create_error_response,
24
+ create_timeout_error,
25
+ create_validation_error,
26
+ )
27
+ from ..utils.usage_logger import log_tool_call
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ async def get_connected_ws_client(
33
+ base_url: str, token: str
34
+ ) -> tuple[HomeAssistantWebSocketClient | None, dict[str, Any] | None]:
35
+ """
36
+ Create and connect a WebSocket client.
37
+
38
+ Args:
39
+ base_url: Home Assistant base URL
40
+ token: Authentication token
41
+
42
+ Returns:
43
+ Tuple of (ws_client, error_dict). If connection fails, ws_client is None.
44
+ """
45
+ ws_client = HomeAssistantWebSocketClient(base_url, token)
46
+ connected = await ws_client.connect()
47
+ if not connected:
48
+ return None, create_connection_error(
49
+ "Failed to connect to Home Assistant WebSocket",
50
+ details="WebSocket connection could not be established",
51
+ )
52
+ return ws_client, None
53
+
54
+
55
+ def exception_to_structured_error(
56
+ error: Exception,
57
+ context: dict[str, Any] | None = None,
58
+ ) -> dict[str, Any]:
59
+ """
60
+ Convert an exception to a structured error response.
61
+
62
+ This function maps common exception types to appropriate error codes
63
+ and creates informative error responses.
64
+
65
+ Args:
66
+ error: The exception to convert
67
+ context: Additional context to include in the response
68
+
69
+ Returns:
70
+ Structured error response dictionary
71
+ """
72
+ error_str = str(error).lower()
73
+ error_msg = str(error)
74
+
75
+ # Handle specific exception types
76
+ if isinstance(error, HomeAssistantConnectionError):
77
+ if "timeout" in error_str:
78
+ return create_connection_error(error_msg, timeout=True)
79
+ return create_connection_error(error_msg)
80
+
81
+ if isinstance(error, HomeAssistantAuthError):
82
+ if "expired" in error_str:
83
+ return create_auth_error(error_msg, expired=True)
84
+ return create_auth_error(error_msg)
85
+
86
+ if isinstance(error, HomeAssistantAPIError):
87
+ # Check for specific error patterns
88
+ if error.status_code == 404:
89
+ # Entity or resource not found
90
+ entity_id = context.get("entity_id") if context else None
91
+ if entity_id:
92
+ return create_entity_not_found_error(entity_id, details=error_msg)
93
+ return create_error_response(
94
+ ErrorCode.RESOURCE_NOT_FOUND,
95
+ error_msg,
96
+ context=context,
97
+ )
98
+ if error.status_code == 401:
99
+ return create_auth_error(error_msg)
100
+ if error.status_code == 400:
101
+ return create_validation_error(error_msg, context=context)
102
+
103
+ # Generic API error
104
+ return create_error_response(
105
+ ErrorCode.SERVICE_CALL_FAILED,
106
+ error_msg,
107
+ context=context,
108
+ )
109
+
110
+ if isinstance(error, TimeoutError):
111
+ operation = context.get("operation", "request") if context else "request"
112
+ timeout_seconds = context.get("timeout_seconds", 30) if context else 30
113
+ return create_timeout_error(operation, timeout_seconds, details=error_msg)
114
+
115
+ if isinstance(error, ValueError):
116
+ return create_validation_error(error_msg)
117
+
118
+ # Check for common error patterns in error message
119
+ if "not found" in error_str or "404" in error_str:
120
+ entity_id = context.get("entity_id") if context else None
121
+ if entity_id:
122
+ return create_entity_not_found_error(entity_id, details=error_msg)
123
+ return create_error_response(
124
+ ErrorCode.RESOURCE_NOT_FOUND,
125
+ error_msg,
126
+ context=context,
127
+ )
128
+
129
+ if "timeout" in error_str:
130
+ return create_timeout_error("operation", 30, details=error_msg)
131
+
132
+ if "connection" in error_str or "connect" in error_str:
133
+ return create_connection_error(error_msg)
134
+
135
+ if "auth" in error_str or "token" in error_str or "401" in error_str:
136
+ return create_auth_error(error_msg)
137
+
138
+ # Default to internal error
139
+ return create_error_response(
140
+ ErrorCode.INTERNAL_ERROR,
141
+ error_msg,
142
+ details="An unexpected error occurred",
143
+ context=context,
144
+ )
145
+
146
+
147
+ def log_tool_usage(func: Any) -> Any:
148
+ """
149
+ Decorator to automatically log MCP tool usage.
150
+
151
+ Tracks execution time, success/failure, and response size for all tool calls.
152
+ """
153
+
154
+ @functools.wraps(func)
155
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
156
+ start_time = time.time()
157
+ tool_name = func.__name__
158
+ success = True
159
+ error_message = None
160
+ response_size = None
161
+
162
+ try:
163
+ result = await func(*args, **kwargs)
164
+ if isinstance(result, str):
165
+ response_size = len(result.encode("utf-8"))
166
+ elif hasattr(result, "__len__"):
167
+ response_size = len(str(result).encode("utf-8"))
168
+ return result
169
+ except Exception as e:
170
+ success = False
171
+ error_message = str(e)
172
+ raise
173
+ finally:
174
+ execution_time_ms = (time.time() - start_time) * 1000
175
+ log_tool_call(
176
+ tool_name=tool_name,
177
+ parameters=kwargs,
178
+ execution_time_ms=execution_time_ms,
179
+ success=success,
180
+ error_message=error_message,
181
+ response_size_bytes=response_size,
182
+ )
183
+
184
+ return wrapper
@@ -0,0 +1,199 @@
1
+ """
2
+ Tools registry for Smart MCP Server - manages registration of all MCP tools.
3
+
4
+ This module uses lazy auto-discovery to find and register all tool modules.
5
+ Tool modules are discovered at startup but only imported when first accessed,
6
+ improving server startup time significantly (especially for binary distributions).
7
+
8
+ Adding a new tools module is simple:
9
+ 1. Create tools_*.py file with a register_*_tools(mcp, client, **kwargs) function
10
+ 2. The function will be auto-discovered and registered lazily
11
+
12
+ No changes to this file are needed when adding new tool modules!
13
+
14
+ Tool filtering:
15
+ Set ENABLED_TOOL_MODULES environment variable to filter which tools are loaded:
16
+ - "all" (default): Load all tools
17
+ - "automation": Load only automation-related tools (automations, scripts, traces, blueprints)
18
+ - Comma-separated list: Load specific modules (e.g., "tools_config_automations,tools_search")
19
+ """
20
+
21
+ import logging
22
+ import pkgutil
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Modules that don't follow the tools_*.py naming convention
29
+ # These are handled explicitly for backward compatibility
30
+ EXPLICIT_MODULES = {
31
+ "backup": "register_backup_tools",
32
+ }
33
+
34
+ # Preset module groups for common use cases
35
+ MODULE_PRESETS = {
36
+ "automation": [
37
+ "tools_config_automations",
38
+ "tools_config_scripts",
39
+ "tools_traces",
40
+ "tools_blueprints",
41
+ "tools_search", # Useful for finding entities in automations
42
+ ],
43
+ }
44
+
45
+
46
+ class ToolsRegistry:
47
+ """Manages registration of all MCP tools for the smart server.
48
+
49
+ Implements lazy loading pattern: tool modules are discovered at startup
50
+ but only imported and registered when the server starts accepting connections.
51
+ This significantly improves startup time for binary distributions.
52
+
53
+ Tool filtering is controlled via ENABLED_TOOL_MODULES environment variable:
54
+ - "all": Load all tools (default)
55
+ - "automation": Load automation-related tools only
56
+ - Comma-separated list: Load specific modules
57
+ """
58
+
59
+ def __init__(self, server: Any, enabled_modules: str = "all") -> None:
60
+ self.server = server
61
+ self.client = server.client
62
+ self.mcp = server.mcp
63
+ self._enabled_modules = enabled_modules
64
+ # These are now lazily initialized via server properties
65
+ self._smart_tools = None
66
+ self._device_tools = None
67
+ self._modules_registered = False
68
+ # Discover modules at init time (fast - no imports)
69
+ self._discovered_modules = self._discover_tool_modules()
70
+
71
+ @property
72
+ def smart_tools(self) -> Any:
73
+ """Lazily get smart_tools from server."""
74
+ if self._smart_tools is None:
75
+ self._smart_tools = self.server.smart_tools
76
+ return self._smart_tools
77
+
78
+ @property
79
+ def device_tools(self) -> Any:
80
+ """Lazily get device_tools from server."""
81
+ if self._device_tools is None:
82
+ self._device_tools = self.server.device_tools
83
+ return self._device_tools
84
+
85
+ def _get_enabled_module_list(self) -> set[str] | None:
86
+ """Parse enabled_modules config into a set of module names.
87
+
88
+ Returns None if all modules should be enabled.
89
+ """
90
+ if self._enabled_modules.lower() == "all":
91
+ return None
92
+
93
+ # Check for preset names
94
+ if self._enabled_modules.lower() in MODULE_PRESETS:
95
+ return set(MODULE_PRESETS[self._enabled_modules.lower()])
96
+
97
+ # Parse comma-separated list
98
+ modules = {m.strip() for m in self._enabled_modules.split(",") if m.strip()}
99
+ return modules if modules else None
100
+
101
+ def _discover_tool_modules(self) -> list[str]:
102
+ """Discover tool module names without importing them.
103
+
104
+ This is a fast operation that only reads file names.
105
+ Returns list of module names that follow the tools_*.py convention,
106
+ filtered by ENABLED_TOOL_MODULES configuration.
107
+ """
108
+ enabled_set = self._get_enabled_module_list()
109
+ discovered = []
110
+ package_path = Path(__file__).parent
111
+
112
+ for module_info in pkgutil.iter_modules([str(package_path)]):
113
+ module_name = module_info.name
114
+ if module_name.startswith("tools_"):
115
+ # Filter if enabled_set is specified
116
+ if enabled_set is None or module_name in enabled_set:
117
+ discovered.append(module_name)
118
+
119
+ # Add explicit modules (only if enabled or no filter)
120
+ for module_name in EXPLICIT_MODULES.keys():
121
+ if enabled_set is None or module_name in enabled_set:
122
+ discovered.append(module_name)
123
+
124
+ if enabled_set is not None:
125
+ logger.info(
126
+ f"Tool filtering active: {len(discovered)} modules enabled "
127
+ f"(filter: {self._enabled_modules})"
128
+ )
129
+ else:
130
+ logger.debug(f"Discovered {len(discovered)} tool modules (not yet imported)")
131
+
132
+ return discovered
133
+
134
+ def register_all_tools(self) -> None:
135
+ """Register all tools with the MCP server using lazy auto-discovery.
136
+
137
+ Tool modules are imported and registered only when this method is called,
138
+ which happens after the MCP server is ready to accept connections.
139
+ """
140
+ if self._modules_registered:
141
+ logger.debug("Tools already registered, skipping")
142
+ return
143
+
144
+ import importlib
145
+
146
+ # Build kwargs with all available dependencies (lazy access)
147
+ kwargs = {
148
+ "smart_tools": self.smart_tools,
149
+ "device_tools": self.device_tools,
150
+ }
151
+
152
+ registered_count = 0
153
+
154
+ # Import and register tools_*.py modules
155
+ for module_name in self._discovered_modules:
156
+ # Skip explicit modules - handled separately
157
+ if module_name in EXPLICIT_MODULES:
158
+ continue
159
+
160
+ try:
161
+ module = importlib.import_module(f".{module_name}", "ha_mcp.tools")
162
+
163
+ # Find the register function (convention: register_*_tools)
164
+ register_func = None
165
+ for attr_name in dir(module):
166
+ if attr_name.startswith("register_") and attr_name.endswith("_tools"):
167
+ register_func = getattr(module, attr_name)
168
+ break
169
+
170
+ if register_func:
171
+ register_func(self.mcp, self.client, **kwargs)
172
+ registered_count += 1
173
+ logger.debug(f"Registered tools from {module_name}")
174
+ else:
175
+ logger.warning(
176
+ f"Module {module_name} has no register_*_tools function"
177
+ )
178
+
179
+ except Exception as e:
180
+ logger.error(f"Failed to register tools from {module_name}: {e}")
181
+ raise
182
+
183
+ # Register explicit modules (those not following tools_*.py convention)
184
+ # Only register if they were included in discovered modules (respects filtering)
185
+ for module_name, func_name in EXPLICIT_MODULES.items():
186
+ if module_name not in self._discovered_modules:
187
+ continue
188
+ try:
189
+ module = importlib.import_module(f".{module_name}", "ha_mcp.tools")
190
+ register_func = getattr(module, func_name)
191
+ register_func(self.mcp, self.client, **kwargs)
192
+ registered_count += 1
193
+ logger.debug(f"Registered tools from {module_name}")
194
+ except Exception as e:
195
+ logger.error(f"Failed to register tools from {module_name}: {e}")
196
+ raise
197
+
198
+ self._modules_registered = True
199
+ logger.info(f"Auto-discovery registered tools from {registered_count} modules")