ha-mcp-dev 7.0.0.dev272__tar.gz → 7.0.0.dev274__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {ha_mcp_dev-7.0.0.dev272/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.0.0.dev274}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/server.py +118 -11
  4. ha_mcp_dev-7.0.0.dev274/src/ha_mcp/tools/best_practice_checker.py +392 -0
  5. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_automations.py +30 -7
  6. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_scripts.py +29 -8
  7. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  8. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  9. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/LICENSE +0 -0
  10. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/MANIFEST.in +0 -0
  11. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/README.md +0 -0
  12. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/setup.cfg +0 -0
  13. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/__init__.py +0 -0
  14. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/__main__.py +0 -0
  15. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/_pypi_marker +0 -0
  16. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/auth/__init__.py +0 -0
  17. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/auth/consent_form.py +0 -0
  18. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/auth/provider.py +0 -0
  19. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/rest_client.py +0 -0
  21. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/websocket_client.py +0 -0
  22. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/websocket_listener.py +0 -0
  23. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/config.py +0 -0
  24. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/errors.py +0 -0
  25. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/py.typed +0 -0
  26. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/card_types.json +0 -0
  27. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/dashboard_guide.md +0 -0
  28. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  29. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  30. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  31. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  32. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  33. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  34. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  35. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  36. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  37. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  38. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  39. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  40. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  41. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  42. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  43. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  44. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/smoke_test.py +0 -0
  45. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/__init__.py +0 -0
  46. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/backup.py +0 -0
  47. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/device_control.py +0 -0
  48. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/enhanced.py +0 -0
  49. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/helpers.py +0 -0
  50. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/registry.py +0 -0
  51. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/smart_search.py +0 -0
  52. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_addons.py +0 -0
  53. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_areas.py +0 -0
  54. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  55. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  56. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_calendar.py +0 -0
  57. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_camera.py +0 -0
  58. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  59. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  60. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  61. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_info.py +0 -0
  62. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_entities.py +0 -0
  63. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  64. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_groups.py +0 -0
  65. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_hacs.py +0 -0
  66. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_history.py +0 -0
  67. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_integrations.py +0 -0
  68. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_labels.py +0 -0
  69. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  70. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_registry.py +0 -0
  71. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_resources.py +0 -0
  72. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_search.py +0 -0
  73. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_service.py +0 -0
  74. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_services.py +0 -0
  75. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_system.py +0 -0
  76. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_todo.py +0 -0
  77. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_traces.py +0 -0
  78. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_updates.py +0 -0
  79. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_utility.py +0 -0
  80. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  81. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_zones.py +0 -0
  82. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/util_helpers.py +0 -0
  83. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/__init__.py +0 -0
  84. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/domain_handlers.py +0 -0
  85. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  86. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/operation_manager.py +0 -0
  87. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/python_sandbox.py +0 -0
  88. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/usage_logger.py +0 -0
  89. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  90. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  91. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  92. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  93. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/tests/__init__.py +0 -0
  94. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/tests/test_constants.py +0 -0
  95. {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.0.0.dev272
3
+ Version: 7.0.0.dev274
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.0.0.dev272"
7
+ version = "7.0.0.dev274"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -67,6 +67,7 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
67
67
  self._smart_tools: Any = None
68
68
  self._device_tools: Any = None
69
69
  self._tools_registry: ToolsRegistry | None = None
70
+ self._skill_tool_names: list[str] = []
70
71
 
71
72
  # Get server name/version from settings if no client provided
72
73
  if not self._client_provided:
@@ -208,14 +209,12 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
208
209
 
209
210
  return header + "\n".join(skill_blocks)
210
211
 
211
- def _build_skill_block(
212
- self, skill_name: str, main_file: Path
213
- ) -> str | None:
214
- """Build an instruction block for a single skill.
212
+ @staticmethod
213
+ def _parse_skill_frontmatter(main_file: Path) -> dict | None:
214
+ """Parse YAML frontmatter from a SKILL.md file.
215
215
 
216
- Reads the description field from YAML frontmatter and includes it
217
- verbatim. The description is designed for LLM consumption and
218
- contains its own trigger conditions and symptom indicators.
216
+ Returns the frontmatter dict if valid, or None with a logged
217
+ warning for each failure case.
219
218
  """
220
219
  try:
221
220
  content = main_file.read_text(encoding="utf-8")
@@ -223,7 +222,6 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
223
222
  logger.warning("Could not read %s", main_file)
224
223
  return None
225
224
 
226
- # Extract YAML frontmatter between --- markers
227
225
  parts = content.split("---", 2)
228
226
  if len(parts) < 3:
229
227
  logger.warning("No valid frontmatter delimiters in %s", main_file)
@@ -239,15 +237,34 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
239
237
  logger.warning("Frontmatter is not a mapping in %s", main_file)
240
238
  return None
241
239
 
242
- description = frontmatter.get("description", "")
243
- if not description:
244
- logger.warning("No description in frontmatter for skill %s", skill_name)
240
+ if not frontmatter.get("description", ""):
241
+ logger.warning(
242
+ "No description in frontmatter for %s", main_file.parent.name
243
+ )
244
+ return None
245
+
246
+ return frontmatter
247
+
248
+ def _build_skill_block(
249
+ self, skill_name: str, main_file: Path
250
+ ) -> str | None:
251
+ """Build an instruction block for a single skill.
252
+
253
+ Reads the description field from YAML frontmatter and includes it
254
+ verbatim. The description is designed for LLM consumption and
255
+ contains its own trigger conditions and symptom indicators.
256
+ """
257
+ frontmatter = self._parse_skill_frontmatter(main_file)
258
+ if not frontmatter:
245
259
  return None
246
260
 
261
+ description = frontmatter["description"]
247
262
  uri = f"skill://{skill_name}/SKILL.md"
248
263
 
249
264
  return f"\n### Skill: {skill_name} ({uri})\n{description.strip()}"
250
265
 
266
+
267
+
251
268
  def _register_skills(self) -> None:
252
269
  """Register bundled HA best-practice skills as MCP resources.
253
270
 
@@ -308,6 +325,96 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
308
325
  "Failed to expose skills as tools (resources still available)"
309
326
  )
310
327
 
328
+ # Phase 4: Register skill guidance tools for clients that don't read
329
+ # server instructions (e.g., claude.ai). The tool description contains
330
+ # the trigger conditions so the AI sees them in the tool listing.
331
+ # Names stored for pinning in search transforms (always-visible).
332
+ self._register_skill_guidance_tools(skills_dir)
333
+
334
+ def _register_skill_guidance_tools(self, skills_dir: Path) -> None:
335
+ """Register a lightweight guidance tool per skill.
336
+
337
+ Clients like claude.ai don't read the MCP server instructions field,
338
+ so the bootstrap prompt (trigger conditions, symptoms) is invisible.
339
+ This registers a tool per skill whose description contains the trigger
340
+ conditions. The tool itself just lists available reference files —
341
+ actual content is loaded on demand via read_resource.
342
+ """
343
+ try:
344
+ entries = sorted(skills_dir.iterdir())
345
+ except OSError:
346
+ logger.warning("Could not read skills directory: %s", skills_dir)
347
+ return
348
+
349
+ for skill_dir in entries:
350
+ main_file = skill_dir / "SKILL.md"
351
+ if not skill_dir.is_dir() or not main_file.exists():
352
+ continue
353
+
354
+ frontmatter = self._parse_skill_frontmatter(main_file)
355
+ if not frontmatter:
356
+ continue
357
+
358
+ description = frontmatter["description"].strip()
359
+ skill_name = skill_dir.name
360
+ tool_name = f"ha_get_skill_{skill_name.replace('-', '_')}"
361
+ uri = f"skill://{skill_name}/SKILL.md"
362
+
363
+ tool_description = (
364
+ f"CALL THIS FIRST before performing matching actions. "
365
+ f"{description}\n\n"
366
+ f"Returns available reference files. Use read_resource with "
367
+ f"the file URI to load specific guides as needed."
368
+ )
369
+
370
+ # Collect available reference files for the listing.
371
+ # Filter out symlinks and verify path containment to prevent
372
+ # traversal via symlinked directories.
373
+ ref_files = []
374
+ resolved_root = skill_dir.resolve()
375
+ try:
376
+ for f in sorted(skill_dir.rglob("*")):
377
+ if not f.is_file() or f.is_symlink():
378
+ continue
379
+ # Ensure resolved path stays within the skill directory
380
+ if not f.resolve().is_relative_to(resolved_root):
381
+ continue
382
+ rel = f.relative_to(skill_dir)
383
+ ref_uri = f"skill://{skill_name}/{rel}"
384
+ ref_files.append({"name": str(rel), "uri": ref_uri})
385
+ except OSError:
386
+ logger.warning("Error reading skill files in %s", skill_dir)
387
+
388
+ # Use factory to capture ref_files in closure
389
+ def _make_skill_handler(
390
+ s_name: str, s_uri: str, files: list[dict[str, str]],
391
+ ):
392
+ async def handler() -> dict[str, Any]:
393
+ return {
394
+ "skill": s_name,
395
+ "skill_uri": s_uri,
396
+ "how_to_use": (
397
+ "Use read_resource with a file URI below to load "
398
+ "the specific reference you need. Start with "
399
+ "SKILL.md for the decision workflow."
400
+ ),
401
+ "available_files": files,
402
+ }
403
+ return handler
404
+
405
+ self.mcp.tool(
406
+ name=tool_name,
407
+ description=tool_description,
408
+ annotations={"readOnlyHint": True},
409
+ )(_make_skill_handler(skill_name, uri, ref_files))
410
+
411
+ self._skill_tool_names.append(tool_name)
412
+ logger.info(
413
+ "Registered skill guidance tool %s (%d reference files)",
414
+ tool_name,
415
+ len(ref_files),
416
+ )
417
+
311
418
  # Helper methods required by EnhancedToolsMixin
312
419
 
313
420
  async def smart_entity_search(
@@ -0,0 +1,392 @@
1
+ """Reactive best-practice checker for HA automation/script configs.
2
+
3
+ Stateless payload inspection — returns warnings pointing to skill reference
4
+ files. Zero overhead on clean calls (returns empty list).
5
+
6
+ When skills are enabled (ENABLE_SKILLS=true), warnings include skill:// URIs
7
+ so the LLM can read the relevant reference file. When skills are disabled,
8
+ callers should pass a fallback prefix (e.g. GitHub URLs) or None to omit
9
+ references entirely.
10
+
11
+ Anti-patterns sourced from:
12
+ https://github.com/homeassistant-ai/skills
13
+ skill://home-assistant-best-practices
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any
20
+
21
+ _SKILL_URI_PREFIX = "skill://home-assistant-best-practices/references"
22
+ _GITHUB_URL_PREFIX = (
23
+ "https://github.com/homeassistant-ai/skills/blob/main"
24
+ "/skills/home-assistant-best-practices/references"
25
+ )
26
+ _DEFAULT_SKILL_PREFIX = _SKILL_URI_PREFIX
27
+
28
+
29
+ def get_skill_prefix() -> str:
30
+ """Return the appropriate skill prefix based on global settings.
31
+
32
+ When skills are enabled (ENABLE_SKILLS=true), returns skill:// URIs.
33
+ Otherwise falls back to GitHub URLs for the reference files.
34
+ """
35
+ from ..config import get_global_settings
36
+
37
+ if get_global_settings().enable_skills:
38
+ return _SKILL_URI_PREFIX
39
+ return _GITHUB_URL_PREFIX
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Regex patterns for template anti-patterns
43
+ # ---------------------------------------------------------------------------
44
+
45
+ # float/int comparison: | float > 25, | int(0) >= 10, float(x) < 5
46
+ _RE_NUMERIC_CMP = re.compile(
47
+ r"\|\s*(?:float|int)\s*(?:\([^)]*\)\s*)?[><]=?"
48
+ r"|(?:float|int)\s*\([^)]*\)\s*[><]=?"
49
+ )
50
+ # is_state() call (not is_state_attr)
51
+ _RE_IS_STATE = re.compile(r"\bis_state\s*\(")
52
+ # now().hour or now().minute
53
+ _RE_NOW_TIME = re.compile(r"\bnow\(\)\s*\.\s*(?:hour|minute)\b")
54
+ # now().weekday() / now().isoweekday() / now().strftime('%A'|'%w')
55
+ _RE_WEEKDAY = re.compile(
56
+ r"\bnow\(\)\s*\.\s*(?:weekday|isoweekday)\s*\("
57
+ r"|\bnow\(\)\s*\.\s*strftime\s*\(\s*['\"]%[Aaw]['\"]"
58
+ )
59
+ # sun.sun entity references
60
+ _RE_SUN = re.compile(r"(?:is_state|state_attr|states)\s*\(\s*['\"]sun\.sun['\"]")
61
+ # states('x') in [...] or states('x') in (...)
62
+ _RE_STATE_IN = re.compile(r"states\s*\([^)]+\)\s+in\s+[\[(]")
63
+ # Unsafe direct state access: states.sensor.x.state
64
+ _RE_DIRECT_STATE = re.compile(r"\bstates\.\w+\.\w+\.state\b")
65
+ # Motion entity pattern
66
+ _RE_MOTION = re.compile(r"binary_sensor\.\w*motion", re.IGNORECASE)
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Public API
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def check_automation_config(
75
+ config: dict[str, Any],
76
+ *,
77
+ skill_prefix: str | None = _DEFAULT_SKILL_PREFIX,
78
+ ) -> list[str]:
79
+ """Return best-practice warnings for an automation config.
80
+
81
+ Args:
82
+ config: The automation configuration dict.
83
+ skill_prefix: Base URI for skill references (e.g.
84
+ "skill://home-assistant-best-practices/references").
85
+ Pass None when skills are disabled — warnings still fire
86
+ but without the "See skill://..." suffix.
87
+ """
88
+ if "use_blueprint" in config:
89
+ return []
90
+
91
+ warnings: list[str] = []
92
+
93
+ # Condition templates
94
+ _check_condition_templates(config.get("condition", []), warnings, skill_prefix)
95
+
96
+ # Action tree (wait_template + nested conditions)
97
+ _check_action_tree(config.get("action", []), warnings, skill_prefix)
98
+
99
+ # Trigger templates + device_id
100
+ _check_triggers(config.get("trigger", []), warnings, skill_prefix)
101
+
102
+ # Mode vs motion pattern
103
+ _check_mode_motion(config, warnings, skill_prefix)
104
+
105
+ return _dedupe(warnings)
106
+
107
+
108
+ def check_script_config(
109
+ config: dict[str, Any],
110
+ *,
111
+ skill_prefix: str | None = _DEFAULT_SKILL_PREFIX,
112
+ ) -> list[str]:
113
+ """Return best-practice warnings for a script config.
114
+
115
+ Args:
116
+ config: The script configuration dict.
117
+ skill_prefix: Base URI for skill references.
118
+ Pass None when skills are disabled.
119
+ """
120
+ if "use_blueprint" in config:
121
+ return []
122
+
123
+ warnings: list[str] = []
124
+ _check_action_tree(config.get("sequence", []), warnings, skill_prefix)
125
+ return _dedupe(warnings)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Skill reference helper
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def _ref(skill_prefix: str | None, path: str) -> str:
134
+ """Return a ' See <URI>' suffix when skills are enabled, empty otherwise."""
135
+ if skill_prefix:
136
+ return f" See {skill_prefix}/{path}"
137
+ return ""
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Condition template checks
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def _check_condition_templates(
146
+ conditions: Any, warnings: list[str], skill_prefix: str | None
147
+ ) -> None:
148
+ """Check condition tree for template anti-patterns."""
149
+ for cond in _as_list(conditions):
150
+ if isinstance(cond, str) and "{{" in cond:
151
+ # Shorthand template condition
152
+ _check_template_string(cond, warnings, skill_prefix)
153
+ elif isinstance(cond, dict):
154
+ if cond.get("condition") == "template":
155
+ vt = cond.get("value_template", "")
156
+ if isinstance(vt, str):
157
+ _check_template_string(vt, warnings, skill_prefix)
158
+ # Recurse into compound conditions (and/or/not)
159
+ nested = cond.get("conditions")
160
+ if nested:
161
+ _check_condition_templates(nested, warnings, skill_prefix)
162
+
163
+
164
+ def _check_template_string(
165
+ template: str, warnings: list[str], skill_prefix: str | None
166
+ ) -> None:
167
+ """Check a single template string for known anti-patterns."""
168
+ if _RE_NUMERIC_CMP.search(template):
169
+ warnings.append(
170
+ "Condition uses template with float/int comparison — use native "
171
+ "`numeric_state` condition instead."
172
+ + _ref(skill_prefix, "automation-patterns.md#native-conditions")
173
+ )
174
+ if _RE_SUN.search(template):
175
+ warnings.append(
176
+ "Condition uses template referencing `sun.sun` — use native "
177
+ "`sun` condition instead."
178
+ + _ref(skill_prefix, "automation-patterns.md#native-conditions")
179
+ )
180
+ elif _RE_IS_STATE.search(template):
181
+ # Only flag if not already flagged as sun pattern
182
+ warnings.append(
183
+ "Condition uses template with `is_state()` — use native "
184
+ "`state` condition instead."
185
+ + _ref(skill_prefix, "automation-patterns.md#native-conditions")
186
+ )
187
+ if _RE_NOW_TIME.search(template):
188
+ warnings.append(
189
+ "Condition uses template with `now().hour/minute` — use native "
190
+ "`time` condition instead."
191
+ + _ref(skill_prefix, "automation-patterns.md#native-conditions")
192
+ )
193
+ if _RE_WEEKDAY.search(template):
194
+ warnings.append(
195
+ "Condition uses template for day-of-week check — use native "
196
+ "`time` condition with `weekday:` list instead."
197
+ + _ref(skill_prefix, "automation-patterns.md#native-conditions")
198
+ )
199
+ if _RE_STATE_IN.search(template):
200
+ warnings.append(
201
+ "Condition uses template with `states(...) in [...]` — use native "
202
+ "`state` condition with `state:` list instead."
203
+ + _ref(skill_prefix, "automation-patterns.md#native-conditions")
204
+ )
205
+ if _RE_DIRECT_STATE.search(template):
206
+ warnings.append(
207
+ "Template uses `states.domain.entity.state` direct access which "
208
+ "errors if entity doesn't exist — use `states('entity_id')` "
209
+ "function instead."
210
+ + _ref(skill_prefix, "template-guidelines.md#common-patterns")
211
+ )
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Action tree checks
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def _check_action_tree(
220
+ actions: Any, warnings: list[str], skill_prefix: str | None
221
+ ) -> None:
222
+ """Walk action tree checking for wait_template and nested conditions."""
223
+ for action in _as_list(actions):
224
+ if not isinstance(action, dict):
225
+ continue
226
+
227
+ if "wait_template" in action:
228
+ warnings.append(
229
+ "Action uses `wait_template` — consider `wait_for_trigger` "
230
+ "with a state trigger (note: different semantics — "
231
+ "`wait_for_trigger` waits for a *change*, `wait_template` "
232
+ "passes immediately if already true)."
233
+ + _ref(skill_prefix, "automation-patterns.md#wait-actions")
234
+ )
235
+
236
+ # Nested conditions in choose/if/repeat
237
+ if "choose" in action:
238
+ for option in _as_list(action["choose"]):
239
+ if isinstance(option, dict):
240
+ _check_condition_templates(
241
+ option.get("conditions", []), warnings, skill_prefix
242
+ )
243
+ _check_action_tree(
244
+ option.get("sequence", []), warnings, skill_prefix
245
+ )
246
+
247
+ if "if" in action:
248
+ _check_condition_templates(action["if"], warnings, skill_prefix)
249
+
250
+ for key in ("then", "else", "default"):
251
+ nested = action.get(key)
252
+ if isinstance(nested, list):
253
+ _check_action_tree(nested, warnings, skill_prefix)
254
+
255
+ if "repeat" in action and isinstance(action["repeat"], dict):
256
+ repeat = action["repeat"]
257
+ _check_condition_templates(
258
+ repeat.get("while", []), warnings, skill_prefix
259
+ )
260
+ _check_condition_templates(
261
+ repeat.get("until", []), warnings, skill_prefix
262
+ )
263
+ _check_action_tree(
264
+ repeat.get("sequence", []), warnings, skill_prefix
265
+ )
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Trigger checks
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ def _check_triggers(
274
+ triggers: Any, warnings: list[str], skill_prefix: str | None
275
+ ) -> None:
276
+ """Check triggers for device_id and template anti-patterns."""
277
+ for trigger in _as_list(triggers):
278
+ if not isinstance(trigger, dict):
279
+ continue
280
+
281
+ platform = trigger.get("platform", trigger.get("trigger", ""))
282
+
283
+ # Device trigger → prefer entity_id-based triggers
284
+ if platform == "device":
285
+ warnings.append(
286
+ "Trigger uses `device` platform with `device_id` — prefer "
287
+ "`state` or `event` trigger with `entity_id` when possible "
288
+ "(device_id breaks on re-add)."
289
+ + _ref(skill_prefix, "device-control.md#entity-id-vs-device-id")
290
+ )
291
+
292
+ # Template trigger with detectable native alternative
293
+ if platform == "template":
294
+ vt = trigger.get("value_template", "")
295
+ if isinstance(vt, str):
296
+ if _RE_NUMERIC_CMP.search(vt):
297
+ warnings.append(
298
+ "Trigger uses template with float/int comparison — "
299
+ "use native `numeric_state` trigger instead."
300
+ + _ref(
301
+ skill_prefix,
302
+ "automation-patterns.md#trigger-types",
303
+ )
304
+ )
305
+ if _RE_IS_STATE.search(vt):
306
+ warnings.append(
307
+ "Trigger uses template with `is_state()` — use "
308
+ "native `state` trigger instead."
309
+ + _ref(
310
+ skill_prefix,
311
+ "automation-patterns.md#trigger-types",
312
+ )
313
+ )
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Mode + motion check
318
+ # ---------------------------------------------------------------------------
319
+
320
+
321
+ def _check_mode_motion(
322
+ config: dict[str, Any], warnings: list[str], skill_prefix: str | None
323
+ ) -> None:
324
+ """Detect mode:single (default) with motion triggers and delay/wait."""
325
+ mode = config.get("mode", "single")
326
+ if mode != "single":
327
+ return
328
+
329
+ triggers = _as_list(config.get("trigger", []))
330
+ has_motion = any(
331
+ isinstance(t, dict)
332
+ and any(
333
+ isinstance(e, str) and _RE_MOTION.search(e)
334
+ for e in _as_list(t.get("entity_id", []))
335
+ )
336
+ for t in triggers
337
+ )
338
+ if not has_motion:
339
+ return
340
+
341
+ if _has_delay_or_wait(config.get("action", [])):
342
+ warnings.append(
343
+ "Automation uses motion trigger with delay/wait but "
344
+ "`mode: single` (default) — consider `mode: restart` so "
345
+ "re-triggers reset the timer."
346
+ + _ref(skill_prefix, "automation-patterns.md#automation-modes")
347
+ )
348
+
349
+
350
+ def _has_delay_or_wait(actions: Any) -> bool:
351
+ """Recursively check if any action uses delay or wait."""
352
+ for action in _as_list(actions):
353
+ if not isinstance(action, dict):
354
+ continue
355
+ if any(k in action for k in ("delay", "wait_for_trigger", "wait_template")):
356
+ return True
357
+ for key in ("then", "else", "default", "sequence"):
358
+ if key in action and _has_delay_or_wait(action[key]):
359
+ return True
360
+ if "choose" in action:
361
+ for opt in _as_list(action["choose"]):
362
+ if isinstance(opt, dict) and _has_delay_or_wait(
363
+ opt.get("sequence", [])
364
+ ):
365
+ return True
366
+ if "repeat" in action and isinstance(action["repeat"], dict):
367
+ if _has_delay_or_wait(action["repeat"].get("sequence", [])):
368
+ return True
369
+ return False
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Utilities
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ def _as_list(val: Any) -> list:
378
+ """Coerce a value to a list."""
379
+ if isinstance(val, list):
380
+ return val
381
+ return [val] if val else []
382
+
383
+
384
+ def _dedupe(warnings: list[str]) -> list[str]:
385
+ """Remove duplicate warnings while preserving order."""
386
+ seen: set[str] = set()
387
+ result: list[str] = []
388
+ for w in warnings:
389
+ if w not in seen:
390
+ seen.add(w)
391
+ result.append(w)
392
+ return result
@@ -16,6 +16,12 @@ from ..errors import (
16
16
  create_resource_not_found_error,
17
17
  create_validation_error,
18
18
  )
19
+ from .best_practice_checker import (
20
+ check_automation_config as _check_best_practices,
21
+ )
22
+ from .best_practice_checker import (
23
+ get_skill_prefix as _get_skill_prefix,
24
+ )
19
25
  from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
20
26
  from .util_helpers import (
21
27
  coerce_bool_param,
@@ -416,6 +422,7 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
416
422
  - Use ha_eval_template() to test Jinja2 templates before using in automations
417
423
  - Use ha_search_entities(domain_filter='automation') to find existing automations
418
424
  """
425
+ bp_warnings: list[str] = []
419
426
  try:
420
427
  # Parse JSON config if provided as string
421
428
  try:
@@ -468,6 +475,13 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
468
475
  "To create a genuinely new automation, remove the 'id' field from the config.",
469
476
  ))
470
477
 
478
+ # Pre-check for best-practice issues (used for both success
479
+ # warnings and error enrichment if the API call fails).
480
+ # Pre-check for best-practice issues.
481
+ bp_warnings = _check_best_practices(
482
+ config_dict, skill_prefix=_get_skill_prefix()
483
+ )
484
+
471
485
  result = await client.upsert_automation_config(config_dict, identifier)
472
486
 
473
487
  # If the client could not verify the entity was registered, warn but don't hard-fail.
@@ -493,6 +507,9 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
493
507
  except Exception as e:
494
508
  result["warning"] = f"Automation created but verification failed: {e}"
495
509
 
510
+ if bp_warnings:
511
+ result["best_practice_warnings"] = bp_warnings
512
+
496
513
  return {
497
514
  "success": True,
498
515
  **result,
@@ -502,16 +519,22 @@ def register_config_automation_tools(mcp: Any, client: Any, **kwargs: Any) -> No
502
519
  raise
503
520
  except Exception as e:
504
521
  logger.error(f"Error upserting automation: {e}")
522
+ suggestions = [
523
+ "Check automation configuration format",
524
+ "Ensure required fields: alias, trigger, action",
525
+ "Use entity_id format: automation.morning_routine or unique_id",
526
+ "Use ha_search_entities(domain_filter='automation') to find automations",
527
+ "Use ha_get_domain_docs('automation') for comprehensive configuration help",
528
+ ]
529
+ if bp_warnings:
530
+ suggestions.append(
531
+ "Config had best-practice issues that may be related: "
532
+ + "; ".join(bp_warnings)
533
+ )
505
534
  exception_to_structured_error(
506
535
  e,
507
536
  context={"identifier": identifier},
508
- suggestions=[
509
- "Check automation configuration format",
510
- "Ensure required fields: alias, trigger, action",
511
- "Use entity_id format: automation.morning_routine or unique_id",
512
- "Use ha_search_entities(domain_filter='automation') to find automations",
513
- "Use ha_get_domain_docs('automation') for comprehensive configuration help",
514
- ],
537
+ suggestions=suggestions,
515
538
  )
516
539
 
517
540
  @mcp.tool(
@@ -12,6 +12,12 @@ from fastmcp.exceptions import ToolError
12
12
  from pydantic import Field
13
13
 
14
14
  from ..errors import ErrorCode, create_error_response
15
+ from .best_practice_checker import (
16
+ check_script_config as _check_best_practices,
17
+ )
18
+ from .best_practice_checker import (
19
+ get_skill_prefix as _get_skill_prefix,
20
+ )
15
21
  from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
16
22
  from .util_helpers import (
17
23
  coerce_bool_param,
@@ -231,6 +237,7 @@ def register_config_script_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
231
237
  Note: Scripts use Home Assistant's action syntax. Check the documentation for advanced
232
238
  features like conditions, variables, parallel execution, and service call options.
233
239
  """
240
+ bp_warnings: list[str] = []
234
241
  try:
235
242
  # Parse JSON config if provided as string
236
243
  try:
@@ -264,6 +271,11 @@ def register_config_script_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
264
271
  context={"script_id": script_id, "required_fields": ["sequence OR use_blueprint"]},
265
272
  ))
266
273
 
274
+ # Pre-check for best-practice issues.
275
+ bp_warnings = _check_best_practices(
276
+ config_dict, skill_prefix=_get_skill_prefix()
277
+ )
278
+
267
279
  result = await client.upsert_script_config(config_dict, script_id)
268
280
 
269
281
  # Wait for script to be queryable
@@ -277,6 +289,9 @@ def register_config_script_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
277
289
  except Exception as e:
278
290
  result["warning"] = f"Script created but verification failed: {e}"
279
291
 
292
+ if bp_warnings:
293
+ result["best_practice_warnings"] = bp_warnings
294
+
280
295
  return {
281
296
  "success": True,
282
297
  **result,
@@ -285,17 +300,23 @@ def register_config_script_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
285
300
  except ToolError:
286
301
  raise
287
302
  except Exception as e:
303
+ suggestions = [
304
+ "Ensure config includes either 'sequence' field (regular scripts) or 'use_blueprint' field (blueprint-based scripts)",
305
+ "For blueprint scripts, use ha_get_blueprint(domain='script') to list available blueprints",
306
+ "Validate sequence actions syntax for regular scripts",
307
+ "Check entity_ids exist if using service calls",
308
+ "Use ha_search_entities(domain_filter='script') to find scripts",
309
+ "Use ha_get_domain_docs('script') for configuration help",
310
+ ]
311
+ if bp_warnings:
312
+ suggestions.append(
313
+ "Config had best-practice issues that may be related: "
314
+ + "; ".join(bp_warnings)
315
+ )
288
316
  exception_to_structured_error(
289
317
  e,
290
318
  context={"script_id": script_id},
291
- suggestions=[
292
- "Ensure config includes either 'sequence' field (regular scripts) or 'use_blueprint' field (blueprint-based scripts)",
293
- "For blueprint scripts, use ha_get_blueprint(domain='script') to list available blueprints",
294
- "Validate sequence actions syntax for regular scripts",
295
- "Check entity_ids exist if using service calls",
296
- "Use ha_search_entities(domain_filter='script') to find scripts",
297
- "Use ha_get_domain_docs('script') for configuration help",
298
- ],
319
+ suggestions=suggestions,
299
320
  )
300
321
 
301
322
  @mcp.tool(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.0.0.dev272
3
+ Version: 7.0.0.dev274
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -37,6 +37,7 @@ src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/referenc
37
37
  src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md
38
38
  src/ha_mcp/tools/__init__.py
39
39
  src/ha_mcp/tools/backup.py
40
+ src/ha_mcp/tools/best_practice_checker.py
40
41
  src/ha_mcp/tools/device_control.py
41
42
  src/ha_mcp/tools/enhanced.py
42
43
  src/ha_mcp/tools/helpers.py