1414import logging
1515from collections .abc import Iterator
1616from enum import StrEnum
17- from typing import Any , Literal
17+ from typing import Any , Literal , NoReturn
1818
1919from ..client .rest_client import HomeAssistantAPIError
2020from ..errors import ErrorCode , create_error_response
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+
621696async 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