ha-mcp-dev 7.5.0.dev507__tar.gz → 7.5.0.dev508__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.dev508}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/pyproject.toml +1 -1
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_addons.py +445 -51
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/setup.cfg +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/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.dev508}/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.dev508}/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.dev508}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/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.dev508"
|
|
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,211 @@ 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
|
+
target = next(
|
|
1139
|
+
(
|
|
1140
|
+
it
|
|
1141
|
+
for it in working
|
|
1142
|
+
if isinstance(it, dict) and it.get(id_field) == target_id
|
|
1143
|
+
),
|
|
1144
|
+
None,
|
|
1145
|
+
)
|
|
1146
|
+
if target is None:
|
|
1147
|
+
raise_tool_error(
|
|
1148
|
+
create_error_response(
|
|
1149
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
1150
|
+
f"No item with {id_field}={target_id!r} for patch op #{index}",
|
|
1151
|
+
context={"id_field": id_field, "id": target_id},
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1154
|
+
target.update(patches)
|
|
1155
|
+
summary["patched"].append({"id": target_id, "fields": list(patches.keys())})
|
|
1156
|
+
|
|
1157
|
+
elif op == "delete":
|
|
1158
|
+
target_id = op_spec.get("id")
|
|
1159
|
+
if target_id is None:
|
|
1160
|
+
raise_tool_error(
|
|
1161
|
+
create_validation_error(
|
|
1162
|
+
f"array_patch delete op #{index} missing 'id'",
|
|
1163
|
+
parameter=f"array_patch.operations[{index}].id",
|
|
1164
|
+
)
|
|
1165
|
+
)
|
|
1166
|
+
before = len(working)
|
|
1167
|
+
working = [
|
|
1168
|
+
it
|
|
1169
|
+
for it in working
|
|
1170
|
+
if not (isinstance(it, dict) and it.get(id_field) == target_id)
|
|
1171
|
+
]
|
|
1172
|
+
if len(working) == before:
|
|
1173
|
+
raise_tool_error(
|
|
1174
|
+
create_error_response(
|
|
1175
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
1176
|
+
f"No item with {id_field}={target_id!r} for delete op #{index}",
|
|
1177
|
+
context={"id_field": id_field, "id": target_id},
|
|
1178
|
+
)
|
|
1179
|
+
)
|
|
1180
|
+
summary["deleted"].append({"id": target_id})
|
|
1181
|
+
|
|
1182
|
+
elif op == "add":
|
|
1183
|
+
new_item = op_spec.get("item")
|
|
1184
|
+
if not isinstance(new_item, dict):
|
|
1185
|
+
raise_tool_error(
|
|
1186
|
+
create_validation_error(
|
|
1187
|
+
f"array_patch add op #{index} 'item' must be an object",
|
|
1188
|
+
parameter=f"array_patch.operations[{index}].item",
|
|
1189
|
+
)
|
|
1190
|
+
)
|
|
1191
|
+
if id_field not in new_item:
|
|
1192
|
+
raise_tool_error(
|
|
1193
|
+
create_validation_error(
|
|
1194
|
+
f"array_patch add op #{index} 'item' missing id field "
|
|
1195
|
+
f"{id_field!r}",
|
|
1196
|
+
parameter=f"array_patch.operations[{index}].item",
|
|
1197
|
+
)
|
|
1198
|
+
)
|
|
1199
|
+
new_id = new_item[id_field]
|
|
1200
|
+
if any(
|
|
1201
|
+
isinstance(it, dict) and it.get(id_field) == new_id for it in working
|
|
1202
|
+
):
|
|
1203
|
+
raise_tool_error(
|
|
1204
|
+
create_error_response(
|
|
1205
|
+
ErrorCode.RESOURCE_ALREADY_EXISTS,
|
|
1206
|
+
f"Item with {id_field}={new_id!r} already exists "
|
|
1207
|
+
f"(add op #{index})",
|
|
1208
|
+
context={"id_field": id_field, "id": new_id},
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
working.append(new_item)
|
|
1212
|
+
summary["added"].append({"id": new_id})
|
|
1213
|
+
|
|
1214
|
+
else: # delete_where
|
|
1215
|
+
field = op_spec.get("field")
|
|
1216
|
+
value = op_spec.get("value", _ARRAY_PATCH_MISSING)
|
|
1217
|
+
if not isinstance(field, str) or not field:
|
|
1218
|
+
raise_tool_error(
|
|
1219
|
+
create_validation_error(
|
|
1220
|
+
f"array_patch delete_where op #{index} missing or empty 'field'",
|
|
1221
|
+
parameter=f"array_patch.operations[{index}].field",
|
|
1222
|
+
)
|
|
1223
|
+
)
|
|
1224
|
+
if value is _ARRAY_PATCH_MISSING:
|
|
1225
|
+
raise_tool_error(
|
|
1226
|
+
create_validation_error(
|
|
1227
|
+
f"array_patch delete_where op #{index} missing 'value'",
|
|
1228
|
+
parameter=f"array_patch.operations[{index}].value",
|
|
1229
|
+
)
|
|
1230
|
+
)
|
|
1231
|
+
before = len(working)
|
|
1232
|
+
working = [
|
|
1233
|
+
it
|
|
1234
|
+
for it in working
|
|
1235
|
+
if not (isinstance(it, dict) and it.get(field) == value)
|
|
1236
|
+
]
|
|
1237
|
+
removed = before - len(working)
|
|
1238
|
+
summary["deleted_where"].append(
|
|
1239
|
+
{"field": field, "value": value, "count": removed}
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
return working, summary
|
|
1243
|
+
|
|
1244
|
+
|
|
1053
1245
|
async def _call_addon_api(
|
|
1054
1246
|
client: HomeAssistantClient,
|
|
1055
1247
|
slug: str,
|
|
1056
1248
|
path: str,
|
|
1057
1249
|
method: str = "GET",
|
|
1058
|
-
body: dict[str, Any] | str | None = None,
|
|
1250
|
+
body: dict[str, Any] | list[Any] | str | None = None,
|
|
1059
1251
|
timeout: int = 30,
|
|
1060
1252
|
debug: bool = False,
|
|
1061
1253
|
port: int | None = None,
|
|
1062
1254
|
offset: int = 0,
|
|
1063
1255
|
limit: int | None = None,
|
|
1064
1256
|
python_transform: str | None = None,
|
|
1257
|
+
raw: bool = False,
|
|
1258
|
+
extra_headers: dict[str, str] | None = None,
|
|
1065
1259
|
) -> dict[str, Any]:
|
|
1066
1260
|
"""Call an add-on's web API.
|
|
1067
1261
|
|
|
@@ -1084,7 +1278,7 @@ async def _call_addon_api(
|
|
|
1084
1278
|
slug: Add-on slug (e.g., "<prefix>_nodered")
|
|
1085
1279
|
path: API path relative to add-on root (e.g., "/flows")
|
|
1086
1280
|
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
|
1087
|
-
body: Request body for POST/PUT/PATCH
|
|
1281
|
+
body: Request body for POST/PUT/PATCH (dict, list, or pre-encoded JSON string)
|
|
1088
1282
|
timeout: Request timeout in seconds (default 30)
|
|
1089
1283
|
port: Override port to connect to (e.g., direct access port instead of ingress port)
|
|
1090
1284
|
offset: Skip this many items in array responses (default 0)
|
|
@@ -1093,6 +1287,16 @@ async def _call_addon_api(
|
|
|
1093
1287
|
parsed response body. The variable ``response`` is bound to
|
|
1094
1288
|
``dict | list | str`` depending on content-type. Transform runs
|
|
1095
1289
|
after offset/limit slicing.
|
|
1290
|
+
raw: Internal flag — when True, skip the size-based truncation that
|
|
1291
|
+
otherwise replaces large array/object responses with an error
|
|
1292
|
+
placeholder. Used by array_patch mode in ha_manage_addon, which
|
|
1293
|
+
needs the full parsed response in memory to apply operations
|
|
1294
|
+
even when the JSON is larger than _MAX_RESPONSE_SIZE.
|
|
1295
|
+
extra_headers: Optional caller-supplied request headers. Layered
|
|
1296
|
+
under the proxy's internal framing (`X-Ingress-Path`,
|
|
1297
|
+
`X-Hass-Source`, `Cookie`, `Content-Type`) so the framing
|
|
1298
|
+
always wins on collision. Use this to set addon-API
|
|
1299
|
+
requirements like Node-RED's `Node-RED-Deployment-Type` header.
|
|
1096
1300
|
"""
|
|
1097
1301
|
# 1. Sanitize path to prevent traversal attacks (including URL-encoded)
|
|
1098
1302
|
normalized = unquote(path).lstrip("/")
|
|
@@ -1144,8 +1348,16 @@ async def _call_addon_api(
|
|
|
1144
1348
|
# 5. Resolve route (direct-port / addon-variant / off-host).
|
|
1145
1349
|
url, headers = await _resolve_http_route(client, addon, normalized, port)
|
|
1146
1350
|
|
|
1147
|
-
# 6.
|
|
1148
|
-
|
|
1351
|
+
# 6. Layer caller-supplied headers UNDER the proxy's framing so internal
|
|
1352
|
+
# headers (X-Ingress-Path, X-Hass-Source, Cookie, Content-Type) always
|
|
1353
|
+
# win on collision — a caller cannot forge ingress identity.
|
|
1354
|
+
if extra_headers:
|
|
1355
|
+
merged = dict(extra_headers)
|
|
1356
|
+
merged.update(headers)
|
|
1357
|
+
headers = merged
|
|
1358
|
+
|
|
1359
|
+
# 7. Set content type based on body type
|
|
1360
|
+
if isinstance(body, dict | list):
|
|
1149
1361
|
headers["Content-Type"] = "application/json"
|
|
1150
1362
|
request_content = json.dumps(body).encode()
|
|
1151
1363
|
elif isinstance(body, str):
|
|
@@ -1221,43 +1433,45 @@ async def _call_addon_api(
|
|
|
1221
1433
|
response_data = _apply_response_transform(response_data, python_transform)
|
|
1222
1434
|
transformed = True
|
|
1223
1435
|
|
|
1224
|
-
# 9. Truncate large responses
|
|
1436
|
+
# 9. Truncate large responses (skipped in raw mode; array_patch needs the
|
|
1437
|
+
# full parsed payload in memory regardless of size)
|
|
1225
1438
|
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
|
-
}
|
|
1439
|
+
if not raw:
|
|
1440
|
+
if isinstance(response_data, str) and len(response_data) > _MAX_RESPONSE_SIZE:
|
|
1441
|
+
response_data = response_data[:_MAX_RESPONSE_SIZE]
|
|
1260
1442
|
truncated = True
|
|
1443
|
+
elif isinstance(response_data, list):
|
|
1444
|
+
serialized = json.dumps(response_data, default=str)
|
|
1445
|
+
if len(serialized) > _MAX_RESPONSE_SIZE:
|
|
1446
|
+
total_items = len(response_data)
|
|
1447
|
+
response_data = {
|
|
1448
|
+
"error": "RESPONSE_TOO_LARGE",
|
|
1449
|
+
"message": f"The JSON array ({len(serialized)} bytes, {total_items} items) exceeds the {_MAX_RESPONSE_SIZE // 1024}KB limit.",
|
|
1450
|
+
"total_items": total_items,
|
|
1451
|
+
"hint": "Use offset and limit to paginate. Example: offset=0, limit=20",
|
|
1452
|
+
}
|
|
1453
|
+
truncated = True
|
|
1454
|
+
elif isinstance(response_data, dict):
|
|
1455
|
+
serialized = json.dumps(response_data, default=str)
|
|
1456
|
+
if len(serialized) > _MAX_RESPONSE_SIZE:
|
|
1457
|
+
# Show top-level keys and their approximate sizes to help caller
|
|
1458
|
+
# make more targeted API calls
|
|
1459
|
+
key_info = {}
|
|
1460
|
+
for k, v in response_data.items():
|
|
1461
|
+
v_serialized = json.dumps(v, default=str)
|
|
1462
|
+
if isinstance(v, list):
|
|
1463
|
+
key_info[k] = f"array[{len(v)}] ({len(v_serialized)} bytes)"
|
|
1464
|
+
elif isinstance(v, dict):
|
|
1465
|
+
key_info[k] = f"object ({len(v_serialized)} bytes)"
|
|
1466
|
+
else:
|
|
1467
|
+
key_info[k] = f"{type(v).__name__} ({len(v_serialized)} bytes)"
|
|
1468
|
+
response_data = {
|
|
1469
|
+
"error": "RESPONSE_TOO_LARGE",
|
|
1470
|
+
"message": f"The JSON object ({len(serialized)} bytes) exceeds the {_MAX_RESPONSE_SIZE // 1024}KB limit.",
|
|
1471
|
+
"top_level_keys": key_info,
|
|
1472
|
+
"hint": "Use a more specific API path to request individual keys/sections.",
|
|
1473
|
+
}
|
|
1474
|
+
truncated = True
|
|
1261
1475
|
|
|
1262
1476
|
result: dict[str, Any] = {
|
|
1263
1477
|
"success": response.status_code < 400,
|
|
@@ -1596,17 +1810,44 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1596
1810
|
default=None,
|
|
1597
1811
|
),
|
|
1598
1812
|
] = None,
|
|
1813
|
+
array_patch: Annotated[
|
|
1814
|
+
dict[str, Any] | None,
|
|
1815
|
+
Field(
|
|
1816
|
+
description=(
|
|
1817
|
+
"Array-patch mode: atomically GET a JSON array endpoint, "
|
|
1818
|
+
"apply ordered ops, then POST the mutated array back. "
|
|
1819
|
+
"Requires 'path'; mutually exclusive with body / websocket / "
|
|
1820
|
+
"offset / limit and config params. See the docstring Examples "
|
|
1821
|
+
"and ha_get_skill_home_assistant_best_practices for op shapes."
|
|
1822
|
+
),
|
|
1823
|
+
default=None,
|
|
1824
|
+
),
|
|
1825
|
+
] = None,
|
|
1826
|
+
request_headers: Annotated[
|
|
1827
|
+
dict[str, str] | None,
|
|
1828
|
+
Field(
|
|
1829
|
+
description=(
|
|
1830
|
+
"Proxy/array-patch mode: extra HTTP headers to send to the addon API. "
|
|
1831
|
+
"Useful for addon-specific requirements such as Node-RED's "
|
|
1832
|
+
"`Node-RED-Deployment-Type: full`. The proxy's internal framing "
|
|
1833
|
+
"(`X-Ingress-Path`, `X-Hass-Source`, `Content-Type`) is layered on "
|
|
1834
|
+
"top, so caller-supplied values for those keys are overridden. "
|
|
1835
|
+
"Not valid in config mode."
|
|
1836
|
+
),
|
|
1837
|
+
default=None,
|
|
1838
|
+
),
|
|
1839
|
+
] = None,
|
|
1599
1840
|
) -> dict[str, Any]:
|
|
1600
1841
|
"""Manage a Home Assistant add-on — update its configuration or call its internal API.
|
|
1601
1842
|
|
|
1602
|
-
|
|
1843
|
+
Three mutually exclusive operating modes:
|
|
1603
1844
|
|
|
1604
1845
|
**Config mode** (when any of options/network/boot/auto_update/watchdog is provided):
|
|
1605
1846
|
Updates the add-on's Supervisor configuration via POST /addons/{slug}/options.
|
|
1606
1847
|
All config parameters are optional; only provided fields are updated — current values
|
|
1607
1848
|
are fetched and merged automatically (including one level of nested dicts).
|
|
1608
1849
|
|
|
1609
|
-
**Proxy mode** (when path is provided):
|
|
1850
|
+
**Proxy mode** (when path is provided without array_patch):
|
|
1610
1851
|
Routes HTTP or WebSocket requests through Home Assistant's Ingress
|
|
1611
1852
|
proxy by default (works on HAOS, Supervised, and off-host PyPI/uvx
|
|
1612
1853
|
installs). Pass `port=...` to bypass Ingress and connect directly to
|
|
@@ -1614,6 +1855,13 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1614
1855
|
share Home Assistant's container network (i.e. only the HAOS addon).
|
|
1615
1856
|
Use ha_get_addon(slug="...") to discover available ports and endpoints.
|
|
1616
1857
|
|
|
1858
|
+
**Array-patch mode** (when path AND array_patch are provided):
|
|
1859
|
+
Atomic "GET array, mutate, POST array" workflow for addon APIs whose write
|
|
1860
|
+
contract is "send the whole resource collection back". Operations are applied
|
|
1861
|
+
in order to a working copy; if any op fails validation (unknown id, collision,
|
|
1862
|
+
malformed shape) nothing is posted. Returns a compact summary instead of the
|
|
1863
|
+
full array. Designed for Node-RED /flows and similar endpoints.
|
|
1864
|
+
|
|
1617
1865
|
**Response shaping (proxy mode):**
|
|
1618
1866
|
- WebSocket streams can be noisy (ESPHome /validate often emits hundreds of
|
|
1619
1867
|
config-dump lines). By default, `summarize=True` collapses long runs of
|
|
@@ -1649,6 +1897,26 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1649
1897
|
- Quick WS health check (50 msgs, raw): ha_manage_addon(slug="...", path="/logs", websocket=True, message_limit=50, summarize=False)
|
|
1650
1898
|
- 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
1899
|
- HTTP subset: ha_manage_addon(slug="...", path="/flows", python_transform="response = [f['id'] for f in response]")
|
|
1900
|
+
- Array-patch (Node-RED, rename a node):
|
|
1901
|
+
ha_manage_addon(
|
|
1902
|
+
slug="a0d7b954_nodered", path="/flows",
|
|
1903
|
+
array_patch={"operations": [
|
|
1904
|
+
{"op": "patch", "id": "abc123", "patches": {"name": "New Name"}},
|
|
1905
|
+
]},
|
|
1906
|
+
)
|
|
1907
|
+
- Array-patch (Node-RED, replace one tab's nodes atomically):
|
|
1908
|
+
ha_manage_addon(
|
|
1909
|
+
slug="a0d7b954_nodered", path="/flows",
|
|
1910
|
+
array_patch={"operations": [
|
|
1911
|
+
{"op": "delete_where", "field": "z", "value": "tab-id"},
|
|
1912
|
+
{"op": "add", "item": {"id": "n1", "type": "inject", "z": "tab-id", ...}},
|
|
1913
|
+
{"op": "add", "item": {"id": "n2", "type": "function", "z": "tab-id", ...}},
|
|
1914
|
+
]},
|
|
1915
|
+
request_headers={"Node-RED-Deployment-Type": "full"},
|
|
1916
|
+
)
|
|
1917
|
+
- Custom request headers (proxy mode):
|
|
1918
|
+
ha_manage_addon(slug="...", path="/api/state",
|
|
1919
|
+
request_headers={"Accept": "text/plain"})
|
|
1652
1920
|
"""
|
|
1653
1921
|
# Build config payload from provided config parameters
|
|
1654
1922
|
config_data: dict[str, Any] = {}
|
|
@@ -1720,6 +1988,10 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1720
1988
|
proxy_overrides.append(("summarize", "summarize=False"))
|
|
1721
1989
|
if python_transform is not None:
|
|
1722
1990
|
proxy_overrides.append(("python_transform", "python_transform"))
|
|
1991
|
+
if array_patch is not None:
|
|
1992
|
+
proxy_overrides.append(("array_patch", "array_patch"))
|
|
1993
|
+
if request_headers is not None:
|
|
1994
|
+
proxy_overrides.append(("request_headers", "request_headers"))
|
|
1723
1995
|
if proxy_overrides:
|
|
1724
1996
|
raise_tool_error(
|
|
1725
1997
|
create_validation_error(
|
|
@@ -1729,6 +2001,54 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1729
2001
|
)
|
|
1730
2002
|
)
|
|
1731
2003
|
|
|
2004
|
+
# array_patch needs its own input shape validation (the field is dict[str, Any]
|
|
2005
|
+
# at the schema level so the model will happily send malformed payloads)
|
|
2006
|
+
if array_patch is not None:
|
|
2007
|
+
if not isinstance(array_patch, dict):
|
|
2008
|
+
raise_tool_error(
|
|
2009
|
+
create_validation_error(
|
|
2010
|
+
"array_patch must be an object",
|
|
2011
|
+
parameter="array_patch",
|
|
2012
|
+
)
|
|
2013
|
+
)
|
|
2014
|
+
if websocket:
|
|
2015
|
+
raise_tool_error(
|
|
2016
|
+
create_validation_error(
|
|
2017
|
+
"array_patch is HTTP-only and cannot be combined with websocket=True",
|
|
2018
|
+
parameter="array_patch",
|
|
2019
|
+
)
|
|
2020
|
+
)
|
|
2021
|
+
if body is not None:
|
|
2022
|
+
raise_tool_error(
|
|
2023
|
+
create_validation_error(
|
|
2024
|
+
"array_patch builds the POST body itself; remove the explicit 'body' parameter",
|
|
2025
|
+
parameter="array_patch",
|
|
2026
|
+
)
|
|
2027
|
+
)
|
|
2028
|
+
if offset != 0 or limit is not None:
|
|
2029
|
+
raise_tool_error(
|
|
2030
|
+
create_validation_error(
|
|
2031
|
+
"array_patch needs the full array; offset/limit are not supported in this mode",
|
|
2032
|
+
parameter="array_patch",
|
|
2033
|
+
)
|
|
2034
|
+
)
|
|
2035
|
+
id_field = array_patch.get("id_field", "id")
|
|
2036
|
+
if not isinstance(id_field, str) or not id_field:
|
|
2037
|
+
raise_tool_error(
|
|
2038
|
+
create_validation_error(
|
|
2039
|
+
"array_patch.id_field must be a non-empty string",
|
|
2040
|
+
parameter="array_patch.id_field",
|
|
2041
|
+
)
|
|
2042
|
+
)
|
|
2043
|
+
ops = array_patch.get("operations")
|
|
2044
|
+
if not isinstance(ops, list) or not ops:
|
|
2045
|
+
raise_tool_error(
|
|
2046
|
+
create_validation_error(
|
|
2047
|
+
"array_patch.operations must be a non-empty list",
|
|
2048
|
+
parameter="array_patch.operations",
|
|
2049
|
+
)
|
|
2050
|
+
)
|
|
2051
|
+
|
|
1732
2052
|
# Config mode: update Supervisor settings
|
|
1733
2053
|
if config_data:
|
|
1734
2054
|
ignored_fields: list[str] = [] # populated only when options are provided
|
|
@@ -1759,8 +2079,12 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1759
2079
|
# lets the caller correct mistakes before any state is changed.
|
|
1760
2080
|
schema_ui: list | None = addon_info.get("schema")
|
|
1761
2081
|
if schema_ui is not None:
|
|
1762
|
-
allowed_keys = {
|
|
1763
|
-
|
|
2082
|
+
allowed_keys = {
|
|
2083
|
+
item["name"] for item in schema_ui if "name" in item
|
|
2084
|
+
}
|
|
2085
|
+
ignored_fields = [
|
|
2086
|
+
k for k in config_data["options"] if k not in allowed_keys
|
|
2087
|
+
]
|
|
1764
2088
|
# Remove unknown fields from the merged dict so Supervisor does not
|
|
1765
2089
|
# silently strip them after the write succeeds.
|
|
1766
2090
|
for k in ignored_fields:
|
|
@@ -1818,6 +2142,75 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1818
2142
|
# Proxy mode: call add-on container API
|
|
1819
2143
|
# At this point path is guaranteed non-None (validated above)
|
|
1820
2144
|
assert path is not None
|
|
2145
|
+
|
|
2146
|
+
# Array-patch mode: GET → mutate → POST atomically.
|
|
2147
|
+
# Done before the websocket/HTTP branches so it can fully own the
|
|
2148
|
+
# response shape and skip the truncation/pagination paths.
|
|
2149
|
+
# id_field and ops were validated in the early validation block above.
|
|
2150
|
+
if array_patch is not None:
|
|
2151
|
+
fetch_result = await _call_addon_api(
|
|
2152
|
+
client=client,
|
|
2153
|
+
slug=slug,
|
|
2154
|
+
path=path,
|
|
2155
|
+
method="GET",
|
|
2156
|
+
debug=debug,
|
|
2157
|
+
port=port,
|
|
2158
|
+
raw=True,
|
|
2159
|
+
extra_headers=request_headers,
|
|
2160
|
+
)
|
|
2161
|
+
if not fetch_result.get("success"):
|
|
2162
|
+
raise_tool_error(fetch_result)
|
|
2163
|
+
|
|
2164
|
+
fetched = fetch_result.get("response")
|
|
2165
|
+
if not isinstance(fetched, list):
|
|
2166
|
+
raise_tool_error(
|
|
2167
|
+
create_validation_error(
|
|
2168
|
+
f"array_patch requires a JSON array at {path!r}; "
|
|
2169
|
+
f"got {type(fetched).__name__}",
|
|
2170
|
+
parameter="path",
|
|
2171
|
+
)
|
|
2172
|
+
)
|
|
2173
|
+
|
|
2174
|
+
# Re-narrow operations for the type checker — the early validation
|
|
2175
|
+
# block established `ops` is a non-empty list, but the narrowing
|
|
2176
|
+
# doesn't carry across the intervening config_data branch.
|
|
2177
|
+
# cast() (vs assert) keeps the narrowing under `python -O`.
|
|
2178
|
+
new_array, summary = _apply_array_ops(
|
|
2179
|
+
fetched, cast(list[Any], ops), id_field
|
|
2180
|
+
)
|
|
2181
|
+
|
|
2182
|
+
# POST response is small (deploy revision string or status); skip
|
|
2183
|
+
# raw mode here so the size-based truncation guard is in effect.
|
|
2184
|
+
post_result = await _call_addon_api(
|
|
2185
|
+
client=client,
|
|
2186
|
+
slug=slug,
|
|
2187
|
+
path=path,
|
|
2188
|
+
method="POST",
|
|
2189
|
+
body=new_array,
|
|
2190
|
+
debug=debug,
|
|
2191
|
+
port=port,
|
|
2192
|
+
extra_headers=request_headers,
|
|
2193
|
+
)
|
|
2194
|
+
if not post_result.get("success"):
|
|
2195
|
+
raise_tool_error(post_result)
|
|
2196
|
+
|
|
2197
|
+
response_payload: dict[str, Any] = {
|
|
2198
|
+
"success": True,
|
|
2199
|
+
"slug": slug,
|
|
2200
|
+
"addon_name": fetch_result.get("addon_name"),
|
|
2201
|
+
"path": path,
|
|
2202
|
+
"id_field": id_field,
|
|
2203
|
+
"items_before": len(fetched),
|
|
2204
|
+
"items_after": len(new_array),
|
|
2205
|
+
"summary": summary,
|
|
2206
|
+
}
|
|
2207
|
+
if debug:
|
|
2208
|
+
response_payload["_debug"] = {
|
|
2209
|
+
"fetch": fetch_result.get("_debug"),
|
|
2210
|
+
"post": post_result.get("_debug"),
|
|
2211
|
+
}
|
|
2212
|
+
return response_payload
|
|
2213
|
+
|
|
1821
2214
|
# WebSocket
|
|
1822
2215
|
if websocket:
|
|
1823
2216
|
result = await _call_addon_ws(
|
|
@@ -1870,6 +2263,7 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
|
|
|
1870
2263
|
offset=offset,
|
|
1871
2264
|
limit=limit,
|
|
1872
2265
|
python_transform=python_transform,
|
|
2266
|
+
extra_headers=request_headers,
|
|
1873
2267
|
)
|
|
1874
2268
|
if not result.get("success"):
|
|
1875
2269
|
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.dev508}/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.dev508}/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.dev508}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/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.dev508}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/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.dev508}/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.dev508}/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.dev508}/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
|