ha-mcp-dev 7.5.0.dev507__tar.gz → 7.5.0.dev509__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.5.0.dev507/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev509}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/pyproject.toml +1 -1
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_addons.py +494 -51
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/setup.cfg +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/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.5.0.
|
|
7
|
+
version = "7.5.0.dev509"
|
|
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"
|
|
@@ -12,7 +12,7 @@ import json
|
|
|
12
12
|
import logging
|
|
13
13
|
import re
|
|
14
14
|
import time
|
|
15
|
-
from typing import Annotated, Any
|
|
15
|
+
from typing import Annotated, Any, cast
|
|
16
16
|
from urllib.parse import unquote, urlsplit
|
|
17
17
|
|
|
18
18
|
import httpx
|
|
@@ -183,9 +183,7 @@ def _apply_response_transform(response: Any, expr: str) -> Any:
|
|
|
183
183
|
try:
|
|
184
184
|
return safe_execute_expression(expr, {"response": response}, "response")
|
|
185
185
|
except PythonSandboxError as e:
|
|
186
|
-
message, suggestions = format_sandbox_error(
|
|
187
|
-
e, expr, variable_name="response"
|
|
188
|
-
)
|
|
186
|
+
message, suggestions = format_sandbox_error(e, expr, variable_name="response")
|
|
189
187
|
raise_tool_error(
|
|
190
188
|
create_error_response(
|
|
191
189
|
ErrorCode.VALIDATION_FAILED,
|
|
@@ -516,7 +514,9 @@ async def get_addon_info(client: HomeAssistantClient, slug: str) -> dict[str, An
|
|
|
516
514
|
"""
|
|
517
515
|
response = await _supervisor_api_call(client, f"/addons/{slug}/info")
|
|
518
516
|
if not response.get("success"):
|
|
519
|
-
return
|
|
517
|
+
return (
|
|
518
|
+
response # TODO(tech-debt): should raise ToolError per AGENTS.md Pattern B
|
|
519
|
+
)
|
|
520
520
|
|
|
521
521
|
addon = response["result"] if isinstance(response["result"], dict) else {}
|
|
522
522
|
result: dict[str, Any] = {"success": True, "addon": addon}
|
|
@@ -549,8 +549,7 @@ def _extract_addon_log_level(addon: dict[str, Any]) -> str | None:
|
|
|
549
549
|
|
|
550
550
|
schema = addon.get("schema")
|
|
551
551
|
if isinstance(schema, list) and any(
|
|
552
|
-
isinstance(item, dict) and item.get("name") == "log_level"
|
|
553
|
-
for item in schema
|
|
552
|
+
isinstance(item, dict) and item.get("name") == "log_level" for item in schema
|
|
554
553
|
):
|
|
555
554
|
return "default"
|
|
556
555
|
|
|
@@ -571,7 +570,9 @@ async def list_addons(
|
|
|
571
570
|
"""
|
|
572
571
|
response = await _supervisor_api_call(client, "/addons")
|
|
573
572
|
if not response.get("success"):
|
|
574
|
-
return
|
|
573
|
+
return (
|
|
574
|
+
response # TODO(tech-debt): should raise ToolError per AGENTS.md Pattern B
|
|
575
|
+
)
|
|
575
576
|
|
|
576
577
|
data = response["result"]
|
|
577
578
|
addons = data.get("addons", [])
|
|
@@ -1050,18 +1051,247 @@ async def _call_addon_ws(
|
|
|
1050
1051
|
return result
|
|
1051
1052
|
|
|
1052
1053
|
|
|
1054
|
+
_ARRAY_PATCH_OPS = {"patch", "delete", "add", "delete_where"}
|
|
1055
|
+
|
|
1056
|
+
# Sentinel used to distinguish "key absent" from "key explicitly set to None"
|
|
1057
|
+
# in array_patch validation. dict.get() with this default lets us detect a
|
|
1058
|
+
# missing 'value' field without rejecting legitimate {"value": None} ops.
|
|
1059
|
+
_ARRAY_PATCH_MISSING: Any = object()
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _apply_array_ops(
|
|
1063
|
+
items: list[Any],
|
|
1064
|
+
operations: list[dict[str, Any]],
|
|
1065
|
+
id_field: str,
|
|
1066
|
+
) -> tuple[list[Any], dict[str, Any]]:
|
|
1067
|
+
"""Apply a sequence of array_patch operations to a list of resource dicts.
|
|
1068
|
+
|
|
1069
|
+
Operations are applied in order against a working copy. Any validation
|
|
1070
|
+
failure (unknown op, missing reference, id collision, missing required
|
|
1071
|
+
field) raises ToolError before the caller posts anything back, giving
|
|
1072
|
+
fail-fast all-or-nothing semantics from the server's perspective.
|
|
1073
|
+
|
|
1074
|
+
Args:
|
|
1075
|
+
items: Current array fetched from the addon (mutated copy is built here).
|
|
1076
|
+
operations: Ordered list of op dicts. Supported shapes:
|
|
1077
|
+
{"op": "patch", "id": <value>, "patches": {field: value, ...}}
|
|
1078
|
+
{"op": "delete", "id": <value>}
|
|
1079
|
+
{"op": "add", "item": {<id_field>: <value>, ...}}
|
|
1080
|
+
{"op": "delete_where", "field": <name>, "value": <value>}
|
|
1081
|
+
id_field: Field name on each item used as its identifier.
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
Tuple of (new_array, summary). Summary lists what each op touched —
|
|
1085
|
+
IDs only, no full payloads — so the response stays compact even when
|
|
1086
|
+
the underlying array is large.
|
|
1087
|
+
"""
|
|
1088
|
+
# Shallow copy of the outer list. The inner item dicts are NOT copied —
|
|
1089
|
+
# patch ops mutate them in place via `target.update(...)`. Callers must
|
|
1090
|
+
# not retain references to `items` and expect them unchanged; this is
|
|
1091
|
+
# safe here because the dispatcher only uses `items` to build the POST
|
|
1092
|
+
# body and then discards it.
|
|
1093
|
+
working = list(items)
|
|
1094
|
+
|
|
1095
|
+
summary: dict[str, list[Any]] = {
|
|
1096
|
+
"patched": [],
|
|
1097
|
+
"deleted": [],
|
|
1098
|
+
"added": [],
|
|
1099
|
+
"deleted_where": [],
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
for index, op_spec in enumerate(operations):
|
|
1103
|
+
if not isinstance(op_spec, dict):
|
|
1104
|
+
raise_tool_error(
|
|
1105
|
+
create_validation_error(
|
|
1106
|
+
f"array_patch operation #{index} is not an object",
|
|
1107
|
+
parameter="array_patch.operations",
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
op = op_spec.get("op")
|
|
1112
|
+
if op not in _ARRAY_PATCH_OPS:
|
|
1113
|
+
raise_tool_error(
|
|
1114
|
+
create_validation_error(
|
|
1115
|
+
f"array_patch op '{op}' not recognised "
|
|
1116
|
+
f"(expected one of: {sorted(_ARRAY_PATCH_OPS)})",
|
|
1117
|
+
parameter=f"array_patch.operations[{index}].op",
|
|
1118
|
+
)
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
if op == "patch":
|
|
1122
|
+
target_id = op_spec.get("id")
|
|
1123
|
+
patches = op_spec.get("patches")
|
|
1124
|
+
if target_id is None:
|
|
1125
|
+
raise_tool_error(
|
|
1126
|
+
create_validation_error(
|
|
1127
|
+
f"array_patch patch op #{index} missing 'id'",
|
|
1128
|
+
parameter=f"array_patch.operations[{index}].id",
|
|
1129
|
+
)
|
|
1130
|
+
)
|
|
1131
|
+
if not isinstance(patches, dict):
|
|
1132
|
+
raise_tool_error(
|
|
1133
|
+
create_validation_error(
|
|
1134
|
+
f"array_patch patch op #{index} 'patches' must be an object",
|
|
1135
|
+
parameter=f"array_patch.operations[{index}].patches",
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
if not patches:
|
|
1139
|
+
# `target.update({})` is a no-op; reject so the caller learns
|
|
1140
|
+
# their op was meaningless rather than seeing a silent
|
|
1141
|
+
# `summary["patched"]` entry with `"fields": []`.
|
|
1142
|
+
raise_tool_error(
|
|
1143
|
+
create_validation_error(
|
|
1144
|
+
f"array_patch patch op #{index} 'patches' cannot be empty "
|
|
1145
|
+
"(no fields to update)",
|
|
1146
|
+
parameter=f"array_patch.operations[{index}].patches",
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1149
|
+
target = next(
|
|
1150
|
+
(
|
|
1151
|
+
it
|
|
1152
|
+
for it in working
|
|
1153
|
+
if isinstance(it, dict) and it.get(id_field) == target_id
|
|
1154
|
+
),
|
|
1155
|
+
None,
|
|
1156
|
+
)
|
|
1157
|
+
if target is None:
|
|
1158
|
+
raise_tool_error(
|
|
1159
|
+
create_error_response(
|
|
1160
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
1161
|
+
f"No item with {id_field}={target_id!r} for patch op #{index}",
|
|
1162
|
+
context={"id_field": id_field, "id": target_id},
|
|
1163
|
+
)
|
|
1164
|
+
)
|
|
1165
|
+
target.update(patches)
|
|
1166
|
+
summary["patched"].append({"id": target_id, "fields": list(patches.keys())})
|
|
1167
|
+
|
|
1168
|
+
elif op == "delete":
|
|
1169
|
+
target_id = op_spec.get("id")
|
|
1170
|
+
if target_id is None:
|
|
1171
|
+
raise_tool_error(
|
|
1172
|
+
create_validation_error(
|
|
1173
|
+
f"array_patch delete op #{index} missing 'id'",
|
|
1174
|
+
parameter=f"array_patch.operations[{index}].id",
|
|
1175
|
+
)
|
|
1176
|
+
)
|
|
1177
|
+
before = len(working)
|
|
1178
|
+
working = [
|
|
1179
|
+
it
|
|
1180
|
+
for it in working
|
|
1181
|
+
if not (isinstance(it, dict) and it.get(id_field) == target_id)
|
|
1182
|
+
]
|
|
1183
|
+
if len(working) == before:
|
|
1184
|
+
raise_tool_error(
|
|
1185
|
+
create_error_response(
|
|
1186
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
1187
|
+
f"No item with {id_field}={target_id!r} for delete op #{index}",
|
|
1188
|
+
context={"id_field": id_field, "id": target_id},
|
|
1189
|
+
)
|
|
1190
|
+
)
|
|
1191
|
+
summary["deleted"].append({"id": target_id})
|
|
1192
|
+
|
|
1193
|
+
elif op == "add":
|
|
1194
|
+
new_item = op_spec.get("item")
|
|
1195
|
+
if not isinstance(new_item, dict):
|
|
1196
|
+
raise_tool_error(
|
|
1197
|
+
create_validation_error(
|
|
1198
|
+
f"array_patch add op #{index} 'item' must be an object",
|
|
1199
|
+
parameter=f"array_patch.operations[{index}].item",
|
|
1200
|
+
)
|
|
1201
|
+
)
|
|
1202
|
+
if id_field not in new_item:
|
|
1203
|
+
raise_tool_error(
|
|
1204
|
+
create_validation_error(
|
|
1205
|
+
f"array_patch add op #{index} 'item' missing id field "
|
|
1206
|
+
f"{id_field!r}",
|
|
1207
|
+
parameter=f"array_patch.operations[{index}].item",
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
new_id = new_item[id_field]
|
|
1211
|
+
if new_id is None or new_id == "":
|
|
1212
|
+
# Items missing the id field have `dict.get(id_field) == None`
|
|
1213
|
+
# by default, so allowing None/"" ids would let later patch /
|
|
1214
|
+
# delete ops match unrelated items.
|
|
1215
|
+
raise_tool_error(
|
|
1216
|
+
create_validation_error(
|
|
1217
|
+
f"array_patch add op #{index} item {id_field!r} cannot be "
|
|
1218
|
+
"None or an empty string",
|
|
1219
|
+
parameter=f"array_patch.operations[{index}].item.{id_field}",
|
|
1220
|
+
)
|
|
1221
|
+
)
|
|
1222
|
+
if any(
|
|
1223
|
+
isinstance(it, dict) and it.get(id_field) == new_id for it in working
|
|
1224
|
+
):
|
|
1225
|
+
raise_tool_error(
|
|
1226
|
+
create_error_response(
|
|
1227
|
+
ErrorCode.RESOURCE_ALREADY_EXISTS,
|
|
1228
|
+
f"Item with {id_field}={new_id!r} already exists "
|
|
1229
|
+
f"(add op #{index})",
|
|
1230
|
+
context={"id_field": id_field, "id": new_id},
|
|
1231
|
+
)
|
|
1232
|
+
)
|
|
1233
|
+
working.append(new_item)
|
|
1234
|
+
summary["added"].append({"id": new_id})
|
|
1235
|
+
|
|
1236
|
+
else: # delete_where
|
|
1237
|
+
field = op_spec.get("field")
|
|
1238
|
+
value = op_spec.get("value", _ARRAY_PATCH_MISSING)
|
|
1239
|
+
if not isinstance(field, str) or not field:
|
|
1240
|
+
raise_tool_error(
|
|
1241
|
+
create_validation_error(
|
|
1242
|
+
f"array_patch delete_where op #{index} missing or empty 'field'",
|
|
1243
|
+
parameter=f"array_patch.operations[{index}].field",
|
|
1244
|
+
)
|
|
1245
|
+
)
|
|
1246
|
+
if value is _ARRAY_PATCH_MISSING:
|
|
1247
|
+
raise_tool_error(
|
|
1248
|
+
create_validation_error(
|
|
1249
|
+
f"array_patch delete_where op #{index} missing 'value'",
|
|
1250
|
+
parameter=f"array_patch.operations[{index}].value",
|
|
1251
|
+
)
|
|
1252
|
+
)
|
|
1253
|
+
before = len(working)
|
|
1254
|
+
working = [
|
|
1255
|
+
it
|
|
1256
|
+
for it in working
|
|
1257
|
+
if not (isinstance(it, dict) and it.get(field) == value)
|
|
1258
|
+
]
|
|
1259
|
+
removed = before - len(working)
|
|
1260
|
+
entry: dict[str, Any] = {"field": field, "value": value, "count": removed}
|
|
1261
|
+
# Distinguish "value not present" from "field name unknown to any
|
|
1262
|
+
# item" — the latter is almost always a typo and would otherwise
|
|
1263
|
+
# be a silent count=0. Only warn when there are dict items to
|
|
1264
|
+
# inspect; an empty array or all-non-dict array would `not any(...)`
|
|
1265
|
+
# trivially and produce a misleading typo suggestion.
|
|
1266
|
+
inspectable = [it for it in working if isinstance(it, dict)]
|
|
1267
|
+
if (
|
|
1268
|
+
removed == 0
|
|
1269
|
+
and inspectable
|
|
1270
|
+
and not any(field in it for it in inspectable)
|
|
1271
|
+
):
|
|
1272
|
+
entry["warning"] = (
|
|
1273
|
+
f"field {field!r} is not present on any item — "
|
|
1274
|
+
"check for a typo in the field name"
|
|
1275
|
+
)
|
|
1276
|
+
summary["deleted_where"].append(entry)
|
|
1277
|
+
|
|
1278
|
+
return working, summary
|
|
1279
|
+
|
|
1280
|
+
|
|
1053
1281
|
async def _call_addon_api(
|
|
1054
1282
|
client: HomeAssistantClient,
|
|
1055
1283
|
slug: str,
|
|
1056
1284
|
path: str,
|
|
1057
1285
|
method: str = "GET",
|
|
1058
|
-
body: dict[str, Any] | str | None = None,
|
|
1286
|
+
body: dict[str, Any] | list[Any] | str | None = None,
|
|
1059
1287
|
timeout: int = 30,
|
|
1060
1288
|
debug: bool = False,
|
|
1061
1289
|
port: int | None = None,
|
|
1062
1290
|
offset: int = 0,
|
|
1063
1291
|
limit: int | None = None,
|
|
1064
1292
|
python_transform: str | None = None,
|
|
1293
|
+
raw: bool = False,
|
|
1294
|
+
extra_headers: dict[str, str] | None = None,
|
|
1065
1295
|
) -> dict[str, Any]:
|
|
1066
1296
|
"""Call an add-on's web API.
|
|
1067
1297
|
|
|
@@ -1084,7 +1314,7 @@ async def _call_addon_api(
|
|
|
1084
1314
|
slug: Add-on slug (e.g., "<prefix>_nodered")
|
|
1085
1315
|
path: API path relative to add-on root (e.g., "/flows")
|
|
1086
1316
|
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
|
1087
|
-
body: Request body for POST/PUT/PATCH
|
|
1317
|
+
body: Request body for POST/PUT/PATCH (dict, list, or pre-encoded JSON string)
|
|
1088
1318
|
timeout: Request timeout in seconds (default 30)
|
|
1089
1319
|
port: Override port to connect to (e.g., direct access port instead of ingress port)
|
|
1090
1320
|
offset: Skip this many items in array responses (default 0)
|
|
@@ -1093,6 +1323,16 @@ async def _call_addon_api(
|
|
|
1093
1323
|
parsed response body. The variable ``response`` is bound to
|
|
1094
1324
|
``dict | list | str`` depending on content-type. Transform runs
|
|
1095
1325
|
after offset/limit slicing.
|
|
1326
|
+
raw: Internal flag — when True, skip the size-based truncation that
|
|
1327
|
+
otherwise replaces large array/object responses with an error
|
|
1328
|
+
placeholder. Used by array_patch mode in ha_manage_addon, which
|
|
1329
|
+
needs the full parsed response in memory to apply operations
|
|
1330
|
+
even when the JSON is larger than _MAX_RESPONSE_SIZE.
|
|
1331
|
+
extra_headers: Optional caller-supplied request headers. Layered
|
|
1332
|
+
under the proxy's internal framing (`X-Ingress-Path`,
|
|
1333
|
+
`X-Hass-Source`, `Cookie`, `Content-Type`) so the framing
|
|
1334
|
+
always wins on collision. Use this to set addon-API
|
|
1335
|
+
requirements like Node-RED's `Node-RED-Deployment-Type` header.
|
|
1096
1336
|
"""
|
|
1097
1337
|
# 1. Sanitize path to prevent traversal attacks (including URL-encoded)
|
|
1098
1338
|
normalized = unquote(path).lstrip("/")
|
|
@@ -1144,8 +1384,16 @@ async def _call_addon_api(
|
|
|
1144
1384
|
# 5. Resolve route (direct-port / addon-variant / off-host).
|
|
1145
1385
|
url, headers = await _resolve_http_route(client, addon, normalized, port)
|
|
1146
1386
|
|
|
1147
|
-
# 6.
|
|
1148
|
-
|
|
1387
|
+
# 6. Layer caller-supplied headers UNDER the proxy's framing so internal
|
|
1388
|
+
# headers (X-Ingress-Path, X-Hass-Source, Cookie, Content-Type) always
|
|
1389
|
+
# win on collision — a caller cannot forge ingress identity.
|
|
1390
|
+
if extra_headers:
|
|
1391
|
+
merged = dict(extra_headers)
|
|
1392
|
+
merged.update(headers)
|
|
1393
|
+
headers = merged
|
|
1394
|
+
|
|
1395
|
+
# 7. Set content type based on body type
|
|
1396
|
+
if isinstance(body, dict | list):
|
|
1149
1397
|
headers["Content-Type"] = "application/json"
|
|
1150
1398
|
request_content = json.dumps(body).encode()
|
|
1151
1399
|
elif isinstance(body, str):
|
|
@@ -1221,43 +1469,45 @@ async def _call_addon_api(
|
|
|
1221
1469
|
response_data = _apply_response_transform(response_data, python_transform)
|
|
1222
1470
|
transformed = True
|
|
1223
1471
|
|
|
1224
|
-
# 9. Truncate large responses
|
|
1472
|
+
# 9. Truncate large responses (skipped in raw mode; array_patch needs the
|
|
1473
|
+
# full parsed payload in memory regardless of size)
|
|
1225
1474
|
truncated = False
|
|
1226
|
-
if
|
|
1227
|
-
response_data
|
|
1228
|
-
|
|
1229
|
-
elif isinstance(response_data, list):
|
|
1230
|
-
serialized = json.dumps(response_data, default=str)
|
|
1231
|
-
if len(serialized) > _MAX_RESPONSE_SIZE:
|
|
1232
|
-
total_items = len(response_data)
|
|
1233
|
-
response_data = {
|
|
1234
|
-
"error": "RESPONSE_TOO_LARGE",
|
|
1235
|
-
"message": f"The JSON array ({len(serialized)} bytes, {total_items} items) exceeds the {_MAX_RESPONSE_SIZE // 1024}KB limit.",
|
|
1236
|
-
"total_items": total_items,
|
|
1237
|
-
"hint": "Use offset and limit to paginate. Example: offset=0, limit=20",
|
|
1238
|
-
}
|
|
1239
|
-
truncated = True
|
|
1240
|
-
elif isinstance(response_data, dict):
|
|
1241
|
-
serialized = json.dumps(response_data, default=str)
|
|
1242
|
-
if len(serialized) > _MAX_RESPONSE_SIZE:
|
|
1243
|
-
# Show top-level keys and their approximate sizes to help caller
|
|
1244
|
-
# make more targeted API calls
|
|
1245
|
-
key_info = {}
|
|
1246
|
-
for k, v in response_data.items():
|
|
1247
|
-
v_serialized = json.dumps(v, default=str)
|
|
1248
|
-
if isinstance(v, list):
|
|
1249
|
-
key_info[k] = f"array[{len(v)}] ({len(v_serialized)} bytes)"
|
|
1250
|
-
elif isinstance(v, dict):
|
|
1251
|
-
key_info[k] = f"object ({len(v_serialized)} bytes)"
|
|
1252
|
-
else:
|
|
1253
|
-
key_info[k] = f"{type(v).__name__} ({len(v_serialized)} bytes)"
|
|
1254
|
-
response_data = {
|
|
1255
|
-
"error": "RESPONSE_TOO_LARGE",
|
|
1256
|
-
"message": f"The JSON object ({len(serialized)} bytes) exceeds the {_MAX_RESPONSE_SIZE // 1024}KB limit.",
|
|
1257
|
-
"top_level_keys": key_info,
|
|
1258
|
-
"hint": "Use a more specific API path to request individual keys/sections.",
|
|
1259
|
-
}
|
|
1475
|
+
if not raw:
|
|
1476
|
+
if isinstance(response_data, str) and len(response_data) > _MAX_RESPONSE_SIZE:
|
|
1477
|
+
response_data = response_data[:_MAX_RESPONSE_SIZE]
|
|
1260
1478
|
truncated = True
|
|
1479
|
+
elif isinstance(response_data, list):
|
|
1480
|
+
serialized = json.dumps(response_data, default=str)
|
|
1481
|
+
if len(serialized) > _MAX_RESPONSE_SIZE:
|
|
1482
|
+
total_items = len(response_data)
|
|
1483
|
+
response_data = {
|
|
1484
|
+
"error": "RESPONSE_TOO_LARGE",
|
|
1485
|
+
"message": f"The JSON array ({len(serialized)} bytes, {total_items} items) exceeds the {_MAX_RESPONSE_SIZE // 1024}KB limit.",
|
|
1486
|
+
"total_items": total_items,
|
|
1487
|
+
"hint": "Use offset and limit to paginate. Example: offset=0, limit=20",
|
|
1488
|
+
}
|
|
1489
|
+
truncated = True
|
|
1490
|
+
elif isinstance(response_data, dict):
|
|
1491
|
+
serialized = json.dumps(response_data, default=str)
|
|
1492
|
+
if len(serialized) > _MAX_RESPONSE_SIZE:
|
|
1493
|
+
# Show top-level keys and their approximate sizes to help caller
|
|
1494
|
+
# make more targeted API calls
|
|
1495
|
+
key_info = {}
|
|
1496
|
+
for k, v in response_data.items():
|
|
1497
|
+
v_serialized = json.dumps(v, default=str)
|
|
1498
|
+
if isinstance(v, list):
|
|
1499
|
+
key_info[k] = f"array[{len(v)}] ({len(v_serialized)} bytes)"
|
|
1500
|
+
elif isinstance(v, dict):
|
|
1501
|
+
key_info[k] = f"object ({len(v_serialized)} bytes)"
|
|
1502
|
+
else:
|
|
1503
|
+
key_info[k] = f"{type(v).__name__} ({len(v_serialized)} bytes)"
|
|
1504
|
+
response_data = {
|
|
1505
|
+
"error": "RESPONSE_TOO_LARGE",
|
|
1506
|
+
"message": f"The JSON object ({len(serialized)} bytes) exceeds the {_MAX_RESPONSE_SIZE // 1024}KB limit.",
|
|
1507
|
+
"top_level_keys": key_info,
|
|
1508
|
+
"hint": "Use a more specific API path to request individual keys/sections.",
|
|
1509
|
+
}
|
|
1510
|
+
truncated = True
|
|
1261
1511
|
|
|
1262
1512
|
result: dict[str, Any] = {
|
|
1263
1513
|
"success": response.status_code < 400,
|
|
@@ -1596,17 +1846,44 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1596
1846
|
default=None,
|
|
1597
1847
|
),
|
|
1598
1848
|
] = None,
|
|
1849
|
+
array_patch: Annotated[
|
|
1850
|
+
dict[str, Any] | None,
|
|
1851
|
+
Field(
|
|
1852
|
+
description=(
|
|
1853
|
+
"Array-patch mode: atomically GET a JSON array endpoint, "
|
|
1854
|
+
"apply ordered ops, then POST the mutated array back. "
|
|
1855
|
+
"Requires 'path'; mutually exclusive with body / websocket / "
|
|
1856
|
+
"offset / limit and config params. See the docstring Examples "
|
|
1857
|
+
"and ha_get_skill_home_assistant_best_practices for op shapes."
|
|
1858
|
+
),
|
|
1859
|
+
default=None,
|
|
1860
|
+
),
|
|
1861
|
+
] = None,
|
|
1862
|
+
request_headers: Annotated[
|
|
1863
|
+
dict[str, str] | None,
|
|
1864
|
+
Field(
|
|
1865
|
+
description=(
|
|
1866
|
+
"Proxy/array-patch mode: extra HTTP headers to send to the addon API. "
|
|
1867
|
+
"Useful for addon-specific requirements such as Node-RED's "
|
|
1868
|
+
"`Node-RED-Deployment-Type: full`. The proxy's internal framing "
|
|
1869
|
+
"(`X-Ingress-Path`, `X-Hass-Source`, `Cookie`, `Content-Type`) is "
|
|
1870
|
+
"layered on top, so caller-supplied values for those keys are "
|
|
1871
|
+
"overridden. Not valid in config or websocket mode."
|
|
1872
|
+
),
|
|
1873
|
+
default=None,
|
|
1874
|
+
),
|
|
1875
|
+
] = None,
|
|
1599
1876
|
) -> dict[str, Any]:
|
|
1600
1877
|
"""Manage a Home Assistant add-on — update its configuration or call its internal API.
|
|
1601
1878
|
|
|
1602
|
-
|
|
1879
|
+
Three mutually exclusive operating modes:
|
|
1603
1880
|
|
|
1604
1881
|
**Config mode** (when any of options/network/boot/auto_update/watchdog is provided):
|
|
1605
1882
|
Updates the add-on's Supervisor configuration via POST /addons/{slug}/options.
|
|
1606
1883
|
All config parameters are optional; only provided fields are updated — current values
|
|
1607
1884
|
are fetched and merged automatically (including one level of nested dicts).
|
|
1608
1885
|
|
|
1609
|
-
**Proxy mode** (when path is provided):
|
|
1886
|
+
**Proxy mode** (when path is provided without array_patch):
|
|
1610
1887
|
Routes HTTP or WebSocket requests through Home Assistant's Ingress
|
|
1611
1888
|
proxy by default (works on HAOS, Supervised, and off-host PyPI/uvx
|
|
1612
1889
|
installs). Pass `port=...` to bypass Ingress and connect directly to
|
|
@@ -1614,6 +1891,13 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1614
1891
|
share Home Assistant's container network (i.e. only the HAOS addon).
|
|
1615
1892
|
Use ha_get_addon(slug="...") to discover available ports and endpoints.
|
|
1616
1893
|
|
|
1894
|
+
**Array-patch mode** (when path AND array_patch are provided):
|
|
1895
|
+
Atomic "GET array, mutate, POST array" workflow for addon APIs whose write
|
|
1896
|
+
contract is "send the whole resource collection back". Operations are applied
|
|
1897
|
+
in order to a working copy; if any op fails validation (unknown id, collision,
|
|
1898
|
+
malformed shape) nothing is posted. Returns a compact summary instead of the
|
|
1899
|
+
full array. Designed for Node-RED /flows and similar endpoints.
|
|
1900
|
+
|
|
1617
1901
|
**Response shaping (proxy mode):**
|
|
1618
1902
|
- WebSocket streams can be noisy (ESPHome /validate often emits hundreds of
|
|
1619
1903
|
config-dump lines). By default, `summarize=True` collapses long runs of
|
|
@@ -1649,6 +1933,26 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1649
1933
|
- Quick WS health check (50 msgs, raw): ha_manage_addon(slug="...", path="/logs", websocket=True, message_limit=50, summarize=False)
|
|
1650
1934
|
- 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)]")
|
|
1651
1935
|
- HTTP subset: ha_manage_addon(slug="...", path="/flows", python_transform="response = [f['id'] for f in response]")
|
|
1936
|
+
- Array-patch (Node-RED, rename a node):
|
|
1937
|
+
ha_manage_addon(
|
|
1938
|
+
slug="a0d7b954_nodered", path="/flows",
|
|
1939
|
+
array_patch={"operations": [
|
|
1940
|
+
{"op": "patch", "id": "abc123", "patches": {"name": "New Name"}},
|
|
1941
|
+
]},
|
|
1942
|
+
)
|
|
1943
|
+
- Array-patch (Node-RED, replace one tab's nodes atomically):
|
|
1944
|
+
ha_manage_addon(
|
|
1945
|
+
slug="a0d7b954_nodered", path="/flows",
|
|
1946
|
+
array_patch={"operations": [
|
|
1947
|
+
{"op": "delete_where", "field": "z", "value": "tab-id"},
|
|
1948
|
+
{"op": "add", "item": {"id": "n1", "type": "inject", "z": "tab-id", ...}},
|
|
1949
|
+
{"op": "add", "item": {"id": "n2", "type": "function", "z": "tab-id", ...}},
|
|
1950
|
+
]},
|
|
1951
|
+
request_headers={"Node-RED-Deployment-Type": "full"},
|
|
1952
|
+
)
|
|
1953
|
+
- Custom request headers (proxy mode):
|
|
1954
|
+
ha_manage_addon(slug="...", path="/api/state",
|
|
1955
|
+
request_headers={"Accept": "text/plain"})
|
|
1652
1956
|
"""
|
|
1653
1957
|
# Build config payload from provided config parameters
|
|
1654
1958
|
config_data: dict[str, Any] = {}
|
|
@@ -1720,6 +2024,10 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1720
2024
|
proxy_overrides.append(("summarize", "summarize=False"))
|
|
1721
2025
|
if python_transform is not None:
|
|
1722
2026
|
proxy_overrides.append(("python_transform", "python_transform"))
|
|
2027
|
+
if array_patch is not None:
|
|
2028
|
+
proxy_overrides.append(("array_patch", "array_patch"))
|
|
2029
|
+
if request_headers is not None:
|
|
2030
|
+
proxy_overrides.append(("request_headers", "request_headers"))
|
|
1723
2031
|
if proxy_overrides:
|
|
1724
2032
|
raise_tool_error(
|
|
1725
2033
|
create_validation_error(
|
|
@@ -1729,6 +2037,67 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1729
2037
|
)
|
|
1730
2038
|
)
|
|
1731
2039
|
|
|
2040
|
+
# array_patch needs its own input shape validation (the field is dict[str, Any]
|
|
2041
|
+
# at the schema level so the model will happily send malformed payloads)
|
|
2042
|
+
if array_patch is not None:
|
|
2043
|
+
if not isinstance(array_patch, dict):
|
|
2044
|
+
raise_tool_error(
|
|
2045
|
+
create_validation_error(
|
|
2046
|
+
"array_patch must be an object",
|
|
2047
|
+
parameter="array_patch",
|
|
2048
|
+
)
|
|
2049
|
+
)
|
|
2050
|
+
if websocket:
|
|
2051
|
+
raise_tool_error(
|
|
2052
|
+
create_validation_error(
|
|
2053
|
+
"array_patch is HTTP-only and cannot be combined with websocket=True",
|
|
2054
|
+
parameter="array_patch",
|
|
2055
|
+
)
|
|
2056
|
+
)
|
|
2057
|
+
if body is not None:
|
|
2058
|
+
raise_tool_error(
|
|
2059
|
+
create_validation_error(
|
|
2060
|
+
"array_patch builds the POST body itself; remove the explicit 'body' parameter",
|
|
2061
|
+
parameter="array_patch",
|
|
2062
|
+
)
|
|
2063
|
+
)
|
|
2064
|
+
if offset != 0 or limit is not None:
|
|
2065
|
+
raise_tool_error(
|
|
2066
|
+
create_validation_error(
|
|
2067
|
+
"array_patch needs the full array; offset/limit are not supported in this mode",
|
|
2068
|
+
parameter="array_patch",
|
|
2069
|
+
)
|
|
2070
|
+
)
|
|
2071
|
+
id_field = array_patch.get("id_field", "id")
|
|
2072
|
+
if not isinstance(id_field, str) or not id_field:
|
|
2073
|
+
raise_tool_error(
|
|
2074
|
+
create_validation_error(
|
|
2075
|
+
"array_patch.id_field must be a non-empty string",
|
|
2076
|
+
parameter="array_patch.id_field",
|
|
2077
|
+
)
|
|
2078
|
+
)
|
|
2079
|
+
ops = array_patch.get("operations")
|
|
2080
|
+
if not isinstance(ops, list) or not ops:
|
|
2081
|
+
raise_tool_error(
|
|
2082
|
+
create_validation_error(
|
|
2083
|
+
"array_patch.operations must be a non-empty list",
|
|
2084
|
+
parameter="array_patch.operations",
|
|
2085
|
+
)
|
|
2086
|
+
)
|
|
2087
|
+
|
|
2088
|
+
# request_headers applies only to HTTP / array_patch mode.
|
|
2089
|
+
# _call_addon_ws does not accept caller headers — reject the combo
|
|
2090
|
+
# rather than silently dropping them (matches the fail-loud-on-misroute
|
|
2091
|
+
# pattern used for message_limit / message_offset / summarize on HTTP).
|
|
2092
|
+
if request_headers is not None and websocket:
|
|
2093
|
+
raise_tool_error(
|
|
2094
|
+
create_validation_error(
|
|
2095
|
+
"request_headers applies only to HTTP and array_patch modes; "
|
|
2096
|
+
"remove it or set websocket=False",
|
|
2097
|
+
parameter="request_headers",
|
|
2098
|
+
)
|
|
2099
|
+
)
|
|
2100
|
+
|
|
1732
2101
|
# Config mode: update Supervisor settings
|
|
1733
2102
|
if config_data:
|
|
1734
2103
|
ignored_fields: list[str] = [] # populated only when options are provided
|
|
@@ -1759,8 +2128,12 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1759
2128
|
# lets the caller correct mistakes before any state is changed.
|
|
1760
2129
|
schema_ui: list | None = addon_info.get("schema")
|
|
1761
2130
|
if schema_ui is not None:
|
|
1762
|
-
allowed_keys = {
|
|
1763
|
-
|
|
2131
|
+
allowed_keys = {
|
|
2132
|
+
item["name"] for item in schema_ui if "name" in item
|
|
2133
|
+
}
|
|
2134
|
+
ignored_fields = [
|
|
2135
|
+
k for k in config_data["options"] if k not in allowed_keys
|
|
2136
|
+
]
|
|
1764
2137
|
# Remove unknown fields from the merged dict so Supervisor does not
|
|
1765
2138
|
# silently strip them after the write succeeds.
|
|
1766
2139
|
for k in ignored_fields:
|
|
@@ -1818,6 +2191,75 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1818
2191
|
# Proxy mode: call add-on container API
|
|
1819
2192
|
# At this point path is guaranteed non-None (validated above)
|
|
1820
2193
|
assert path is not None
|
|
2194
|
+
|
|
2195
|
+
# Array-patch mode: GET → mutate → POST atomically.
|
|
2196
|
+
# Done before the websocket/HTTP branches so it can fully own the
|
|
2197
|
+
# response shape and skip the truncation/pagination paths.
|
|
2198
|
+
# id_field and ops were validated in the early validation block above.
|
|
2199
|
+
if array_patch is not None:
|
|
2200
|
+
fetch_result = await _call_addon_api(
|
|
2201
|
+
client=client,
|
|
2202
|
+
slug=slug,
|
|
2203
|
+
path=path,
|
|
2204
|
+
method="GET",
|
|
2205
|
+
debug=debug,
|
|
2206
|
+
port=port,
|
|
2207
|
+
raw=True,
|
|
2208
|
+
extra_headers=request_headers,
|
|
2209
|
+
)
|
|
2210
|
+
if not fetch_result.get("success"):
|
|
2211
|
+
raise_tool_error(fetch_result)
|
|
2212
|
+
|
|
2213
|
+
fetched = fetch_result.get("response")
|
|
2214
|
+
if not isinstance(fetched, list):
|
|
2215
|
+
raise_tool_error(
|
|
2216
|
+
create_validation_error(
|
|
2217
|
+
f"array_patch requires a JSON array at {path!r}; "
|
|
2218
|
+
f"got {type(fetched).__name__}",
|
|
2219
|
+
parameter="path",
|
|
2220
|
+
)
|
|
2221
|
+
)
|
|
2222
|
+
|
|
2223
|
+
# Re-narrow operations for the type checker — the early validation
|
|
2224
|
+
# block established `ops` is a non-empty list, but the narrowing
|
|
2225
|
+
# doesn't carry across the intervening config_data branch.
|
|
2226
|
+
# cast() (vs assert) keeps the narrowing under `python -O`.
|
|
2227
|
+
new_array, summary = _apply_array_ops(
|
|
2228
|
+
fetched, cast(list[Any], ops), id_field
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
# POST response is small (deploy revision string or status); skip
|
|
2232
|
+
# raw mode here so the size-based truncation guard is in effect.
|
|
2233
|
+
post_result = await _call_addon_api(
|
|
2234
|
+
client=client,
|
|
2235
|
+
slug=slug,
|
|
2236
|
+
path=path,
|
|
2237
|
+
method="POST",
|
|
2238
|
+
body=new_array,
|
|
2239
|
+
debug=debug,
|
|
2240
|
+
port=port,
|
|
2241
|
+
extra_headers=request_headers,
|
|
2242
|
+
)
|
|
2243
|
+
if not post_result.get("success"):
|
|
2244
|
+
raise_tool_error(post_result)
|
|
2245
|
+
|
|
2246
|
+
response_payload: dict[str, Any] = {
|
|
2247
|
+
"success": True,
|
|
2248
|
+
"slug": slug,
|
|
2249
|
+
"addon_name": fetch_result.get("addon_name"),
|
|
2250
|
+
"path": path,
|
|
2251
|
+
"id_field": id_field,
|
|
2252
|
+
"items_before": len(fetched),
|
|
2253
|
+
"items_after": len(new_array),
|
|
2254
|
+
"summary": summary,
|
|
2255
|
+
}
|
|
2256
|
+
if debug:
|
|
2257
|
+
response_payload["_debug"] = {
|
|
2258
|
+
"fetch": fetch_result.get("_debug"),
|
|
2259
|
+
"post": post_result.get("_debug"),
|
|
2260
|
+
}
|
|
2261
|
+
return response_payload
|
|
2262
|
+
|
|
1821
2263
|
# WebSocket
|
|
1822
2264
|
if websocket:
|
|
1823
2265
|
result = await _call_addon_ws(
|
|
@@ -1870,6 +2312,7 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1870
2312
|
offset=offset,
|
|
1871
2313
|
limit=limit,
|
|
1872
2314
|
python_transform=python_transform,
|
|
2315
|
+
extra_headers=request_headers,
|
|
1873
2316
|
)
|
|
1874
2317
|
if not result.get("success"):
|
|
1875
2318
|
raise_tool_error(result)
|
|
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
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/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.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/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
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/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.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/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.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/lite_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/kill_signal_diagnostics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/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
|