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.
- {ha_mcp_dev-7.0.0.dev272/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.0.0.dev274}/PKG-INFO +1 -1
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/pyproject.toml +1 -1
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/server.py +118 -11
- ha_mcp_dev-7.0.0.dev274/src/ha_mcp/tools/best_practice_checker.py +392 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_automations.py +30 -7
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_scripts.py +29 -8
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/LICENSE +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/README.md +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/setup.cfg +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/card_types.json +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/dashboard_guide.md +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_info.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/utils/usage_logger.py +0 -0
- {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
- {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
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/tests/test_env_manager.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
@@ -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(
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
@@ -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(
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.0.0.dev272 → ha_mcp_dev-7.0.0.dev274}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|