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,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)