Skip to content

Commit 50e0815

Browse files
committed
Merge remote-tracking branch 'upstream/master' into issue-2239-dependency-diagnostics
2 parents bf617f2 + bf61660 commit 50e0815

13 files changed

Lines changed: 1364 additions & 62 deletions

File tree

custom_components/ha_mcp_tools/websocket_api.py

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
33
This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the
44
ha-mcp server calls in-process (same HA core, no REST/WS round-trips) behind a
5-
capability gate. It registers twenty-two commands (twenty-two capabilities — the
6-
``search_visibility`` capability is a flag on the existing ``search`` command,
7-
and ``info`` itself carries no capability entry):
5+
capability gate. It registers twenty-three commands. It advertises twenty-five
6+
capabilities: twenty-two command capabilities plus three additive flags
7+
(dashboards_doc_search, search_visibility, and search_entity_membership);
8+
the info handshake carries no capability entry:
89
910
* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
1011
+ ``component_version`` + advisory ``limits`` + the instance ``timezone``
@@ -13,6 +14,7 @@
1314
negotiation, NOT a version floor).
1415
* ``ha_mcp_tools/search`` — a unified in-process search over live registries and
1516
states, joined and scored, mirroring today's ``ha_search`` response envelope.
17+
The search_entity_membership flag gates opt-in generic group metadata.
1618
* ``ha_mcp_tools/overview`` — the raw in-process reads the server's
1719
``get_system_overview`` + ``ha_get_overview`` wrapper consume (states,
1820
services, entity/device/area registries, ``hass.config``, persistent
@@ -262,7 +264,7 @@
262264

263265
import logging
264266
import re
265-
from collections.abc import Mapping
267+
from collections.abc import Collection, Mapping
266268
from dataclasses import dataclass
267269
from difflib import SequenceMatcher
268270
from pathlib import Path
@@ -336,12 +338,15 @@
336338
# bump it (the server checks ``schema_version >= N`` before using a new shape).
337339
SCHEMA_VERSION = 1
338340

339-
# Which commands exist. Grows one entry per shipped command; the server gates
341+
# Advertised command support and additive feature flags; the server gates
340342
# each consumer on ``capability in caps.capabilities``. Never remove an entry
341343
# without a major bump. (``info`` is always present in 1.1.0+, so it carries no
342344
# capability key of its own.)
343345
CAPABILITIES: list[str] = [
344346
"search",
347+
# A flag on search: gates its additive result_fields request and generic
348+
# is_group/member_entity_ids response fields.
349+
"search_entity_membership",
345350
"overview",
346351
"helpers_list",
347352
"states",
@@ -650,13 +655,15 @@ def _visibility_param_schema() -> Any:
650655

651656

652657
def _search_schema() -> dict[Any, Any]:
658+
"""Build the schema for search WebSocket requests."""
653659
return {
654660
vol.Required("type"): WS_SEARCH,
655661
vol.Optional("query"): vol.Any(str, None),
656662
vol.Optional("search_types"): [vol.In(ALL_SEARCH_TYPES)],
657663
vol.Optional("domain_filter"): str,
658664
vol.Optional("area_filter"): str,
659665
vol.Optional("state_filter"): str,
666+
vol.Optional("result_fields"): [vol.In(("is_group", "member_entity_ids"))],
660667
vol.Optional("exact", default=True): bool,
661668
vol.Optional("include_hidden", default=True): bool,
662669
vol.Optional("include_config", default=False): bool,
@@ -1011,6 +1018,7 @@ def _do_search(
10111018
domain_filter = params.get("domain_filter")
10121019
area_filter = params.get("area_filter")
10131020
state_filter = params.get("state_filter")
1021+
membership_requested = bool(params.get("result_fields"))
10141022
# Opt-in visibility filter (search_visibility capability). A non-empty dict of
10151023
# the server's raw VisibilityConfig fields; applied as a hard entity exclude.
10161024
visibility = params.get("visibility")
@@ -1023,6 +1031,7 @@ def _do_search(
10231031
# filter is applied. Surfaced additively so the fast path isn't silent about
10241032
# incomplete filtering (parity with the server's load_hidden_set warnings).
10251033
visibility_warnings: list[str] = []
1034+
hidden: set[str] = set()
10261035

10271036
# ``secret_values`` (loaded off-loop by _search_prep) scrubs resolved-!secret
10281037
# plaintext from the config-body match corpus: a YAML-loaded automation/script/
@@ -1045,6 +1054,7 @@ def _do_search(
10451054
domain_filter=domain_filter,
10461055
area_filter=area_filter,
10471056
state_filter=state_filter,
1057+
include_membership=membership_requested,
10481058
)
10491059
# Opt-in visibility filter: a hard exclude applied BEFORE counts/pagination,
10501060
# exactly where the legacy path drops ``visibility_hidden`` entities at the
@@ -1078,8 +1088,17 @@ def _do_search(
10781088
scored_entities.sort(key=lambda r: (-r["score"], r["entity_id"]))
10791089
entity_total = len(scored_entities)
10801090
page = scored_entities[offset : offset + limit]
1091+
_redact_hidden_members(
1092+
page,
1093+
hidden,
1094+
view=view,
1095+
include_hidden=include_hidden,
1096+
enabled=membership_requested,
1097+
)
10811098
entity_has_more = offset + len(page) < entity_total
1082-
entities = [_project_entity(r) for r in page]
1099+
entities = [
1100+
_project_entity(r, include_membership=membership_requested) for r in page
1101+
]
10831102

10841103
# --- Config surfaces (automations + scripts + scenes + helpers) ----------
10851104
# One combined pagination window, mirroring the server's config branch.
@@ -1277,6 +1296,7 @@ def _search_entities(
12771296
domain_filter: str | None,
12781297
area_filter: str | None,
12791298
state_filter: str | None,
1299+
include_membership: bool = False,
12801300
) -> list[dict[str, Any]]:
12811301
"""Score every state against the query over the joined registry view."""
12821302
results: list[dict[str, Any]] = []
@@ -1286,7 +1306,7 @@ def _search_entities(
12861306
# matches state_filter="vacation").
12871307
state_filter_lower = state_filter.lower() if state_filter is not None else None
12881308
for state in _iter_states(hass):
1289-
rec = _entity_record(state, view)
1309+
rec = _entity_record(state, view, include_membership=include_membership)
12901310
if domain_filter and rec["domain"] != domain_filter:
12911311
continue
12921312
if rec["_hidden"] and not include_hidden:
@@ -1448,12 +1468,15 @@ def _registry_enrichment(view: _RegistryView, entity_id: str) -> dict[str, Any]:
14481468
}
14491469

14501470

1451-
def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]:
1471+
def _entity_record(
1472+
state: Any, view: _RegistryView, *, include_membership: bool = False
1473+
) -> dict[str, Any]:
14521474
"""Join a state with the entity/device/area/floor/label registries."""
14531475
entity_id = getattr(state, "entity_id", "") or ""
14541476
domain = entity_id.split(".")[0] if "." in entity_id else ""
14551477
attrs = getattr(state, "attributes", None) or {}
14561478
friendly = attrs.get("friendly_name", entity_id)
1479+
members = _normalize_member_entity_ids(attrs) if include_membership else None
14571480

14581481
join = _registry_enrichment(view, entity_id)
14591482
area_name = join["area"]
@@ -1481,6 +1504,14 @@ def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]:
14811504
"floor": floor_name,
14821505
"labels": label_names,
14831506
"aliases": aliases,
1507+
**(
1508+
{
1509+
"is_group": members is not None,
1510+
**({"member_entity_ids": members} if members is not None else {}),
1511+
}
1512+
if include_membership
1513+
else {}
1514+
),
14841515
"_hidden": join["_hidden"],
14851516
"_area_id": join["_area_id"],
14861517
"_match_texts": match_texts,
@@ -1495,7 +1526,9 @@ def _entity_matches_area(rec: dict[str, Any], area_filter_lower: str) -> bool:
14951526
return bool(area_name and str(area_name).lower() == area_filter_lower)
14961527

14971528

1498-
def _project_entity(rec: dict[str, Any]) -> dict[str, Any]:
1529+
def _project_entity(
1530+
rec: dict[str, Any], *, include_membership: bool = False
1531+
) -> dict[str, Any]:
14991532
"""Strip internal ``_``-prefixed keys for the wire response."""
15001533
return {
15011534
"entity_id": rec["entity_id"],
@@ -1508,9 +1541,80 @@ def _project_entity(rec: dict[str, Any]) -> dict[str, Any]:
15081541
"aliases": rec["aliases"],
15091542
"score": rec["score"],
15101543
"match_type": rec["match_type"],
1544+
**(
1545+
{"is_group": rec["is_group"]}
1546+
if include_membership and "is_group" in rec
1547+
else {}
1548+
),
1549+
**(
1550+
{"member_entity_ids": rec["member_entity_ids"]}
1551+
if include_membership and "member_entity_ids" in rec
1552+
else {}
1553+
),
15111554
}
15121555

15131556

1557+
def _redact_hidden_members(
1558+
records: list[dict[str, Any]],
1559+
hidden: set[str],
1560+
*,
1561+
view: _RegistryView | None = None,
1562+
include_hidden: bool = True,
1563+
enabled: bool = True,
1564+
) -> None:
1565+
"""Withhold members excluded by visibility or include_hidden."""
1566+
if not enabled:
1567+
return
1568+
for record in records:
1569+
members = record.get("member_entity_ids")
1570+
denied = bool(members and hidden.intersection(members))
1571+
if members and not include_hidden and view is not None:
1572+
denied = denied or any(
1573+
getattr(_reg_entity(view, member), "hidden_by", None) is not None
1574+
for member in members
1575+
)
1576+
if denied:
1577+
record.pop("member_entity_ids", None)
1578+
1579+
1580+
def _normalize_member_entity_ids(attributes: Any) -> list[str] | None:
1581+
"""Normalize HA's modern or historical explicit group membership."""
1582+
if not isinstance(attributes, Mapping):
1583+
return None
1584+
for key in ("group_entities", "entity_id"):
1585+
raw = attributes.get(key)
1586+
if isinstance(raw, (str, bytes, bytearray, Mapping)):
1587+
continue
1588+
if not isinstance(raw, Collection):
1589+
continue
1590+
members: set[str] = set()
1591+
valid = True
1592+
for value in raw:
1593+
if not _is_entity_id(value):
1594+
valid = False
1595+
break
1596+
members.add(value)
1597+
if valid:
1598+
return sorted(members)
1599+
return None
1600+
1601+
1602+
def _is_entity_id(value: Any) -> bool:
1603+
"""Return whether a value has the Home Assistant entity ID shape."""
1604+
if not isinstance(value, str) or value.count(".") != 1:
1605+
return False
1606+
domain, object_id = value.split(".", 1)
1607+
return bool(
1608+
domain
1609+
and object_id
1610+
and value == value.lower()
1611+
and all(
1612+
char in "abcdefghijklmnopqrstuvwxyz0123456789_"
1613+
for char in domain + object_id
1614+
)
1615+
)
1616+
1617+
15141618
# --- Config surfaces (automation/script/scene) -------------------------------
15151619
def _search_config_surface(
15161620
hass: HomeAssistant,

homeassistant-addon-dev/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: "Home Assistant MCP Server (Dev)"
22
description: "Development channel - AI assistant integration via MCP (unstable)"
3-
version: "8.3.0.dev2387"
3+
version: "8.3.0.dev2390"
44
slug: "ha_mcp_dev"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
stage: experimental

site/src/data/tools.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2910,7 +2910,7 @@
29102910
{
29112911
"name": "ha_search",
29122912
"title": "Search",
2913-
"description": "Search for entities (lights, sensors, switches, climate, etc.) by name, domain, or area — AND inside automation/script/scene/helper/dashboard configurations — in one call.\n\nTwo surfaces run in parallel and return tagged results:\n - **entities**: entity-registry matches (entity_id, friendly name,\n area). Filter with `domain_filter`/`area_filter`/`state_filter`;\n omit `query` to enumerate a domain, area, or state.\n - **automations / scripts / scenes / helpers / dashboards**: matches\n *inside* config definitions — triggers, actions, sequences, scene\n entity-sets, helper bodies, dashboard cards. Driven by `query`;\n narrow with `search_types`.\n\nUse this whenever you need to find something in HA without deciding\nentity-name vs config-body search up front.\n\nWhen NOT to use:\n - To read a known entity_id's state: use `ha_get_state` (cheaper).\n - To inspect one automation/script/scene config by id: use the\n matching `ha_config_get_*`.\n - To list installed Apps (add-ons): use `ha_get_app`.\n\nConfig-body search is skipped when `domain_filter`/`area_filter`/\n`state_filter` signal entity-only intent (keeping name lookups off the\nexpensive backend); a `warnings[]` entry names the skip. Pass\n`search_types=[...]` to force config search.\n\nCaveats:\n - `partial: True` means results are NOT exhaustive — a surface raised,\n or the config-body branch lost data (per-id time budget exhausted,\n an individual fetch failed, or a helper-type list fetch failed).\n Empty buckets with `partial: True` mean \"search failed\", not \"no\n results\". The cause is in `partial_reason`, also mirrored into\n `warnings[]` with an \"incomplete results: \" prefix. Do not treat a\n partial response as complete.\n - `count` is items in this response (post-pagination), not corpus\n totals — use `entity_total_matches` + `config_total_matches`.\n - `limit`/`offset` apply per-surface. Flat `has_more`/`next_offset`\n page the next call (iterate `offset = next_offset`); per-surface\n `entity_*`/`config_*` variants show which surface still has results.\n\nFor parameters, schema, and worked examples, see ha_get_skill_guide.\n\nExamples:\n - List sensors in an area: ha_search(domain_filter=\"sensor\", area_filter=\"Living Room\")\n - Find a light by name: ha_search(\"kitchen\", domain_filter=\"light\")\n - Which automations use an entity: ha_search(\"light.bed_light\")\n - Scenes touching a light: ha_search(\"light.kitchen\", search_types=[\"scene\"])\n - Narrow the response to the entity bucket: ha_search(\"kitchen\", fields=[\"entities\"])\n - All unavailable entities: ha_search(state_filter=\"unavailable\")",
2913+
"description": "Search for entities (lights, sensors, switches, climate, etc.) by name, domain, or area — AND inside automation/script/scene/helper/dashboard configurations — in one call.\n\nTwo surfaces run in parallel and return tagged results:\n - **entities**: entity-registry matches (entity_id, friendly name,\n area). Filter with `domain_filter`/`area_filter`/`state_filter`;\n omit `query` to enumerate a domain, area, or state.\n - **automations / scripts / scenes / helpers / dashboards**: matches\n *inside* config definitions — triggers, actions, sequences, scene\n entity-sets, helper bodies, dashboard cards. Driven by `query`;\n narrow with `search_types`.\n\nUse this whenever you need to find something in HA without deciding\nentity-name vs config-body search up front.\n\nFor control requests with exclusions such as \"except\", \"excluding\", or\n\"but not\", include `is_group` and `member_entity_ids` in `result_fields`.\nDo not control an aggregate whose members include an excluded entity;\nprefer leaf entities when the exception cannot be verified safely.\nA withheld member list still returns is_group=true; absence of\nmember_entity_ids must not be interpreted as a leaf entity.\n\nWhen NOT to use:\n - To read a known entity_id's state: use `ha_get_state` (cheaper).\n - To inspect one automation/script/scene config by id: use the\n matching `ha_config_get_*`.\n - To list installed Apps (add-ons): use `ha_get_app`.\n\nConfig-body search is skipped when `domain_filter`/`area_filter`/\n`state_filter` signal entity-only intent (keeping name lookups off the\nexpensive backend); a `warnings[]` entry names the skip. Pass\n`search_types=[...]` to force config search.\n\nCaveats:\n - `partial: True` means results are NOT exhaustive — a surface raised,\n or the config-body branch lost data (per-id time budget exhausted,\n an individual fetch failed, or a helper-type list fetch failed).\n Empty buckets with `partial: True` mean \"search failed\", not \"no\n results\". The cause is in `partial_reason`, also mirrored into\n `warnings[]` with an \"incomplete results: \" prefix. Do not treat a\n partial response as complete.\n - `count` is items in this response (post-pagination), not corpus\n totals — use `entity_total_matches` + `config_total_matches`.\n - `limit`/`offset` apply per-surface. Flat `has_more`/`next_offset`\n page the next call (iterate `offset = next_offset`); per-surface\n `entity_*`/`config_*` variants show which surface still has results.\n\nFor parameters, schema, and worked examples, see ha_get_skill_guide.\n\nExamples:\n - List sensors in an area: ha_search(domain_filter=\"sensor\", area_filter=\"Living Room\")\n - Find a light by name: ha_search(\"kitchen\", domain_filter=\"light\")\n - Find lights safely before an \"all except one\" control request:\n ha_search(\"living room\", domain_filter=\"light\",\n result_fields=[\"entity_id\", \"friendly_name\", \"is_group\",\n \"member_entity_ids\"])\n - Which automations use an entity: ha_search(\"light.bed_light\")\n - Scenes touching a light: ha_search(\"light.kitchen\", search_types=[\"scene\"])\n - Narrow the response to the entity bucket: ha_search(\"kitchen\", fields=[\"entities\"])\n - All unavailable entities: ha_search(state_filter=\"unavailable\")",
29142914
"inputSchema": {
29152915
"properties": {
29162916
"query": {
@@ -2962,7 +2962,7 @@
29622962
"default": null
29632963
},
29642964
"result_fields": {
2965-
"type": "Annotated[str | list[str] | None, JSON_STRING_COERCION, Field(default=None, description='Project each entity-registry record to only the specified keys (e.g. [\"entity_id\", \"state\"]). None = full records. Base keys: entity_id, friendly_name, domain, state, score, match_type. Opt-in enrichment keys (joined on request): area, floor, labels, aliases. An unknown key is rejected.')]",
2965+
"type": "Annotated[str | list[str] | None, JSON_STRING_COERCION, Field(default=None, description='Project each entity-registry record to only the specified keys (e.g. [\"entity_id\", \"state\"]). None = full records. Base keys: entity_id, friendly_name, domain, state, score, match_type. Opt-in enrichment/membership keys (computed on request): area, floor, labels, aliases, is_group, member_entity_ids. Membership is recognized only when HA explicitly exposes a valid group_entities or legacy entity_id collection; member IDs are sorted, direct (not recursively expanded), and omitted if visibility/include_hidden excludes a member. is_group remains true when member IDs are withheld; requesting member_entity_ids also retains is_group. An unknown key is rejected.')]",
29662966
"default": null
29672967
},
29682968
"fields": {

0 commit comments

Comments
 (0)