Skip to content

Commit 0cfc6e1

Browse files
swissmoclaude
andcommitted
fix: demote disconnect-caused stateless session crash logs
Every HTTP entry point runs Streamable HTTP in stateless mode. When a tool call outlives the client's patience, the mcp SDK's session manager already catches the resulting anyio.ClosedResourceError (the client is gone, the response can't be delivered) but logs it as an alarming ERROR-level traceback under "Stateless session crashed" -- an expected protocol race, not a server bug. Add SessionDisconnectLogFilter, following the existing StatelessSessionLogFilter/ToolValidationLogFilter pattern, to demote only this specific known-benign case to a one-line WARNING; any other exception on that logger keeps its full ERROR traceback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 45824cd commit 0cfc6e1

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

src/ha_mcp/__main__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from collections.abc import Coroutine # noqa: E402
3434
from typing import TYPE_CHECKING, Any, NoReturn # noqa: E402
3535

36+
import anyio # noqa: E402
3637
from fastmcp.exceptions import ToolError # noqa: E402
3738
from pydantic import ValidationError as PydanticValidationError # noqa: E402
3839

@@ -446,6 +447,43 @@ def filter(self, record: logging.LogRecord) -> bool:
446447
return True
447448

448449

450+
class SessionDisconnectLogFilter(logging.Filter):
451+
"""Demote 'session crashed' tracebacks caused by an already-gone client.
452+
453+
Every HTTP entry point runs Streamable HTTP in stateless mode (see
454+
``_http_run_kwargs``). A tool call slow enough to outlast the client's
455+
patience -- a busy Home Assistant instance, a resource-contended local
456+
LLM host on the client side, or an ordinary HTTP timeout -- lets the SDK
457+
finish serving the response and tear the transport down while
458+
``app.run()`` is still working; the eventual attempt to deliver the
459+
response then writes into an already-closed memory stream and raises
460+
``anyio.ClosedResourceError``. The SDK already catches this (``except
461+
Exception: logger.exception(...)`` in both the stateless and stateful
462+
session runners of mcp/server/streamable_http_manager.py) -- it just logs
463+
it as an alarming ERROR-level traceback. That's an expected race in a
464+
stateless HTTP protocol (the client already gave up), not a server bug,
465+
so demote it the same way ToolValidationLogFilter demotes other
466+
known-benign failures. Any other exception on this logger -- an actual
467+
crash -- is left untouched.
468+
"""
469+
470+
def filter(self, record: logging.LogRecord) -> bool:
471+
if record.name != "mcp.server.streamable_http_manager" or not record.exc_info:
472+
return True
473+
474+
err = record.exc_info[1]
475+
if not isinstance(err, anyio.ClosedResourceError):
476+
return True
477+
478+
record.msg = f"{record.getMessage()}: client disconnected before response delivery"
479+
record.args = ()
480+
record.levelno = logging.WARNING
481+
record.levelname = "WARNING"
482+
record.exc_info = None
483+
record.exc_text = None
484+
return True
485+
486+
449487
class ProbeAccessLogFilter(logging.Filter):
450488
"""Drop benign, non-MCP HTTP probe noise from the uvicorn access log.
451489
@@ -531,6 +569,9 @@ def _setup_logging(log_level_str: str, force: bool = True) -> None:
531569
logging.getLogger("mcp.server.streamable_http").addFilter(
532570
StatelessSessionLogFilter()
533571
)
572+
logging.getLogger("mcp.server.streamable_http_manager").addFilter(
573+
SessionDisconnectLogFilter()
574+
)
534575
logging.getLogger("fastmcp.server.server").addFilter(ToolValidationLogFilter())
535576

536577

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Unit tests for SessionDisconnectLogFilter."""
2+
3+
import logging
4+
5+
import anyio
6+
7+
from ha_mcp.__main__ import SessionDisconnectLogFilter
8+
9+
10+
class TestSessionDisconnectLogFilter:
11+
"""Verify the filter demotes disconnect-caused 'session crashed' tracebacks."""
12+
13+
def setup_method(self):
14+
self.log_filter = SessionDisconnectLogFilter()
15+
16+
def _make_record(
17+
self,
18+
name: str,
19+
msg: str,
20+
exc: BaseException | None,
21+
) -> logging.LogRecord:
22+
exc_info = (type(exc), exc, None) if exc is not None else None
23+
return logging.LogRecord(
24+
name=name,
25+
level=logging.ERROR,
26+
pathname="",
27+
lineno=0,
28+
msg=msg,
29+
args=(),
30+
exc_info=exc_info,
31+
)
32+
33+
def test_demotes_stateless_session_crash_from_closed_resource_error(self):
34+
# Exactly what mcp/server/streamable_http_manager.py's
35+
# _handle_stateless_request logs when the client already disconnected.
36+
err = anyio.ClosedResourceError()
37+
record = self._make_record(
38+
"mcp.server.streamable_http_manager",
39+
"Stateless session crashed",
40+
err,
41+
)
42+
assert self.log_filter.filter(record) is True
43+
assert record.levelno == logging.WARNING
44+
assert record.levelname == "WARNING"
45+
assert record.exc_info is None
46+
assert record.exc_text is None
47+
assert "client disconnected before response delivery" in record.getMessage()
48+
assert "Stateless session crashed" in record.getMessage()
49+
50+
def test_demotes_stateful_session_crash_from_closed_resource_error(self):
51+
# The stateful runner's equivalent log line -- same race, same fix,
52+
# even though every HTTP entry point currently forces stateless_http.
53+
err = anyio.ClosedResourceError()
54+
record = self._make_record(
55+
"mcp.server.streamable_http_manager",
56+
"Session abc123 crashed",
57+
err,
58+
)
59+
assert self.log_filter.filter(record) is True
60+
assert record.levelno == logging.WARNING
61+
assert record.exc_info is None
62+
63+
def test_passes_bare_exception_through_untouched(self):
64+
# An actual server bug on this logger must keep its traceback and
65+
# ERROR level -- only the known-benign disconnect race is demoted.
66+
err = RuntimeError("server bug")
67+
record = self._make_record(
68+
"mcp.server.streamable_http_manager",
69+
"Stateless session crashed",
70+
err,
71+
)
72+
original_exc_info = record.exc_info
73+
assert self.log_filter.filter(record) is True
74+
assert record.levelno == logging.ERROR
75+
assert record.exc_info is original_exc_info
76+
77+
def test_leaves_other_loggers_unchanged(self):
78+
err = anyio.ClosedResourceError()
79+
record = self._make_record(
80+
"some.other.logger",
81+
"Stateless session crashed",
82+
err,
83+
)
84+
assert self.log_filter.filter(record) is True
85+
assert record.levelno == logging.ERROR
86+
assert record.exc_info is not None
87+
88+
def test_passes_record_without_exc_info(self):
89+
record = self._make_record(
90+
"mcp.server.streamable_http_manager",
91+
"Stateless session crashed",
92+
None,
93+
)
94+
assert self.log_filter.filter(record) is True
95+
assert record.levelno == logging.ERROR
96+
97+
def test_setup_logging_wires_filter_and_demotes_output(self, monkeypatch):
98+
"""Integration: ``_setup_logging`` attaches the filter to the SDK's
99+
session-manager logger, so a real ``ClosedResourceError``-caused
100+
'Stateless session crashed' entry loses its traceback and ERROR level
101+
in actual output, while an unrelated exception on the same logger
102+
still comes through as a full ERROR traceback.
103+
104+
``logging.basicConfig`` is stubbed to a no-op for the same reason as
105+
the sibling ``StatelessSessionLogFilter`` wiring test: its real
106+
``basicConfig(force=True)`` would tear down and *close* the root
107+
logger's handlers, including pytest's capture handlers.
108+
"""
109+
import io
110+
111+
from ha_mcp import __main__ as ha_main
112+
113+
sdk_logger = logging.getLogger("mcp.server.streamable_http_manager")
114+
# _setup_logging also attaches filters to sibling loggers; save/restore
115+
# them too or they leak into other tests.
116+
stateless_logger = logging.getLogger("mcp.server.streamable_http")
117+
fastmcp_logger = logging.getLogger("fastmcp.server.server")
118+
saved_sdk_filters = sdk_logger.filters[:]
119+
saved_stateless_filters = stateless_logger.filters[:]
120+
saved_fastmcp_filters = fastmcp_logger.filters[:]
121+
saved_propagate = sdk_logger.propagate
122+
saved_level = sdk_logger.level
123+
monkeypatch.setattr(ha_main.logging, "basicConfig", lambda *a, **k: None)
124+
try:
125+
ha_main._setup_logging("INFO", force=True)
126+
assert any(
127+
isinstance(f, SessionDisconnectLogFilter) for f in sdk_logger.filters
128+
), "_setup_logging must attach SessionDisconnectLogFilter to the SDK's session-manager logger"
129+
130+
disconnect_buf = io.StringIO()
131+
handler = logging.StreamHandler(disconnect_buf)
132+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
133+
sdk_logger.addHandler(handler)
134+
sdk_logger.setLevel(logging.INFO)
135+
sdk_logger.propagate = False
136+
try:
137+
try:
138+
raise anyio.ClosedResourceError()
139+
except anyio.ClosedResourceError:
140+
sdk_logger.exception("Stateless session crashed")
141+
finally:
142+
sdk_logger.removeHandler(handler)
143+
144+
disconnect_out = disconnect_buf.getvalue()
145+
assert "client disconnected before response delivery" in disconnect_out
146+
assert "WARNING" in disconnect_out
147+
assert "Traceback" not in disconnect_out, (
148+
"the ClosedResourceError-caused crash must lose its traceback"
149+
)
150+
151+
bug_buf = io.StringIO()
152+
handler = logging.StreamHandler(bug_buf)
153+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
154+
sdk_logger.addHandler(handler)
155+
try:
156+
try:
157+
raise RuntimeError("real bug")
158+
except RuntimeError:
159+
sdk_logger.exception("Stateless session crashed")
160+
finally:
161+
sdk_logger.removeHandler(handler)
162+
163+
bug_out = bug_buf.getvalue()
164+
assert "real bug" in bug_out
165+
assert "Traceback" in bug_out, (
166+
"an unrelated exception on this logger must keep its traceback"
167+
)
168+
finally:
169+
sdk_logger.filters[:] = saved_sdk_filters
170+
stateless_logger.filters[:] = saved_stateless_filters
171+
fastmcp_logger.filters[:] = saved_fastmcp_filters
172+
sdk_logger.propagate = saved_propagate
173+
sdk_logger.setLevel(saved_level)

tests/src/unit/test_standard_mode_logging.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def _standard_mode_logging_state():
6060
(logger, logger.filters[:])
6161
for logger in (
6262
logging.getLogger("mcp.server.streamable_http"),
63+
logging.getLogger("mcp.server.streamable_http_manager"),
6364
logging.getLogger("fastmcp.server.server"),
6465
)
6566
]

0 commit comments

Comments
 (0)