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.
- ha_mcp/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- tests/test_env_manager.py +336 -0
ha_mcp/tools/enhanced.py
ADDED
|
@@ -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
|
ha_mcp/tools/helpers.py
ADDED
|
@@ -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
|
ha_mcp/tools/registry.py
ADDED
|
@@ -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")
|