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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Search and discovery tools for Home Assistant MCP server.
|
|
3
|
+
|
|
4
|
+
This module provides entity search, system overview, deep search, and state retrieval tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Annotated, Any, Literal, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
|
|
12
|
+
from ..errors import create_entity_not_found_error
|
|
13
|
+
from .helpers import exception_to_structured_error, log_tool_usage
|
|
14
|
+
from .util_helpers import add_timezone_metadata, coerce_bool_param, parse_string_list_param
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _exact_match_search(
|
|
20
|
+
client, query: str, domain_filter: str | None, limit: int
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""
|
|
23
|
+
Fallback exact match search when fuzzy search fails.
|
|
24
|
+
|
|
25
|
+
Performs simple substring matching on entity_id and friendly_name.
|
|
26
|
+
"""
|
|
27
|
+
all_entities = await client.get_states()
|
|
28
|
+
query_lower = query.lower().strip()
|
|
29
|
+
|
|
30
|
+
results = []
|
|
31
|
+
for entity in all_entities:
|
|
32
|
+
entity_id = entity.get("entity_id", "")
|
|
33
|
+
attributes = entity.get("attributes", {})
|
|
34
|
+
friendly_name = attributes.get("friendly_name", entity_id)
|
|
35
|
+
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
36
|
+
|
|
37
|
+
# Apply domain filter if provided
|
|
38
|
+
if domain_filter and domain != domain_filter:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# Check for exact substring match in entity_id or friendly_name
|
|
42
|
+
if query_lower in entity_id.lower() or query_lower in friendly_name.lower():
|
|
43
|
+
results.append({
|
|
44
|
+
"entity_id": entity_id,
|
|
45
|
+
"friendly_name": friendly_name,
|
|
46
|
+
"domain": domain,
|
|
47
|
+
"state": entity.get("state", "unknown"),
|
|
48
|
+
"score": 100 if query_lower == entity_id.lower() or query_lower == friendly_name.lower() else 80,
|
|
49
|
+
"match_type": "exact_match",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
# Sort by score descending
|
|
53
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
54
|
+
total_matches = len(results)
|
|
55
|
+
limited_results = results[:limit]
|
|
56
|
+
return {
|
|
57
|
+
"success": True,
|
|
58
|
+
"query": query,
|
|
59
|
+
"total_matches": total_matches,
|
|
60
|
+
"results": limited_results,
|
|
61
|
+
"is_truncated": total_matches > len(limited_results),
|
|
62
|
+
"search_type": "exact_match",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _partial_results_search(
|
|
67
|
+
client, query: str, domain_filter: str | None, limit: int
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Last resort fallback - return any entities that might be relevant.
|
|
71
|
+
|
|
72
|
+
Returns entities from the specified domain (if any) or a sample of all entities.
|
|
73
|
+
"""
|
|
74
|
+
all_entities = await client.get_states()
|
|
75
|
+
|
|
76
|
+
results = []
|
|
77
|
+
for entity in all_entities:
|
|
78
|
+
entity_id = entity.get("entity_id", "")
|
|
79
|
+
attributes = entity.get("attributes", {})
|
|
80
|
+
friendly_name = attributes.get("friendly_name", entity_id)
|
|
81
|
+
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
82
|
+
|
|
83
|
+
# Apply domain filter if provided
|
|
84
|
+
if domain_filter and domain != domain_filter:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
results.append({
|
|
88
|
+
"entity_id": entity_id,
|
|
89
|
+
"friendly_name": friendly_name,
|
|
90
|
+
"domain": domain,
|
|
91
|
+
"state": entity.get("state", "unknown"),
|
|
92
|
+
"score": 0, # No match score for partial results
|
|
93
|
+
"match_type": "partial_listing",
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
total_matches = len(results)
|
|
97
|
+
limited_results = results[:limit]
|
|
98
|
+
return {
|
|
99
|
+
"success": True,
|
|
100
|
+
"partial": True,
|
|
101
|
+
"query": query,
|
|
102
|
+
"total_matches": total_matches,
|
|
103
|
+
"results": limited_results,
|
|
104
|
+
"is_truncated": total_matches > len(limited_results),
|
|
105
|
+
"search_type": "partial_listing",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def register_search_tools(mcp, client, **kwargs):
|
|
110
|
+
"""Register search and discovery tools with the MCP server."""
|
|
111
|
+
smart_tools = kwargs.get("smart_tools")
|
|
112
|
+
if not smart_tools:
|
|
113
|
+
raise ValueError("smart_tools is required for search tools registration")
|
|
114
|
+
|
|
115
|
+
@mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["search"], "title": "Search Entities"})
|
|
116
|
+
@log_tool_usage
|
|
117
|
+
async def ha_search_entities(
|
|
118
|
+
query: str,
|
|
119
|
+
domain_filter: str | None = None,
|
|
120
|
+
area_filter: str | None = None,
|
|
121
|
+
limit: int = 10,
|
|
122
|
+
group_by_domain: bool | str = False,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""Comprehensive entity search with fuzzy matching, domain/area filtering, and optional grouping.
|
|
125
|
+
|
|
126
|
+
**Listing Entities by Domain:**
|
|
127
|
+
Use domain_filter with an empty query to list all entities of a specific type:
|
|
128
|
+
- ha_search_entities(query="", domain_filter="calendar") - List all calendars
|
|
129
|
+
- ha_search_entities(query="", domain_filter="todo") - List all todo lists
|
|
130
|
+
- ha_search_entities(query="", domain_filter="scene") - List all scenes
|
|
131
|
+
- ha_search_entities(query="", domain_filter="zone") - List all zones (as entities)
|
|
132
|
+
|
|
133
|
+
**BEST PRACTICE:** Before performing searches, call ha_get_overview() first to understand:
|
|
134
|
+
- Smart home size and scale (total entities, domains, areas)
|
|
135
|
+
- Language used in entity naming (French/English/mixed)
|
|
136
|
+
- Available areas/rooms and their entity distribution
|
|
137
|
+
|
|
138
|
+
Choose overview detail level based on task:
|
|
139
|
+
- 'minimal': Quick orientation (10 entities per domain sample) - RECOMMENDED for searches
|
|
140
|
+
- 'standard': Complete picture (all entities, friendly names only) - for comprehensive tasks
|
|
141
|
+
- 'full': Maximum detail (includes states, device types, services) - for deep analysis"""
|
|
142
|
+
# Coerce boolean parameter that may come as string from XML-style calls
|
|
143
|
+
group_by_domain_bool = coerce_bool_param(group_by_domain, "group_by_domain", default=False) or False
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
# If area_filter is provided, use area-based search
|
|
147
|
+
if area_filter:
|
|
148
|
+
area_result = await smart_tools.get_entities_by_area(
|
|
149
|
+
area_filter, group_by_domain=True
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# If we also have a query, filter the area results
|
|
153
|
+
if query and query.strip():
|
|
154
|
+
# Get all entities from all areas in the result
|
|
155
|
+
all_area_entities = []
|
|
156
|
+
if "areas" in area_result:
|
|
157
|
+
for area_data in area_result["areas"].values():
|
|
158
|
+
if "entities" in area_data:
|
|
159
|
+
if isinstance(
|
|
160
|
+
area_data["entities"], dict
|
|
161
|
+
): # grouped by domain
|
|
162
|
+
for domain_entities in area_data["entities"].values():
|
|
163
|
+
all_area_entities.extend(domain_entities)
|
|
164
|
+
else: # flat list
|
|
165
|
+
all_area_entities.extend(area_data["entities"])
|
|
166
|
+
|
|
167
|
+
# Apply fuzzy search to area entities
|
|
168
|
+
from ..utils.fuzzy_search import create_fuzzy_searcher
|
|
169
|
+
|
|
170
|
+
fuzzy_searcher = create_fuzzy_searcher(threshold=80)
|
|
171
|
+
|
|
172
|
+
# Convert to format expected by fuzzy searcher
|
|
173
|
+
entities_for_search = []
|
|
174
|
+
for entity in all_area_entities:
|
|
175
|
+
entities_for_search.append(
|
|
176
|
+
{
|
|
177
|
+
"entity_id": entity.get("entity_id", ""),
|
|
178
|
+
"attributes": {
|
|
179
|
+
"friendly_name": entity.get("friendly_name", "")
|
|
180
|
+
},
|
|
181
|
+
"state": entity.get("state", "unknown"),
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
matches, total_matches = fuzzy_searcher.search_entities(
|
|
186
|
+
entities_for_search, query, limit
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Format matches similar to smart_entity_search
|
|
190
|
+
results = []
|
|
191
|
+
for match in matches:
|
|
192
|
+
results.append(
|
|
193
|
+
{
|
|
194
|
+
"entity_id": match["entity_id"],
|
|
195
|
+
"friendly_name": match["friendly_name"],
|
|
196
|
+
"domain": match["domain"],
|
|
197
|
+
"state": match["state"],
|
|
198
|
+
"score": match["score"],
|
|
199
|
+
"match_type": match["match_type"],
|
|
200
|
+
"area_filter": area_filter,
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Group by domain if requested
|
|
205
|
+
if group_by_domain_bool:
|
|
206
|
+
by_domain: dict[str, list[dict[str, Any]]] = {}
|
|
207
|
+
for result in results:
|
|
208
|
+
domain = result["domain"]
|
|
209
|
+
if domain not in by_domain:
|
|
210
|
+
by_domain[domain] = []
|
|
211
|
+
by_domain[domain].append(result)
|
|
212
|
+
|
|
213
|
+
search_data = {
|
|
214
|
+
"success": True,
|
|
215
|
+
"query": query,
|
|
216
|
+
"area_filter": area_filter,
|
|
217
|
+
"total_matches": total_matches,
|
|
218
|
+
"results": results,
|
|
219
|
+
"is_truncated": total_matches > len(results),
|
|
220
|
+
"by_domain": by_domain,
|
|
221
|
+
"search_type": "area_filtered_query",
|
|
222
|
+
}
|
|
223
|
+
return await add_timezone_metadata(client, search_data)
|
|
224
|
+
else:
|
|
225
|
+
search_data = {
|
|
226
|
+
"success": True,
|
|
227
|
+
"query": query,
|
|
228
|
+
"area_filter": area_filter,
|
|
229
|
+
"total_matches": total_matches,
|
|
230
|
+
"results": results,
|
|
231
|
+
"is_truncated": total_matches > len(results),
|
|
232
|
+
"search_type": "area_filtered_query",
|
|
233
|
+
}
|
|
234
|
+
return await add_timezone_metadata(client, search_data)
|
|
235
|
+
else:
|
|
236
|
+
# Just area filter, return area results with enhanced format
|
|
237
|
+
if "areas" in area_result and area_result["areas"]:
|
|
238
|
+
first_area = next(iter(area_result["areas"].values()))
|
|
239
|
+
by_domain = first_area.get("entities", {})
|
|
240
|
+
|
|
241
|
+
# Flatten for results while keeping by_domain structure
|
|
242
|
+
all_results = []
|
|
243
|
+
for domain, entities in by_domain.items():
|
|
244
|
+
for entity in entities:
|
|
245
|
+
entity["domain"] = domain
|
|
246
|
+
all_results.append(entity)
|
|
247
|
+
|
|
248
|
+
area_search_data = {
|
|
249
|
+
"success": True,
|
|
250
|
+
"area_filter": area_filter,
|
|
251
|
+
"total_matches": len(all_results),
|
|
252
|
+
"results": all_results,
|
|
253
|
+
"by_domain": by_domain,
|
|
254
|
+
"search_type": "area_only",
|
|
255
|
+
"area_name": first_area.get("area_name", area_filter),
|
|
256
|
+
}
|
|
257
|
+
return await add_timezone_metadata(client, area_search_data)
|
|
258
|
+
else:
|
|
259
|
+
empty_area_data = {
|
|
260
|
+
"success": True,
|
|
261
|
+
"area_filter": area_filter,
|
|
262
|
+
"total_matches": 0,
|
|
263
|
+
"results": [],
|
|
264
|
+
"by_domain": {},
|
|
265
|
+
"search_type": "area_only",
|
|
266
|
+
"message": f"No entities found in area: {area_filter}",
|
|
267
|
+
}
|
|
268
|
+
return await add_timezone_metadata(client, empty_area_data)
|
|
269
|
+
|
|
270
|
+
# Regular entity search (no area filter)
|
|
271
|
+
# Handle empty query with domain_filter - list all entities of that domain
|
|
272
|
+
if domain_filter and (not query or not query.strip()):
|
|
273
|
+
# Get all entities directly from the client
|
|
274
|
+
all_entities = await client.get_states()
|
|
275
|
+
|
|
276
|
+
# Filter by domain
|
|
277
|
+
filtered_entities = [
|
|
278
|
+
e for e in all_entities
|
|
279
|
+
if e.get("entity_id", "").startswith(f"{domain_filter}.")
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
# Format results to match fuzzy search output
|
|
283
|
+
results = []
|
|
284
|
+
for entity in filtered_entities[:limit]:
|
|
285
|
+
entity_id = entity.get("entity_id", "")
|
|
286
|
+
attributes = entity.get("attributes", {})
|
|
287
|
+
results.append({
|
|
288
|
+
"entity_id": entity_id,
|
|
289
|
+
"friendly_name": attributes.get("friendly_name", entity_id),
|
|
290
|
+
"domain": domain_filter,
|
|
291
|
+
"state": entity.get("state", "unknown"),
|
|
292
|
+
"score": 100, # Perfect match since we're listing by domain
|
|
293
|
+
"match_type": "domain_listing",
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
total_filtered = len(filtered_entities)
|
|
297
|
+
# Build response data (avoid duplication by conditionally adding by_domain)
|
|
298
|
+
domain_list_data = {
|
|
299
|
+
"success": True,
|
|
300
|
+
"query": query,
|
|
301
|
+
"domain_filter": domain_filter,
|
|
302
|
+
"total_matches": total_filtered,
|
|
303
|
+
"results": results,
|
|
304
|
+
"is_truncated": total_filtered > len(results),
|
|
305
|
+
"search_type": "domain_listing",
|
|
306
|
+
"note": f"Listing all {domain_filter} entities (empty query with domain_filter)",
|
|
307
|
+
}
|
|
308
|
+
if group_by_domain_bool:
|
|
309
|
+
domain_list_data["by_domain"] = {domain_filter: results}
|
|
310
|
+
return await add_timezone_metadata(client, domain_list_data)
|
|
311
|
+
|
|
312
|
+
# Graceful degradation with fallback search methods
|
|
313
|
+
# 1. Try fuzzy search (primary method)
|
|
314
|
+
# 2. If that fails, try exact match
|
|
315
|
+
# 3. If that fails, return partial results with warning
|
|
316
|
+
# 4. Only error if all methods fail
|
|
317
|
+
|
|
318
|
+
result = None
|
|
319
|
+
warning = None
|
|
320
|
+
search_type = "fuzzy_search"
|
|
321
|
+
|
|
322
|
+
# Step 1: Try fuzzy search
|
|
323
|
+
try:
|
|
324
|
+
result = await smart_tools.smart_entity_search(query, limit, domain_filter=domain_filter)
|
|
325
|
+
search_type = "fuzzy_search"
|
|
326
|
+
except Exception as fuzzy_error:
|
|
327
|
+
logger.warning(f"Fuzzy search failed, trying exact match: {fuzzy_error}")
|
|
328
|
+
|
|
329
|
+
# Step 2: Try exact match fallback
|
|
330
|
+
try:
|
|
331
|
+
result = await _exact_match_search(client, query, domain_filter, limit)
|
|
332
|
+
warning = "Fuzzy search unavailable, using exact match"
|
|
333
|
+
search_type = "exact_match"
|
|
334
|
+
except Exception as exact_error:
|
|
335
|
+
logger.warning(f"Exact match failed, trying partial results: {exact_error}")
|
|
336
|
+
|
|
337
|
+
# Step 3: Try partial results fallback
|
|
338
|
+
try:
|
|
339
|
+
result = await _partial_results_search(client, query, domain_filter, limit)
|
|
340
|
+
warning = "Search degraded, returning partial results"
|
|
341
|
+
search_type = "partial_listing"
|
|
342
|
+
except Exception as partial_error:
|
|
343
|
+
# Step 4: All methods failed - raise to outer exception handler
|
|
344
|
+
logger.error(f"All search methods failed: {partial_error}")
|
|
345
|
+
raise Exception(
|
|
346
|
+
f"All search methods failed. Fuzzy: {fuzzy_error}, "
|
|
347
|
+
f"Exact: {exact_error}, Partial: {partial_error}"
|
|
348
|
+
) from partial_error
|
|
349
|
+
|
|
350
|
+
# Convert 'matches' to 'results' for backward compatibility
|
|
351
|
+
if "matches" in result:
|
|
352
|
+
result["results"] = result.pop("matches")
|
|
353
|
+
|
|
354
|
+
# Add domain_filter to result if it was provided (for API consistency)
|
|
355
|
+
if domain_filter:
|
|
356
|
+
result["domain_filter"] = domain_filter
|
|
357
|
+
|
|
358
|
+
# Ensure is_truncated field exists in result
|
|
359
|
+
if "is_truncated" not in result:
|
|
360
|
+
# For backward compatibility, calculate if not present
|
|
361
|
+
result["is_truncated"] = result.get("total_matches", 0) > len(result.get("results", []))
|
|
362
|
+
|
|
363
|
+
# Group by domain if requested
|
|
364
|
+
if group_by_domain_bool and "results" in result:
|
|
365
|
+
by_domain = {}
|
|
366
|
+
for entity in result["results"]:
|
|
367
|
+
domain = entity.get("domain", entity["entity_id"].split(".")[0])
|
|
368
|
+
if domain not in by_domain:
|
|
369
|
+
by_domain[domain] = []
|
|
370
|
+
by_domain[domain].append(entity)
|
|
371
|
+
result["by_domain"] = by_domain
|
|
372
|
+
|
|
373
|
+
result["search_type"] = search_type
|
|
374
|
+
|
|
375
|
+
# Add warning and partial flag if fallback was used
|
|
376
|
+
if warning:
|
|
377
|
+
result["warning"] = warning
|
|
378
|
+
result["partial"] = True
|
|
379
|
+
|
|
380
|
+
return await add_timezone_metadata(client, result)
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
error_response = exception_to_structured_error(
|
|
384
|
+
e,
|
|
385
|
+
context={
|
|
386
|
+
"query": query,
|
|
387
|
+
"domain_filter": domain_filter,
|
|
388
|
+
"area_filter": area_filter,
|
|
389
|
+
},
|
|
390
|
+
)
|
|
391
|
+
# Add search-specific suggestions
|
|
392
|
+
if "error" in error_response and isinstance(error_response["error"], dict):
|
|
393
|
+
error_response["error"]["suggestions"] = [
|
|
394
|
+
"Check Home Assistant connection",
|
|
395
|
+
"Try simpler search terms",
|
|
396
|
+
"Check area/domain filter spelling",
|
|
397
|
+
]
|
|
398
|
+
return await add_timezone_metadata(client, error_response)
|
|
399
|
+
|
|
400
|
+
@mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["search"], "title": "Get System Overview"})
|
|
401
|
+
@log_tool_usage
|
|
402
|
+
async def ha_get_overview(
|
|
403
|
+
detail_level: Annotated[
|
|
404
|
+
Literal["minimal", "standard", "full"],
|
|
405
|
+
Field(
|
|
406
|
+
default="standard",
|
|
407
|
+
description=(
|
|
408
|
+
"Level of detail - "
|
|
409
|
+
"'minimal': 10 random entities per domain (friendly_name only); "
|
|
410
|
+
"'standard': ALL entities per domain (friendly_name only, default); "
|
|
411
|
+
"'full': ALL entities with entity_id + friendly_name + state"
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
] = "standard",
|
|
415
|
+
max_entities_per_domain: Annotated[
|
|
416
|
+
int | None,
|
|
417
|
+
Field(
|
|
418
|
+
default=None,
|
|
419
|
+
description="Override max entities per domain (None = all). Minimal defaults to 10.",
|
|
420
|
+
),
|
|
421
|
+
] = None,
|
|
422
|
+
include_state: Annotated[
|
|
423
|
+
bool | str | None,
|
|
424
|
+
Field(
|
|
425
|
+
default=None,
|
|
426
|
+
description="Include state field for entities (None = auto based on level). Full defaults to True.",
|
|
427
|
+
),
|
|
428
|
+
] = None,
|
|
429
|
+
include_entity_id: Annotated[
|
|
430
|
+
bool | str | None,
|
|
431
|
+
Field(
|
|
432
|
+
default=None,
|
|
433
|
+
description="Include entity_id field for entities (None = auto based on level). Full defaults to True.",
|
|
434
|
+
),
|
|
435
|
+
] = None,
|
|
436
|
+
) -> dict[str, Any]:
|
|
437
|
+
"""Get AI-friendly system overview with intelligent categorization.
|
|
438
|
+
|
|
439
|
+
Returns comprehensive system information at the requested detail level,
|
|
440
|
+
including Home Assistant base_url, version, location, timezone, and entity overview.
|
|
441
|
+
Use 'standard' (default) for most queries. Optionally customize entity fields and limits.
|
|
442
|
+
"""
|
|
443
|
+
# Coerce boolean parameters that may come as strings from XML-style calls
|
|
444
|
+
include_state_bool = coerce_bool_param(include_state, "include_state", default=None)
|
|
445
|
+
include_entity_id_bool = coerce_bool_param(include_entity_id, "include_entity_id", default=None)
|
|
446
|
+
|
|
447
|
+
result = await smart_tools.get_system_overview(
|
|
448
|
+
detail_level, max_entities_per_domain, include_state_bool, include_entity_id_bool
|
|
449
|
+
)
|
|
450
|
+
result = cast(dict[str, Any], result)
|
|
451
|
+
|
|
452
|
+
# Include comprehensive system info in the overview
|
|
453
|
+
# This replaces the deprecated ha_get_system_info and ha_get_system_version tools
|
|
454
|
+
try:
|
|
455
|
+
config = await client.get_config()
|
|
456
|
+
result["system_info"] = {
|
|
457
|
+
"base_url": client.base_url,
|
|
458
|
+
"version": config.get("version"),
|
|
459
|
+
"location_name": config.get("location_name"),
|
|
460
|
+
"time_zone": config.get("time_zone"),
|
|
461
|
+
"language": config.get("language"),
|
|
462
|
+
"country": config.get("country"),
|
|
463
|
+
"currency": config.get("currency"),
|
|
464
|
+
"unit_system": config.get("unit_system", {}),
|
|
465
|
+
"latitude": config.get("latitude"),
|
|
466
|
+
"longitude": config.get("longitude"),
|
|
467
|
+
"elevation": config.get("elevation"),
|
|
468
|
+
"config_dir": config.get("config_dir"),
|
|
469
|
+
"allowlist_external_dirs": config.get("allowlist_external_dirs", []),
|
|
470
|
+
"allowlist_external_urls": config.get("allowlist_external_urls", []),
|
|
471
|
+
"components": config.get("components", []),
|
|
472
|
+
"components_loaded": len(config.get("components", [])),
|
|
473
|
+
"state": config.get("state"),
|
|
474
|
+
"safe_mode": config.get("safe_mode", False),
|
|
475
|
+
"internal_url": config.get("internal_url"),
|
|
476
|
+
"external_url": config.get("external_url"),
|
|
477
|
+
}
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.warning(f"Failed to fetch system info for overview: {e}")
|
|
480
|
+
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
@mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["search"], "title": "Deep Search"})
|
|
484
|
+
@log_tool_usage
|
|
485
|
+
async def ha_deep_search(
|
|
486
|
+
query: str,
|
|
487
|
+
search_types: Annotated[
|
|
488
|
+
str | list[str] | None,
|
|
489
|
+
Field(
|
|
490
|
+
default=None,
|
|
491
|
+
description=(
|
|
492
|
+
"Types to search in: 'automation', 'script', 'helper'. Pass as a list of strings, "
|
|
493
|
+
"e.g. ['automation'], or a JSON array string '[\"automation\"]'. Default: all types"
|
|
494
|
+
),
|
|
495
|
+
),
|
|
496
|
+
] = None,
|
|
497
|
+
limit: int = 20,
|
|
498
|
+
) -> dict[str, Any]:
|
|
499
|
+
"""Deep search across automation, script, and helper definitions.
|
|
500
|
+
|
|
501
|
+
Searches not only entity names but also within configuration definitions including
|
|
502
|
+
triggers, actions, sequences, and other config fields. Perfect for finding automations
|
|
503
|
+
that use specific services, helpers referenced in scripts, or tracking down where
|
|
504
|
+
particular entities are being used.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
query: Search query (can be partial, with typos)
|
|
508
|
+
search_types: Types to search (list of strings, default: ["automation", "script", "helper"])
|
|
509
|
+
limit: Maximum total results to return (default: 20)
|
|
510
|
+
|
|
511
|
+
Examples:
|
|
512
|
+
- Find automations using a service: ha_deep_search("light.turn_on")
|
|
513
|
+
- Find scripts with delays: ha_deep_search("delay")
|
|
514
|
+
- Find helpers with specific options: ha_deep_search("option_a")
|
|
515
|
+
- Search all types for an entity: ha_deep_search("sensor.temperature")
|
|
516
|
+
- Search only automations: ha_deep_search("motion", search_types=["automation"])
|
|
517
|
+
|
|
518
|
+
Returns detailed matches with:
|
|
519
|
+
- match_in_name: True if query matched the entity name
|
|
520
|
+
- match_in_config: True if query matched within the configuration
|
|
521
|
+
- config: Full configuration for matched items
|
|
522
|
+
- score: Match quality score (higher is better)
|
|
523
|
+
"""
|
|
524
|
+
# Parse search_types to handle JSON string input from MCP clients
|
|
525
|
+
parsed_search_types = parse_string_list_param(search_types, "search_types")
|
|
526
|
+
try:
|
|
527
|
+
result = await smart_tools.deep_search(query, parsed_search_types, limit)
|
|
528
|
+
return cast(dict[str, Any], result)
|
|
529
|
+
except Exception as e:
|
|
530
|
+
import traceback
|
|
531
|
+
return {
|
|
532
|
+
"success": False,
|
|
533
|
+
"error": str(e),
|
|
534
|
+
"error_type": type(e).__name__,
|
|
535
|
+
"traceback": traceback.format_exc(),
|
|
536
|
+
"query": query,
|
|
537
|
+
"search_types": parsed_search_types,
|
|
538
|
+
"limit": limit,
|
|
539
|
+
"suggestions": [
|
|
540
|
+
"Check Home Assistant connection",
|
|
541
|
+
"Try simpler search terms",
|
|
542
|
+
"Check search_types are valid: 'automation', 'script', 'helper'",
|
|
543
|
+
],
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
@mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["search"], "title": "Get Entity State"})
|
|
547
|
+
@log_tool_usage
|
|
548
|
+
async def ha_get_state(entity_id: str) -> dict[str, Any]:
|
|
549
|
+
"""Get detailed state information for a Home Assistant entity with timezone metadata."""
|
|
550
|
+
try:
|
|
551
|
+
result = await client.get_entity_state(entity_id)
|
|
552
|
+
return await add_timezone_metadata(client, result)
|
|
553
|
+
except Exception as e:
|
|
554
|
+
error_str = str(e).lower()
|
|
555
|
+
# Check if entity not found
|
|
556
|
+
if "404" in error_str or "not found" in error_str:
|
|
557
|
+
error_response = create_entity_not_found_error(
|
|
558
|
+
entity_id,
|
|
559
|
+
details=str(e),
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
error_response = exception_to_structured_error(
|
|
563
|
+
e,
|
|
564
|
+
context={"entity_id": entity_id},
|
|
565
|
+
)
|
|
566
|
+
# Add entity-specific suggestions
|
|
567
|
+
if "error" in error_response and isinstance(error_response["error"], dict):
|
|
568
|
+
error_response["error"]["suggestions"] = [
|
|
569
|
+
f"Verify entity '{entity_id}' exists in Home Assistant",
|
|
570
|
+
"Check Home Assistant connection",
|
|
571
|
+
"Use ha_search_entities() to find correct entity IDs",
|
|
572
|
+
]
|
|
573
|
+
return await add_timezone_metadata(client, error_response)
|