ha-mcp-dev 7.4.1.dev411__tar.gz → 7.4.1.dev413__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.1.dev411/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev413}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_addons.py +341 -15
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_bug_report.py +177 -7
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/util_helpers.py +4 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/python_sandbox.py +101 -18
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/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.1.
|
|
7
|
+
version = "7.4.1.dev413"
|
|
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,23 +28,169 @@ 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,
|
|
34
35
|
log_tool_usage,
|
|
35
36
|
raise_tool_error,
|
|
36
37
|
)
|
|
38
|
+
from .util_helpers import ANSI_ESCAPE_RE
|
|
37
39
|
|
|
38
40
|
logger = logging.getLogger(__name__)
|
|
39
41
|
|
|
40
42
|
# Maximum response size to return from add-on API calls (50 KB)
|
|
41
43
|
_MAX_RESPONSE_SIZE = 50 * 1024
|
|
42
44
|
|
|
43
|
-
#
|
|
45
|
+
# Hard safety cap on WebSocket messages collected per call. `message_limit`
|
|
46
|
+
# can lower this but never raise it.
|
|
44
47
|
_MAX_WS_MESSAGES = 1000
|
|
45
48
|
|
|
46
|
-
#
|
|
47
|
-
|
|
49
|
+
# Substrings that flag a WebSocket message as "signal" for the summarize pass.
|
|
50
|
+
# Keep conservative: false negatives get elided, false positives just mean
|
|
51
|
+
# no elision. Case-insensitive match on the JSON-stringified message.
|
|
52
|
+
_SIGNAL_PATTERNS = re.compile(
|
|
53
|
+
r"(?:^|[^A-Za-z])(INFO|WARN(?:ING)?|ERROR|FATAL|FAIL(?:ED|URE)?|EXCEPTION|"
|
|
54
|
+
r"TRACEBACK|Configuration is valid|Successfully|unsuccessful|exit|"
|
|
55
|
+
r"returncode|Compiling|Linking)",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Consecutive non-signal messages needed to trigger elision. Below this,
|
|
60
|
+
# the run passes through untouched.
|
|
61
|
+
_SUMMARIZE_RUN_THRESHOLD = 10
|
|
62
|
+
|
|
63
|
+
# Messages preserved verbatim at each end of an elided run for context.
|
|
64
|
+
_SUMMARIZE_CONTEXT_KEEP = 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _slice_ws_messages(
|
|
68
|
+
messages: list[Any],
|
|
69
|
+
offset: int,
|
|
70
|
+
limit: int | None,
|
|
71
|
+
) -> tuple[list[Any], dict[str, Any]]:
|
|
72
|
+
"""Apply offset/limit to a collected WebSocket message list.
|
|
73
|
+
|
|
74
|
+
Returns ``(sliced_messages, pagination_metadata)``. Pagination metadata
|
|
75
|
+
is always returned so the response shape is stable regardless of whether
|
|
76
|
+
offset/limit were applied.
|
|
77
|
+
"""
|
|
78
|
+
total_collected = len(messages)
|
|
79
|
+
if offset < 0:
|
|
80
|
+
offset = 0
|
|
81
|
+
if offset > total_collected:
|
|
82
|
+
sliced: list[Any] = []
|
|
83
|
+
elif limit is None:
|
|
84
|
+
sliced = messages[offset:]
|
|
85
|
+
else:
|
|
86
|
+
if limit < 0:
|
|
87
|
+
limit = 0
|
|
88
|
+
sliced = messages[offset : offset + limit]
|
|
89
|
+
|
|
90
|
+
pagination: dict[str, Any] = {
|
|
91
|
+
"total_collected": total_collected,
|
|
92
|
+
"offset": offset,
|
|
93
|
+
"returned": len(sliced),
|
|
94
|
+
}
|
|
95
|
+
if limit is not None:
|
|
96
|
+
pagination["limit"] = limit
|
|
97
|
+
return sliced, pagination
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_signal_message(msg: Any) -> bool:
|
|
101
|
+
"""Return True if ``msg`` looks like a log line or terminal event worth keeping.
|
|
102
|
+
|
|
103
|
+
The heuristic errs toward keeping messages — false positives just mean
|
|
104
|
+
a run doesn't get elided.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(msg, (dict, list)):
|
|
107
|
+
serialized = json.dumps(msg, default=str)
|
|
108
|
+
else:
|
|
109
|
+
serialized = str(msg)
|
|
110
|
+
return bool(_SIGNAL_PATTERNS.search(serialized[:2000]))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _summarize_ws_messages(
|
|
114
|
+
messages: list[Any],
|
|
115
|
+
*,
|
|
116
|
+
run_threshold: int = _SUMMARIZE_RUN_THRESHOLD,
|
|
117
|
+
context_keep: int = _SUMMARIZE_CONTEXT_KEEP,
|
|
118
|
+
) -> tuple[list[Any], dict[str, Any]]:
|
|
119
|
+
"""Collapse runs of non-signal WebSocket messages into elision markers.
|
|
120
|
+
|
|
121
|
+
Each run of ≥ ``run_threshold`` consecutive non-signal entries becomes:
|
|
122
|
+
``context_keep`` originals, one elision dict
|
|
123
|
+
``{"elided": N, "note": "..."}``, then ``context_keep`` originals.
|
|
124
|
+
Signal messages always pass through unchanged.
|
|
125
|
+
"""
|
|
126
|
+
result: list[Any] = []
|
|
127
|
+
run_start: int | None = None
|
|
128
|
+
elided_total = 0
|
|
129
|
+
|
|
130
|
+
def flush(run_end: int) -> None:
|
|
131
|
+
nonlocal elided_total
|
|
132
|
+
assert run_start is not None
|
|
133
|
+
run_len = run_end - run_start
|
|
134
|
+
if run_len >= run_threshold:
|
|
135
|
+
result.extend(messages[run_start : run_start + context_keep])
|
|
136
|
+
elided_count = run_len - 2 * context_keep
|
|
137
|
+
result.append(
|
|
138
|
+
{
|
|
139
|
+
"elided": elided_count,
|
|
140
|
+
"note": (
|
|
141
|
+
f"{elided_count} non-signal messages elided; "
|
|
142
|
+
"pass summarize=False for full output"
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
result.extend(messages[run_end - context_keep : run_end])
|
|
147
|
+
elided_total += elided_count
|
|
148
|
+
else:
|
|
149
|
+
result.extend(messages[run_start:run_end])
|
|
150
|
+
|
|
151
|
+
for i, msg in enumerate(messages):
|
|
152
|
+
if _is_signal_message(msg):
|
|
153
|
+
if run_start is not None:
|
|
154
|
+
flush(i)
|
|
155
|
+
run_start = None
|
|
156
|
+
result.append(msg)
|
|
157
|
+
else:
|
|
158
|
+
if run_start is None:
|
|
159
|
+
run_start = i
|
|
160
|
+
|
|
161
|
+
if run_start is not None:
|
|
162
|
+
flush(len(messages))
|
|
163
|
+
|
|
164
|
+
return result, {
|
|
165
|
+
"original_count": len(messages),
|
|
166
|
+
"summarized_count": len(result),
|
|
167
|
+
"elided_count": elided_total,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _apply_response_transform(response: Any, expr: str) -> Any:
|
|
172
|
+
"""Run a sandboxed ``python_transform`` expression against ``response``.
|
|
173
|
+
|
|
174
|
+
Exposes the value to the expression as ``response``. Supports both
|
|
175
|
+
in-place mutation and reassignment (``response = [...]``). Raises
|
|
176
|
+
ToolError with VALIDATION_FAILED on sandbox errors so the agent gets
|
|
177
|
+
a structured code it can react to.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
return safe_execute_expression(expr, {"response": response}, "response")
|
|
181
|
+
except PythonSandboxError as e:
|
|
182
|
+
raise_tool_error(
|
|
183
|
+
create_error_response(
|
|
184
|
+
ErrorCode.VALIDATION_FAILED,
|
|
185
|
+
f"python_transform failed: {e!s}",
|
|
186
|
+
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
|
+
],
|
|
192
|
+
)
|
|
193
|
+
)
|
|
48
194
|
|
|
49
195
|
|
|
50
196
|
def _merge_options(base: dict, override: dict) -> dict:
|
|
@@ -325,6 +471,10 @@ async def _call_addon_ws(
|
|
|
325
471
|
debug: bool = False,
|
|
326
472
|
port: int | None = None,
|
|
327
473
|
wait_for_close: bool = True,
|
|
474
|
+
message_limit: int | None = None,
|
|
475
|
+
message_offset: int = 0,
|
|
476
|
+
summarize: bool = True,
|
|
477
|
+
python_transform: str | None = None,
|
|
328
478
|
) -> dict[str, Any]:
|
|
329
479
|
"""Connect to an add-on's WebSocket API and collect messages.
|
|
330
480
|
|
|
@@ -338,6 +488,20 @@ async def _call_addon_ws(
|
|
|
338
488
|
port: Override port (same as HTTP tool)
|
|
339
489
|
wait_for_close: If True, collect messages until server closes or timeout.
|
|
340
490
|
If False, return after first batch of messages (up to 2s of silence).
|
|
491
|
+
message_limit: Cap on messages collected from the wire. Bounded by the
|
|
492
|
+
hard ceiling ``_MAX_WS_MESSAGES``. None means "collect up to the
|
|
493
|
+
ceiling" (legacy behavior).
|
|
494
|
+
message_offset: Drop this many messages from the start of the collected
|
|
495
|
+
list before returning. Useful for paginating past a known-noisy
|
|
496
|
+
header when re-running the same call.
|
|
497
|
+
summarize: When True (default), collapse runs of non-signal messages
|
|
498
|
+
(typically YAML config dumps) into short elision markers. Set to
|
|
499
|
+
False to return the raw stream.
|
|
500
|
+
python_transform: Optional sandboxed Python expression that post-
|
|
501
|
+
processes the response. The variable ``response`` is bound to
|
|
502
|
+
the list of parsed messages (``list[dict | str]``); the value
|
|
503
|
+
of ``response`` after execution replaces ``messages`` in the
|
|
504
|
+
output. See ``ha_manage_addon`` docstring for details.
|
|
341
505
|
|
|
342
506
|
Returns:
|
|
343
507
|
Dictionary with collected messages, metadata, and status.
|
|
@@ -427,6 +591,16 @@ async def _call_addon_ws(
|
|
|
427
591
|
close_reason = "unknown"
|
|
428
592
|
start_time = time.monotonic()
|
|
429
593
|
|
|
594
|
+
# Effective collection cap: callers may lower _MAX_WS_MESSAGES via
|
|
595
|
+
# message_limit but cannot raise it. A caller's message_limit interacts
|
|
596
|
+
# with message_offset — we collect enough to satisfy `offset + limit`
|
|
597
|
+
# so requesting a later window actually returns the window.
|
|
598
|
+
if message_limit is None:
|
|
599
|
+
collection_cap = _MAX_WS_MESSAGES
|
|
600
|
+
else:
|
|
601
|
+
requested = max(0, message_offset) + max(0, message_limit)
|
|
602
|
+
collection_cap = min(_MAX_WS_MESSAGES, requested)
|
|
603
|
+
|
|
430
604
|
try:
|
|
431
605
|
async with websockets.connect(
|
|
432
606
|
ws_url,
|
|
@@ -451,8 +625,15 @@ async def _call_addon_ws(
|
|
|
451
625
|
close_reason = "timeout"
|
|
452
626
|
break
|
|
453
627
|
|
|
454
|
-
if len(collected) >=
|
|
455
|
-
|
|
628
|
+
if len(collected) >= collection_cap:
|
|
629
|
+
# Distinguish caller-set cap from the global safety ceiling
|
|
630
|
+
# so an agent reading the response can tell "I capped this"
|
|
631
|
+
# from "ha-mcp's hard ceiling kicked in".
|
|
632
|
+
close_reason = (
|
|
633
|
+
"message_limit"
|
|
634
|
+
if message_limit is not None
|
|
635
|
+
else "safety_ceiling"
|
|
636
|
+
)
|
|
456
637
|
break
|
|
457
638
|
|
|
458
639
|
if total_size >= _MAX_RESPONSE_SIZE:
|
|
@@ -478,7 +659,7 @@ async def _call_addon_ws(
|
|
|
478
659
|
continue
|
|
479
660
|
|
|
480
661
|
# Strip ANSI escape codes
|
|
481
|
-
clean =
|
|
662
|
+
clean = ANSI_ESCAPE_RE.sub("", message)
|
|
482
663
|
collected.append(clean)
|
|
483
664
|
total_size += len(clean)
|
|
484
665
|
|
|
@@ -527,7 +708,9 @@ async def _call_addon_ws(
|
|
|
527
708
|
elapsed = round(time.monotonic() - start_time, 2)
|
|
528
709
|
|
|
529
710
|
# 8. Build result
|
|
530
|
-
# Try to parse each message as JSON; keep as string if not JSON
|
|
711
|
+
# Try to parse each message as JSON; keep as string if not JSON.
|
|
712
|
+
# Result shape is list[dict | str] — the heterogeneity is part of the
|
|
713
|
+
# python_transform contract (see ha_manage_addon docstring).
|
|
531
714
|
parsed_messages: list[Any] = []
|
|
532
715
|
for msg in collected:
|
|
533
716
|
try:
|
|
@@ -535,22 +718,61 @@ async def _call_addon_ws(
|
|
|
535
718
|
except (json.JSONDecodeError, ValueError):
|
|
536
719
|
parsed_messages.append(msg)
|
|
537
720
|
|
|
721
|
+
# 8a. Apply offset/limit slicing before summarize/transform so users
|
|
722
|
+
# paginate the raw collected list, not the post-summarize output.
|
|
723
|
+
sliced_messages, pagination = _slice_ws_messages(
|
|
724
|
+
parsed_messages,
|
|
725
|
+
offset=message_offset,
|
|
726
|
+
limit=message_limit,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# 8b. Summarize (default on) — collapse bulk non-signal runs.
|
|
730
|
+
summary_meta: dict[str, Any] | None = None
|
|
731
|
+
processed_messages: list[Any] = sliced_messages
|
|
732
|
+
if summarize:
|
|
733
|
+
processed_messages, summary_meta = _summarize_ws_messages(sliced_messages)
|
|
734
|
+
|
|
735
|
+
# 8c. python_transform (optional) — user-controlled post-processing.
|
|
736
|
+
transformed = False
|
|
737
|
+
pre_transform_count = len(processed_messages)
|
|
738
|
+
if python_transform is not None:
|
|
739
|
+
processed_messages = _apply_response_transform(
|
|
740
|
+
processed_messages,
|
|
741
|
+
python_transform,
|
|
742
|
+
)
|
|
743
|
+
transformed = True
|
|
744
|
+
|
|
538
745
|
result: dict[str, Any] = {
|
|
539
746
|
"success": True,
|
|
540
|
-
"messages":
|
|
541
|
-
"message_count":
|
|
747
|
+
"messages": processed_messages,
|
|
748
|
+
"message_count": (
|
|
749
|
+
len(processed_messages) if isinstance(processed_messages, list) else None
|
|
750
|
+
),
|
|
542
751
|
"closed_by": close_reason,
|
|
543
752
|
"duration_seconds": elapsed,
|
|
544
753
|
"addon_name": addon_name,
|
|
545
754
|
"slug": slug,
|
|
546
755
|
}
|
|
547
756
|
|
|
757
|
+
# Pagination metadata is always present when offset/limit were used so
|
|
758
|
+
# callers have a stable shape to reason about.
|
|
759
|
+
if message_offset > 0 or message_limit is not None:
|
|
760
|
+
result["pagination"] = pagination
|
|
761
|
+
|
|
762
|
+
if summary_meta is not None and summary_meta["elided_count"] > 0:
|
|
763
|
+
result["summary"] = summary_meta
|
|
764
|
+
|
|
765
|
+
if transformed:
|
|
766
|
+
result["transformed"] = True
|
|
767
|
+
result["pre_transform_message_count"] = pre_transform_count
|
|
768
|
+
|
|
548
769
|
if debug:
|
|
549
770
|
result["_debug"] = {
|
|
550
771
|
"ws_url": ws_url,
|
|
551
772
|
"request_headers": dict(headers),
|
|
552
773
|
"initial_message": body,
|
|
553
774
|
"total_bytes_collected": total_size,
|
|
775
|
+
"collection_cap": collection_cap,
|
|
554
776
|
}
|
|
555
777
|
|
|
556
778
|
# Cap the serialized result size (raw bytes undercount due to JSON + MCP overhead)
|
|
@@ -559,17 +781,20 @@ async def _call_addon_ws(
|
|
|
559
781
|
result = {
|
|
560
782
|
"success": True,
|
|
561
783
|
"error": "RESPONSE_TOO_LARGE",
|
|
562
|
-
"message": f"WebSocket
|
|
563
|
-
f"
|
|
564
|
-
|
|
565
|
-
|
|
784
|
+
"message": f"WebSocket response ({len(result_serialized)} bytes "
|
|
785
|
+
f"serialized) exceeds {_MAX_RESPONSE_SIZE // 1024}KB limit.",
|
|
786
|
+
"message_count": (
|
|
787
|
+
len(processed_messages)
|
|
788
|
+
if isinstance(processed_messages, list)
|
|
789
|
+
else None
|
|
790
|
+
),
|
|
566
791
|
"closed_by": close_reason,
|
|
567
792
|
"duration_seconds": elapsed,
|
|
568
793
|
"addon_name": addon_name,
|
|
569
794
|
"slug": slug,
|
|
570
795
|
"truncated": True,
|
|
571
|
-
"hint": "
|
|
572
|
-
"or
|
|
796
|
+
"hint": "Lower message_limit, raise message_offset, keep summarize=True, "
|
|
797
|
+
"or narrow the response with python_transform.",
|
|
573
798
|
}
|
|
574
799
|
|
|
575
800
|
return result
|
|
@@ -586,6 +811,7 @@ async def _call_addon_api(
|
|
|
586
811
|
port: int | None = None,
|
|
587
812
|
offset: int = 0,
|
|
588
813
|
limit: int | None = None,
|
|
814
|
+
python_transform: str | None = None,
|
|
589
815
|
) -> dict[str, Any]:
|
|
590
816
|
"""Call an add-on's web API through Home Assistant's Ingress proxy.
|
|
591
817
|
|
|
@@ -599,6 +825,10 @@ async def _call_addon_api(
|
|
|
599
825
|
port: Override port to connect to (e.g., direct access port instead of ingress port)
|
|
600
826
|
offset: Skip this many items in array responses (default 0)
|
|
601
827
|
limit: Return at most this many items from array responses
|
|
828
|
+
python_transform: Optional sandboxed Python expression applied to the
|
|
829
|
+
parsed response body. The variable ``response`` is bound to
|
|
830
|
+
``dict | list | str`` depending on content-type. Transform runs
|
|
831
|
+
after offset/limit slicing.
|
|
602
832
|
|
|
603
833
|
Returns:
|
|
604
834
|
Dictionary with response data, status code, and content type.
|
|
@@ -756,6 +986,13 @@ async def _call_addon_api(
|
|
|
756
986
|
"returned": len(response_data),
|
|
757
987
|
}
|
|
758
988
|
|
|
989
|
+
# 8a. python_transform (optional) — runs after slicing, before size cap,
|
|
990
|
+
# so an agent can narrow a large response down under the limit.
|
|
991
|
+
transformed = False
|
|
992
|
+
if python_transform is not None:
|
|
993
|
+
response_data = _apply_response_transform(response_data, python_transform)
|
|
994
|
+
transformed = True
|
|
995
|
+
|
|
759
996
|
# 9. Truncate large responses
|
|
760
997
|
truncated = False
|
|
761
998
|
if isinstance(response_data, str) and len(response_data) > _MAX_RESPONSE_SIZE:
|
|
@@ -814,6 +1051,9 @@ async def _call_addon_api(
|
|
|
814
1051
|
if pagination_meta:
|
|
815
1052
|
result["pagination"] = pagination_meta
|
|
816
1053
|
|
|
1054
|
+
if transformed:
|
|
1055
|
+
result["transformed"] = True
|
|
1056
|
+
|
|
817
1057
|
if truncated:
|
|
818
1058
|
result["truncated"] = True
|
|
819
1059
|
result["note"] = (
|
|
@@ -1046,6 +1286,44 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1046
1286
|
default=True,
|
|
1047
1287
|
),
|
|
1048
1288
|
] = True,
|
|
1289
|
+
message_limit: Annotated[
|
|
1290
|
+
int | None,
|
|
1291
|
+
Field(
|
|
1292
|
+
description="Proxy mode only. WebSocket: cap on messages collected from the wire, "
|
|
1293
|
+
"bounded by an internal safety ceiling. None = collect up to the ceiling. "
|
|
1294
|
+
"Lower to save tokens on noisy streams (e.g., message_limit=50 for a quick health check).",
|
|
1295
|
+
default=None,
|
|
1296
|
+
),
|
|
1297
|
+
] = None,
|
|
1298
|
+
message_offset: Annotated[
|
|
1299
|
+
int,
|
|
1300
|
+
Field(
|
|
1301
|
+
description="Proxy mode only. WebSocket: drop this many messages from the start of the "
|
|
1302
|
+
"collected list before returning. Useful for paginating past known-noisy headers. Default: 0.",
|
|
1303
|
+
default=0,
|
|
1304
|
+
),
|
|
1305
|
+
] = 0,
|
|
1306
|
+
summarize: Annotated[
|
|
1307
|
+
bool,
|
|
1308
|
+
Field(
|
|
1309
|
+
description="Proxy mode only. WebSocket: when True (default), collapse runs of "
|
|
1310
|
+
"non-signal messages (typically YAML config dumps) into short elision markers. "
|
|
1311
|
+
"Set to False to return the raw stream.",
|
|
1312
|
+
default=True,
|
|
1313
|
+
),
|
|
1314
|
+
] = True,
|
|
1315
|
+
python_transform: Annotated[
|
|
1316
|
+
str | None,
|
|
1317
|
+
Field(
|
|
1318
|
+
description="Proxy mode only. Sandboxed Python expression that post-processes the response. "
|
|
1319
|
+
"Variable `response` is exposed — a list[dict | str] for WebSocket (parsed JSON or raw text), "
|
|
1320
|
+
"or dict/list/str for HTTP (parsed body). Supports in-place mutation "
|
|
1321
|
+
"(response.append(...)) or reassignment (response = [...]). "
|
|
1322
|
+
"Example: response = [m for m in response if 'ERROR' in str(m)]. "
|
|
1323
|
+
"Post-processing only — does not provide optimistic-locking write semantics.",
|
|
1324
|
+
default=None,
|
|
1325
|
+
),
|
|
1326
|
+
] = None,
|
|
1049
1327
|
options: Annotated[
|
|
1050
1328
|
dict[str, Any] | None,
|
|
1051
1329
|
Field(
|
|
@@ -1095,6 +1373,23 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1095
1373
|
Sends requests directly to the add-on container's own web API via HTTP or WebSocket.
|
|
1096
1374
|
Use ha_get_addon(slug="...") to discover available ports and endpoints.
|
|
1097
1375
|
|
|
1376
|
+
**Response shaping (proxy mode):**
|
|
1377
|
+
- WebSocket streams can be noisy (ESPHome /validate often emits hundreds of
|
|
1378
|
+
config-dump lines). By default, `summarize=True` collapses long runs of
|
|
1379
|
+
non-signal messages into short elision markers; INFO/WARNING/ERROR/exit
|
|
1380
|
+
lines always pass through. Pagination via `message_offset` / `message_limit`
|
|
1381
|
+
works on the raw collected list before summarize runs.
|
|
1382
|
+
- `python_transform` applies a sandboxed Python expression as a final
|
|
1383
|
+
post-processing step in both HTTP and WebSocket modes. The variable
|
|
1384
|
+
`response` is bound to:
|
|
1385
|
+
* WebSocket: `list[dict | str]` — parsed JSON messages are dicts,
|
|
1386
|
+
undecodable frames stay as ANSI-stripped strings. Elision markers
|
|
1387
|
+
appear as `{"elided": N, "note": "..."}` dicts when summarize ran.
|
|
1388
|
+
* HTTP: `dict | list | str` — whichever the content-type produced.
|
|
1389
|
+
Transforms may mutate in place (response.append(...), del response[k])
|
|
1390
|
+
or reassign (response = [...]). This is post-processing only — it does
|
|
1391
|
+
NOT provide optimistic-locking or write-back semantics.
|
|
1392
|
+
|
|
1098
1393
|
**WARNING:** Setting boot="auto"/"manual" will fail for add-ons whose Supervisor
|
|
1099
1394
|
metadata locks the boot mode. The Supervisor returns an error in this case.
|
|
1100
1395
|
|
|
@@ -1110,6 +1405,9 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1110
1405
|
- Call HTTP API: ha_manage_addon(slug="...", path="/api/events")
|
|
1111
1406
|
- Direct port: ha_manage_addon(slug="...", path="/flows", port=1880)
|
|
1112
1407
|
- WebSocket: ha_manage_addon(slug="...", path="/validate", port=6052, websocket=True, body={"type": "spawn", "configuration": "device.yaml"})
|
|
1408
|
+
- Quick WS health check (50 msgs, raw): ha_manage_addon(slug="...", path="/logs", websocket=True, message_limit=50, summarize=False)
|
|
1409
|
+
- 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)]")
|
|
1410
|
+
- HTTP subset: ha_manage_addon(slug="...", path="/flows", python_transform="response = [f['id'] for f in response]")
|
|
1113
1411
|
"""
|
|
1114
1412
|
# Build config payload from provided config parameters
|
|
1115
1413
|
config_data: dict[str, Any] = {}
|
|
@@ -1169,6 +1467,18 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1169
1467
|
proxy_overrides.append(("websocket", "websocket=True"))
|
|
1170
1468
|
if not wait_for_close:
|
|
1171
1469
|
proxy_overrides.append(("wait_for_close", "wait_for_close=False"))
|
|
1470
|
+
if message_limit is not None:
|
|
1471
|
+
proxy_overrides.append(
|
|
1472
|
+
("message_limit", f"message_limit={message_limit}")
|
|
1473
|
+
)
|
|
1474
|
+
if message_offset != 0:
|
|
1475
|
+
proxy_overrides.append(
|
|
1476
|
+
("message_offset", f"message_offset={message_offset}")
|
|
1477
|
+
)
|
|
1478
|
+
if not summarize:
|
|
1479
|
+
proxy_overrides.append(("summarize", "summarize=False"))
|
|
1480
|
+
if python_transform is not None:
|
|
1481
|
+
proxy_overrides.append(("python_transform", "python_transform"))
|
|
1172
1482
|
if proxy_overrides:
|
|
1173
1483
|
raise_tool_error(
|
|
1174
1484
|
create_validation_error(
|
|
@@ -1278,6 +1588,10 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1278
1588
|
debug=debug,
|
|
1279
1589
|
port=port,
|
|
1280
1590
|
wait_for_close=wait_for_close,
|
|
1591
|
+
message_limit=message_limit,
|
|
1592
|
+
message_offset=message_offset,
|
|
1593
|
+
summarize=summarize,
|
|
1594
|
+
python_transform=python_transform,
|
|
1281
1595
|
)
|
|
1282
1596
|
if not result.get("success"):
|
|
1283
1597
|
raise_tool_error(result)
|
|
@@ -1293,6 +1607,17 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1293
1607
|
)
|
|
1294
1608
|
)
|
|
1295
1609
|
|
|
1610
|
+
# HTTP mode does not use WebSocket-specific params. Reject explicit
|
|
1611
|
+
# use so misroutes surface immediately rather than silently ignoring.
|
|
1612
|
+
if message_limit is not None or message_offset != 0 or not summarize:
|
|
1613
|
+
raise_tool_error(
|
|
1614
|
+
create_validation_error(
|
|
1615
|
+
"message_limit / message_offset / summarize apply only to "
|
|
1616
|
+
"WebSocket mode. Set websocket=True or remove them.",
|
|
1617
|
+
parameter="message_limit",
|
|
1618
|
+
)
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1296
1621
|
result = await _call_addon_api(
|
|
1297
1622
|
client=client,
|
|
1298
1623
|
slug=slug,
|
|
@@ -1303,6 +1628,7 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1303
1628
|
port=port,
|
|
1304
1629
|
offset=offset,
|
|
1305
1630
|
limit=limit,
|
|
1631
|
+
python_transform=python_transform,
|
|
1306
1632
|
)
|
|
1307
1633
|
if not result.get("success"):
|
|
1308
1634
|
raise_tool_error(result)
|