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.
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.dev509}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_addons.py +494 -51
  4. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  5. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/LICENSE +0 -0
  6. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/MANIFEST.in +0 -0
  7. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/README.md +0 -0
  8. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/setup.cfg +0 -0
  9. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/__init__.py +0 -0
  10. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/__main__.py +0 -0
  11. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/_pypi_marker +0 -0
  12. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/_version.py +0 -0
  13. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/auth/__init__.py +0 -0
  14. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/auth/consent_form.py +0 -0
  15. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/auth/provider.py +0 -0
  16. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/__init__.py +0 -0
  17. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/rest_client.py +0 -0
  18. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/supervisor_client.py +0 -0
  19. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/websocket_client.py +0 -0
  20. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/py.typed +0 -0
  24. {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
  25. {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
  26. {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
  27. {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
  28. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  29. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  30. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  31. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  32. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/server.py +0 -0
  45. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/settings_ui.py +0 -0
  46. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/smoke_test.py +0 -0
  47. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/__init__.py +0 -0
  48. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/backup.py +0 -0
  49. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  50. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/device_control.py +0 -0
  51. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/enhanced.py +0 -0
  52. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/helpers.py +0 -0
  53. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/reference_validator.py +0 -0
  54. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/registry.py +0 -0
  55. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/smart_search.py +0 -0
  56. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_areas.py +0 -0
  57. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  58. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  59. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_calendar.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_camera.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_categories.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_code.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  65. {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
  66. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_energy.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_entities.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_groups.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_hacs.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_history.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_integrations.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_labels.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_registry.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_resources.py +0 -0
  80. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_search.py +0 -0
  81. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_service.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_services.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_system.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_todo.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_traces.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_updates.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_utility.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/tools_zones.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/tools/util_helpers.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/__init__.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/categorized_search.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/__init__.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/config_hash.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/data_paths.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/domain_handlers.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/operation_manager.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/python_sandbox.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp/utils/usage_logger.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  105. {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
  106. {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
  107. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  108. {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
  109. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/tests/__init__.py +0 -0
  110. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/tests/test_constants.py +0 -0
  111. {ha_mcp_dev-7.5.0.dev507 → ha_mcp_dev-7.5.0.dev509}/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.dev509
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.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 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,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. Set content type based on body type
1148
- if isinstance(body, dict):
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 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
- }
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
- Two mutually exclusive operating modes:
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 = {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]
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)
@@ -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.dev509
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