Skip to content

Commit 2ed3887

Browse files
fix: add exact_match to all search tools, badge search, and dashboard deep search (#814)
* fix: add badge search, exact match mode, and dashboard search to deep search (#801) - ha_dashboard_find_card now searches view-level badges (views[n].badges), catching entity references in badge chips that were previously missed during rename operations - ha_deep_search gains exact_match parameter (default: True) that uses substring matching instead of fuzzy scoring, eliminating false positives when searching for known entity IDs - ha_deep_search gains 'dashboard' search type that fetches and searches all storage-mode dashboard configurations for entity references Closes #801 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract _score_deep_match to deduplicate exact_match scoring logic Addresses Gemini Code Assist review — the scoring logic for exact_match was duplicated across automations, scripts, and helpers. Extracted into a single _score_deep_match helper method. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add exact_match parameter to ha_search_entities and ha_get_integration Per maintainer discussion in #801, fuzzy matching should be disabled by default across all search tools. This adds exact_match=True (default) to: - ha_search_entities: routes directly to substring matching, skipping fuzzy search entirely. Set exact_match=False for typo-tolerant search. - ha_get_integration: skips fuzzy scoring branch when filtering by query, keeping only exact substring matches. Set exact_match=False for fuzzy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: update E2E tests for exact_match default in ha_search_entities Tests that specifically verify fuzzy search behavior now pass exact_match=False, since the default changed to True. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: also search sections-view header cards in ha_dashboard_find_card Addresses feedback from @Patch76 on #801 — views[n].header.card in sections views accepts a card (typically Markdown with Jinja2 templates) that can contain entity references. Like badges, it's a sibling of views[n].cards and was missed by card-focused search. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address sergeykad review — error handling, bool coercion, tests - Dashboard search now raises on failure instead of silently returning empty results (fixes false-confidence problem) - Dropped `or False` from `coerce_bool_param(..., default=True)` calls that undermined the True default (3 locations) - Fixed potential None.lower() crash in ha_get_integration query filter - Badge search now triggers on card_type="badge" (not just entity_id) - Added unit tests for badge search, header card search, strategy dashboards - Added E2E tests for exact_match default, fuzzy opt-in, dashboard search type, and search_entities exact_match default Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f62e800 commit 2ed3887

7 files changed

Lines changed: 1310 additions & 406 deletions

File tree

src/ha_mcp/tools/smart_search.py

Lines changed: 246 additions & 51 deletions
Large diffs are not rendered by default.

src/ha_mcp/tools/tools_config_dashboards.py

Lines changed: 341 additions & 199 deletions
Large diffs are not rendered by default.

src/ha_mcp/tools/tools_integrations.py

Lines changed: 90 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@
2121
def register_integration_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
2222
"""Register integration management tools with the MCP server."""
2323

24-
@mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["integration"], "title": "Get Integration"})
24+
@mcp.tool(
25+
annotations={
26+
"idempotentHint": True,
27+
"readOnlyHint": True,
28+
"tags": ["integration"],
29+
"title": "Get Integration",
30+
}
31+
)
2532
@log_tool_usage
2633
async def ha_get_integration(
2734
entry_id: Annotated[
@@ -35,7 +42,8 @@ async def ha_get_integration(
3542
query: Annotated[
3643
str | None,
3744
Field(
38-
description="When listing, fuzzy search by domain or title.",
45+
description="When listing, search by domain or title. "
46+
"Uses exact substring matching by default; set exact_match=False for fuzzy.",
3947
default=None,
4048
),
4149
] = None,
@@ -65,6 +73,16 @@ async def ha_get_integration(
6573
default=False,
6674
),
6775
] = False,
76+
exact_match: Annotated[
77+
bool | str,
78+
Field(
79+
description=(
80+
"Use exact substring matching for query filter (default: True). "
81+
"Set to False for fuzzy matching when the query may contain typos."
82+
),
83+
default=True,
84+
),
85+
] = True,
6886
) -> dict[str, Any]:
6987
"""
7088
Get integration (config entry) information - list all or get a specific one.
@@ -96,8 +114,15 @@ async def ha_get_integration(
96114
- options_schema: Options flow schema when include_schema=True and supports_options=true
97115
"""
98116
try:
99-
include_opts = coerce_bool_param(include_options, "include_options", default=False)
100-
include_schema_bool = coerce_bool_param(include_schema, "include_schema", default=False)
117+
include_opts = coerce_bool_param(
118+
include_options, "include_options", default=False
119+
)
120+
include_schema_bool = coerce_bool_param(
121+
include_schema, "include_schema", default=False
122+
)
123+
exact_match_bool = coerce_bool_param(
124+
exact_match, "exact_match", default=True
125+
)
101126
# Auto-enable options when domain filter is set
102127
if domain is not None:
103128
include_opts = True
@@ -106,7 +131,11 @@ async def ha_get_integration(
106131
if entry_id is not None:
107132
try:
108133
result = await client.get_config_entry(entry_id)
109-
resp: dict[str, Any] = {"success": True, "entry_id": entry_id, "entry": result}
134+
resp: dict[str, Any] = {
135+
"success": True,
136+
"entry_id": entry_id,
137+
"entry": result,
138+
}
110139

111140
# Optionally fetch options flow schema (logically read-only: start+abort)
112141
if include_schema_bool and result.get("supports_options"):
@@ -128,49 +157,57 @@ async def ha_get_integration(
128157
"menu_options": flow_result.get("menu_options", []),
129158
}
130159
except Exception as schema_err:
131-
logger.debug(f"Failed to fetch options schema for {entry_id}: {schema_err}")
160+
logger.debug(
161+
f"Failed to fetch options schema for {entry_id}: {schema_err}"
162+
)
132163
finally:
133164
if flow_id:
134165
try:
135166
await client.abort_options_flow(flow_id)
136167
except Exception as abort_err:
137-
logger.debug(f"Failed to abort options flow {flow_id}: {abort_err}")
168+
logger.debug(
169+
f"Failed to abort options flow {flow_id}: {abort_err}"
170+
)
138171

139172
return resp
140173
except ToolError:
141174
raise
142175
except Exception as e:
143176
error_msg = str(e)
144177
if "404" in error_msg or "not found" in error_msg.lower():
145-
raise_tool_error(create_error_response(
146-
ErrorCode.RESOURCE_NOT_FOUND,
147-
f"Config entry not found: {entry_id}",
148-
context={"entry_id": entry_id},
149-
suggestions=[
150-
"Use ha_get_integration() without entry_id to see all config entries",
151-
],
152-
))
178+
raise_tool_error(
179+
create_error_response(
180+
ErrorCode.RESOURCE_NOT_FOUND,
181+
f"Config entry not found: {entry_id}",
182+
context={"entry_id": entry_id},
183+
suggestions=[
184+
"Use ha_get_integration() without entry_id to see all config entries",
185+
],
186+
)
187+
)
153188
raise
154189

155190
# List mode - get all config entries
156191
# Use REST API endpoint for config entries
157-
response = await client._request(
158-
"GET", "/config/config_entries/entry"
159-
)
192+
response = await client._request("GET", "/config/config_entries/entry")
160193

161194
if not isinstance(response, list):
162-
raise_tool_error(create_error_response(
163-
ErrorCode.SERVICE_CALL_FAILED,
164-
"Unexpected response format from Home Assistant",
165-
context={"response_type": type(response).__name__},
166-
))
195+
raise_tool_error(
196+
create_error_response(
197+
ErrorCode.SERVICE_CALL_FAILED,
198+
"Unexpected response format from Home Assistant",
199+
context={"response_type": type(response).__name__},
200+
)
201+
)
167202

168203
entries = response
169204

170205
# Apply domain filter before formatting
171206
if domain:
172207
domain_lower = domain.strip().lower()
173-
entries = [e for e in entries if e.get("domain", "").lower() == domain_lower]
208+
entries = [
209+
e for e in entries if e.get("domain", "").lower() == domain_lower
210+
]
174211

175212
# Format entries for response
176213
formatted_entries = []
@@ -202,24 +239,22 @@ async def ha_get_integration(
202239

203240
formatted_entries.append(formatted_entry)
204241

205-
# Apply fuzzy search filter if query provided
242+
# Apply search filter if query provided
206243
if query and query.strip():
207-
from ..utils.fuzzy_search import calculate_ratio
208-
209-
# Perform fuzzy search with both exact and fuzzy matching
210244
matches = []
211245
query_lower = query.strip().lower()
212246

213247
for entry in formatted_entries:
214-
domain_lower = entry['domain'].lower()
215-
title_lower = entry['title'].lower()
248+
domain_lower = (entry.get("domain") or "").lower()
249+
title_lower = (entry.get("title") or "").lower()
216250

217251
# Check for exact substring matches first (highest priority)
218252
if query_lower in domain_lower or query_lower in title_lower:
219-
# Exact substring match gets score of 100
220253
matches.append((100, entry))
221-
else:
222-
# Try fuzzy matching on domain and title separately
254+
elif not exact_match_bool:
255+
# Fuzzy matching only when exact_match is disabled
256+
from ..utils.fuzzy_search import calculate_ratio
257+
223258
domain_score = calculate_ratio(query_lower, domain_lower)
224259
title_score = calculate_ratio(query_lower, title_lower)
225260
best_score = max(domain_score, title_score)
@@ -294,19 +329,25 @@ async def ha_set_integration_enabled(
294329
error_msg = result.get("error", {})
295330
if isinstance(error_msg, dict):
296331
error_msg = error_msg.get("message", str(error_msg))
297-
raise_tool_error(create_error_response(
298-
ErrorCode.SERVICE_CALL_FAILED,
299-
f"Failed to {'enable' if enabled_bool else 'disable'} integration: {error_msg}",
300-
context={"entry_id": entry_id},
301-
))
332+
raise_tool_error(
333+
create_error_response(
334+
ErrorCode.SERVICE_CALL_FAILED,
335+
f"Failed to {'enable' if enabled_bool else 'disable'} integration: {error_msg}",
336+
context={"entry_id": entry_id},
337+
)
338+
)
302339

303340
# Get updated entry info
304341
require_restart = result.get("result", {}).get("require_restart", False)
305342

306343
if require_restart:
307344
note = "Home Assistant restart required for changes to take effect."
308345
else:
309-
note = "Integration has been loaded." if enabled_bool else "Integration has been unloaded."
346+
note = (
347+
"Integration has been loaded."
348+
if enabled_bool
349+
else "Integration has been unloaded."
350+
)
310351

311352
return {
312353
"success": True,
@@ -344,14 +385,16 @@ async def ha_delete_config_entry(
344385
confirm_bool = coerce_bool_param(confirm, "confirm", default=False)
345386

346387
if not confirm_bool:
347-
raise_tool_error(create_error_response(
348-
ErrorCode.VALIDATION_INVALID_PARAMETER,
349-
"Deletion not confirmed. Set confirm=True to proceed.",
350-
context={
351-
"entry_id": entry_id,
352-
"warning": "This will permanently delete the config entry. This cannot be undone.",
353-
},
354-
))
388+
raise_tool_error(
389+
create_error_response(
390+
ErrorCode.VALIDATION_INVALID_PARAMETER,
391+
"Deletion not confirmed. Set confirm=True to proceed.",
392+
context={
393+
"entry_id": entry_id,
394+
"warning": "This will permanently delete the config entry. This cannot be undone.",
395+
},
396+
)
397+
)
355398

356399
result = await client.delete_config_entry(entry_id)
357400
require_restart = result.get("require_restart", False)

0 commit comments

Comments
 (0)