Skip to content

Commit b797151

Browse files
feat: drive integration config flows and options flows from ha_set_integration (#1822)
* feat: drive integration config flows and options flows from ha_set_integration Renames ha_set_integration_enabled to ha_set_integration and adds two flow modes alongside enable/disable (issue #1814): - Add integration: domain (+ config) starts and walks the domain's config flow via the generic flow driver that already powers ha_config_set_helper (menus and multi-step forms included, no helper-domain allowlist). - Update options: entry_id + config drives the entry's options flow, the same as the Configure button in the HA UI. The shared flow walker now surfaces external (browser/OAuth) and progress steps as structured errors instead of failing with INTERNAL_UNEXPECTED, and the helper create/update wrappers delegate to the new generic create_config_entry / update_config_entry_options functions. Also fixes the stale policy value-source registry key (the old entry pointed at a nonexistent args.entity_id path) by mapping ha_set_integration's args.entry_id to a new ha_config_entries source. Closes #1814 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address PR #1822 review findings - Guard require_restart read against a present-but-null result payload (Gemini finding). - Consume the awaited coroutine explicitly in the mode-routing test helper (CodeQL py/ineffectual-statement, the one red CI finding). - Add unit coverage for the undrivable-step branch (external/progress step types) and for the new ha_config_entries value-source fetcher + registry mapping. - Attach an existing-entry hint to benign config-flow aborts (already_configured / single_instance_allowed), with tests. - Docstring accuracy: rewrite the stale HAOS module rationale, correct the OAuth-caveat wording, and drop the implied prior helper gating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: raise on options/config flows that consume none of the supplied keys When a flow presents form steps and none of the caller's config keys match any step's schema (typos, wrong field names), the walker used to submit empty forms — HA saved defaults — and still report 'updated successfully' with only a warning. LLM callers gating on success treat that as done. Raise VALIDATION_INVALID_PARAMETER instead, pointing at the schema route. Partial matches and zero-form instant creates keep the established success + warnings contract. Applies to both ha_set_integration and ha_config_set_helper (shared walker). Extracts the CREATE_ENTRY and ABORT handling into helpers to stay under the complexity limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 46bb4c9 commit b797151

10 files changed

Lines changed: 883 additions & 112 deletions

src/ha_mcp/policy/value_sources.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
("ha_get_history", "args.entity_ids"): "ha_entities",
4141
("ha_remove_entity", "args.entity_id"): "ha_entities",
4242
("ha_get_entity_exposure", "args.entity_id"): "ha_entities",
43-
("ha_set_integration_enabled", "args.entity_id"): "ha_entities",
43+
("ha_set_integration", "args.entry_id"): "ha_config_entries",
4444
}
4545

4646
# Fetched-value TTL cache so the UI can click through path options
@@ -171,8 +171,24 @@ async def _fetch_ha_entities(client: Any, params: dict[str, str]) -> list[str]:
171171
return out
172172

173173

174+
async def _fetch_ha_config_entries(client: Any, _params: dict[str, str]) -> list[str]:
175+
entries = await client._request("GET", "/config/config_entries/entry")
176+
if not isinstance(entries, list):
177+
logger.warning(
178+
"ha_config_entries: config entry list returned unexpected shape %s",
179+
type(entries).__name__,
180+
)
181+
return []
182+
return [
183+
e["entry_id"]
184+
for e in entries
185+
if isinstance(e, dict) and isinstance(e.get("entry_id"), str)
186+
]
187+
188+
174189
_FETCHERS: dict[str, Callable[[Any, dict[str, str]], Awaitable[list[str]]]] = {
175190
"ha_domains": _fetch_ha_domains,
176191
"ha_services": _fetch_ha_services,
177192
"ha_entities": _fetch_ha_entities,
193+
"ha_config_entries": _fetch_ha_config_entries,
178194
}

src/ha_mcp/tools/config_entry_flow.py

Lines changed: 177 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import logging
1515
from collections.abc import Iterator
1616
from enum import StrEnum
17-
from typing import Any, Literal
17+
from typing import Any, Literal, NoReturn
1818

1919
from ..client.rest_client import HomeAssistantAPIError
2020
from ..errors import ErrorCode, create_error_response
@@ -65,6 +65,13 @@
6565

6666
# Keys used to specify a menu selection — stripped before submitting form data.
6767
_MENU_SELECTION_KEYS = frozenset({"group_type", "next_step_id", "menu_option"})
68+
69+
# Flow step types an MCP client cannot drive: external steps need a browser
70+
# (OAuth / cloud authorization), progress steps wait on HA-side async work.
71+
# Surfaced as structured errors instead of attempted (issue #1814).
72+
_UNDRIVABLE_STEP_TYPES = frozenset(
73+
{"external", "external_done", "progress", "progress_done"}
74+
)
6875
_RECONFIGURE_SUCCESS_REASONS = frozenset(
6976
{
7077
"reauth_successful",
@@ -618,6 +625,74 @@ async def _submit_step(
618625
raise
619626

620627

628+
def _finish_flow_entry(
629+
flow_id: str,
630+
current_step: dict[str, Any],
631+
*,
632+
supplied_keys: list[str],
633+
saw_form_step: bool,
634+
any_form_key_consumed: bool,
635+
ignored_config_keys: set[str],
636+
remaining_config: dict[str, Any],
637+
) -> dict[str, Any]:
638+
"""Build the CREATE_ENTRY success response, or raise on total key miss.
639+
640+
When the flow presented at least one form step, the caller supplied
641+
config keys, and NONE were consumed by any form (typos / wrong field
642+
names), the flow was walked on empty forms and HA saved form defaults —
643+
not the caller's values. A success + warning there reads as "done" to an
644+
LLM caller, so fail loudly with the schema route instead. Partial
645+
consumption — and flows that complete without any form step (instant
646+
creates) — keep the established success + warnings contract.
647+
"""
648+
if supplied_keys and saw_form_step and not any_form_key_consumed:
649+
raise_tool_error(
650+
create_error_response(
651+
ErrorCode.VALIDATION_INVALID_PARAMETER,
652+
"Flow completed without consuming any of the supplied "
653+
"config keys — every form step was submitted empty, so "
654+
"the flow saved its defaults, not your values",
655+
suggestions=[
656+
"Check the field names against the flow's data_schema — "
657+
"ha_get_integration(entry_id=..., include_schema=True) "
658+
"shows the accepted fields — then retry with corrected "
659+
"keys.",
660+
],
661+
context={
662+
"flow_id": flow_id,
663+
"supplied_keys": supplied_keys,
664+
"details": current_step,
665+
},
666+
)
667+
)
668+
response: dict[str, Any] = {"success": True, "entry": current_step}
669+
warnings = _ignored_keys_warnings(ignored_config_keys, remaining_config)
670+
if warnings:
671+
response["warnings"] = warnings
672+
return response
673+
674+
675+
def _raise_flow_abort(flow_id: str, current_step: dict[str, Any]) -> NoReturn:
676+
"""Raise the structured error for an ABORT flow step."""
677+
reason = current_step.get("reason")
678+
abort_suggestions: list[str] = []
679+
if reason in ("already_configured", "single_instance_allowed"):
680+
# Common benign aborts on the add-integration path (#1814): give the
681+
# caller a route to the existing entry instead of a bare failure.
682+
abort_suggestions.append(
683+
"The integration is already set up — use "
684+
"ha_get_integration() to find the existing entry."
685+
)
686+
raise_tool_error(
687+
create_error_response(
688+
ErrorCode.SERVICE_CALL_FAILED,
689+
f"Flow aborted: {reason}",
690+
suggestions=abort_suggestions or None,
691+
context={"flow_id": flow_id, "details": current_step},
692+
)
693+
)
694+
695+
621696
async def _handle_flow_steps(
622697
client: Any,
623698
flow_id: str,
@@ -650,35 +725,40 @@ async def _handle_flow_steps(
650725
651726
Returns:
652727
``{"success": True, "entry": result}`` on success, plus ``warnings``
653-
when caller-supplied config keys were not declared by any flow step.
654-
Raises ToolError on any failure.
728+
when SOME caller-supplied config keys were not declared by any flow
729+
step. When the flow presented at least one form step and NONE of the
730+
supplied keys were consumed, raises ``VALIDATION_INVALID_PARAMETER``
731+
instead of reporting a misleading success — the flow completed on
732+
empty forms (defaults), applying nothing the caller asked for (see
733+
:func:`_finish_flow_entry`). Raises ToolError on any failure.
655734
"""
656735
if submit_fn is None:
657736
submit_fn = client.submit_config_flow_step
658737
remaining_config = dict(config)
659738
current_step = initial_step
660739
last_menu_choice: str | None = None
661740
ignored_config_keys: set[str] = set()
741+
supplied_keys = sorted(k for k in config if k not in _MENU_SELECTION_KEYS)
742+
saw_form_step = False
743+
any_form_key_consumed = False
662744
max_steps = 10
663745

664746
for step_num in range(max_steps):
665747
result_type = current_step.get("type")
666748

667749
if result_type == _FlowType.CREATE_ENTRY:
668-
response: dict[str, Any] = {"success": True, "entry": current_step}
669-
warnings = _ignored_keys_warnings(ignored_config_keys, remaining_config)
670-
if warnings:
671-
response["warnings"] = warnings
672-
return response
750+
return _finish_flow_entry(
751+
flow_id,
752+
current_step,
753+
supplied_keys=supplied_keys,
754+
saw_form_step=saw_form_step,
755+
any_form_key_consumed=any_form_key_consumed,
756+
ignored_config_keys=ignored_config_keys,
757+
remaining_config=remaining_config,
758+
)
673759

674760
if result_type == _FlowType.ABORT:
675-
raise_tool_error(
676-
create_error_response(
677-
ErrorCode.SERVICE_CALL_FAILED,
678-
f"Flow aborted: {current_step.get('reason')}",
679-
context={"flow_id": flow_id, "details": current_step},
680-
)
681-
)
761+
_raise_flow_abort(flow_id, current_step)
682762

683763
if result_type == _FlowType.MENU:
684764
menu_choice = _handle_menu_step(flow_id, current_step, remaining_config)
@@ -702,12 +782,15 @@ async def _handle_flow_steps(
702782
# step's data_schema, leaving any other keys in remaining_config
703783
# for subsequent steps (HA can present multi-step forms, e.g.
704784
# statistics: user step then pick-characteristic step).
785+
saw_form_step = True
705786
form_data = _handle_form_step(
706787
flow_id,
707788
current_step,
708789
remaining_config,
709790
ignored_config_keys,
710791
)
792+
if form_data:
793+
any_form_key_consumed = True
711794
logger.debug(
712795
f"Flow step {step_num}: form submit "
713796
f"(step_id={current_step.get('step_id')}, keys={list(form_data.keys())})"
@@ -722,6 +805,21 @@ async def _handle_flow_steps(
722805
current_step=current_step,
723806
)
724807

808+
elif result_type in _UNDRIVABLE_STEP_TYPES:
809+
raise_tool_error(
810+
create_error_response(
811+
ErrorCode.SERVICE_CALL_FAILED,
812+
f"Flow reached a '{result_type}' step that cannot be "
813+
"completed via MCP (browser/OAuth authorization or an "
814+
"asynchronous provider step)",
815+
suggestions=[
816+
"Complete this flow in the Home Assistant UI "
817+
"(Settings > Devices & Services)."
818+
],
819+
context={"flow_id": flow_id, "details": current_step},
820+
)
821+
)
822+
725823
else:
726824
raise_tool_error(
727825
create_error_response(
@@ -981,30 +1079,35 @@ async def get_user_step_field_names(client: Any, helper_type: str) -> set[str] |
9811079
)
9821080

9831081

984-
async def update_flow_helper(
1082+
async def update_config_entry_options(
9851083
client: Any,
986-
helper_type: str,
987-
config_dict: dict[str, Any],
9881084
entry_id: str,
1085+
config_dict: dict[str, Any],
1086+
*,
1087+
expected_domain: str | None = None,
1088+
noun: str = "integration",
9891089
) -> dict[str, Any]:
990-
"""Update an existing flow-based helper via its options flow.
1090+
"""Update an existing config entry via its options flow.
9911091
992-
Verifies the entry domain matches helper_type, starts an options flow,
993-
walks the flow steps, and returns the result. Aborts the flow on error.
1092+
When ``expected_domain`` is provided, verifies the entry's domain matches
1093+
it first (the helper path passes the helper_type; the generic
1094+
``ha_set_integration`` path passes ``None`` to accept any domain). Starts
1095+
an options flow, walks the flow steps, and returns the result. Aborts the
1096+
flow on error. ``noun`` only affects response wording.
9941097
"""
9951098
config_entry = await client.get_config_entry(entry_id)
9961099
actual_domain = config_entry.get("domain")
997-
if actual_domain != helper_type:
1100+
if expected_domain is not None and actual_domain != expected_domain:
9981101
raise_tool_error(
9991102
create_error_response(
10001103
ErrorCode.VALIDATION_INVALID_PARAMETER,
1001-
f"entry_id '{entry_id}' belongs to domain '{actual_domain}', not '{helper_type}'",
1104+
f"entry_id '{entry_id}' belongs to domain '{actual_domain}', not '{expected_domain}'",
10021105
suggestions=[
1003-
f"Use ha_get_integration(domain='{helper_type}') to find valid entry IDs",
1106+
f"Use ha_get_integration(domain='{expected_domain}') to find valid entry IDs",
10041107
],
10051108
context={
10061109
"entry_id": entry_id,
1007-
"expected": helper_type,
1110+
"expected": expected_domain,
10081111
"actual": actual_domain,
10091112
},
10101113
)
@@ -1032,7 +1135,7 @@ async def update_flow_helper(
10321135
flow_result,
10331136
config_dict,
10341137
submit_fn=client.submit_options_flow_step,
1035-
helper_type=helper_type,
1138+
helper_type=expected_domain,
10361139
)
10371140
except Exception:
10381141
try:
@@ -1048,26 +1151,49 @@ async def update_flow_helper(
10481151
"success": True,
10491152
"entry_id": entry_id,
10501153
"title": entry.get("title"),
1051-
"domain": helper_type,
1052-
"message": f"{helper_type} helper updated successfully",
1154+
"domain": actual_domain,
1155+
"message": f"{actual_domain} {noun} updated successfully",
10531156
"updated": True,
10541157
}
10551158
if result.get("warnings"):
10561159
response["warnings"] = result["warnings"]
10571160
return response
10581161

10591162

1060-
async def create_flow_helper(
1163+
async def update_flow_helper(
10611164
client: Any,
10621165
helper_type: str,
10631166
config_dict: dict[str, Any],
1167+
entry_id: str,
10641168
) -> dict[str, Any]:
1065-
"""Create a new flow-based helper via the config flow.
1169+
"""Update an existing flow-based helper via its options flow.
10661170
1067-
Starts a config flow, walks the flow steps, and returns the result.
1068-
Aborts the flow on error.
1171+
Verifies the entry domain matches helper_type, starts an options flow,
1172+
walks the flow steps, and returns the result. Aborts the flow on error.
10691173
"""
1070-
flow_result = await client.start_config_flow(helper_type)
1174+
return await update_config_entry_options(
1175+
client,
1176+
entry_id,
1177+
config_dict,
1178+
expected_domain=helper_type,
1179+
noun="helper",
1180+
)
1181+
1182+
1183+
async def create_config_entry(
1184+
client: Any,
1185+
domain: str,
1186+
config_dict: dict[str, Any],
1187+
*,
1188+
noun: str = "integration",
1189+
) -> dict[str, Any]:
1190+
"""Create a config entry by driving ``domain``'s config flow.
1191+
1192+
Starts a config flow, walks the flow steps (menus and multi-step forms),
1193+
and returns the result. Aborts the flow on error. ``noun`` only affects
1194+
response wording.
1195+
"""
1196+
flow_result = await client.start_config_flow(domain)
10711197
flow_id = flow_result.get("flow_id")
10721198

10731199
if not flow_id:
@@ -1076,9 +1202,9 @@ async def create_flow_helper(
10761202
ErrorCode.SERVICE_CALL_FAILED,
10771203
"Failed to start config flow",
10781204
suggestions=[
1079-
"Check that the helper type is supported and Home Assistant is reachable"
1205+
f"Check that the {noun} domain exists and Home Assistant is reachable"
10801206
],
1081-
context={"helper_type": helper_type, "details": flow_result},
1207+
context={"domain": domain, "details": flow_result},
10821208
)
10831209
)
10841210

@@ -1088,7 +1214,7 @@ async def create_flow_helper(
10881214
flow_id,
10891215
flow_result,
10901216
config_dict,
1091-
helper_type=helper_type,
1217+
helper_type=domain,
10921218
)
10931219
except Exception:
10941220
try:
@@ -1104,9 +1230,22 @@ async def create_flow_helper(
11041230
"success": True,
11051231
"entry_id": entry.get("entry_id"),
11061232
"title": entry.get("title"),
1107-
"domain": helper_type,
1108-
"message": f"{helper_type} helper created successfully",
1233+
"domain": domain,
1234+
"message": f"{domain} {noun} created successfully",
11091235
}
11101236
if result.get("warnings"):
11111237
response["warnings"] = result["warnings"]
11121238
return response
1239+
1240+
1241+
async def create_flow_helper(
1242+
client: Any,
1243+
helper_type: str,
1244+
config_dict: dict[str, Any],
1245+
) -> dict[str, Any]:
1246+
"""Create a new flow-based helper via the config flow.
1247+
1248+
Starts a config flow, walks the flow steps, and returns the result.
1249+
Aborts the flow on error.
1250+
"""
1251+
return await create_config_entry(client, helper_type, config_dict, noun="helper")

0 commit comments

Comments
 (0)