ha-mcp-dev 7.4.1.dev448__tar.gz → 7.4.1.dev450__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 (108) hide show
  1. {ha_mcp_dev-7.4.1.dev448/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev450}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_addons.py +10 -7
  4. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_config_automations.py +4 -7
  5. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_config_dashboards.py +4 -7
  6. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_config_scripts.py +4 -7
  7. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_search.py +73 -51
  8. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/python_sandbox.py +182 -22
  9. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  10. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/LICENSE +0 -0
  11. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/README.md +0 -0
  13. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/_version.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/auth/__init__.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/auth/consent_form.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/auth/provider.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/client/__init__.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/client/rest_client.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/client/websocket_client.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/client/websocket_listener.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/config.py +0 -0
  26. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/errors.py +0 -0
  27. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/py.typed +0 -0
  28. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  29. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  30. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  31. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  36. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  39. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  45. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  46. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  47. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  48. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/server.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/settings_ui.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/smoke_test.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/__init__.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/backup.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/device_control.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/enhanced.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/helpers.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/reference_validator.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/registry.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/smart_search.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_areas.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_calendar.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_camera.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_categories.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_code.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_energy.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_entities.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_groups.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_hacs.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_history.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_integrations.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_labels.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_registry.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_resources.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_service.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_services.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_system.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_todo.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_traces.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_updates.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_utility.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/tools_zones.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/tools/util_helpers.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/transforms/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/transforms/categorized_search.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/__init__.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/config_hash.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/data_paths.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/domain_handlers.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/operation_manager.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp/utils/usage_logger.py +0 -0
  101. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  104. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  105. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  106. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/tests/__init__.py +0 -0
  107. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/tests/test_constants.py +0 -0
  108. {ha_mcp_dev-7.4.1.dev448 → ha_mcp_dev-7.4.1.dev450}/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.4.1.dev448
3
+ Version: 7.4.1.dev450
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.4.1.dev448"
7
+ version = "7.4.1.dev450"
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"
@@ -28,7 +28,11 @@ from ..errors import (
28
28
  create_error_response,
29
29
  create_validation_error,
30
30
  )
31
- from ..utils.python_sandbox import PythonSandboxError, safe_execute_expression
31
+ from ..utils.python_sandbox import (
32
+ PythonSandboxError,
33
+ format_sandbox_error,
34
+ safe_execute_expression,
35
+ )
32
36
  from .helpers import (
33
37
  exception_to_structured_error,
34
38
  get_connected_ws_client,
@@ -179,16 +183,15 @@ def _apply_response_transform(response: Any, expr: str) -> Any:
179
183
  try:
180
184
  return safe_execute_expression(expr, {"response": response}, "response")
181
185
  except PythonSandboxError as e:
186
+ message, suggestions = format_sandbox_error(
187
+ e, expr, variable_name="response"
188
+ )
182
189
  raise_tool_error(
183
190
  create_error_response(
184
191
  ErrorCode.VALIDATION_FAILED,
185
- f"python_transform failed: {e!s}",
192
+ message,
186
193
  context={"expression_preview": expr[:200]},
187
- suggestions=[
188
- "Operate on the `response` variable (in-place or reassign)",
189
- "Allowed: dict/list access, assignment, loops, "
190
- "comprehensions, whitelisted str/list/dict methods",
191
- ],
194
+ suggestions=suggestions,
192
195
  )
193
196
  )
194
197
 
@@ -22,6 +22,7 @@ from ..errors import (
22
22
  from ..utils.config_hash import compute_config_hash
23
23
  from ..utils.python_sandbox import (
24
24
  PythonSandboxError,
25
+ format_sandbox_error,
25
26
  get_security_documentation,
26
27
  safe_execute,
27
28
  )
@@ -552,16 +553,12 @@ class AutomationConfigTools:
552
553
  try:
553
554
  transformed_config = safe_execute(python_transform, current_config)
554
555
  except PythonSandboxError as e:
556
+ message, suggestions = format_sandbox_error(e, python_transform)
555
557
  raise_tool_error(
556
558
  create_error_response(
557
559
  ErrorCode.VALIDATION_FAILED,
558
- str(e),
559
- suggestions=[
560
- "Check expression syntax",
561
- "Ensure only allowed operations are used",
562
- "See tool description for allowed operations",
563
- f"Expression: {python_transform[:100]}{'...' if len(python_transform) > 100 else ''}",
564
- ],
560
+ message,
561
+ suggestions=suggestions,
565
562
  context={"action": "python_transform", "identifier": identifier},
566
563
  )
567
564
  )
@@ -16,6 +16,7 @@ from ..errors import ErrorCode, create_error_response, create_resource_not_found
16
16
  from ..utils.config_hash import compute_config_hash
17
17
  from ..utils.python_sandbox import (
18
18
  PythonSandboxError,
19
+ format_sandbox_error,
19
20
  get_security_documentation,
20
21
  safe_execute,
21
22
  )
@@ -1069,16 +1070,12 @@ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> Non
1069
1070
  try:
1070
1071
  transformed_config = safe_execute(python_transform, current_config)
1071
1072
  except PythonSandboxError as e:
1073
+ message, suggestions = format_sandbox_error(e, python_transform)
1072
1074
  raise_tool_error(
1073
1075
  create_error_response(
1074
1076
  ErrorCode.VALIDATION_FAILED,
1075
- str(e),
1076
- suggestions=[
1077
- "Check expression syntax",
1078
- "Ensure only allowed operations are used",
1079
- "See tool description for allowed operations",
1080
- f"Expression: {python_transform[:100]}...",
1081
- ],
1077
+ message,
1078
+ suggestions=suggestions,
1082
1079
  context={
1083
1080
  "action": "python_transform",
1084
1081
  "url_path": url_path,
@@ -16,6 +16,7 @@ from ..errors import ErrorCode, create_error_response
16
16
  from ..utils.config_hash import compute_config_hash
17
17
  from ..utils.python_sandbox import (
18
18
  PythonSandboxError,
19
+ format_sandbox_error,
19
20
  get_security_documentation,
20
21
  safe_execute,
21
22
  )
@@ -446,16 +447,12 @@ class ConfigScriptTools:
446
447
  try:
447
448
  transformed_config = safe_execute(python_transform, actual_config)
448
449
  except PythonSandboxError as e:
450
+ message, suggestions = format_sandbox_error(e, python_transform)
449
451
  raise_tool_error(
450
452
  create_error_response(
451
453
  ErrorCode.VALIDATION_FAILED,
452
- str(e),
453
- suggestions=[
454
- "Check expression syntax",
455
- "Ensure only allowed operations are used",
456
- "See tool description for allowed operations",
457
- f"Expression: {python_transform[:100]}{'...' if len(python_transform) > 100 else ''}",
458
- ],
454
+ message,
455
+ suggestions=suggestions,
459
456
  context={"action": "python_transform", "script_id": script_id},
460
457
  )
461
458
  )
@@ -32,20 +32,13 @@ def _build_pagination_metadata(
32
32
  """Build standardized pagination metadata for search responses.
33
33
 
34
34
  Thin wrapper around the shared ``build_pagination_metadata`` helper that
35
- keeps the existing call-site signature (accepts a *results* list and uses
36
- ``total_matches`` as the key name expected by search tools).
35
+ keeps the existing call-site signature (accepts a *results* list) and
36
+ renames ``total_count`` ``total_matches`` to match the search tools'
37
+ response shape.
37
38
  """
38
39
  meta = build_pagination_metadata(total_matches, offset, limit, len(results))
39
- # Search tools use "total_matches" instead of "total_count"
40
- # construct explicitly to avoid fragile dependency on shared helper's key names
41
- return {
42
- "total_matches": meta["total_count"],
43
- "offset": meta["offset"],
44
- "limit": meta["limit"],
45
- "count": meta["count"],
46
- "has_more": meta["has_more"],
47
- "next_offset": meta["next_offset"],
48
- }
40
+ meta["total_matches"] = meta.pop("total_count")
41
+ return meta
49
42
 
50
43
 
51
44
  async def _exact_match_search(
@@ -241,20 +234,18 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
241
234
 
242
235
  # If we also have a query, filter the area results
243
236
  if query and query.strip():
244
- # Get all entities from all areas in the result
237
+ # Collect entities from all matched areas, applying
238
+ # domain_filter if present. get_entities_by_area is called
239
+ # with group_by_domain=True above, so entities is always a
240
+ # dict keyed by domain.
245
241
  all_area_entities = []
246
- if "areas" in area_result:
247
- for area_data in area_result["areas"].values():
248
- if "entities" in area_data:
249
- if isinstance(
250
- area_data["entities"], dict
251
- ): # grouped by domain
252
- for domain_entities in area_data[
253
- "entities"
254
- ].values():
255
- all_area_entities.extend(domain_entities)
256
- else: # flat list
257
- all_area_entities.extend(area_data["entities"])
242
+ for area_data in area_result.get("areas", {}).values():
243
+ entities = area_data.get("entities") or {}
244
+ if domain_filter:
245
+ all_area_entities.extend(entities.get(domain_filter, []))
246
+ else:
247
+ for domain_entities in entities.values():
248
+ all_area_entities.extend(domain_entities)
258
249
 
259
250
  # Apply fuzzy search to area entities
260
251
  from ..utils.fuzzy_search import create_fuzzy_searcher
@@ -303,6 +294,8 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
303
294
  "results": results,
304
295
  "search_type": "area_filtered_query",
305
296
  }
297
+ if domain_filter:
298
+ search_data["domain_filter"] = domain_filter
306
299
 
307
300
  if group_by_domain_bool:
308
301
  by_domain: dict[str, list[dict[str, Any]]] = {}
@@ -318,35 +311,58 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
318
311
  # Just area filter, return area results with enhanced format
319
312
  if area_result.get("areas"):
320
313
  first_area = next(iter(area_result["areas"].values()))
321
- by_domain = first_area.get("entities", {})
314
+ entities_data = first_area.get("entities")
315
+
316
+ # Build a flat results list, applying domain_filter and
317
+ # tagging each entity with its `domain` so the optional
318
+ # by_domain rebuild below can group without re-parsing
319
+ # entity_id. `{**entity, "domain": domain}` avoids
320
+ # mutating dicts owned by the helper.
321
+ all_results: list[dict[str, Any]] = []
322
+ for domain, entities in (entities_data or {}).items():
323
+ if domain_filter and domain != domain_filter:
324
+ continue
325
+ all_results.extend(
326
+ {**entity, "domain": domain} for entity in entities
327
+ )
322
328
 
323
- # Flatten for results while keeping by_domain structure
324
- all_results = []
325
- for domain, entities in by_domain.items():
326
- for entity in entities:
327
- entity["domain"] = domain
328
- all_results.append(entity)
329
+ paginated = all_results[offset : offset + limit]
329
330
 
330
- area_search_data = {
331
+ area_search_data: dict[str, Any] = {
331
332
  "success": True,
332
333
  "area_filter": area_filter,
333
- "total_matches": len(all_results),
334
- "results": all_results,
335
- "by_domain": by_domain,
334
+ **_build_pagination_metadata(
335
+ len(all_results), offset, limit, paginated
336
+ ),
337
+ "results": paginated,
336
338
  "search_type": "area_only",
337
339
  "area_name": first_area.get("area_name", area_filter),
338
340
  }
341
+ if domain_filter:
342
+ area_search_data["domain_filter"] = domain_filter
343
+ if group_by_domain_bool:
344
+ # Group the paginated slice (not all_results) so
345
+ # by_domain and results stay in sync.
346
+ paginated_by_domain: dict[str, list[dict[str, Any]]] = {}
347
+ for entity in paginated:
348
+ paginated_by_domain.setdefault(
349
+ entity["domain"], []
350
+ ).append(entity)
351
+ area_search_data["by_domain"] = paginated_by_domain
339
352
  return await add_timezone_metadata(client, area_search_data)
340
353
  else:
341
- empty_area_data = {
354
+ empty_area_data: dict[str, Any] = {
342
355
  "success": True,
343
356
  "area_filter": area_filter,
344
- "total_matches": 0,
357
+ **_build_pagination_metadata(0, offset, limit, []),
345
358
  "results": [],
346
- "by_domain": {},
347
359
  "search_type": "area_only",
348
360
  "message": f"No entities found in area: {area_filter}",
349
361
  }
362
+ if domain_filter:
363
+ empty_area_data["domain_filter"] = domain_filter
364
+ if group_by_domain_bool:
365
+ empty_area_data["by_domain"] = {}
350
366
  return await add_timezone_metadata(client, empty_area_data)
351
367
 
352
368
  # Regular entity search (no area filter)
@@ -929,22 +945,28 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
929
945
  MAX_ENTITIES = 100
930
946
 
931
947
  if not isinstance(entity_ids, list) or not entity_ids:
932
- raise_tool_error(create_validation_error(
933
- "entity_id must be a non-empty string or list of entity ID strings",
934
- parameter="entity_id",
935
- ))
948
+ raise_tool_error(
949
+ create_validation_error(
950
+ "entity_id must be a non-empty string or list of entity ID strings",
951
+ parameter="entity_id",
952
+ )
953
+ )
936
954
 
937
955
  if not all(isinstance(eid, str) for eid in entity_ids):
938
- raise_tool_error(create_validation_error(
939
- "All entity_id values must be strings",
940
- parameter="entity_id",
941
- ))
956
+ raise_tool_error(
957
+ create_validation_error(
958
+ "All entity_id values must be strings",
959
+ parameter="entity_id",
960
+ )
961
+ )
942
962
 
943
963
  if len(entity_ids) > MAX_ENTITIES:
944
- raise_tool_error(create_validation_error(
945
- f"Too many entity IDs: {len(entity_ids)} exceeds maximum of {MAX_ENTITIES}",
946
- parameter="entity_id",
947
- ))
964
+ raise_tool_error(
965
+ create_validation_error(
966
+ f"Too many entity IDs: {len(entity_ids)} exceeds maximum of {MAX_ENTITIES}",
967
+ parameter="entity_id",
968
+ )
969
+ )
948
970
 
949
971
  # Deduplicate while preserving order
950
972
  unique_ids = list(dict.fromkeys(entity_ids))
@@ -11,8 +11,43 @@ from typing import Any, cast
11
11
 
12
12
 
13
13
  class PythonSandboxError(Exception):
14
- """Raised when expression validation fails."""
14
+ """Base class for sandbox failures.
15
15
 
16
+ Catch this when callers don't need to distinguish validation-time
17
+ rejection from runtime exceptions — otherwise prefer the subclasses.
18
+ """
19
+
20
+
21
+ class PythonSandboxValidationError(PythonSandboxError):
22
+ """Raised when AST validation rejects the expression before execution.
23
+
24
+ The expression contains a forbidden node, function, or method, or
25
+ failed to parse. The user can fix the input.
26
+ """
27
+
28
+
29
+ class PythonSandboxExecutionError(PythonSandboxError):
30
+ """Raised when a validated expression raised at runtime.
31
+
32
+ The expression passed AST validation but produced a Python exception
33
+ when executed (e.g. KeyError on a missing key, TypeError on a bad
34
+ operation). Different from a validation failure: the *shape* of the
35
+ expression is fine, but it doesn't apply cleanly to the input data.
36
+ """
37
+
38
+
39
+ # Cap on how much of a runtime exception's text gets surfaced. HA configs
40
+ # can carry tokens / passwords / device addresses, and Python's default
41
+ # repr happily embeds dict and list values into KeyError/TypeError text.
42
+ # 240 chars is enough to identify the failure (exception type + a short
43
+ # snippet) without pasting the input config back to the caller.
44
+ _EXECUTION_ERROR_TEXT_LIMIT = 240
45
+
46
+
47
+ def _truncate_for_error(text: str, limit: int = _EXECUTION_ERROR_TEXT_LIMIT) -> str:
48
+ if len(text) <= limit:
49
+ return text
50
+ return text[: limit - 3] + "..."
16
51
 
17
52
 
18
53
  # Whitelist of safe AST node types
@@ -23,16 +58,19 @@ SAFE_NODES = {
23
58
  ast.Assign,
24
59
  ast.AugAssign, # +=, -=, etc.
25
60
  ast.AnnAssign, # type annotations
61
+ ast.Pass, # explicit no-op
26
62
  # Control flow
27
63
  ast.If,
28
64
  ast.For,
29
65
  ast.While,
30
66
  ast.Break,
31
67
  ast.Continue,
68
+ ast.IfExp, # ternary: x if c else y
32
69
  # Data access
33
70
  ast.Subscript,
34
71
  ast.Attribute,
35
72
  ast.Index,
73
+ ast.Slice, # list[1:3]
36
74
  ast.Name,
37
75
  ast.Load,
38
76
  ast.Store,
@@ -43,6 +81,9 @@ SAFE_NODES = {
43
81
  ast.Dict,
44
82
  ast.Tuple,
45
83
  ast.Set,
84
+ ast.Starred, # *iterable in calls/literals: f(*xs), [*xs, y]
85
+ ast.JoinedStr, # f"…" — outer node holding parts
86
+ ast.FormattedValue, # the {expr} part inside an f-string
46
87
  # Operations
47
88
  ast.Delete,
48
89
  ast.BinOp,
@@ -73,13 +114,54 @@ SAFE_NODES = {
73
114
  ast.IsNot,
74
115
  # Function calls (validated separately)
75
116
  ast.Call,
117
+ ast.keyword, # keyword arguments: func(key=value)
76
118
  # Comprehensions
77
119
  ast.ListComp,
78
120
  ast.DictComp,
79
121
  ast.SetComp,
122
+ ast.GeneratorExp, # (x for x in ...)
80
123
  ast.comprehension,
81
- # Lambda (for comprehensions)
124
+ # Lambda — useful as `key=` for sorted/min/max. ast.arguments and
125
+ # ast.arg are the structure nodes ast.walk descends into for the
126
+ # parameter list; they have no execution semantics on their own
127
+ # (FunctionDef would be blocked at the SAFE_NODES check above).
82
128
  ast.Lambda,
129
+ ast.arguments,
130
+ ast.arg,
131
+ }
132
+
133
+
134
+ # Hints to help agents recover when a forbidden node is encountered.
135
+ # Keyed on AST class name (string, not class) so entries for
136
+ # version-specific nodes like Match (3.10+) or TryStar (3.11+) stay
137
+ # evaluable on any Python. Unmapped keys fall through to the generic
138
+ # "Forbidden node type: X" message in `_validate_node`.
139
+ _NODE_SUGGESTIONS: dict[str, str] = {
140
+ "Try": "validate inputs with isinstance/in/.get() instead of try/except",
141
+ "TryStar": "validate inputs with isinstance/in/.get() instead of try/except",
142
+ "ExceptHandler": "validate inputs with isinstance/in/.get() instead of try/except",
143
+ "With": "perform the inner logic directly; with-blocks aren't supported",
144
+ "AsyncWith": "perform the inner logic directly; with-blocks aren't supported",
145
+ "FunctionDef": "use a list comprehension or inline the logic",
146
+ "AsyncFunctionDef": "use a list comprehension or inline the logic",
147
+ "ClassDef": "use a dict literal instead of defining a class",
148
+ "Yield": "build a list with a comprehension or for-loop append",
149
+ "YieldFrom": "build a list with a comprehension or for-loop append",
150
+ "Global": "assign directly to the variable; scope keywords aren't supported",
151
+ "Nonlocal": "assign directly to the variable; scope keywords aren't supported",
152
+ "Import": "imports aren't available; built-ins like isinstance/len/range are exposed",
153
+ "ImportFrom": "imports aren't available; built-ins like isinstance/len/range are exposed",
154
+ "Match": "use if/elif/else or a dict lookup instead of match/case",
155
+ # If Match ever enters SAFE_NODES, the sub-pattern nodes shouldn't
156
+ # silently slip through with a generic message.
157
+ "MatchAs": "use if/elif/else or a dict lookup instead of match/case",
158
+ "MatchValue": "use if/elif/else or a dict lookup instead of match/case",
159
+ "MatchClass": "use if/elif/else or a dict lookup instead of match/case",
160
+ "MatchSingleton": "use if/elif/else or a dict lookup instead of match/case",
161
+ "MatchSequence": "use if/elif/else or a dict lookup instead of match/case",
162
+ "MatchMapping": "use if/elif/else or a dict lookup instead of match/case",
163
+ "MatchOr": "use if/elif/else or a dict lookup instead of match/case",
164
+ "MatchStar": "use if/elif/else or a dict lookup instead of match/case",
83
165
  }
84
166
 
85
167
  # Whitelist of safe methods that can be called
@@ -201,12 +283,20 @@ def validate_expression(expr: str) -> tuple[bool, str]:
201
283
 
202
284
 
203
285
  def _validate_node(node: ast.AST) -> str | None:
204
- """Validate a single AST node. Returns error message or None if safe."""
205
- if type(node) not in SAFE_NODES:
206
- return f"Forbidden node type: {type(node).__name__}"
286
+ """Validate a single AST node. Returns error message or None if safe.
207
287
 
208
- if isinstance(node, (ast.Import, ast.ImportFrom)):
209
- return "Forbidden: imports not allowed"
288
+ Whitelist check first: any node not in ``SAFE_NODES`` is rejected with
289
+ its class name and (when available) a recovery hint from
290
+ ``_NODE_SUGGESTIONS``. After that, only nodes that *are* safe but need
291
+ extra checks (Attribute → block dunder access, Call → block forbidden
292
+ functions/methods) get further validation.
293
+ """
294
+ if type(node) not in SAFE_NODES:
295
+ name = type(node).__name__
296
+ hint = _NODE_SUGGESTIONS.get(name)
297
+ if hint:
298
+ return f"Forbidden node type: {name} — {hint}"
299
+ return f"Forbidden node type: {name}"
210
300
 
211
301
  if isinstance(node, ast.Attribute):
212
302
  if node.attr.startswith("__") and node.attr.endswith("__"):
@@ -215,15 +305,6 @@ def _validate_node(node: ast.AST) -> str | None:
215
305
  if isinstance(node, ast.Call):
216
306
  return _validate_call_node(node)
217
307
 
218
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
219
- return "Forbidden: function/class definitions not allowed"
220
-
221
- if isinstance(node, (ast.With, ast.AsyncWith)):
222
- return "Forbidden: with statements not allowed"
223
-
224
- if isinstance(node, (ast.Try, ast.ExceptHandler)):
225
- return "Forbidden: try/except not allowed"
226
-
227
308
  return None
228
309
 
229
310
 
@@ -279,10 +360,10 @@ def safe_execute_expression(
279
360
  """
280
361
  valid, error = validate_expression(expr)
281
362
  if not valid:
282
- raise PythonSandboxError(f"Expression validation failed: {error}")
363
+ raise PythonSandboxValidationError(error)
283
364
 
284
365
  if result_key not in variables:
285
- raise PythonSandboxError(
366
+ raise PythonSandboxValidationError(
286
367
  f"result_key {result_key!r} not found in variables",
287
368
  )
288
369
 
@@ -295,8 +376,24 @@ def safe_execute_expression(
295
376
 
296
377
  try:
297
378
  exec(expr, safe_globals, safe_locals)
379
+ except (MemoryError, RecursionError):
380
+ # Resource exhaustion — let the host decide. Reframing
381
+ # "ran out of memory" as "your transform was bad" would
382
+ # mislead the agent into rewriting an expression that
383
+ # was structurally fine.
384
+ #
385
+ # FastMCP's tool dispatch (server.py call_tool) catches
386
+ # `except Exception` and wraps in
387
+ # ``ToolError(f"Error calling tool {name!r}: {e}") from e`` —
388
+ # so the original exception's class name and text reach the
389
+ # agent, with the raw exception preserved as ``__cause__``.
390
+ # That's an acceptable surfacing (not opaque INTERNAL_ERROR).
391
+ raise
298
392
  except Exception as e:
299
- raise PythonSandboxError(f"Execution error: {type(e).__name__}: {e}") from e
393
+ # Truncate so embedded reprs of input data (config dicts, tokens,
394
+ # etc.) don't reach the caller verbatim.
395
+ detail = _truncate_for_error(f"{type(e).__name__}: {e}")
396
+ raise PythonSandboxExecutionError(detail) from e
300
397
 
301
398
  return safe_locals[result_key]
302
399
 
@@ -335,6 +432,54 @@ def safe_execute(expr: str, config: dict[str, Any]) -> dict[str, Any]:
335
432
  )
336
433
 
337
434
 
435
+ def format_sandbox_error(
436
+ error: PythonSandboxError,
437
+ expr: str,
438
+ variable_name: str = "config",
439
+ ) -> tuple[str, list[str]]:
440
+ """Build a (message, suggestions) pair appropriate for the error subclass.
441
+
442
+ ``PythonSandboxValidationError`` means the expression's shape was
443
+ rejected before execution — suggestions point at syntax/allowed-ops.
444
+ ``PythonSandboxExecutionError`` means the expression was accepted
445
+ but raised at runtime — suggestions point at keys/types/values.
446
+ Plain ``PythonSandboxError`` (no subclass) falls back to the
447
+ validation form.
448
+
449
+ ``variable_name`` is the name of the mutable target the expression
450
+ operates on. The default ``"config"`` matches the dashboard /
451
+ automation / script callers; addon helpers pass ``"response"`` and
452
+ a one-liner about that name is prepended to the suggestions so
453
+ agents know which variable to mutate.
454
+
455
+ Used by ``ha_config_set_*`` and addon helpers so each caller emits
456
+ the same shape of MCP error without duplicating the boilerplate.
457
+ """
458
+ preview = expr[:100] + ("..." if len(expr) > 100 else "")
459
+ if isinstance(error, PythonSandboxExecutionError):
460
+ message = f"Expression raised at runtime: {error}"
461
+ suggestions = [
462
+ "Verify referenced keys/indices exist in the input",
463
+ "Check that types match (e.g. dict vs list operations)",
464
+ "Use .get(key, default) to handle missing keys",
465
+ f"Expression: {preview}",
466
+ ]
467
+ else:
468
+ message = f"Expression validation failed: {error}"
469
+ suggestions = [
470
+ "Check expression syntax",
471
+ "Ensure only allowed operations are used",
472
+ "See tool description for allowed operations",
473
+ f"Expression: {preview}",
474
+ ]
475
+ if variable_name != "config":
476
+ suggestions = [
477
+ f"Operate on the `{variable_name}` variable (in-place or reassign)",
478
+ *suggestions,
479
+ ]
480
+ return message, suggestions
481
+
482
+
338
483
  def get_security_documentation() -> str:
339
484
  """
340
485
  Get formatted documentation of security restrictions.
@@ -346,12 +491,18 @@ PYTHON TRANSFORM SECURITY:
346
491
 
347
492
  ✅ ALLOWED:
348
493
  - Dictionary/list access: config['views'][0]['cards'][1]
494
+ - Slicing: config['views'][0]['cards'][1:3]
349
495
  - Assignment: config['key'] = 'value'
350
496
  - Deletion: del config['key'] or config.pop('key')
351
497
  - List methods: append, insert, pop, remove, clear, extend
352
498
  - Dict methods: update, get, setdefault, keys, values, items
353
- - Loops: for, while, if/else
354
- - Comprehensions: [x for x in ...]
499
+ - Loops: for, while, if/else, pass, break, continue
500
+ - Comprehensions: [x for x in ...], {k: v for ...}, (x for x in ...)
501
+ - Ternary: x if condition else y
502
+ - Iterable unpacking (* in calls/literals): f(*xs), [*xs, y]
503
+ - Dict unpacking (**) in calls and dict literals: {**d, 'k': v}
504
+ - Keyword arguments: func(key=value)
505
+ - Lambdas (e.g. for `key=`): sorted(items, key=lambda x: x['score'])
355
506
  - String methods: startswith, endswith, lower, upper, split, join
356
507
  - Safe builtins: isinstance, len, range, enumerate, zip, sorted, reversed,
357
508
  min, max, sum, abs, any, all, round, str, int, float, bool, list, dict,
@@ -363,5 +514,14 @@ PYTHON TRANSFORM SECURITY:
363
514
  - Dunder access: __class__, __bases__, __subclasses__
364
515
  - Dangerous builtins: eval, exec, compile, getattr, setattr, delattr, hasattr
365
516
  - Function definitions: def, class
366
- - Exception handling: try/except (use validation instead)
517
+ - Exception handling: try/except (validate with isinstance/in/.get() instead)
518
+
519
+ 🎯 PATTERNS:
520
+ - Filter cards: cards = [c for c in cards if keep(c)]
521
+ - Skip in a loop: prefer `continue` over an empty `pass` branch (clearer)
522
+ - Conditionally include: build a new list and `.append(x)` only the
523
+ cards you want, instead of iterating the original and using if/pass
524
+ branches to drop entries
525
+ - Modify in place when possible (single pass, fewer surprises) over
526
+ reconstructing the entire list
367
527
  """.strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev448
3
+ Version: 7.4.1.dev450
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