Skip to content

Commit 414aa49

Browse files
committed
feat(bulk): add deterministic structural selectors
1 parent bf61660 commit 414aa49

9 files changed

Lines changed: 1045 additions & 58 deletions

File tree

homeassistant-addon/DOCS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ The add-on provides 88+ MCP tools for controlling Home Assistant:
631631
- `ha_search` — Search for entities (lights, sensors, switches, climate, etc.) by name, domain, or area — AND inside automation/script/scene/helper/dashboard configurations — in one call.
632632

633633
### Service & Device Control
634-
- `ha_bulk_control` — Manage multiple entity actions in one request.
634+
- `ha_bulk_control` — Manage explicit operations or one deterministic structural bulk action.
635635
- `ha_call_event` — Execute a custom event on the Home Assistant event bus.
636636
- `ha_call_service` — Execute Home Assistant services to control entities and trigger automations.
637637
- `ha_get_operation_status` — Get the status of one or more device operations with real-time WebSocket verification.

site/src/data/tools.json

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2988,20 +2988,41 @@
29882988
{
29892989
"name": "ha_bulk_control",
29902990
"title": "Bulk Control",
2991-
"description": "Manage multiple entity actions in one request.\n\nWhen NOT to use: use ``ha_call_service`` for one service call targeting a\ngroup or for service-specific payloads that do not fit device actions.\n\nUse this when one request should apply actions to multiple independent\nentities. Optional item parameters carry brightness, temperature, position,\nor other action data.\n\nCaveats: put every target in ``operations`` and call the tool once. Parallel\nexecution is the default, and invalid items are reported without aborting\nvalid operations in the same batch. A batch in which every item fails\nvalidation dispatches nothing and fails the call.",
2991+
"description": "Manage explicit operations or one deterministic structural bulk action.\n\nWhen NOT to use: use ``ha_call_service`` for service-specific payloads or\nbackend-native group targeting, and ``ha_search`` for fuzzy name discovery.\n\nUse selector mode with exact area or floor IDs when exclusions must be\napplied after recursively expanding generic aggregate membership.\n\nCaveats: selector mode resolves a frozen visible leaf set before dispatch;\nit is not transactional, so Home Assistant may still report per-leaf failures.\nSet ``dry_run`` to preview the resolved set without changing state.",
29922992
"inputSchema": {
29932993
"properties": {
29942994
"operations": {
2995-
"type": "Annotated[list[SkipValidation[BulkControlOperation]], JSON_STRING_COERCION, Field(description=\"All entity operations to execute in this single tool call. Each item requires entity_id and action. Use action='off', not service='turn_off'; do not include domain or service. Example: [{'entity_id': 'light.hall', 'action': 'off'}, {'entity_id': 'light.cave', 'action': 'off'}]\")]"
2995+
"type": "Annotated[list[SkipValidation[BulkControlOperation]], JSON_STRING_COERCION, Field(description=\"Explicit entity operations. Use this or selector, never both. Each item requires exact entity_id and action. Use action='off', not service='turn_off'.\")]"
29962996
},
29972997
"parallel": {
29982998
"type": "bool",
29992999
"default": true
3000+
},
3001+
"selector": {
3002+
"type": "Annotated[SkipValidation[BulkControlSelector] | None, JSON_STRING_COERCION, Field(description='Optional exact structural scope using domain plus area_ids and/or floor_ids, with optional exclude_entity_ids.')]",
3003+
"default": null
3004+
},
3005+
"action": {
3006+
"type": "Annotated[str | None, Field(description='One device action applied to every resolved leaf.')]",
3007+
"default": null
3008+
},
3009+
"parameters": {
3010+
"type": "Annotated[dict[str, Any] | None, JSON_STRING_COERCION, Field(description='Optional action parameters for selector mode.')]",
3011+
"default": null
3012+
},
3013+
"timeout_seconds": {
3014+
"type": "Annotated[float | None, Field(ge=0, le=60, allow_inf_nan=False, strict=True)]",
3015+
"default": null
3016+
},
3017+
"validate_first": {
3018+
"type": "Annotated[bool, Field(strict=True)]",
3019+
"default": true
3020+
},
3021+
"dry_run": {
3022+
"type": "Annotated[bool, Field(strict=True)]",
3023+
"default": false
30003024
}
3001-
},
3002-
"required": [
3003-
"operations"
3004-
]
3025+
}
30053026
},
30063027
"annotations": {
30073028
"openWorldHint": false,

src/ha_mcp/policy/evaluator.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,14 @@ def evaluate(tool_name: str, args: dict[str, Any], policy: Policy) -> Verdict:
163163
and any(rule.tool_name in ("ha_call_service", "*") for rule in policy.rules)
164164
):
165165
return Verdict.REQUIRE_APPROVAL
166+
# Structural selectors are resolved inside the tool, after this middleware.
167+
# A pre-existing rule that inspects args.operations.* therefore cannot inspect
168+
# the eventual leaf targets. Fail safe whenever an operator configured any
169+
# rule applicable to ha_bulk_control; selector-aware rules still match above.
170+
if (
171+
tool_name == "ha_bulk_control"
172+
and args.get("selector") is not None
173+
and any(rule.tool_name in ("ha_bulk_control", "*") for rule in policy.rules)
174+
):
175+
return Verdict.REQUIRE_APPROVAL
166176
return Verdict.ALLOW

0 commit comments

Comments
 (0)