Server._handle_message can loop forever re-logging warnings when a log handler itself emits a warning (v1.x) #3122
Description
Activity
- added a commit that references this issue
on Jul 17, 2026 I reproduced this on
v1.xate8283746and have a focused patch plus regression test ready locally. The patch moves warning re-logging outsidecatch_warnings, so warnings emitted by logging handlers cannot extend the captured list during iteration. The regression test fails before the change by re-logging the handler warning and passes after.Validation: 522 server tests passed; the full suite completed with 1152 passed, 95 skipped, and 1 expected xfail; Ruff and Pyright also pass.
If this approach fits the project, could you mark #3122 ready for work or assign it to me? I will submit against
v1.x.Nice, that matches what I saw when I filed this. Moving the re-log out of catch_warnings fixes the actual cause — the captured list can't grow while it's being iterated anymore.
FWIW the trigger on my end was a utcnow() DeprecationWarning raised inside a logging formatter, so every re-log re-triggered the handler and it spun until CI killed the job. Might be worth having the test cover a handler that warns every time it runs (not just once) — that's the worst case.
Patch sounds right to me, hope it gets picked up.
This one is specific to the 1.x line: the
catch_warningsre-log block in_handle_messagewas removed in the v2 receive-path rewrite, and v2 is now released, so it can't happen there. On 1.x it needs both analwayswarnings filter and a log handler that warns on every record, which makes it essentially a test-environment hang with a straightforward workaround (fix the formatter, or filter that warning), and 1.x is only taking critical fixes at this point. Feel free to reopen if you're hitting this in a production 1.x deployment where the workaround isn't viable.
Affects: mcp 1.x (verified 1.27.2 and 1.28.1); the block was removed in the
v2 rewrite (receive-path swap, PR #2710), so v2.0.0a2+ is not affected.
Summary
mcp/server/lowlevel/server.py::Server._handle_messagewraps message handlingin
warnings.catch_warnings(record=True)and then, still inside the samecatch_warningsblock, logs every recorded warning:If any logging handler/formatter attached to that logger (or the root logger)
itself raises a warning during
emit()— e.g. a JSON formatter that callsdatetime.utcnow(), which raisesDeprecationWarningon Python 3.12+ — thewarning is appended to
wwhilewis being iterated. Eachlogger.infothen produces one more list element, so the
forloop never terminates. Theloop is fully synchronous (no await points), so task cancellation cannot
interrupt it; under pytest-xdist this wedges the worker until an external
timeout kills the process (
[gwN] node down: Not properly terminated).Two preconditions, both common in test environments:
plugin runs every test under
simplefilter("always"), so once-per-locationdeduplication never kicks in;
per record (a
utcnow()-based timestamp formatter is the canonical case on3.12+).
Note
catch_warningsis also documented as thread/task-unsafe (CPythongh-91505 / gh-128384; context-aware only on 3.14 behind
-X context_aware_warnings), so withtg.start_soon(self._handle_message, ...)per message, interleaved enter/exit across tasks can additionally leak the
recording state — but the amplification above reproduces without any
concurrency.
Minimal reproduction (Python 3.12+, mcp 1.28.1)
Observed: the server's own
logger.info("Processing request of type ListToolsRequest")seeds the first recorded warning; the re-log loop thenself-amplifies (~50k log lines/sec in our measurements) and
list_tools()never completes.
Suggested fixes (any one suffices)
for warning in list(w): ...— bounds the loop(each pass logs the snapshot; new warnings belong to the next message).
catch_warningsblock.Context
Found while diagnosing a CI flake: pytest-xdist workers hung for the full
pytest-timeout budget (600 s) and died with
[gwN] node down, randomlydistributed across Python 3.12/3.13/3.14 matrix legs (3.10/3.11 immune — no
utcnowDeprecationWarning there). Post-mortem stacks always sat at thelogger.infoline of the warning re-log loop.