Skip to content

Commit 852361a

Browse files
swissmoclaude
andcommitted
fix: diagnose silent filter-install failures, dedup filters, address CI findings
- embedded_server.py's ImportError guard around the log-filter install was too broad: it silently swallowed ANY import failure, not just "older ha-mcp missing the module" (the intended case). Since third-party dependencies (fastmcp, pydantic) are deliberately NOT reinstalled per config entry (_purge_ha_mcp_modules only purges ha_mcp.* modules), a version mismatch there could break the import invisibly -- no crash, no log, the filters just silently never install. Narrow the tolerated case to ModuleNotFoundError for exactly "ha_mcp.log_filters", and log a WARNING for anything else (including a failure from install_sdk_log_filters() itself), so this class of failure is never invisible again. - install_sdk_log_filters() had no dedup guard: the in-process embedded server calls it on every _serve() reload without a process restart, so filters accumulated one more redundant instance per reload (CodeRabbit). Add _add_filter_once() and a regression test that calls the installer twice and asserts exactly one filter of each type. - Narrow a test helper's `except BaseException` to `except Exception` (CodeQL py/catch-base-exception): the child coroutines it runs only ever raise Exception subclasses. - Add docstrings to functions/methods touched by this change that lacked one (log_filters.py's three filter() methods, new/modified test methods and fixtures in test_session_disconnect_log_filter.py and test_embedded_server.py). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 7f93c77 commit 852361a

5 files changed

Lines changed: 142 additions & 32 deletions

File tree

custom_components/ha_mcp_tools/embedded_server.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,40 @@ def _install_log_filters_if_available() -> None:
251251
site: the installed server version is user-controlled (channel choice,
252252
pip-spec override), so an older ha-mcp without ``ha_mcp.log_filters`` must
253253
keep serving -- the filters are simply absent there, as they are today.
254+
255+
Only a ``ModuleNotFoundError`` for exactly ``ha_mcp.log_filters`` is that
256+
"older ha-mcp" case and is swallowed silently. Anything else -- a version
257+
mismatch in a third-party dependency this import touches (fastmcp,
258+
pydantic), which ``_purge_ha_mcp_modules`` deliberately does NOT
259+
reinstall per config entry, so a stale one left over from a previous
260+
install can break this import -- is logged instead of silently doing
261+
nothing: log cosmetics must never block startup, but they also must
262+
never fail invisibly.
254263
"""
255264
try:
256265
from ha_mcp.log_filters import install_sdk_log_filters
257-
except ImportError:
258-
# Older installed ha-mcp: no filter helper to install.
266+
except ModuleNotFoundError as err:
267+
if err.name != "ha_mcp.log_filters":
268+
_LOGGER.warning(
269+
"Could not install MCP SDK log-noise filters (missing "
270+
"dependency %s); continuing without them: %s",
271+
err.name,
272+
err,
273+
)
259274
return
260-
install_sdk_log_filters()
275+
except ImportError as err:
276+
_LOGGER.warning(
277+
"Could not install MCP SDK log-noise filters; continuing without them: %s",
278+
err,
279+
)
280+
return
281+
try:
282+
install_sdk_log_filters()
283+
except Exception as err:
284+
_LOGGER.warning(
285+
"Could not install MCP SDK log-noise filters; continuing without them: %s",
286+
err,
287+
)
261288

262289

263290
class EmbeddedServerManager:

src/ha_mcp/log_filters.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class StatelessSessionLogFilter(logging.Filter):
4040
"""
4141

4242
def filter(self, record: logging.LogRecord) -> bool:
43+
"""Drop the routine stateless-teardown record; pass everything else."""
4344
if record.name != "mcp.server.streamable_http":
4445
return True
4546
try:
@@ -63,6 +64,7 @@ class ToolValidationLogFilter(logging.Filter):
6364
"""
6465

6566
def filter(self, record: logging.LogRecord) -> bool:
67+
"""Demote a known-benign validation/tool-error record to WARNING."""
6668
if record.name != "fastmcp.server.server" or not record.exc_info:
6769
return True
6870

@@ -128,6 +130,7 @@ class SessionDisconnectLogFilter(logging.Filter):
128130
"""
129131

130132
def filter(self, record: logging.LogRecord) -> bool:
133+
"""Demote a disconnect-caused 'session crashed' record to WARNING."""
131134
if record.name != "mcp.server.streamable_http_manager" or not record.exc_info:
132135
return True
133136

@@ -146,19 +149,30 @@ def filter(self, record: logging.LogRecord) -> bool:
146149
return True
147150

148151

152+
def _add_filter_once(logger_name: str, filter_cls: type[logging.Filter]) -> None:
153+
"""Attach one ``filter_cls`` instance to ``logger_name``, unless already present.
154+
155+
``install_sdk_log_filters()`` can run more than once per process: the
156+
in-process embedded server calls it on every ``_serve()`` reload without
157+
a process restart, and process-wide ``logging`` state (including each
158+
named logger's filter list) persists across those reloads. Without this
159+
guard, filters would accumulate one more redundant instance per reload.
160+
"""
161+
logger = logging.getLogger(logger_name)
162+
if any(isinstance(f, filter_cls) for f in logger.filters):
163+
return
164+
logger.addFilter(filter_cls())
165+
166+
149167
def install_sdk_log_filters() -> None:
150168
"""Attach the demotion filters above to their target SDK/fastmcp loggers.
151169
152170
Every HTTP launcher must call this: the CLI (``ha_mcp.__main__``), the
153171
Home Assistant app's ``start.py``, and the in-process embedded server
154172
(``ha_mcp_tools/embedded_server.py``) each build and run their own
155173
Streamable HTTP app, so none of them share another launcher's logging
156-
setup.
174+
setup. Safe to call repeatedly -- see ``_add_filter_once``.
157175
"""
158-
logging.getLogger("mcp.server.streamable_http").addFilter(
159-
StatelessSessionLogFilter()
160-
)
161-
logging.getLogger("mcp.server.streamable_http_manager").addFilter(
162-
SessionDisconnectLogFilter()
163-
)
164-
logging.getLogger("fastmcp.server.server").addFilter(ToolValidationLogFilter())
176+
_add_filter_once("mcp.server.streamable_http", StatelessSessionLogFilter)
177+
_add_filter_once("mcp.server.streamable_http_manager", SessionDisconnectLogFilter)
178+
_add_filter_once("fastmcp.server.server", ToolValidationLogFilter)

tests/src/unit/test_embedded_server.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2629,6 +2629,7 @@ class TestServeLogFilters:
26292629

26302630
@pytest.fixture(autouse=True)
26312631
def _isolate_env(self):
2632+
"""Snapshot/restore the env vars _thread_main stages, per-test."""
26322633
keys = ("HA_MCP_CONFIG_DIR", "HA_MCP_EMBEDDED")
26332634
saved = {k: os.environ.get(k) for k in keys}
26342635
for key in keys:
@@ -2641,6 +2642,7 @@ def _isolate_env(self):
26412642
os.environ[key] = value
26422643

26432644
def test_serve_installs_log_filters(self, tmp_path, monkeypatch):
2645+
"""_serve calls install_sdk_log_filters() when it is available."""
26442646
mgr, _hass, _entry = _manager(
26452647
tmp_path, options={OPT_SERVER_URL: "http://ha.local:8123"}
26462648
)
@@ -2664,9 +2666,10 @@ def http_app(self, path, stateless_http):
26642666
assert isinstance(mgr._thread_exc, _StopServe)
26652667

26662668
def test_serve_tolerates_missing_log_filters_module(self, tmp_path, monkeypatch):
2667-
# Backward-compat: an OLDER bundled ha-mcp (the component reaches users
2668-
# ahead of the server) has no log_filters module. _serve must swallow
2669-
# the ImportError and keep serving, same as the browser-landing guard.
2669+
"""Backward-compat: an OLDER bundled ha-mcp (the component reaches
2670+
users ahead of the server) has no log_filters module. _serve must
2671+
swallow the ImportError and keep serving, same as the
2672+
browser-landing guard."""
26702673
mgr, _hass, _entry = _manager(
26712674
tmp_path, options={OPT_SERVER_URL: "http://ha.local:8123"}
26722675
)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Unit tests for install_sdk_log_filters()'s idempotency."""
2+
3+
import logging
4+
5+
from ha_mcp.log_filters import (
6+
SessionDisconnectLogFilter,
7+
StatelessSessionLogFilter,
8+
ToolValidationLogFilter,
9+
install_sdk_log_filters,
10+
)
11+
12+
_TARGET_LOGGERS = {
13+
"mcp.server.streamable_http": StatelessSessionLogFilter,
14+
"mcp.server.streamable_http_manager": SessionDisconnectLogFilter,
15+
"fastmcp.server.server": ToolValidationLogFilter,
16+
}
17+
18+
19+
class TestInstallSdkLogFiltersIdempotent:
20+
"""The in-process embedded server calls this on every reload without a
21+
process restart, so process-wide logger filter lists persist across
22+
calls -- a second call must not add a second instance of each filter."""
23+
24+
def setup_method(self):
25+
"""Snapshot each target logger's current filter list."""
26+
self._saved = {
27+
name: logging.getLogger(name).filters[:] for name in _TARGET_LOGGERS
28+
}
29+
30+
def teardown_method(self):
31+
"""Restore each target logger's filter list, undoing this test's calls."""
32+
for name, filters in self._saved.items():
33+
logging.getLogger(name).filters[:] = filters
34+
35+
def test_second_call_does_not_duplicate_filters(self):
36+
"""Calling the installer twice must not attach a filter twice."""
37+
install_sdk_log_filters()
38+
install_sdk_log_filters()
39+
40+
for name, filter_cls in _TARGET_LOGGERS.items():
41+
matches = [
42+
f for f in logging.getLogger(name).filters if isinstance(f, filter_cls)
43+
]
44+
assert len(matches) == 1, (
45+
f"{name} must carry exactly one {filter_cls.__name__}, got {len(matches)}"
46+
)

tests/src/unit/test_session_disconnect_log_filter.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212

1313

1414
async def _raise_closed_resource_error() -> None:
15+
"""Raise the exact exception the disconnect race produces."""
1516
raise anyio.ClosedResourceError()
1617

1718

1819
async def _raise_runtime_error() -> None:
20+
"""Raise an unrelated failure, standing in for a real server bug."""
1921
raise RuntimeError("real bug")
2022

2123

22-
async def _run_in_task_group(*coro_funcs) -> BaseException:
24+
async def _run_in_task_group(*coro_funcs) -> Exception:
2325
"""Run each of ``coro_funcs`` as a task-group child and return the raised
2426
exception -- reproducing the actual shape mcp.server.lowlevel.server.Server.run()
2527
produces: it dispatches each incoming message via
@@ -28,12 +30,17 @@ async def _run_in_task_group(*coro_funcs) -> BaseException:
2830
child. anyio always wraps that in an ExceptionGroup, even for a single
2931
failure -- a hand-built ``exc_info`` with a bare exception does not
3032
reproduce that boundary.
33+
34+
Narrowed to ``Exception`` (not ``BaseException``): the child coroutines
35+
here only ever raise ``Exception`` subclasses, so anyio's task group
36+
wraps them in ``ExceptionGroup`` (an ``Exception``), never
37+
``BaseExceptionGroup``-only content like a cancellation.
3138
"""
3239
try:
3340
async with anyio.create_task_group() as tg:
3441
for coro_func in coro_funcs:
3542
tg.start_soon(coro_func)
36-
except BaseException as exc:
43+
except Exception as exc:
3744
return exc
3845
raise AssertionError("task group did not raise")
3946

@@ -42,6 +49,7 @@ class TestSessionDisconnectLogFilter:
4249
"""Verify the filter demotes disconnect-caused 'session crashed' tracebacks."""
4350

4451
def setup_method(self):
52+
"""Create a fresh filter instance for each test."""
4553
self.log_filter = SessionDisconnectLogFilter()
4654

4755
def _make_record(
@@ -50,6 +58,7 @@ def _make_record(
5058
msg: str,
5159
exc: BaseException | None,
5260
) -> logging.LogRecord:
61+
"""Build a bare LogRecord carrying ``exc`` as its exc_info, if given."""
5362
exc_info = (type(exc), exc, None) if exc is not None else None
5463
return logging.LogRecord(
5564
name=name,
@@ -62,8 +71,8 @@ def _make_record(
6271
)
6372

6473
def test_demotes_stateless_session_crash_from_closed_resource_error(self):
65-
# Exactly what mcp/server/streamable_http_manager.py's
66-
# _handle_stateless_request logs when the client already disconnected.
74+
"""Exactly what mcp/server/streamable_http_manager.py's
75+
_handle_stateless_request logs when the client already disconnected."""
6776
err = anyio.ClosedResourceError()
6877
record = self._make_record(
6978
"mcp.server.streamable_http_manager",
@@ -79,8 +88,8 @@ def test_demotes_stateless_session_crash_from_closed_resource_error(self):
7988
assert "Stateless session crashed" in record.getMessage()
8089

8190
def test_demotes_stateful_session_crash_from_closed_resource_error(self):
82-
# The stateful runner's equivalent log line -- same race, same fix,
83-
# even though every HTTP entry point currently forces stateless_http.
91+
"""The stateful runner's equivalent log line -- same race, same fix,
92+
even though every HTTP entry point currently forces stateless_http."""
8493
err = anyio.ClosedResourceError()
8594
record = self._make_record(
8695
"mcp.server.streamable_http_manager",
@@ -92,10 +101,10 @@ def test_demotes_stateful_session_crash_from_closed_resource_error(self):
92101
assert record.exc_info is None
93102

94103
async def test_demotes_real_task_group_exception_group(self):
95-
# The shape actually logged in production: mcp.server.lowlevel.server
96-
# dispatches message handling via anyio.create_task_group().start_soon,
97-
# so a ClosedResourceError from _send_response arrives here wrapped in
98-
# an ExceptionGroup, not as a bare exception.
104+
"""The shape actually logged in production: mcp.server.lowlevel.server
105+
dispatches message handling via anyio.create_task_group().start_soon,
106+
so a ClosedResourceError from _send_response arrives here wrapped in
107+
an ExceptionGroup, not as a bare exception."""
99108
caught = await _run_in_task_group(_raise_closed_resource_error)
100109
assert isinstance(caught, BaseExceptionGroup)
101110

@@ -110,9 +119,9 @@ async def test_demotes_real_task_group_exception_group(self):
110119
assert "client disconnected before response delivery" in record.getMessage()
111120

112121
async def test_leaves_mixed_exception_group_at_error(self):
113-
# A task group with one ClosedResourceError AND one unrelated failure
114-
# signals a real problem alongside the expected disconnect race -- the
115-
# whole record must stay at ERROR with its traceback intact.
122+
"""A task group with one ClosedResourceError AND one unrelated failure
123+
signals a real problem alongside the expected disconnect race -- the
124+
whole record must stay at ERROR with its traceback intact."""
116125
caught = await _run_in_task_group(
117126
_raise_closed_resource_error, _raise_runtime_error
118127
)
@@ -129,8 +138,8 @@ async def test_leaves_mixed_exception_group_at_error(self):
129138
assert record.exc_info is original_exc_info
130139

131140
def test_passes_bare_exception_through_untouched(self):
132-
# An actual server bug on this logger must keep its traceback and
133-
# ERROR level -- only the known-benign disconnect race is demoted.
141+
"""An actual server bug on this logger must keep its traceback and
142+
ERROR level -- only the known-benign disconnect race is demoted."""
134143
err = RuntimeError("server bug")
135144
record = self._make_record(
136145
"mcp.server.streamable_http_manager",
@@ -143,6 +152,7 @@ def test_passes_bare_exception_through_untouched(self):
143152
assert record.exc_info is original_exc_info
144153

145154
def test_leaves_other_loggers_unchanged(self):
155+
"""Only the SDK's session-manager logger is ever touched."""
146156
err = anyio.ClosedResourceError()
147157
record = self._make_record(
148158
"some.other.logger",
@@ -154,6 +164,7 @@ def test_leaves_other_loggers_unchanged(self):
154164
assert record.exc_info is not None
155165

156166
def test_passes_record_without_exc_info(self):
167+
"""A record with no attached exception is never touched."""
157168
record = self._make_record(
158169
"mcp.server.streamable_http_manager",
159170
"Stateless session crashed",
@@ -167,33 +178,42 @@ class TestIsOnlyClosedResourceErrors:
167178
"""Direct coverage of the recursive classifier the filter relies on."""
168179

169180
def test_bare_closed_resource_error(self):
181+
"""A bare ClosedResourceError matches on its own."""
170182
assert _is_only_closed_resource_errors(anyio.ClosedResourceError()) is True
171183

172184
def test_bare_other_exception(self):
185+
"""An unrelated bare exception never matches."""
173186
assert _is_only_closed_resource_errors(RuntimeError("x")) is False
174187

175188
def test_group_of_one_closed_resource_error(self):
189+
"""A single-item group wrapping just the known-benign exception matches."""
176190
group = ExceptionGroup("eg", [anyio.ClosedResourceError()])
177191
assert _is_only_closed_resource_errors(group) is True
178192

179193
def test_nested_group_of_closed_resource_errors(self):
194+
"""Nested groups are unwrapped recursively, matching all-benign leaves."""
180195
inner = ExceptionGroup("inner", [anyio.ClosedResourceError()])
181196
outer = ExceptionGroup("outer", [inner, anyio.ClosedResourceError()])
182197
assert _is_only_closed_resource_errors(outer) is True
183198

184199
def test_mixed_group_is_rejected(self):
200+
"""A group with even one non-benign leaf must not match."""
185201
group = ExceptionGroup("eg", [anyio.ClosedResourceError(), RuntimeError("x")])
186202
assert _is_only_closed_resource_errors(group) is False
187203

188204
def test_empty_group_is_rejected(self):
189-
# Defensive: an ExceptionGroup always carries at least one exception
190-
# in practice, but `all([])` is vacuously True -- guard against ever
191-
# demoting on a group with nothing in it.
205+
"""Defensive: an ExceptionGroup always carries at least one exception
206+
in practice, but `all([])` is vacuously True -- guard against ever
207+
demoting on a group with nothing in it. ExceptionGroup itself refuses
208+
to construct empty, so this pins that guarantee rather than exercising
209+
the classifier's own guard directly."""
192210
with pytest.raises(ValueError):
193211
ExceptionGroup("empty", [])
194212

195213

196214
class TestSessionDisconnectLogFilterWiring:
215+
"""End-to-end: _setup_logging wires the filter onto real logger output."""
216+
197217
def test_setup_logging_wires_filter_and_demotes_output(self, monkeypatch):
198218
"""Integration: ``_setup_logging`` attaches the filter to the SDK's
199219
session-manager logger, so a real ``ClosedResourceError``-caused

0 commit comments

Comments
 (0)