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.
Files changed (111) hide show
  1. {ha_mcp_dev-7.5.0.dev507/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev508}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_addons.py +445 -51
  4. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  5. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/LICENSE +0 -0
  6. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/MANIFEST.in +0 -0
  7. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/README.md +0 -0
  8. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/setup.cfg +0 -0
  9. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/__init__.py +0 -0
  10. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/__main__.py +0 -0
  11. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/_pypi_marker +0 -0
  12. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/_version.py +0 -0
  13. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/auth/__init__.py +0 -0
  14. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/auth/consent_form.py +0 -0
  15. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/auth/provider.py +0 -0
  16. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/__init__.py +0 -0
  17. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/rest_client.py +0 -0
  18. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/supervisor_client.py +0 -0
  19. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/websocket_client.py +0 -0
  20. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/py.typed +0 -0
  24. {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
  25. {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
  26. {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
  27. {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
  28. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  29. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  30. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  31. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  32. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/server.py +0 -0
  45. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/settings_ui.py +0 -0
  46. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/smoke_test.py +0 -0
  47. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/__init__.py +0 -0
  48. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/backup.py +0 -0
  49. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  50. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/device_control.py +0 -0
  51. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/enhanced.py +0 -0
  52. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/helpers.py +0 -0
  53. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/reference_validator.py +0 -0
  54. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/registry.py +0 -0
  55. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/smart_search.py +0 -0
  56. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_areas.py +0 -0
  57. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  58. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  59. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_calendar.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_camera.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_categories.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_code.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  65. {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
  66. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_energy.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_entities.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_groups.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_hacs.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_history.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_integrations.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_labels.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_registry.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_resources.py +0 -0
  80. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_search.py +0 -0
  81. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_service.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_services.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_system.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_todo.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_traces.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_updates.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_utility.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/tools_zones.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/tools/util_helpers.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/transforms/__init__.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/transforms/categorized_search.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/__init__.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/config_hash.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/data_paths.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/domain_handlers.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/operation_manager.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/python_sandbox.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp/utils/usage_logger.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  105. {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
  106. {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
  107. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  108. {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
  109. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/tests/__init__.py +0 -0
  110. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/tests/test_constants.py +0 -0
  111. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev508}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.5.0.dev507
3
+ Version: 7.5.0.dev508
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.5.0.dev507"
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 response # TODO(tech-debt): should raise ToolError per AGENTS.md Pattern B
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 response # TODO(tech-debt): should raise ToolError per AGENTS.md Pattern B
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. Set content type based on body type
1148
- if isinstance(body, dict):
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 isinstance(response_data, str) and len(response_data) > _MAX_RESPONSE_SIZE:
1227
- response_data = response_data[:_MAX_RESPONSE_SIZE]
1228
- truncated = True
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
- Two mutually exclusive operating modes:
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 = {item["name"] for item in schema_ui if "name" in item}
1763
- ignored_fields = [k for k in config_data["options"] if k not in allowed_keys]
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.5.0.dev507
3
+ Version: 7.5.0.dev508
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT