Skip to content

Commit 156e67e

Browse files
fix: persist cleared override fields in the in-process server options flow (#1834)
* fix: persist cleared override fields in the in-process server options flow The server entry's options flow pre-filled its optional text fields (pip-spec / server-URL / external-URL / webhook-id / secret-path) with a voluptuous schema `default` equal to the saved value. HA's frontend drops an emptied optional field from the submitted payload, so the flow manager re-applied that default and clearing never stuck: the "Developer: ha-mcp package override" field kept re-installing the old build and re-appeared populated on reopen. Typing the default dist name ("ha-mcp") was the only way to clear it, because `_normalize` collapses the default to empty. Pre-fill via `description={"suggested_value": ...}` instead. It shows the same value but is not re-injected on an empty submit, so a cleared field stays cleared while an untouched field is still submitted unchanged. `ha_dev_manage_server("update_source")` drives the same flow with a partial submit and relied on those defaults to preserve the fields it does not change. With the defaults gone, resend the user's current overrides (reading `description.suggested_value` first) so a channel or pip-spec change no longer blanks the server URL or connect secrets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review — normalize server_url, doc accuracy, CodeQL, tests - config_flow: normalize server_url in _normalize (strip + trim trailing slash, drop when blank) so a whitespace-only Home Assistant URL can't be stored verbatim and bypass the empty->loopback fallback (Gemini review). - config_flow: correct the stale _normalize docstring (the pip-spec field is pre-filled with the saved override or blank, not DEFAULT_PIP_SPEC) and the misquoted "Leave empty" help text. - tools_dev: fix the _field_prefill / find_server_config_entry docstrings (the component sets suggested_value directly; note the value fallback), shorten the update_source comment, and log a rejected deferred self-restart at warning. - tests: split pop() out of the assert in test_form_prefills_* to clear the CodeQL py/side-effect-in-assert findings; add server_url whitespace / trailing- slash tests and a dev-tool test that a caller pip_spec wins over the preserved pin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c4a367a commit 156e67e

4 files changed

Lines changed: 258 additions & 28 deletions

File tree

custom_components/ha_mcp_tools/config_flow.py

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,27 @@ async def async_step_init(
262262
),
263263
vol.Optional(
264264
OPT_PIP_SPEC,
265-
# Pre-fill only a genuinely saved override. The normalized
266-
# "no override" state renders an EMPTY field — pre-filling
267-
# DEFAULT_PIP_SPEC as a hint made a field whose help text
268-
# says "leave blank" always look populated, and showed the
269-
# STABLE dist name even on the dev channel.
270-
default=opts.get(OPT_PIP_SPEC, ""),
265+
# Pre-fill via suggested_value, NOT a schema default: a
266+
# default equal to the saved value makes the field
267+
# impossible to clear. HA's frontend drops an emptied
268+
# optional field from the submitted payload, so voluptuous
269+
# re-applies the default (the old override) and clearing
270+
# never sticks. suggested_value pre-fills the same value but
271+
# is not re-injected on an empty submit. (Applies to every
272+
# optional text field below.) Only a genuinely saved
273+
# override is suggested; the normalized "no override" state
274+
# renders an EMPTY field — the help text says "Leave empty",
275+
# and pre-filling DEFAULT_PIP_SPEC would show the STABLE dist
276+
# name even on the dev channel.
277+
description={"suggested_value": opts.get(OPT_PIP_SPEC, "")},
271278
): str,
272279
vol.Optional(
273280
OPT_SERVER_URL,
274-
default=opts.get(OPT_SERVER_URL, DEFAULT_LOOPBACK_URL),
281+
description={
282+
"suggested_value": opts.get(
283+
OPT_SERVER_URL, DEFAULT_LOOPBACK_URL
284+
)
285+
},
275286
): str,
276287
vol.Required(
277288
OPT_ENABLE_WEBHOOK,
@@ -301,17 +312,23 @@ async def async_step_init(
301312
mode=SelectSelectorMode.DROPDOWN,
302313
)
303314
),
315+
# suggested_value (not default) so these clear properly on an
316+
# empty submit — see the OPT_PIP_SPEC note above.
304317
vol.Optional(
305318
OPT_EXTERNAL_URL,
306-
default=opts.get(OPT_EXTERNAL_URL, ""),
319+
description={"suggested_value": opts.get(OPT_EXTERNAL_URL, "")},
307320
): str,
308321
vol.Optional(
309322
OPT_WEBHOOK_ID_OVERRIDE,
310-
default=opts.get(OPT_WEBHOOK_ID_OVERRIDE, ""),
323+
description={
324+
"suggested_value": opts.get(OPT_WEBHOOK_ID_OVERRIDE, "")
325+
},
311326
): str,
312327
vol.Optional(
313328
OPT_SECRET_PATH_OVERRIDE,
314-
default=opts.get(OPT_SECRET_PATH_OVERRIDE, ""),
329+
description={
330+
"suggested_value": opts.get(OPT_SECRET_PATH_OVERRIDE, "")
331+
},
315332
): str,
316333
vol.Optional(
317334
OPT_REGENERATE_SECRETS,
@@ -343,14 +360,16 @@ async def async_step_init(
343360

344361
@staticmethod
345362
def _normalize(user_input: dict[str, Any]) -> dict[str, Any]:
346-
"""Collapse the default pip spec to empty so it is not stored as an override.
347-
348-
The pip-spec field is pre-filled with ``DEFAULT_PIP_SPEC`` (the unpinned
349-
``ha-mcp`` distribution) as a hint. Persisting that value verbatim would
350-
read as an intentional override and disable the stable channel's
351-
automatic updates. Collapsing "equals the default" (or empty) to empty
352-
keeps the entry tracking the selected channel; a genuine override (any
353-
other string) is stored as-is.
363+
"""Normalize the submitted options before they are persisted.
364+
365+
Collapses the pip-spec field to empty when it is empty or equals
366+
``DEFAULT_PIP_SPEC`` (the unpinned ``ha-mcp`` distribution): the field is
367+
pre-filled with the saved override or blank, but a user may also type the
368+
default dist name, and persisting it verbatim would read as an
369+
intentional override and disable the stable channel's automatic updates.
370+
Empty means "no override" (track the selected channel); any other string
371+
is a genuine override, stored as-is. Also strips the URL / secret
372+
override fields, and drops a blank ``server_url`` so its default applies.
354373
"""
355374
cleaned = dict(user_input)
356375
if cleaned.get(OPT_PIP_SPEC, "").strip() in ("", DEFAULT_PIP_SPEC):
@@ -362,6 +381,15 @@ def _normalize(user_input: dict[str, Any]) -> dict[str, Any]:
362381
):
363382
cleaned[key] = str(cleaned.get(key, "") or "").strip()
364383
cleaned[OPT_EXTERNAL_URL] = cleaned[OPT_EXTERNAL_URL].rstrip("/")
384+
# server_url gets no _normalize-forced empty like the fields above; strip
385+
# it and drop it entirely when blank so a whitespace-only value can't be
386+
# stored verbatim (it would bypass the consumer's empty -> loopback
387+
# fallback and break the HA connection).
388+
server_url = str(cleaned.get(OPT_SERVER_URL, "") or "").strip().rstrip("/")
389+
if server_url:
390+
cleaned[OPT_SERVER_URL] = server_url
391+
else:
392+
cleaned.pop(OPT_SERVER_URL, None)
365393
return cleaned
366394

367395
async def _versions_hint(self) -> str:

src/ha_mcp/tools/tools_dev.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,25 @@
4444
# (custom_components/ha_mcp_tools/const.py OPT_CHANNEL / OPT_PIP_SPEC).
4545
_OPT_CHANNEL = "channel"
4646
_OPT_PIP_SPEC = "pip_spec"
47+
_OPT_SERVER_URL = "server_url"
48+
_OPT_EXTERNAL_URL = "external_url"
49+
_OPT_WEBHOOK_ID_OVERRIDE = "webhook_id_override"
50+
_OPT_SECRET_PATH_OVERRIDE = "secret_path_override"
4751
_VALID_CHANNELS = ("stable", "dev")
4852

53+
# Optional text fields the component's options flow pre-fills via
54+
# suggested_value (so the UI can clear them). Because an OMITTED optional field
55+
# reads as "cleared" rather than "unchanged", a partial update_source submit
56+
# must resend these at their current values or it would blank the user's
57+
# server-URL / connect-secret overrides.
58+
_PRESERVED_OPTION_KEYS = (
59+
_OPT_PIP_SPEC,
60+
_OPT_SERVER_URL,
61+
_OPT_EXTERNAL_URL,
62+
_OPT_WEBHOOK_ID_OVERRIDE,
63+
_OPT_SECRET_PATH_OVERRIDE,
64+
)
65+
4966
# Delay before a self-affecting action (embedded entry reload / options
5067
# submit) fires, so this tool's JSON response flushes to the MCP client
5168
# before the serving thread is torn down. Mirrors
@@ -85,6 +102,23 @@ def _spawn_background(coro: Any) -> None:
85102
task.add_done_callback(_BACKGROUND_TASKS.discard)
86103

87104

105+
def _field_prefill(item: dict[str, Any]) -> Any:
106+
"""Return a serialized options-flow field's current value.
107+
108+
Reads ``description.suggested_value`` first: a persisted option is
109+
serialized there (as ``add_suggested_values_to_schema`` does; this component
110+
sets it directly on the ``vol.Optional`` marker), and the clearable text
111+
fields carry their value there rather than as a schema ``default`` (a
112+
``default`` equal to the value would make the field impossible to clear).
113+
Falls back to ``default`` then ``value`` for the dropdown/toggle fields.
114+
Mirrors ``tools_integrations.options_from_form_flow``.
115+
"""
116+
description = item.get("description")
117+
if isinstance(description, dict) and description.get("suggested_value") is not None:
118+
return description["suggested_value"]
119+
return item.get("default", item.get("value"))
120+
121+
88122
async def find_server_config_entry(
89123
client: Any,
90124
) -> tuple[str, dict[str, Any], dict[str, Any]] | None:
@@ -95,8 +129,9 @@ async def find_server_config_entry(
95129
(services) entry's flow aborts immediately. Returns
96130
``(entry_id, open_flow, current_options)`` with the options flow left
97131
OPEN (callers must submit or abort it), or ``None`` when no server
98-
entry exists. ``current_options`` maps schema field names to their
99-
defaults — i.e. the entry's current option values.
132+
entry exists. ``current_options`` maps schema field names to their current
133+
values (persisted ``suggested_value`` first, else the schema ``default`` or
134+
``value``, via ``_field_prefill``).
100135
101136
Module-level (not a DevTools method) so the settings UI's embedded
102137
restart handler can share it.
@@ -131,7 +166,7 @@ async def find_server_config_entry(
131166
continue
132167
schema = flow.get("data_schema") or []
133168
fields: dict[str, Any] = {
134-
str(item["name"]): item.get("default")
169+
str(item["name"]): _field_prefill(item)
135170
for item in schema
136171
if isinstance(item, dict) and item.get("name")
137172
}
@@ -400,7 +435,16 @@ async def _delayed_submit_options(
400435
await asyncio.sleep(_SELF_ACTION_FLUSH_DELAY_S)
401436
try:
402437
result = await self._client.submit_options_flow_step(flow_id, user_input)
403-
logger.info("Deferred options submit result: %s", result.get("type"))
438+
if result.get("type") == "create_entry":
439+
logger.info("Deferred options submit applied")
440+
else:
441+
# Fire-and-forget: no caller is left to raise to, so a rejected
442+
# self-restart must at least be discoverable in the log.
443+
logger.warning(
444+
"Deferred options submit was not applied (type=%s, errors=%s)",
445+
result.get("type"),
446+
result.get("errors") or result.get("reason"),
447+
)
404448
except Exception:
405449
logger.exception("Deferred options-flow submit failed")
406450

@@ -819,7 +863,12 @@ async def _update_source(
819863
)
820864
)
821865
entry_id, flow, current = found
822-
user_input: dict[str, Any] = {}
866+
# Resend the user's current overrides (see _PRESERVED_OPTION_KEYS) so a
867+
# channel/pip-spec change here does not blank them — an omitted optional
868+
# field reads as "cleared", not "unchanged".
869+
user_input: dict[str, Any] = {
870+
key: current[key] for key in _PRESERVED_OPTION_KEYS if current.get(key)
871+
}
823872
if channel is not None:
824873
user_input[_OPT_CHANNEL] = channel
825874
if pip_spec is not None:

tests/src/unit/test_config_flow.py

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,10 @@ def test_auto_update_defaults_on(self):
307307
def test_form_prefills_every_field_from_saved_options(self):
308308
# Review gap: the form must show the user's SAVED values, not the
309309
# defaults, for every field (a regression here silently reverts a
310-
# user's config on the next save).
310+
# user's config on the next save). Dropdowns/toggles pre-fill via the
311+
# schema default; the optional text fields pre-fill via suggested_value
312+
# (a default there would make them impossible to clear — see
313+
# test_clearing_an_override_field_sticks).
311314
saved = {
312315
const.OPT_CHANNEL: const.CHANNEL_DEV,
313316
const.OPT_AUTO_UPDATE: False,
@@ -328,10 +331,27 @@ def test_form_prefills_every_field_from_saved_options(self):
328331
data={const.DATA_WEBHOOK_ID: "mcp_abc"}, options=saved
329332
)
330333
form = asyncio.run(flow.async_step_init(None))
331-
defaults = {m.schema: m.default() for m in form["data_schema"].schema}
334+
markers = {m.schema: m for m in form["data_schema"].schema}
335+
336+
# Optional text fields pre-fill via suggested_value so they stay
337+
# clearable; every other field pre-fills via the schema default.
338+
text_fields = (
339+
const.OPT_PIP_SPEC,
340+
const.OPT_SERVER_URL,
341+
const.OPT_EXTERNAL_URL,
342+
const.OPT_WEBHOOK_ID_OVERRIDE,
343+
const.OPT_SECRET_PATH_OVERRIDE,
344+
)
345+
for key in text_fields:
346+
assert markers[key].description["suggested_value"] == saved[key]
347+
348+
defaults = {
349+
key: m.default() for key, m in markers.items() if key not in text_fields
350+
}
332351
# regenerate_secrets is a one-shot action, never pre-filled True;
333352
# enable_webhook / enable_startup_notification / enable_sidebar_panel
334-
# default on when unsaved.
353+
# default on when unsaved. Pop off the schema (not inside assert, which
354+
# `python -O` would strip) before comparing the remainder.
335355
regenerate_default = defaults.pop(const.OPT_REGENERATE_SECRETS)
336356
assert regenerate_default is False
337357
webhook_default = defaults.pop(const.OPT_ENABLE_WEBHOOK)
@@ -340,7 +360,7 @@ def test_form_prefills_every_field_from_saved_options(self):
340360
assert notification_default is True
341361
panel_default = defaults.pop(const.OPT_ENABLE_SIDEBAR_PANEL)
342362
assert panel_default is True
343-
assert defaults == saved
363+
assert defaults == {k: v for k, v in saved.items() if k not in text_fields}
344364

345365
def test_init_submit_round_trips_input_into_entry(self):
346366
flow = _make_options_flow()
@@ -380,6 +400,31 @@ def test_default_pip_spec_normalized_to_empty(self):
380400
)
381401
assert result["data"][const.OPT_PIP_SPEC] == ""
382402

403+
def test_server_url_whitespace_is_dropped_to_default(self):
404+
# A whitespace-only Home Assistant URL must not be stored verbatim: it is
405+
# truthy, so it would bypass the consumer's empty -> loopback fallback and
406+
# break the connection. _normalize drops it so the default applies.
407+
flow = _make_options_flow()
408+
result = asyncio.run(
409+
flow.async_step_init(
410+
{const.OPT_CHANNEL: const.CHANNEL_STABLE, const.OPT_SERVER_URL: " "}
411+
)
412+
)
413+
assert const.OPT_SERVER_URL not in result["data"]
414+
415+
def test_server_url_trailing_slash_stripped(self):
416+
# A real URL is kept, with any trailing slash trimmed.
417+
flow = _make_options_flow()
418+
result = asyncio.run(
419+
flow.async_step_init(
420+
{
421+
const.OPT_CHANNEL: const.CHANNEL_STABLE,
422+
const.OPT_SERVER_URL: "http://ha.local:8123/",
423+
}
424+
)
425+
)
426+
assert result["data"][const.OPT_SERVER_URL] == "http://ha.local:8123"
427+
383428
def test_pip_spec_field_empty_when_no_override(self):
384429
# The "leave blank to follow the channel" field must actually BE
385430
# blank when no override is stored — pre-filling the default dist
@@ -391,7 +436,40 @@ def test_pip_spec_field_empty_when_no_override(self):
391436
marker = next(
392437
m for m in form["data_schema"].schema if m.schema == const.OPT_PIP_SPEC
393438
)
394-
assert marker.default() == ""
439+
assert marker.description["suggested_value"] == ""
440+
441+
def test_clearing_an_override_field_sticks(self):
442+
# Regression: emptying an optional text field must persist as cleared.
443+
# HA's frontend DROPS an emptied optional field from the submitted
444+
# payload; the flow manager then validates that payload against the
445+
# shown schema (filling voluptuous defaults) before the step handler
446+
# runs — the layer a direct-handler unit test skips. A schema
447+
# ``default=<saved value>`` silently re-injects the old value there, so
448+
# clearing never took: the pip-spec override kept re-installing the old
449+
# build and the field re-appeared populated on reopen. Pre-filling with
450+
# ``suggested_value`` (not ``default``) has no such re-injection, so the
451+
# cleared state sticks.
452+
clearable = {
453+
const.OPT_PIP_SPEC: "ha-mcp @ https://example/x.tgz",
454+
const.OPT_SERVER_URL: "https://ha.example:8123",
455+
const.OPT_EXTERNAL_URL: "https://ha.example.com",
456+
const.OPT_WEBHOOK_ID_OVERRIDE: "my_custom_hook",
457+
const.OPT_SECRET_PATH_OVERRIDE: "/custom_path",
458+
}
459+
for field in clearable:
460+
flow = _make_options_flow(
461+
data={const.DATA_WEBHOOK_ID: "mcp_abc"}, options=dict(clearable)
462+
)
463+
form = asyncio.run(flow.async_step_init(None))
464+
# The user cleared exactly one field; the frontend omits it and
465+
# submits the rest. Validate through the shown schema exactly as the
466+
# flow manager does, then hand the result to the step.
467+
submitted = {k: v for k, v in clearable.items() if k != field}
468+
validated = form["data_schema"](submitted)
469+
result = asyncio.run(flow.async_step_init(validated))
470+
assert result["data"].get(field, "") == "", (
471+
f"clearing {field!r} did not persist: {result['data'].get(field)!r}"
472+
)
395473

396474
def test_no_enable_toggle_option_exists(self):
397475
# Regression guard for the single-instance pivot: the enable/disable

0 commit comments

Comments
 (0)