ha-mcp-dev 7.3.0.dev396__tar.gz → 7.3.0.dev397__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 (103) hide show
  1. {ha_mcp_dev-7.3.0.dev396/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.3.0.dev397}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/pyproject.toml +1 -1
  3. ha_mcp_dev-7.3.0.dev397/src/ha_mcp/tools/reference_validator.py +275 -0
  4. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_config_automations.py +11 -0
  5. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_config_scripts.py +11 -0
  6. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/util_helpers.py +31 -0
  7. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  8. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  9. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/LICENSE +0 -0
  10. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/MANIFEST.in +0 -0
  11. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/README.md +0 -0
  12. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/setup.cfg +0 -0
  13. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/__init__.py +0 -0
  14. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/__main__.py +0 -0
  15. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/_pypi_marker +0 -0
  16. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/auth/__init__.py +0 -0
  17. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/auth/consent_form.py +0 -0
  18. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/auth/provider.py +0 -0
  19. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/client/rest_client.py +0 -0
  21. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/client/websocket_client.py +0 -0
  22. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/client/websocket_listener.py +0 -0
  23. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/config.py +0 -0
  24. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/errors.py +0 -0
  25. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/py.typed +0 -0
  26. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  27. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  28. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  29. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  30. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  31. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  32. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  33. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  34. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  35. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  36. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  37. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  38. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  39. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  40. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  41. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  42. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  43. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  44. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  45. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  46. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/server.py +0 -0
  47. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/smoke_test.py +0 -0
  48. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/__init__.py +0 -0
  49. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/backup.py +0 -0
  50. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  51. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/device_control.py +0 -0
  52. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/enhanced.py +0 -0
  53. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/helpers.py +0 -0
  54. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/registry.py +0 -0
  55. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/smart_search.py +0 -0
  56. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_addons.py +0 -0
  57. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_areas.py +0 -0
  58. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  59. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  60. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_calendar.py +0 -0
  61. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_camera.py +0 -0
  62. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_categories.py +0 -0
  63. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  64. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  65. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  66. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_energy.py +0 -0
  67. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_entities.py +0 -0
  68. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  69. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_groups.py +0 -0
  70. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_hacs.py +0 -0
  71. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_history.py +0 -0
  72. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_integrations.py +0 -0
  73. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_labels.py +0 -0
  74. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  75. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_registry.py +0 -0
  76. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_resources.py +0 -0
  77. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_search.py +0 -0
  78. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_service.py +0 -0
  79. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_services.py +0 -0
  80. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_system.py +0 -0
  81. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_todo.py +0 -0
  82. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_traces.py +0 -0
  83. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_updates.py +0 -0
  84. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_utility.py +0 -0
  85. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  86. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  87. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/tools/tools_zones.py +0 -0
  88. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/transforms/__init__.py +0 -0
  89. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/transforms/categorized_search.py +0 -0
  90. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/utils/__init__.py +0 -0
  91. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/utils/config_hash.py +0 -0
  92. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/utils/domain_handlers.py +0 -0
  93. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  94. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/utils/operation_manager.py +0 -0
  95. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/utils/python_sandbox.py +0 -0
  96. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp/utils/usage_logger.py +0 -0
  97. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  98. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  99. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  100. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  101. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/tests/__init__.py +0 -0
  102. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/tests/test_constants.py +0 -0
  103. {ha_mcp_dev-7.3.0.dev396 → ha_mcp_dev-7.3.0.dev397}/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.3.0.dev396
3
+ Version: 7.3.0.dev397
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.3.0.dev396"
7
+ version = "7.3.0.dev397"
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"
@@ -0,0 +1,275 @@
1
+ """Reference validator for automation and script configs.
2
+
3
+ Walks a config dict and extracts every literal service and entity
4
+ reference, then cross-checks them against the live service and entity
5
+ registries. Produces soft warnings that flow into the existing response
6
+ alongside ``best_practice_warnings``.
7
+
8
+ Intentional limits (documented, not bugs):
9
+
10
+ - **Templates** (strings containing ``{{``) are counted and skipped.
11
+ Jinja is not rendered here; template-safe validation would need
12
+ ``POST /api/template`` round-trips and is a later follow-up.
13
+ - **Blueprint automations** (``use_blueprint`` at the root) are skipped
14
+ wholesale. Post-substitution config is not exposed by any HA API, so
15
+ the effective refs cannot be ground-truthed.
16
+ - **``device_id`` / ``area_id`` / ``label_id``** are NOT checked yet.
17
+ They require separate registry fetches and are planned for a later
18
+ pass; see #940.
19
+
20
+ Background: #940 (hallucinated ``notify.mobile_app_andrew_phone`` that
21
+ ``ha_config_set_automation`` accepted silently).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ from typing import Any, TypedDict
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Keys whose value (literal string) names a Home Assistant service in
33
+ # the form ``<domain>.<service_name>``. Both legacy (``service:``) and
34
+ # the new-style (``action:``) keys are recognized — HA accepts either
35
+ # inside automation/script action blocks.
36
+ _SERVICE_KEYS: frozenset[str] = frozenset({"service", "action"})
37
+
38
+ # Keys whose value (string or list of strings) names an entity. These
39
+ # appear in triggers, conditions, ``target:`` blocks, and service
40
+ # ``data:`` blocks — the walker is depth-agnostic so the location
41
+ # doesn't matter.
42
+ _ENTITY_KEYS: frozenset[str] = frozenset({"entity_id"})
43
+
44
+
45
+ class ExtractedRef(TypedDict):
46
+ """One reference pulled out of the config tree."""
47
+
48
+ path: str
49
+ value: str
50
+ kind: str # "service" | "entity"
51
+
52
+
53
+ class WalkerResult(TypedDict):
54
+ """Return value of :func:`extract_refs`."""
55
+
56
+ refs: list[ExtractedRef]
57
+ unvalidated_templates: int
58
+ blueprint_skipped: bool
59
+
60
+
61
+ class ValidationWarning(TypedDict):
62
+ """One warning in the tool response."""
63
+
64
+ path: str
65
+ value: str
66
+ kind: str
67
+ reason: str
68
+
69
+
70
+ def extract_refs(config: Any) -> WalkerResult:
71
+ """Pull every literal service/entity reference out of *config*.
72
+
73
+ Pure function: no network, no mutation of the input. The caller
74
+ decides what to do with the extracted refs.
75
+
76
+ Blueprint configs short-circuit with an empty ref list and
77
+ ``blueprint_skipped=True`` — the effective post-substitution config
78
+ is not reachable from ha-mcp, so it cannot be validated.
79
+ """
80
+ if isinstance(config, dict) and "use_blueprint" in config:
81
+ return {
82
+ "refs": [],
83
+ "unvalidated_templates": 0,
84
+ "blueprint_skipped": True,
85
+ }
86
+
87
+ refs: list[ExtractedRef] = []
88
+ # Wrapped in a single-element list so the inner closure can mutate
89
+ # it without a ``nonlocal`` dance.
90
+ unvalidated_templates = [0]
91
+
92
+ def _walk(node: Any, path: str) -> None:
93
+ if isinstance(node, dict):
94
+ for key, value in node.items():
95
+ sub_path = f"{path}.{key}" if path else key
96
+
97
+ if key in _SERVICE_KEYS and isinstance(value, str):
98
+ if _is_template(value):
99
+ unvalidated_templates[0] += 1
100
+ else:
101
+ refs.append(
102
+ {"path": sub_path, "value": value, "kind": "service"}
103
+ )
104
+ continue
105
+
106
+ if key in _ENTITY_KEYS:
107
+ if isinstance(value, str):
108
+ if _is_template(value):
109
+ unvalidated_templates[0] += 1
110
+ else:
111
+ refs.append(
112
+ {"path": sub_path, "value": value, "kind": "entity"}
113
+ )
114
+ continue
115
+ if isinstance(value, list):
116
+ for i, item in enumerate(value):
117
+ if not isinstance(item, str):
118
+ continue
119
+ item_path = f"{sub_path}[{i}]"
120
+ if _is_template(item):
121
+ unvalidated_templates[0] += 1
122
+ else:
123
+ refs.append(
124
+ {
125
+ "path": item_path,
126
+ "value": item,
127
+ "kind": "entity",
128
+ }
129
+ )
130
+ continue
131
+
132
+ # Neither a service nor an entity key: recurse so deeply
133
+ # nested action blocks (choose/if/parallel/repeat) still
134
+ # get walked.
135
+ _walk(value, sub_path)
136
+
137
+ elif isinstance(node, list):
138
+ for i, item in enumerate(node):
139
+ _walk(item, f"{path}[{i}]")
140
+ # Primitives: nothing to extract.
141
+
142
+ _walk(config, "")
143
+ return {
144
+ "refs": refs,
145
+ "unvalidated_templates": unvalidated_templates[0],
146
+ "blueprint_skipped": False,
147
+ }
148
+
149
+
150
+ def _is_template(value: str) -> bool:
151
+ """Return True if *value* looks like a Jinja template."""
152
+ return "{{" in value
153
+
154
+
155
+ def build_service_index(services_payload: Any) -> dict[str, set[str]]:
156
+ """Turn ``/api/services`` output into a ``{domain: {services}}`` map.
157
+
158
+ HA returns a list of ``{"domain": str, "services": {name: {...}}}``
159
+ objects — one per domain. Any malformed entry is skipped silently.
160
+ """
161
+ index: dict[str, set[str]] = {}
162
+ if not isinstance(services_payload, list):
163
+ return index
164
+ for entry in services_payload:
165
+ if not isinstance(entry, dict):
166
+ continue
167
+ domain = entry.get("domain")
168
+ services = entry.get("services")
169
+ if not isinstance(domain, str) or not isinstance(services, dict):
170
+ continue
171
+ index[domain] = set(services.keys())
172
+ return index
173
+
174
+
175
+ def build_entity_set(states_payload: Any) -> set[str]:
176
+ """Turn ``/api/states`` output into a set of entity_ids."""
177
+ entities: set[str] = set()
178
+ if not isinstance(states_payload, list):
179
+ return entities
180
+ for entry in states_payload:
181
+ if isinstance(entry, dict):
182
+ entity_id = entry.get("entity_id")
183
+ if isinstance(entity_id, str):
184
+ entities.add(entity_id)
185
+ return entities
186
+
187
+
188
+ def check_refs(
189
+ refs: list[ExtractedRef],
190
+ service_index: dict[str, set[str]],
191
+ entity_set: set[str],
192
+ ) -> list[ValidationWarning]:
193
+ """Return one warning per ref that isn't in the registry."""
194
+ warnings: list[ValidationWarning] = []
195
+ for ref in refs:
196
+ value = ref["value"]
197
+ if ref["kind"] == "service":
198
+ domain, _, service_name = value.partition(".")
199
+ if (
200
+ not service_name
201
+ or domain not in service_index
202
+ or service_name not in service_index[domain]
203
+ ):
204
+ warnings.append(
205
+ {
206
+ "path": ref["path"],
207
+ "value": value,
208
+ "kind": "service",
209
+ "reason": "not found in service registry",
210
+ }
211
+ )
212
+ elif ref["kind"] == "entity":
213
+ if value not in entity_set:
214
+ warnings.append(
215
+ {
216
+ "path": ref["path"],
217
+ "value": value,
218
+ "kind": "entity",
219
+ "reason": "not found in entity registry",
220
+ }
221
+ )
222
+ return warnings
223
+
224
+
225
+ async def validate_config_references(
226
+ client: Any, config: dict[str, Any]
227
+ ) -> dict[str, Any]:
228
+ """Walk *config*, fetch registries, return validation metadata.
229
+
230
+ Errors from the two registry fetches are logged and swallowed so
231
+ validation can never break the happy path of
232
+ ``ha_config_set_automation`` / ``ha_config_set_script``.
233
+
234
+ Returns a dict with three keys:
235
+
236
+ - ``warnings`` - list of :class:`ValidationWarning`, empty on success
237
+ - ``unvalidated_templates`` - int, templated strings skipped by the
238
+ walker
239
+ - ``blueprint_skipped`` - bool, True iff the root config uses
240
+ ``use_blueprint``
241
+ """
242
+ walker_result = extract_refs(config)
243
+
244
+ if walker_result["blueprint_skipped"] or not walker_result["refs"]:
245
+ return {
246
+ "warnings": [],
247
+ "unvalidated_templates": walker_result["unvalidated_templates"],
248
+ "blueprint_skipped": walker_result["blueprint_skipped"],
249
+ }
250
+
251
+ try:
252
+ services_payload, states_payload = await asyncio.gather(
253
+ client.get_services(),
254
+ client.get_states(),
255
+ )
256
+ except Exception:
257
+ logger.exception(
258
+ "Reference validator: failed to fetch service/entity registries; "
259
+ "skipping validation for this call"
260
+ )
261
+ return {
262
+ "warnings": [],
263
+ "unvalidated_templates": walker_result["unvalidated_templates"],
264
+ "blueprint_skipped": False,
265
+ }
266
+
267
+ service_index = build_service_index(services_payload)
268
+ entity_set = build_entity_set(states_payload)
269
+ warnings = check_refs(walker_result["refs"], service_index, entity_set)
270
+
271
+ return {
272
+ "warnings": warnings,
273
+ "unvalidated_templates": walker_result["unvalidated_templates"],
274
+ "blueprint_skipped": False,
275
+ }
@@ -37,10 +37,12 @@ from .helpers import (
37
37
  raise_tool_error,
38
38
  register_tool_methods,
39
39
  )
40
+ from .reference_validator import validate_config_references
40
41
  from .util_helpers import (
41
42
  apply_entity_category,
42
43
  coerce_bool_param,
43
44
  fetch_entity_category,
45
+ merge_validation_meta,
44
46
  parse_json_param,
45
47
  wait_for_entity_registered,
46
48
  wait_for_entity_removed,
@@ -641,6 +643,13 @@ class AutomationConfigTools:
641
643
  config_dict, skill_prefix=_get_skill_prefix()
642
644
  )
643
645
 
646
+ # Cross-check literal service and entity references against
647
+ # the live registries. Soft warnings only — the write still
648
+ # happens, even when references don't resolve (#940).
649
+ validation_meta = await validate_config_references(
650
+ self._client, config_dict
651
+ )
652
+
644
653
  result = await self._client.upsert_automation_config(config_dict, identifier)
645
654
 
646
655
  # If the client could not verify the entity was registered, warn but don't hard-fail.
@@ -677,6 +686,8 @@ class AutomationConfigTools:
677
686
  if bp_warnings:
678
687
  result["best_practice_warnings"] = bp_warnings
679
688
 
689
+ merge_validation_meta(result, validation_meta)
690
+
680
691
  return {
681
692
  "success": True,
682
693
  **result,
@@ -31,10 +31,12 @@ from .helpers import (
31
31
  raise_tool_error,
32
32
  register_tool_methods,
33
33
  )
34
+ from .reference_validator import validate_config_references
34
35
  from .util_helpers import (
35
36
  apply_entity_category,
36
37
  coerce_bool_param,
37
38
  fetch_entity_category,
39
+ merge_validation_meta,
38
40
  parse_json_param,
39
41
  wait_for_entity_registered,
40
42
  wait_for_entity_removed,
@@ -523,6 +525,13 @@ class ConfigScriptTools:
523
525
  config_dict, skill_prefix=_get_skill_prefix()
524
526
  )
525
527
 
528
+ # Cross-check literal service and entity references against
529
+ # the live registries. Soft warnings only — the write still
530
+ # happens, even when references don't resolve (#940).
531
+ validation_meta = await validate_config_references(
532
+ self._client, config_dict
533
+ )
534
+
526
535
  result = await self._client.upsert_script_config(config_dict, script_id)
527
536
 
528
537
  # Wait for script to be queryable
@@ -545,6 +554,8 @@ class ConfigScriptTools:
545
554
  if bp_warnings:
546
555
  result["best_practice_warnings"] = bp_warnings
547
556
 
557
+ merge_validation_meta(result, validation_meta)
558
+
548
559
  return {
549
560
  "success": True,
550
561
  **result,
@@ -530,3 +530,34 @@ async def apply_entity_category(
530
530
  result_dict["category_warning"] = (
531
531
  f"{entity_type.capitalize()} saved but failed to set category: {e}"
532
532
  )
533
+
534
+
535
+ def merge_validation_meta(
536
+ result: dict[str, Any], validation_meta: dict[str, Any]
537
+ ) -> None:
538
+ """Attach reference-validator output to a set-tool success ``result``.
539
+
540
+ Produces a single nested ``validation`` field when there's anything
541
+ worth reporting - warnings, skipped templates, or a blueprint
542
+ short-circuit. Keeps the happy-path response unchanged.
543
+
544
+ Shared between ``ha_config_set_automation`` and
545
+ ``ha_config_set_script``; see
546
+ :mod:`ha_mcp.tools.reference_validator` for the validator itself
547
+ and #940 for background.
548
+ """
549
+ warnings = validation_meta.get("warnings") or []
550
+ unvalidated_templates = validation_meta.get("unvalidated_templates") or 0
551
+ blueprint_skipped = bool(validation_meta.get("blueprint_skipped"))
552
+
553
+ if not warnings and not unvalidated_templates and not blueprint_skipped:
554
+ return
555
+
556
+ entry: dict[str, Any] = {}
557
+ if warnings:
558
+ entry["warnings"] = warnings
559
+ if unvalidated_templates:
560
+ entry["unvalidated_templates"] = unvalidated_templates
561
+ if blueprint_skipped:
562
+ entry["blueprint_skipped"] = True
563
+ result["validation"] = entry
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.3.0.dev396
3
+ Version: 7.3.0.dev397
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
@@ -43,6 +43,7 @@ src/ha_mcp/tools/best_practice_checker.py
43
43
  src/ha_mcp/tools/device_control.py
44
44
  src/ha_mcp/tools/enhanced.py
45
45
  src/ha_mcp/tools/helpers.py
46
+ src/ha_mcp/tools/reference_validator.py
46
47
  src/ha_mcp/tools/registry.py
47
48
  src/ha_mcp/tools/smart_search.py
48
49
  src/ha_mcp/tools/tools_addons.py