From 997c92362c2dac937b744f42cbce639e7343a7b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Jul 2026 17:25:26 -0500 Subject: [PATCH 1/2] mcp(fix[wait]): Stop using the deprecated MCP Logging capability why: MCP deprecated the protocol-level Logging capability in SEP-2577, effective revision 2026-07-28, naming stderr as the migration path for stdio servers. wait_for_text was the only place this server exercised it -- three ctx.warning calls reaching the wire as notifications/message. The `logging` capability itself is advertised by FastMCP's handshake, not by us; these three call sites are the only part that is ours to remove. Note FastMCP will not flag this for us. In 4.0.0a2 ctx.sample() emits a warn-once FastMCPDeprecationWarning, but the logging path carries only a static `# ty: ignore[deprecated]` on send_log_message and warns nothing at runtime, so an SDK upgrade would keep this working silently until the multi-round-trip follow-up removes it. what: - Drop _maybe_log; warn through the module logger already at wait.py:36 - Make _compile_patterns synchronous now that it awaits nothing, and drop the ctx argument from both call sites - Leave ctx.report_progress alone; progress is not deprecated and the 2026-07-28 draft keeps it on the request's response stream - Move four tests onto caplog and drop the two stub Context.warning methods the tool no longer calls - Correct the one wait_for_text doc sentence naming the old mechanism --- docs/tools/pane/wait-for-text.md | 4 +- src/libtmux_mcp/tools/pane_tools/wait.py | 67 +++------- tests/test_pane_tools.py | 162 ++++++++--------------- 3 files changed, 74 insertions(+), 159 deletions(-) diff --git a/docs/tools/pane/wait-for-text.md b/docs/tools/pane/wait-for-text.md index d77b2c5b..9a30b24a 100644 --- a/docs/tools/pane/wait-for-text.md +++ b/docs/tools/pane/wait-for-text.md @@ -64,8 +64,8 @@ rather than rejected, so this can be lower than what you asked for. Matching is best-effort once polling enters tmux's history-limit trim-risk band, because older scrollback can be discarded while the wait is active. The server -reports that as an MCP warning notification; use {tooliconl}`wait-for-channel` -for deterministic command completion. +logs that to stderr; use {tooliconl}`wait-for-channel` for deterministic +command completion. ```{fastmcp-tool-input} pane_tools.wait_for_text ``` diff --git a/src/libtmux_mcp/tools/pane_tools/wait.py b/src/libtmux_mcp/tools/pane_tools/wait.py index 9f58df84..bdb952e5 100644 --- a/src/libtmux_mcp/tools/pane_tools/wait.py +++ b/src/libtmux_mcp/tools/pane_tools/wait.py @@ -118,31 +118,6 @@ async def _maybe_report_progress( return -_LogLevel = t.Literal["debug", "info", "warning", "error"] - - -async def _maybe_log( - ctx: Context | None, - *, - level: _LogLevel, - message: str, -) -> None: - """Call the matching ``ctx.{level}`` if a Context is available. - - Sibling to :func:`_maybe_report_progress` for client-visible log - notifications (``notifications/message`` in MCP). Same suppression - contract: silent only when the transport is gone, propagating - everything else so programming errors stay loud. - """ - if ctx is None: - return - method = getattr(ctx, level) - try: - await method(message) - except _TRANSPORT_CLOSED_EXCEPTIONS: - return - - # --------------------------------------------------------------------------- # Timeout-bounded tmux reads # --------------------------------------------------------------------------- @@ -412,13 +387,12 @@ async def _first_pane_of_window( # --------------------------------------------------------------------------- -async def _compile_patterns( +def _compile_patterns( values: list[str], *, label: str, regex: bool, match_case: bool, - ctx: Context | None, ) -> list[re.Pattern[str]]: """Compile one pattern list, raising ``ExpectedToolError`` on bad input.""" flags = 0 if match_case else re.IGNORECASE @@ -431,7 +405,7 @@ async def _compile_patterns( compiled.append(re.compile(value if regex else re.escape(value), flags)) except re.error as e: msg = f"Invalid regex pattern: {e}" - await _maybe_log(ctx, level="warning", message=msg) + logger.warning(msg) raise ExpectedToolError(msg) from e return compiled @@ -556,8 +530,8 @@ async def wait_for_text( ``hsize`` shrinks below the entry value (``clear-history``, and any rollover whose dip is observable between polls). It does not reliably detect ``grid_collect_history`` trim during continuous - output; a runtime ``ctx.warning`` fires when sampled state enters - the trim-risk band. Use ``wait_for_channel`` when correctness + output; a runtime warning is logged to stderr when sampled state + enters the trim-risk band. Use ``wait_for_channel`` when correctness matters more than convenience. """ ceiling = _wait_ceiling_seconds() @@ -578,19 +552,17 @@ async def wait_for_text( # compute, and a field the agent never branches on is permanent # weight in ``outputSchema``. - compiled_patterns = await _compile_patterns( + compiled_patterns = _compile_patterns( patterns or [], label="patterns", regex=regex, match_case=match_case, - ctx=ctx, ) - compiled_stop = await _compile_patterns( + compiled_stop = _compile_patterns( stop or [], label="stop", regex=regex, match_case=match_case, - ctx=ctx, ) server = _get_server(socket_name=socket_name) @@ -727,18 +699,15 @@ async def wait_for_text( trim_batch = max(baseline_hlimit // 10, 1) risk_floor = baseline_hlimit - trim_batch if state.history_size >= risk_floor: - await _maybe_log( - ctx, - level="warning", - message=( - f"pane {target} is polling in the " - "history-limit trim-risk band " - f"(history_size {state.history_size} / " - f"history_limit {baseline_hlimit}); " - "wait_for_text correctness is best-effort " - "here. For deterministic synchronization " - "use wait_for_channel." - ), + logger.warning( + "pane %s is polling in the history-limit " + "trim-risk band (history_size %s / " + "history_limit %s); wait_for_text correctness " + "is best-effort here. For deterministic " + "synchronization use wait_for_channel.", + target, + state.history_size, + baseline_hlimit, ) warned_risk_band = True # Anchored ON the entry cursor row, not below it. That row is @@ -835,10 +804,8 @@ async def wait_for_text( # is a different fix — read the screen, don't retry the wait. outcome = "alternate_screen" if not found: - await _maybe_log( - ctx, - level="warning", - message=f"No match in pane {target} before {effective_timeout}s timeout", + logger.warning( + "No match in pane %s before %ss timeout", target, effective_timeout ) limited_tail = _limit_lines( diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 9db19797..d0ead952 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import logging import os import pathlib import shlex @@ -3050,6 +3051,12 @@ def _stale_settled() -> bool: #: read is internal to the tool and not observable from here. _EMIT_AFTER_BASELINE_SECONDS = 1.0 +#: Logger the wait tools warn through. MCP deprecated the protocol-level +#: Logging capability in SEP-2577, so these warnings travel on stderr +#: rather than as ``notifications/message``; ``caplog`` is how the suite +#: observes them. +_WAIT_LOGGER = "libtmux_mcp.tools.pane_tools.wait" + def test_wait_for_text_matches_new_output_after_baseline( mcp_server: Server, mcp_pane: Pane @@ -3861,9 +3868,6 @@ async def report_progress( ) -> None: progress_calls.append((progress, total, message)) - async def warning(self, message: str) -> None: - return # log notifications not asserted in this test - stub = _StubContext() result = asyncio.run( wait_for_text( @@ -3968,12 +3972,6 @@ async def report_progress( ) -> None: raise anyio.BrokenResourceError - async def warning(self, message: str) -> None: - # Same transport-closed shape on the log channel — the - # wait loop's timeout-warning call must also be suppressed - # silently when the peer is gone. - raise anyio.BrokenResourceError - result = asyncio.run( wait_for_text( patterns=["WILL_NEVER_MATCH_BROKEN_rpt5"], @@ -3988,96 +3986,67 @@ async def warning(self, message: str) -> None: def test_wait_for_text_warns_on_invalid_regex( - mcp_server: Server, mcp_pane: Pane + mcp_server: Server, mcp_pane: Pane, caplog: pytest.LogCaptureFixture ) -> None: - """``wait_for_text`` emits ``ctx.warning`` when the regex won't compile. + """``wait_for_text`` logs a warning when the regex won't compile. Regression guard: agents calling with ``regex=True`` and a malformed - pattern previously saw only a generic ``ToolError``. The new - ``_maybe_log`` helper at ``wait.py`` lets the same condition surface - as a ``notifications/message`` warning so MCP client log panels - record the cause independent of the tool result. + pattern previously saw only a generic ``ToolError``. The warning + lets the same condition reach stderr, so an MCP client's log panel + records the cause independent of the tool result. """ import asyncio - log_calls: list[tuple[str, str]] = [] - - class _RecordingContext: - async def report_progress( - self, - progress: float, - total: float | None = None, - message: str = "", - ) -> None: - return - - async def warning(self, message: str) -> None: - log_calls.append(("warning", message)) - - with pytest.raises(ToolError, match="Invalid regex"): + with ( + caplog.at_level(logging.WARNING, logger=_WAIT_LOGGER), + pytest.raises(ToolError, match="Invalid regex"), + ): asyncio.run( wait_for_text( patterns=["[unclosed"], regex=True, pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name, - ctx=t.cast("t.Any", _RecordingContext()), ) ) - # The ``warning`` ran before the ``ToolError`` was raised. - assert ( - "warning", - "Invalid regex pattern: missing ), unterminated subpattern at position 0", - ) in log_calls or any( - level == "warning" and "Invalid regex" in msg for level, msg in log_calls - ) + # The warning was logged before the ``ToolError`` was raised. + assert any("Invalid regex" in r.getMessage() for r in caplog.records) -def test_wait_for_text_warns_on_timeout(mcp_server: Server, mcp_pane: Pane) -> None: - """``wait_for_text`` warns the client when the poll loop times out. +def test_wait_for_text_warns_on_timeout( + mcp_server: Server, mcp_pane: Pane, caplog: pytest.LogCaptureFixture +) -> None: + """``wait_for_text`` warns on stderr when the poll loop times out. Sibling guard to the invalid-regex warning. The timeout case is - where operators most need a structured signal — the tool returns + where operators most need a signal — the tool returns ``found=False`` but agents and human log readers have to dig into the ``WaitForTextResult`` to notice. The warning surfaces it directly. """ import asyncio - log_calls: list[tuple[str, str]] = [] - - class _RecordingContext: - async def report_progress( - self, - progress: float, - total: float | None = None, - message: str = "", - ) -> None: - return - - async def warning(self, message: str) -> None: - log_calls.append(("warning", message)) - - result = asyncio.run( - wait_for_text( - patterns=["WILL_NEVER_MATCH_TIMEOUT_qZx9"], - pane_id=mcp_pane.pane_id, - timeout=0.2, - interval=0.05, - socket_name=mcp_server.socket_name, - ctx=t.cast("t.Any", _RecordingContext()), + with caplog.at_level(logging.WARNING, logger=_WAIT_LOGGER): + result = asyncio.run( + wait_for_text( + patterns=["WILL_NEVER_MATCH_TIMEOUT_qZx9"], + pane_id=mcp_pane.pane_id, + timeout=0.2, + interval=0.05, + socket_name=mcp_server.socket_name, + ) ) - ) + messages = [r.getMessage() for r in caplog.records] assert result.found is False - assert any( - level == "warning" and "timeout" in msg.lower() for level, msg in log_calls - ), f"expected a timeout warning, got: {log_calls}" + assert any("timeout" in m.lower() for m in messages), ( + f"expected a timeout warning, got: {messages}" + ) def test_wait_for_text_warns_in_history_limit_risk_band( - mcp_server: Server, mcp_pane: Pane + mcp_server: Server, mcp_pane: Pane, caplog: pytest.LogCaptureFixture ) -> None: """``wait_for_text`` emits a warning when polling near ``history-limit``. @@ -4085,8 +4054,8 @@ def test_wait_for_text_warns_in_history_limit_risk_band( ``grid_collect_history`` to fire repeatedly, sampled ``history_size`` enters the trim-risk band (top 10% of ``history_limit``). The wait's strict-shrink predicate cannot see those trims (hsize stays clamped - at the cap), so the tool emits a one-shot ``ctx.warning`` notification - so MCP clients can decide whether to keep waiting, retry, or switch + at the cap), so the tool logs a one-shot warning to stderr so + operators can decide whether to keep waiting, retry, or switch to ``wait_for_channel``. The wait's ``found`` result is intentionally not asserted — once @@ -4110,20 +4079,6 @@ def _hlimit_locked() -> bool: retry_until(_hlimit_locked, 5, raises=True) - log_calls: list[tuple[str, str]] = [] - - class _RecordingContext: - async def report_progress( - self, - progress: float, - total: float | None = None, - message: str = "", - ) -> None: - return - - async def warning(self, message: str) -> None: - log_calls.append(("warning", message)) - async def burst_after_delay() -> None: await asyncio.sleep(0.1) await asyncio.to_thread( @@ -4140,7 +4095,6 @@ async def run() -> None: timeout=2.0, interval=0.05, socket_name=mcp_server.socket_name, - ctx=t.cast("t.Any", _RecordingContext()), ) ) await burst_after_delay() @@ -4152,15 +4106,17 @@ async def run() -> None: # we only assert the warning contract, not the result type. return - asyncio.run(run()) + with caplog.at_level(logging.WARNING, logger=_WAIT_LOGGER): + asyncio.run(run()) - assert any( - level == "warning" and "trim-risk band" in msg for level, msg in log_calls - ), f"expected a trim-risk-band warning, got: {log_calls}" + messages = [r.getMessage() for r in caplog.records] + assert any("trim-risk band" in m for m in messages), ( + f"expected a trim-risk-band warning, got: {messages}" + ) def test_wait_for_text_warns_when_already_in_risk_band( - mcp_server: Server, mcp_pane: Pane + mcp_server: Server, mcp_pane: Pane, caplog: pytest.LogCaptureFixture ) -> None: """``wait_for_text`` warns immediately if entry is already in the risk band. @@ -4193,15 +4149,6 @@ def _prefilled() -> bool: retry_until(_prefilled, 10, raises=True) - log_calls: list[tuple[str, str]] = [] - - class _RecordingContext: - async def report_progress(self, *args: t.Any, **kwargs: t.Any) -> None: - return - - async def warning(self, message: str) -> None: - log_calls.append(("warning", message)) - async def run() -> WaitForTextResult: # Idle wait: no new output, no cursor movement. return await wait_for_text( @@ -4210,17 +4157,18 @@ async def run() -> WaitForTextResult: timeout=0.5, interval=0.1, socket_name=mcp_server.socket_name, - ctx=t.cast("t.Any", _RecordingContext()), ) - # The trim-risk band is surfaced as a client log notification, not - # a result field: an agent cannot act on a boolean it gets after - # the fact, and the field was permanent weight in ``outputSchema``. - asyncio.run(run()) + # The trim-risk band is surfaced as a stderr log line, not a result + # field: an agent cannot act on a boolean it gets after the fact, + # and the field was permanent weight in ``outputSchema``. + with caplog.at_level(logging.WARNING, logger=_WAIT_LOGGER): + asyncio.run(run()) - assert any( - level == "warning" and "trim-risk band" in msg for level, msg in log_calls - ), f"expected a trim-risk-band warning during idle wait, got: {log_calls}" + messages = [r.getMessage() for r in caplog.records] + assert any("trim-risk band" in m for m in messages), ( + f"expected a trim-risk-band warning during idle wait, got: {messages}" + ) def test_wait_for_text_propagates_cancellation( From f78baacab977f825bed6b3aab5843f0cb96e96cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Jul 2026 17:25:26 -0500 Subject: [PATCH 2/2] mcp(docs[CHANGES]): Note the warning channel change why: The channel change is visible to any client that subscribed to MCP log notifications, and it supersedes an explicit promise in the 0.1.0a19 notes that the trim-risk signal is "delivered only as an MCP warning notification". what: - Add a breaking-changes entry and a matching MIGRATION section --- CHANGES | 19 +++++++++++++++++++ MIGRATION | 31 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CHANGES b/CHANGES index af452ca3..207e7a1d 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,25 @@ _Notes on upcoming releases will be added here_ +### Breaking changes + +**{tooliconl}`wait-for-text` warnings move from MCP log notifications to stderr** + +MCP deprecated the protocol-level Logging capability in +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577), +effective with the `2026-07-28` revision, naming stderr as the migration path +for stdio servers. {tooliconl}`wait-for-text` was the only tool that used it — +an invalid regex, a timeout, and the history-limit trim-risk band each emitted a +`notifications/message` warning. All three are now ordinary `libtmux_mcp.*` log +records on stderr, where every other libtmux-mcp record already goes. + +A client that subscribed to MCP log notifications no longer receives these three +over the protocol. Nothing an agent can act on is lost: the invalid regex is +still raised as a tool error, and the timeout is still reported as +{attr}`~libtmux_mcp.models.WaitForTextResult.outcome`. This supersedes the +0.1.0a19 note that the trim-risk signal is "delivered only as an MCP warning +notification". (#110) + ### Documentation #### Caller identity fields are described (#105) diff --git a/MIGRATION b/MIGRATION index 93509ec9..6b4c05ea 100644 --- a/MIGRATION +++ b/MIGRATION @@ -19,6 +19,37 @@ for the full release log. [tracker]: https://github.com/tmux-python/libtmux-mcp/discussions ``` +## libtmux-mcp 0.1.x (unreleased) + +### `wait_for_text` warnings arrive on stderr, not as MCP log notifications + +MCP deprecated the protocol-level Logging capability in [SEP-2577], effective +with the `2026-07-28` revision, and names stderr as the migration path for +stdio servers. `wait_for_text` was the only tool using it. + +Three warnings changed channel — an invalid regex, a wait that timed out, and +polling inside tmux's history-limit trim-risk band. Wording and timing are +unchanged; only the transport differs. + +#### Before + +An MCP `notifications/message` at level `warning`. + +#### After + +An ordinary `libtmux_mcp.tools.pane_tools.wait` log record on stderr. Under the +stdio transport the client already captures that stream, so most log panels +show them as before. + +If you read these through an MCP client's log handler, switch to the server +process's stderr. If you drive the tools from Python, `caplog` or any handler +on the `libtmux_mcp` logger sees them. + +This supersedes the 0.1.0a19 note below stating that the trim-risk signal is +delivered only as an MCP warning notification. + +[SEP-2577]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577 + ## libtmux-mcp 0.1.0a19 (2026-07-25) ### `wait_for_text` takes `patterns`, and `wait_for_content_change` is gone