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.
- {ha_mcp_dev-7.4.0.dev410/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev412}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_addons.py +340 -12
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/python_sandbox.py +101 -18
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/LICENSE +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/README.md +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/py.typed +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.4.
|
|
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
|
-
#
|
|
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) >=
|
|
455
|
-
|
|
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":
|
|
541
|
-
"message_count":
|
|
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
|
|
563
|
-
f"
|
|
564
|
-
|
|
565
|
-
|
|
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": "
|
|
572
|
-
"or
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
>>>
|
|
227
|
-
|
|
228
|
-
{
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.0.dev410 → ha_mcp_dev-7.4.1.dev412}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|