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.
Files changed (105) hide show
  1. {ha_mcp_dev-7.4.1.dev411/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev413}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_addons.py +341 -15
  4. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_bug_report.py +177 -7
  5. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/util_helpers.py +4 -0
  6. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/python_sandbox.py +101 -18
  7. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  8. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/LICENSE +0 -0
  9. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/MANIFEST.in +0 -0
  10. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/README.md +0 -0
  11. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/setup.cfg +0 -0
  12. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/__init__.py +0 -0
  13. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/__main__.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/_pypi_marker +0 -0
  15. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/_version.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/auth/__init__.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/auth/consent_form.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/auth/provider.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/__init__.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/rest_client.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/websocket_client.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/client/websocket_listener.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/config.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/errors.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/py.typed +0 -0
  26. {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
  27. {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
  28. {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
  29. {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
  30. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  31. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  34. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {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
  45. {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
  46. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/server.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/settings_ui.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/smoke_test.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/__init__.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/backup.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/device_control.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/enhanced.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/helpers.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/reference_validator.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/smart_search.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_areas.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_calendar.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_camera.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_categories.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  65. {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
  66. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_energy.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_entities.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_groups.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_hacs.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_history.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_integrations.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_labels.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_registry.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_resources.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_search.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_service.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_services.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_system.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_todo.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_traces.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_updates.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_utility.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/tools/tools_zones.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/transforms/__init__.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/transforms/categorized_search.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/__init__.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/config_hash.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/domain_handlers.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/operation_manager.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp/utils/usage_logger.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  99. {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
  100. {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
  101. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  102. {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
  103. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/tests/__init__.py +0 -0
  104. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/tests/test_constants.py +0 -0
  105. {ha_mcp_dev-7.4.1.dev411 → ha_mcp_dev-7.4.1.dev413}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev411
3
+ Version: 7.4.1.dev413
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.4.1.dev411"
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
- # Maximum number of WebSocket messages to collect
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
- # ANSI escape code pattern for stripping terminal colors from addon output
47
- _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
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) >= _MAX_WS_MESSAGES:
455
- close_reason = "message_limit"
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 = _ANSI_ESCAPE_RE.sub("", message)
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": parsed_messages,
541
- "message_count": len(parsed_messages),
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 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),
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": "Use wait_for_close=false for shorter collection, "
572
- "or use the HTTP endpoint with offset/limit for paginated access.",
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)