Skip to content

Commit 767bd17

Browse files
fix: standard-mode console logging + relay-stub upstream contract pins (#2039)
* fix: make standard-mode console logging effective; pin relay-timeout upstream contracts usage_logger's StartupLogCollector attaches to the root logger at import time, so _setup_logging's basicConfig was a no-op in ha-mcp-web/stdio modes (root stayed at WARNING with no console handler — INFO lines like the annotated 405 probe never surfaced). force=True is now the default, wrapped in preserve_startup_collector() so the collector survives the force sweep un-closed — which also repairs OAuth/OIDC modes, whose explicit force=True has been silently destroying ha_report_issue's startup-log capture on every start. Pin the upstream facts the relay timeout tests rely on: real aiohttp (new dev dependency, floored at HA 2026.6's pin) is compared field-by- field against both ClientTimeout stubs in a pristine subprocess, and sse-starlette's 15 s keepalive default plus the MCP SDK's omission of a ping override are asserted so a drift fails loud instead of leaving tests green while production moves. Also: FAQ migration note for the removed ha-mcp-sse entry point, and component escalated to 1.3.0 (pending minor per AGENTS.md — the merged RFC 9207 iss feature warrants more than a patch). Closes #2031. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * test: pin the force=True default with a foreign-root-handler case Patch76's review: the collector-only test state let a force=False flip stay green (the wrapper empties the handler list, so basicConfig acts regardless), and the docstring blamed the collector for a no-op the wrapper already prevents. The new case installs a non-collector root handler first — red on a default flip — and the docstring now states what force actually guards: foreign root handlers and repeat reconfiguration. Verified by mutation both ways. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * docs: correct the force-default docstring against the call graph Patch76's residual: OAuth/OIDC pass force=True explicitly and never exercise the default; the default's users are exactly the post-settings standard paths, and no entry point reconfigures twice. The docstring now says what the suite proves — foreign handlers from a test runner or dependency — and notes the default is defensive at today's call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 54f4d5a commit 767bd17

10 files changed

Lines changed: 959 additions & 11 deletions

File tree

custom_components/ha_mcp_tools/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
# in CI. The
2626
# capability negotiation — not this version — gates each WS command (see
2727
# ``websocket_api.CAPABILITIES``).
28-
COMPONENT_VERSION = "1.2.4"
28+
COMPONENT_VERSION = "1.3.0"
2929

3030
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
3131
# means "tools" so the pre-existing services entry keeps working across the

custom_components/ha_mcp_tools/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@
2222
"requirements": [
2323
"ruamel.yaml>=0.18.0"
2424
],
25-
"version": "1.2.4"
25+
"version": "1.3.0"
2626
}

docs/FAQ.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ The demo environment resets weekly. Your changes won't persist.
4646

4747
## Troubleshooting
4848

49+
### `ha-mcp-sse` is gone / my SSE config stopped connecting
50+
51+
The legacy `ha-mcp-sse` entry point (deprecated HTTP+SSE transport on port 8087)
52+
was removed — the MCP specification deprecated the HTTP+SSE transport. Switch to
53+
`ha-mcp-web`, which serves Streamable HTTP at `/mcp` on port 8086, and change any
54+
SSE-style client config accordingly (Gemini CLI users: use the `httpUrl` key, not
55+
`url`). An SSE / `--transport sse` client config pointed at `ha-mcp-web` returns
56+
`405` and won't connect.
57+
4958
### OAuth stopped working after upgrading to v7.0.0
5059

5160
v7.0.0 removed the Home Assistant URL field from the OAuth consent form to fix security vulnerabilities (SSRF and XSS). Set `HOMEASSISTANT_URL` as a server-side environment variable before starting ha-mcp. See the [OAuth migration guide](OAUTH.md#migrating-from-v6x) for instructions.

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,13 @@ asyncio_mode = "auto"
198198

199199
[dependency-groups]
200200
dev = [
201+
# Real aiohttp so tests can pin the ClientTimeout defaults the custom
202+
# component's relay and the webhook-proxy addon rely on (both are stubbed
203+
# in unit tests; nothing else would catch stub drift). NOT a runtime
204+
# dependency — src/ never imports aiohttp; HA core provides it in-process.
205+
# Floor = the aiohttp HA core 2026.6 pins (2026.7 pins 3.14.1, 2026.8.dev
206+
# pins 3.14.3), so the version tested here is one a supported HA runs.
207+
"aiohttp>=3.13.5",
201208
"build>=1.2.2",
202209
"docker>=7.1.0",
203210
"httpx2>=2.4.0",

src/ha_mcp/__main__.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -478,14 +478,36 @@ def filter(self, record: logging.LogRecord) -> bool:
478478
return not (status == 405 and path == self._mcp_path)
479479

480480

481-
def _setup_logging(log_level_str: str, force: bool = False) -> None:
482-
"""Configure root logger with consistent timestamp format."""
483-
logging.basicConfig(
484-
level=getattr(logging, log_level_str),
485-
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
486-
datefmt=_LOG_DATE_FORMAT,
487-
force=force,
488-
)
481+
def _setup_logging(log_level_str: str, force: bool = True) -> None:
482+
"""Configure root logger with consistent timestamp format.
483+
484+
``force`` defaults to True so the reconfiguration is deterministic: with
485+
ANY root handler present, ``basicConfig`` is a silent no-op — neither the
486+
console handler nor the level is applied. Since
487+
``preserve_startup_collector`` detaches the collector before
488+
``basicConfig`` runs, the default guards against FOREIGN root handlers —
489+
a test runner's or a dependency's. At the two call sites that use the
490+
default (``main`` and ``_setup_standard_mode``) the only root handler
491+
attached anywhere in ``src/`` is the collector, which the wrapper detaches
492+
first — so today the default is defensive rather than load-bearing. The
493+
OAuth/OIDC entry points pass ``force=True`` explicitly and never exercise
494+
the default.
495+
496+
The historical standard-mode bug was ``usage_logger``'s
497+
``StartupLogCollector`` (attached to root at import time) triggering that
498+
same no-op; the wrapper now detaches it for the duration — keeping it out
499+
of the sweep ``force`` performs (which removes *and closes* every root
500+
handler) so ``ha_report_issue`` keeps its startup diagnostics.
501+
"""
502+
from ha_mcp.utils.usage_logger import preserve_startup_collector
503+
504+
with preserve_startup_collector():
505+
logging.basicConfig(
506+
level=getattr(logging, log_level_str),
507+
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
508+
datefmt=_LOG_DATE_FORMAT,
509+
force=force,
510+
)
489511
logging.getLogger("mcp.server.streamable_http").addFilter(
490512
StatelessSessionLogFilter()
491513
)

src/ha_mcp/utils/usage_logger.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import threading
88
import time
99
from collections import deque
10+
from collections.abc import Iterator
11+
from contextlib import contextmanager
1012
from dataclasses import asdict, dataclass
1113
from datetime import UTC, datetime
1214
from pathlib import Path
@@ -94,6 +96,30 @@ def get_startup_logs() -> list[dict[str, Any]]:
9496
return _startup_collector.get_logs()
9597

9698

99+
@contextmanager
100+
def preserve_startup_collector() -> Iterator[None]:
101+
"""Keep the startup collector attached across a logging reconfiguration.
102+
103+
``logging.basicConfig(force=True)`` removes *and closes* every handler on
104+
the root logger. The collector is attached there at import time and is the
105+
source of ``ha_report_issue``'s startup diagnostics, so it is detached for
106+
the duration of the block — leaving nothing for the sweep to close — and
107+
re-attached afterwards.
108+
"""
109+
collector = _startup_collector
110+
root = logging.getLogger()
111+
if collector is None or collector not in root.handlers:
112+
yield
113+
return
114+
115+
root.removeHandler(collector)
116+
try:
117+
yield
118+
finally:
119+
if collector not in root.handlers:
120+
root.addHandler(collector)
121+
122+
97123
# Parameter keys whose values are masked before a tool call is recorded to the
98124
# in-memory ring buffer or persisted to disk. Tool kwargs can carry secrets
99125
# (alarm/lock ``code``, tokens, passwords); these must never reach the usage

tests/src/unit/test_component_ws_search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ def test_manifest_version_parity(self):
779779
_REPO_ROOT / "custom_components" / "ha_mcp_tools" / "manifest.json"
780780
).read_text(encoding="utf-8")
781781
)
782-
assert manifest["version"] == COMPONENT_VERSION == "1.2.4"
782+
assert manifest["version"] == COMPONENT_VERSION == "1.3.0"
783783

784784

785785
# =============================================================================

0 commit comments

Comments
 (0)