ha-mcp-dev 7.4.0.dev410__tar.gz → 7.4.1.dev412__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 (105) hide show
  1. {ha_mcp_dev-7.4.0.dev410/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev412}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_addons.py +340 -12
  4. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/python_sandbox.py +101 -18
  5. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  6. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/LICENSE +0 -0
  7. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/MANIFEST.in +0 -0
  8. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/README.md +0 -0
  9. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/setup.cfg +0 -0
  10. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/__init__.py +0 -0
  11. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/__main__.py +0 -0
  12. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/_pypi_marker +0 -0
  13. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/_version.py +0 -0
  14. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/auth/__init__.py +0 -0
  15. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/auth/consent_form.py +0 -0
  16. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/auth/provider.py +0 -0
  17. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/__init__.py +0 -0
  18. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/rest_client.py +0 -0
  19. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/websocket_client.py +0 -0
  20. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/py.typed +0 -0
  24. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  25. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  26. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  27. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  28. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  29. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  30. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  31. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  32. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  33. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  34. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  35. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  36. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  37. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  38. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  39. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  40. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  41. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  42. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  43. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  44. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/server.py +0 -0
  45. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/settings_ui.py +0 -0
  46. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/smoke_test.py +0 -0
  47. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/__init__.py +0 -0
  48. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/backup.py +0 -0
  49. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  50. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/device_control.py +0 -0
  51. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/enhanced.py +0 -0
  52. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/helpers.py +0 -0
  53. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/reference_validator.py +0 -0
  54. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/registry.py +0 -0
  55. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/smart_search.py +0 -0
  56. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_areas.py +0 -0
  57. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  58. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  59. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_calendar.py +0 -0
  60. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_camera.py +0 -0
  61. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_categories.py +0 -0
  62. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  63. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  64. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  65. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  66. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  67. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_energy.py +0 -0
  68. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_entities.py +0 -0
  69. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  70. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_groups.py +0 -0
  71. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_hacs.py +0 -0
  72. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_history.py +0 -0
  73. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_integrations.py +0 -0
  74. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_labels.py +0 -0
  75. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  76. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_registry.py +0 -0
  77. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_resources.py +0 -0
  78. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_search.py +0 -0
  79. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_service.py +0 -0
  80. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_services.py +0 -0
  81. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_system.py +0 -0
  82. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_todo.py +0 -0
  83. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_traces.py +0 -0
  84. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_updates.py +0 -0
  85. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_utility.py +0 -0
  86. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  87. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  88. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_zones.py +0 -0
  89. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/util_helpers.py +0 -0
  90. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/transforms/__init__.py +0 -0
  91. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/transforms/categorized_search.py +0 -0
  92. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/__init__.py +0 -0
  93. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/config_hash.py +0 -0
  94. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/domain_handlers.py +0 -0
  95. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  96. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/operation_manager.py +0 -0
  97. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/usage_logger.py +0 -0
  98. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  99. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  100. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  101. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  102. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  103. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/tests/__init__.py +0 -0
  104. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/tests/test_constants.py +0 -0
  105. {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/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.0.dev410
3
+ Version: 7.4.1.dev412
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.0.dev410"
7
+ version = "7.4.1.dev412"
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,6 +28,7 @@ from ..errors import (
28
28
  create_timeout_error,
29
29
  create_validation_error,
30
30
  )
31
+ from ..utils.python_sandbox import PythonSandboxError, safe_execute_expression
31
32
  from .helpers import (
32
33
  exception_to_structured_error,
33
34
  get_connected_ws_client,
@@ -40,12 +41,159 @@ logger = logging.getLogger(__name__)
40
41
  # Maximum response size to return from add-on API calls (50 KB)
41
42
  _MAX_RESPONSE_SIZE = 50 * 1024
42
43
 
43
- # Maximum number of WebSocket messages to collect
44
+ # Hard safety cap on WebSocket messages collected per call. `message_limit`
45
+ # can lower this but never raise it.
44
46
  _MAX_WS_MESSAGES = 1000
45
47
 
46
48
  # ANSI escape code pattern for stripping terminal colors from addon output
47
49
  _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
48
50
 
51
+ # Substrings that flag a WebSocket message as "signal" for the summarize pass.
52
+ # Keep conservative: false negatives get elided, false positives just mean
53
+ # no elision. Case-insensitive match on the JSON-stringified message.
54
+ _SIGNAL_PATTERNS = re.compile(
55
+ r"(?:^|[^A-Za-z])(INFO|WARN(?:ING)?|ERROR|FATAL|FAIL(?:ED|URE)?|EXCEPTION|"
56
+ r"TRACEBACK|Configuration is valid|Successfully|unsuccessful|exit|"
57
+ r"returncode|Compiling|Linking)",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+ # Consecutive non-signal messages needed to trigger elision. Below this,
62
+ # the run passes through untouched.
63
+ _SUMMARIZE_RUN_THRESHOLD = 10
64
+
65
+ # Messages preserved verbatim at each end of an elided run for context.
66
+ _SUMMARIZE_CONTEXT_KEEP = 2
67
+
68
+
69
+ def _slice_ws_messages(
70
+ messages: list[Any],
71
+ offset: int,
72
+ limit: int | None,
73
+ ) -> tuple[list[Any], dict[str, Any]]:
74
+ """Apply offset/limit to a collected WebSocket message list.
75
+
76
+ Returns ``(sliced_messages, pagination_metadata)``. Pagination metadata
77
+ is always returned so the response shape is stable regardless of whether
78
+ offset/limit were applied.
79
+ """
80
+ total_collected = len(messages)
81
+ if offset < 0:
82
+ offset = 0
83
+ if offset > total_collected:
84
+ sliced: list[Any] = []
85
+ elif limit is None:
86
+ sliced = messages[offset:]
87
+ else:
88
+ if limit < 0:
89
+ limit = 0
90
+ sliced = messages[offset : offset + limit]
91
+
92
+ pagination: dict[str, Any] = {
93
+ "total_collected": total_collected,
94
+ "offset": offset,
95
+ "returned": len(sliced),
96
+ }
97
+ if limit is not None:
98
+ pagination["limit"] = limit
99
+ return sliced, pagination
100
+
101
+
102
+ def _is_signal_message(msg: Any) -> bool:
103
+ """Return True if ``msg`` looks like a log line or terminal event worth keeping.
104
+
105
+ The heuristic errs toward keeping messages — false positives just mean
106
+ a run doesn't get elided.
107
+ """
108
+ if isinstance(msg, (dict, list)):
109
+ serialized = json.dumps(msg, default=str)
110
+ else:
111
+ serialized = str(msg)
112
+ return bool(_SIGNAL_PATTERNS.search(serialized[:2000]))
113
+
114
+
115
+ def _summarize_ws_messages(
116
+ messages: list[Any],
117
+ *,
118
+ run_threshold: int = _SUMMARIZE_RUN_THRESHOLD,
119
+ context_keep: int = _SUMMARIZE_CONTEXT_KEEP,
120
+ ) -> tuple[list[Any], dict[str, Any]]:
121
+ """Collapse runs of non-signal WebSocket messages into elision markers.
122
+
123
+ Each run of ≥ ``run_threshold`` consecutive non-signal entries becomes:
124
+ ``context_keep`` originals, one elision dict
125
+ ``{"elided": N, "note": "..."}``, then ``context_keep`` originals.
126
+ Signal messages always pass through unchanged.
127
+ """
128
+ result: list[Any] = []
129
+ run_start: int | None = None
130
+ elided_total = 0
131
+
132
+ def flush(run_end: int) -> None:
133
+ nonlocal elided_total
134
+ assert run_start is not None
135
+ run_len = run_end - run_start
136
+ if run_len >= run_threshold:
137
+ result.extend(messages[run_start : run_start + context_keep])
138
+ elided_count = run_len - 2 * context_keep
139
+ result.append(
140
+ {
141
+ "elided": elided_count,
142
+ "note": (
143
+ f"{elided_count} non-signal messages elided; "
144
+ "pass summarize=False for full output"
145
+ ),
146
+ }
147
+ )
148
+ result.extend(messages[run_end - context_keep : run_end])
149
+ elided_total += elided_count
150
+ else:
151
+ result.extend(messages[run_start:run_end])
152
+
153
+ for i, msg in enumerate(messages):
154
+ if _is_signal_message(msg):
155
+ if run_start is not None:
156
+ flush(i)
157
+ run_start = None
158
+ result.append(msg)
159
+ else:
160
+ if run_start is None:
161
+ run_start = i
162
+
163
+ if run_start is not None:
164
+ flush(len(messages))
165
+
166
+ return result, {
167
+ "original_count": len(messages),
168
+ "summarized_count": len(result),
169
+ "elided_count": elided_total,
170
+ }
171
+
172
+
173
+ def _apply_response_transform(response: Any, expr: str) -> Any:
174
+ """Run a sandboxed ``python_transform`` expression against ``response``.
175
+
176
+ Exposes the value to the expression as ``response``. Supports both
177
+ in-place mutation and reassignment (``response = [...]``). Raises
178
+ ToolError with VALIDATION_FAILED on sandbox errors so the agent gets
179
+ a structured code it can react to.
180
+ """
181
+ try:
182
+ return safe_execute_expression(expr, {"response": response}, "response")
183
+ except PythonSandboxError as e:
184
+ raise_tool_error(
185
+ create_error_response(
186
+ ErrorCode.VALIDATION_FAILED,
187
+ f"python_transform failed: {e!s}",
188
+ context={"expression_preview": expr[:200]},
189
+ suggestions=[
190
+ "Operate on the `response` variable (in-place or reassign)",
191
+ "Allowed: dict/list access, assignment, loops, "
192
+ "comprehensions, whitelisted str/list/dict methods",
193
+ ],
194
+ )
195
+ )
196
+
49
197
 
50
198
  def _merge_options(base: dict, override: dict) -> dict:
51
199
  """Merge caller options into current options with one-level deep merge.
@@ -325,6 +473,10 @@ async def _call_addon_ws(
325
473
  debug: bool = False,
326
474
  port: int | None = None,
327
475
  wait_for_close: bool = True,
476
+ message_limit: int | None = None,
477
+ message_offset: int = 0,
478
+ summarize: bool = True,
479
+ python_transform: str | None = None,
328
480
  ) -> dict[str, Any]:
329
481
  """Connect to an add-on's WebSocket API and collect messages.
330
482
 
@@ -338,6 +490,20 @@ async def _call_addon_ws(
338
490
  port: Override port (same as HTTP tool)
339
491
  wait_for_close: If True, collect messages until server closes or timeout.
340
492
  If False, return after first batch of messages (up to 2s of silence).
493
+ message_limit: Cap on messages collected from the wire. Bounded by the
494
+ hard ceiling ``_MAX_WS_MESSAGES``. None means "collect up to the
495
+ ceiling" (legacy behavior).
496
+ message_offset: Drop this many messages from the start of the collected
497
+ list before returning. Useful for paginating past a known-noisy
498
+ header when re-running the same call.
499
+ summarize: When True (default), collapse runs of non-signal messages
500
+ (typically YAML config dumps) into short elision markers. Set to
501
+ False to return the raw stream.
502
+ python_transform: Optional sandboxed Python expression that post-
503
+ processes the response. The variable ``response`` is bound to
504
+ the list of parsed messages (``list[dict | str]``); the value
505
+ of ``response`` after execution replaces ``messages`` in the
506
+ output. See ``ha_manage_addon`` docstring for details.
341
507
 
342
508
  Returns:
343
509
  Dictionary with collected messages, metadata, and status.
@@ -427,6 +593,16 @@ async def _call_addon_ws(
427
593
  close_reason = "unknown"
428
594
  start_time = time.monotonic()
429
595
 
596
+ # Effective collection cap: callers may lower _MAX_WS_MESSAGES via
597
+ # message_limit but cannot raise it. A caller's message_limit interacts
598
+ # with message_offset — we collect enough to satisfy `offset + limit`
599
+ # so requesting a later window actually returns the window.
600
+ if message_limit is None:
601
+ collection_cap = _MAX_WS_MESSAGES
602
+ else:
603
+ requested = max(0, message_offset) + max(0, message_limit)
604
+ collection_cap = min(_MAX_WS_MESSAGES, requested)
605
+
430
606
  try:
431
607
  async with websockets.connect(
432
608
  ws_url,
@@ -451,8 +627,15 @@ async def _call_addon_ws(
451
627
  close_reason = "timeout"
452
628
  break
453
629
 
454
- if len(collected) >= _MAX_WS_MESSAGES:
455
- close_reason = "message_limit"
630
+ if len(collected) >= collection_cap:
631
+ # Distinguish caller-set cap from the global safety ceiling
632
+ # so an agent reading the response can tell "I capped this"
633
+ # from "ha-mcp's hard ceiling kicked in".
634
+ close_reason = (
635
+ "message_limit"
636
+ if message_limit is not None
637
+ else "safety_ceiling"
638
+ )
456
639
  break
457
640
 
458
641
  if total_size >= _MAX_RESPONSE_SIZE:
@@ -527,7 +710,9 @@ async def _call_addon_ws(
527
710
  elapsed = round(time.monotonic() - start_time, 2)
528
711
 
529
712
  # 8. Build result
530
- # Try to parse each message as JSON; keep as string if not JSON
713
+ # Try to parse each message as JSON; keep as string if not JSON.
714
+ # Result shape is list[dict | str] — the heterogeneity is part of the
715
+ # python_transform contract (see ha_manage_addon docstring).
531
716
  parsed_messages: list[Any] = []
532
717
  for msg in collected:
533
718
  try:
@@ -535,22 +720,61 @@ async def _call_addon_ws(
535
720
  except (json.JSONDecodeError, ValueError):
536
721
  parsed_messages.append(msg)
537
722
 
723
+ # 8a. Apply offset/limit slicing before summarize/transform so users
724
+ # paginate the raw collected list, not the post-summarize output.
725
+ sliced_messages, pagination = _slice_ws_messages(
726
+ parsed_messages,
727
+ offset=message_offset,
728
+ limit=message_limit,
729
+ )
730
+
731
+ # 8b. Summarize (default on) — collapse bulk non-signal runs.
732
+ summary_meta: dict[str, Any] | None = None
733
+ processed_messages: list[Any] = sliced_messages
734
+ if summarize:
735
+ processed_messages, summary_meta = _summarize_ws_messages(sliced_messages)
736
+
737
+ # 8c. python_transform (optional) — user-controlled post-processing.
738
+ transformed = False
739
+ pre_transform_count = len(processed_messages)
740
+ if python_transform is not None:
741
+ processed_messages = _apply_response_transform(
742
+ processed_messages,
743
+ python_transform,
744
+ )
745
+ transformed = True
746
+
538
747
  result: dict[str, Any] = {
539
748
  "success": True,
540
- "messages": parsed_messages,
541
- "message_count": len(parsed_messages),
749
+ "messages": processed_messages,
750
+ "message_count": (
751
+ len(processed_messages) if isinstance(processed_messages, list) else None
752
+ ),
542
753
  "closed_by": close_reason,
543
754
  "duration_seconds": elapsed,
544
755
  "addon_name": addon_name,
545
756
  "slug": slug,
546
757
  }
547
758
 
759
+ # Pagination metadata is always present when offset/limit were used so
760
+ # callers have a stable shape to reason about.
761
+ if message_offset > 0 or message_limit is not None:
762
+ result["pagination"] = pagination
763
+
764
+ if summary_meta is not None and summary_meta["elided_count"] > 0:
765
+ result["summary"] = summary_meta
766
+
767
+ if transformed:
768
+ result["transformed"] = True
769
+ result["pre_transform_message_count"] = pre_transform_count
770
+
548
771
  if debug:
549
772
  result["_debug"] = {
550
773
  "ws_url": ws_url,
551
774
  "request_headers": dict(headers),
552
775
  "initial_message": body,
553
776
  "total_bytes_collected": total_size,
777
+ "collection_cap": collection_cap,
554
778
  }
555
779
 
556
780
  # Cap the serialized result size (raw bytes undercount due to JSON + MCP overhead)
@@ -559,17 +783,20 @@ async def _call_addon_ws(
559
783
  result = {
560
784
  "success": True,
561
785
  "error": "RESPONSE_TOO_LARGE",
562
- "message": f"WebSocket collected {len(parsed_messages)} messages "
563
- f"({len(result_serialized)} bytes serialized) exceeding "
564
- f"{_MAX_RESPONSE_SIZE // 1024}KB limit.",
565
- "message_count": len(parsed_messages),
786
+ "message": f"WebSocket response ({len(result_serialized)} bytes "
787
+ f"serialized) exceeds {_MAX_RESPONSE_SIZE // 1024}KB limit.",
788
+ "message_count": (
789
+ len(processed_messages)
790
+ if isinstance(processed_messages, list)
791
+ else None
792
+ ),
566
793
  "closed_by": close_reason,
567
794
  "duration_seconds": elapsed,
568
795
  "addon_name": addon_name,
569
796
  "slug": slug,
570
797
  "truncated": True,
571
- "hint": "Use wait_for_close=false for shorter collection, "
572
- "or use the HTTP endpoint with offset/limit for paginated access.",
798
+ "hint": "Lower message_limit, raise message_offset, keep summarize=True, "
799
+ "or narrow the response with python_transform.",
573
800
  }
574
801
 
575
802
  return result
@@ -586,6 +813,7 @@ async def _call_addon_api(
586
813
  port: int | None = None,
587
814
  offset: int = 0,
588
815
  limit: int | None = None,
816
+ python_transform: str | None = None,
589
817
  ) -> dict[str, Any]:
590
818
  """Call an add-on's web API through Home Assistant's Ingress proxy.
591
819
 
@@ -599,6 +827,10 @@ async def _call_addon_api(
599
827
  port: Override port to connect to (e.g., direct access port instead of ingress port)
600
828
  offset: Skip this many items in array responses (default 0)
601
829
  limit: Return at most this many items from array responses
830
+ python_transform: Optional sandboxed Python expression applied to the
831
+ parsed response body. The variable ``response`` is bound to
832
+ ``dict | list | str`` depending on content-type. Transform runs
833
+ after offset/limit slicing.
602
834
 
603
835
  Returns:
604
836
  Dictionary with response data, status code, and content type.
@@ -756,6 +988,13 @@ async def _call_addon_api(
756
988
  "returned": len(response_data),
757
989
  }
758
990
 
991
+ # 8a. python_transform (optional) — runs after slicing, before size cap,
992
+ # so an agent can narrow a large response down under the limit.
993
+ transformed = False
994
+ if python_transform is not None:
995
+ response_data = _apply_response_transform(response_data, python_transform)
996
+ transformed = True
997
+
759
998
  # 9. Truncate large responses
760
999
  truncated = False
761
1000
  if isinstance(response_data, str) and len(response_data) > _MAX_RESPONSE_SIZE:
@@ -814,6 +1053,9 @@ async def _call_addon_api(
814
1053
  if pagination_meta:
815
1054
  result["pagination"] = pagination_meta
816
1055
 
1056
+ if transformed:
1057
+ result["transformed"] = True
1058
+
817
1059
  if truncated:
818
1060
  result["truncated"] = True
819
1061
  result["note"] = (
@@ -1046,6 +1288,44 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1046
1288
  default=True,
1047
1289
  ),
1048
1290
  ] = True,
1291
+ message_limit: Annotated[
1292
+ int | None,
1293
+ Field(
1294
+ description="Proxy mode only. WebSocket: cap on messages collected from the wire, "
1295
+ "bounded by an internal safety ceiling. None = collect up to the ceiling. "
1296
+ "Lower to save tokens on noisy streams (e.g., message_limit=50 for a quick health check).",
1297
+ default=None,
1298
+ ),
1299
+ ] = None,
1300
+ message_offset: Annotated[
1301
+ int,
1302
+ Field(
1303
+ description="Proxy mode only. WebSocket: drop this many messages from the start of the "
1304
+ "collected list before returning. Useful for paginating past known-noisy headers. Default: 0.",
1305
+ default=0,
1306
+ ),
1307
+ ] = 0,
1308
+ summarize: Annotated[
1309
+ bool,
1310
+ Field(
1311
+ description="Proxy mode only. WebSocket: when True (default), collapse runs of "
1312
+ "non-signal messages (typically YAML config dumps) into short elision markers. "
1313
+ "Set to False to return the raw stream.",
1314
+ default=True,
1315
+ ),
1316
+ ] = True,
1317
+ python_transform: Annotated[
1318
+ str | None,
1319
+ Field(
1320
+ description="Proxy mode only. Sandboxed Python expression that post-processes the response. "
1321
+ "Variable `response` is exposed — a list[dict | str] for WebSocket (parsed JSON or raw text), "
1322
+ "or dict/list/str for HTTP (parsed body). Supports in-place mutation "
1323
+ "(response.append(...)) or reassignment (response = [...]). "
1324
+ "Example: response = [m for m in response if 'ERROR' in str(m)]. "
1325
+ "Post-processing only — does not provide optimistic-locking write semantics.",
1326
+ default=None,
1327
+ ),
1328
+ ] = None,
1049
1329
  options: Annotated[
1050
1330
  dict[str, Any] | None,
1051
1331
  Field(
@@ -1095,6 +1375,23 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1095
1375
  Sends requests directly to the add-on container's own web API via HTTP or WebSocket.
1096
1376
  Use ha_get_addon(slug="...") to discover available ports and endpoints.
1097
1377
 
1378
+ **Response shaping (proxy mode):**
1379
+ - WebSocket streams can be noisy (ESPHome /validate often emits hundreds of
1380
+ config-dump lines). By default, `summarize=True` collapses long runs of
1381
+ non-signal messages into short elision markers; INFO/WARNING/ERROR/exit
1382
+ lines always pass through. Pagination via `message_offset` / `message_limit`
1383
+ works on the raw collected list before summarize runs.
1384
+ - `python_transform` applies a sandboxed Python expression as a final
1385
+ post-processing step in both HTTP and WebSocket modes. The variable
1386
+ `response` is bound to:
1387
+ * WebSocket: `list[dict | str]` — parsed JSON messages are dicts,
1388
+ undecodable frames stay as ANSI-stripped strings. Elision markers
1389
+ appear as `{"elided": N, "note": "..."}` dicts when summarize ran.
1390
+ * HTTP: `dict | list | str` — whichever the content-type produced.
1391
+ Transforms may mutate in place (response.append(...), del response[k])
1392
+ or reassign (response = [...]). This is post-processing only — it does
1393
+ NOT provide optimistic-locking or write-back semantics.
1394
+
1098
1395
  **WARNING:** Setting boot="auto"/"manual" will fail for add-ons whose Supervisor
1099
1396
  metadata locks the boot mode. The Supervisor returns an error in this case.
1100
1397
 
@@ -1110,6 +1407,9 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1110
1407
  - Call HTTP API: ha_manage_addon(slug="...", path="/api/events")
1111
1408
  - Direct port: ha_manage_addon(slug="...", path="/flows", port=1880)
1112
1409
  - WebSocket: ha_manage_addon(slug="...", path="/validate", port=6052, websocket=True, body={"type": "spawn", "configuration": "device.yaml"})
1410
+ - Quick WS health check (50 msgs, raw): ha_manage_addon(slug="...", path="/logs", websocket=True, message_limit=50, summarize=False)
1411
+ - Filter WS errors only: ha_manage_addon(slug="...", path="/validate", websocket=True, python_transform="response = [m for m in response if 'ERROR' in str(m) or 'WARN' in str(m)]")
1412
+ - HTTP subset: ha_manage_addon(slug="...", path="/flows", python_transform="response = [f['id'] for f in response]")
1113
1413
  """
1114
1414
  # Build config payload from provided config parameters
1115
1415
  config_data: dict[str, Any] = {}
@@ -1169,6 +1469,18 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1169
1469
  proxy_overrides.append(("websocket", "websocket=True"))
1170
1470
  if not wait_for_close:
1171
1471
  proxy_overrides.append(("wait_for_close", "wait_for_close=False"))
1472
+ if message_limit is not None:
1473
+ proxy_overrides.append(
1474
+ ("message_limit", f"message_limit={message_limit}")
1475
+ )
1476
+ if message_offset != 0:
1477
+ proxy_overrides.append(
1478
+ ("message_offset", f"message_offset={message_offset}")
1479
+ )
1480
+ if not summarize:
1481
+ proxy_overrides.append(("summarize", "summarize=False"))
1482
+ if python_transform is not None:
1483
+ proxy_overrides.append(("python_transform", "python_transform"))
1172
1484
  if proxy_overrides:
1173
1485
  raise_tool_error(
1174
1486
  create_validation_error(
@@ -1278,6 +1590,10 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1278
1590
  debug=debug,
1279
1591
  port=port,
1280
1592
  wait_for_close=wait_for_close,
1593
+ message_limit=message_limit,
1594
+ message_offset=message_offset,
1595
+ summarize=summarize,
1596
+ python_transform=python_transform,
1281
1597
  )
1282
1598
  if not result.get("success"):
1283
1599
  raise_tool_error(result)
@@ -1293,6 +1609,17 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1293
1609
  )
1294
1610
  )
1295
1611
 
1612
+ # HTTP mode does not use WebSocket-specific params. Reject explicit
1613
+ # use so misroutes surface immediately rather than silently ignoring.
1614
+ if message_limit is not None or message_offset != 0 or not summarize:
1615
+ raise_tool_error(
1616
+ create_validation_error(
1617
+ "message_limit / message_offset / summarize apply only to "
1618
+ "WebSocket mode. Set websocket=True or remove them.",
1619
+ parameter="message_limit",
1620
+ )
1621
+ )
1622
+
1296
1623
  result = await _call_addon_api(
1297
1624
  client=client,
1298
1625
  slug=slug,
@@ -1303,6 +1630,7 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
1303
1630
  port=port,
1304
1631
  offset=offset,
1305
1632
  limit=limit,
1633
+ python_transform=python_transform,
1306
1634
  )
1307
1635
  if not result.get("success"):
1308
1636
  raise_tool_error(result)
@@ -7,7 +7,7 @@ callers are already authenticated MCP users with full HA access.
7
7
  """
8
8
 
9
9
  import ast
10
- from typing import Any
10
+ from typing import Any, cast
11
11
 
12
12
 
13
13
  class PythonSandboxError(Exception):
@@ -131,6 +131,38 @@ BLOCKED_FUNCTIONS = {
131
131
  }
132
132
 
133
133
 
134
+ # Minimal set of builtins exposed to sandboxed expressions. All entries are
135
+ # pure (no side effects, no I/O, no imports) and commonly needed by data
136
+ # transforms — type checks, length, numeric/string coercion, simple
137
+ # collection helpers. Expanding this list is fine if another pure builtin
138
+ # is genuinely needed; adding anything that touches the filesystem, network,
139
+ # or interpreter state is not.
140
+ _SAFE_BUILTINS: dict[str, Any] = {
141
+ "isinstance": isinstance,
142
+ "len": len,
143
+ "range": range,
144
+ "enumerate": enumerate,
145
+ "zip": zip,
146
+ "sorted": sorted,
147
+ "reversed": reversed,
148
+ "min": min,
149
+ "max": max,
150
+ "sum": sum,
151
+ "abs": abs,
152
+ "any": any,
153
+ "all": all,
154
+ "str": str,
155
+ "int": int,
156
+ "float": float,
157
+ "bool": bool,
158
+ "list": list,
159
+ "dict": dict,
160
+ "tuple": tuple,
161
+ "set": set,
162
+ "round": round,
163
+ }
164
+
165
+
134
166
  def validate_expression(expr: str) -> tuple[bool, str]:
135
167
  """
136
168
  Validate Python expression is safe to execute.
@@ -208,48 +240,96 @@ def _validate_call_node(node: ast.Call) -> str | None:
208
240
  return None
209
241
 
210
242
 
211
- def safe_execute(expr: str, config: dict[str, Any]) -> dict[str, Any]:
243
+ def safe_execute_expression(
244
+ expr: str,
245
+ variables: dict[str, Any],
246
+ result_key: str,
247
+ ) -> Any:
212
248
  """
213
- Execute validated Python expression in restricted environment.
249
+ Execute a validated Python expression in a restricted environment.
250
+
251
+ The expression runs with ``variables`` available as locals. After
252
+ execution, the value bound to ``result_key`` is returned. This supports
253
+ both in-place mutation (``response.append(...)``) and reassignment
254
+ (``response = [...]``) — in the reassignment case the returned object
255
+ is the new one, not the original reference.
214
256
 
215
257
  Args:
216
258
  expr: Python expression to execute
217
- config: Dashboard configuration dict (will be modified in-place)
259
+ variables: Mapping of variable names to values exposed to the expression
260
+ result_key: Name of the variable in ``variables`` whose post-execution
261
+ value should be returned
218
262
 
219
263
  Returns:
220
- Modified config dict
264
+ The value of ``result_key`` in the local namespace after execution
221
265
 
222
266
  Raises:
223
267
  PythonSandboxError: If expression validation fails or execution errors
224
268
 
225
269
  Examples:
226
- >>> config = {'views': [{'cards': [{'icon': 'old'}]}]}
227
- >>> safe_execute("config['views'][0]['cards'][0]['icon'] = 'new'", config)
228
- {'views': [{'cards': [{'icon': 'new'}]}]}
270
+ >>> safe_execute_expression(
271
+ ... "response = [m for m in response if m.get('level') == 'ERROR']",
272
+ ... {"response": [{"level": "INFO"}, {"level": "ERROR"}]},
273
+ ... "response",
274
+ ... )
275
+ [{'level': 'ERROR'}]
229
276
  """
230
- # Validate expression
231
277
  valid, error = validate_expression(expr)
232
278
  if not valid:
233
279
  raise PythonSandboxError(f"Expression validation failed: {error}")
234
280
 
235
- # Execute in restricted environment
236
- # No builtins to prevent access to dangerous functions
281
+ if result_key not in variables:
282
+ raise PythonSandboxError(
283
+ f"result_key {result_key!r} not found in variables",
284
+ )
285
+
237
286
  safe_globals: dict[str, Any] = {
238
- "__builtins__": {},
287
+ "__builtins__": _SAFE_BUILTINS,
239
288
  "__name__": "__main__",
240
289
  "__doc__": None,
241
290
  }
242
-
243
- safe_locals: dict[str, Any] = {
244
- "config": config,
245
- }
291
+ safe_locals: dict[str, Any] = dict(variables)
246
292
 
247
293
  try:
248
294
  exec(expr, safe_globals, safe_locals)
249
295
  except Exception as e:
250
296
  raise PythonSandboxError(f"Execution error: {type(e).__name__}: {e}") from e
251
297
 
252
- return config
298
+ return safe_locals[result_key]
299
+
300
+
301
+ def safe_execute(expr: str, config: dict[str, Any]) -> dict[str, Any]:
302
+ """
303
+ Execute validated Python expression against a ``config`` dict.
304
+
305
+ Thin wrapper around :func:`safe_execute_expression` that exposes the
306
+ input as the variable ``config`` (used by dashboard/automation/script
307
+ transforms).
308
+
309
+ Args:
310
+ expr: Python expression to execute
311
+ config: Configuration dict (may be modified in-place)
312
+
313
+ Returns:
314
+ The value bound to ``config`` after execution — typically the same
315
+ dict mutated in place, but also supports expressions that reassign
316
+ ``config`` to a new object.
317
+
318
+ Raises:
319
+ PythonSandboxError: If expression validation fails or execution errors
320
+
321
+ Examples:
322
+ >>> config = {'views': [{'cards': [{'icon': 'old'}]}]}
323
+ >>> safe_execute("config['views'][0]['cards'][0]['icon'] = 'new'", config)
324
+ {'views': [{'cards': [{'icon': 'new'}]}]}
325
+ """
326
+ # safe_execute_expression returns Any (generic over result_key); at this
327
+ # call site the result is always the dict bound to `config`, so narrow
328
+ # for mypy and existing callers that depend on the dict interface.
329
+ return cast(
330
+ dict[str, Any],
331
+ safe_execute_expression(expr, {"config": config}, "config"),
332
+ )
253
333
 
254
334
 
255
335
  def get_security_documentation() -> str:
@@ -270,12 +350,15 @@ PYTHON TRANSFORM SECURITY:
270
350
  - Loops: for, while, if/else
271
351
  - Comprehensions: [x for x in ...]
272
352
  - String methods: startswith, endswith, lower, upper, split, join
353
+ - Safe builtins: isinstance, len, range, enumerate, zip, sorted, reversed,
354
+ min, max, sum, abs, any, all, round, str, int, float, bool, list, dict,
355
+ tuple, set
273
356
 
274
357
  ❌ FORBIDDEN:
275
358
  - Imports: import, from, __import__
276
359
  - File operations: open, read, write
277
360
  - Dunder access: __class__, __bases__, __subclasses__
278
- - Dangerous builtins: eval, exec, compile
361
+ - Dangerous builtins: eval, exec, compile, getattr, setattr, delattr, hasattr
279
362
  - Function definitions: def, class
280
363
  - Exception handling: try/except (use validation instead)
281
364
  """.strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.0.dev410
3
+ Version: 7.4.1.dev412
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