From 4387b07741990aa40b73ac8f36a675a2e5bd7170 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 16:55:18 -0500 Subject: [PATCH 1/4] py(deps[libtmux]): Raise floor to 0.62.0 why: tmux-python/libtmux#718 shipped in 0.62.0, moving MultipleObjectsReturned and ObjectDoesNotExist -- and TmuxObjectDoesNotExist with them -- under LibTmuxException. The retry and error-mapping fixes that follow depend on that hierarchy, so the floor must require the release that provides it. what: - Raise the libtmux floor from >=0.61.0 to >=0.62.0 and relock against the PyPI release --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e34e7bf6..17748642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ include = [ ] dependencies = [ - "libtmux>=0.61.0,<1.0", + "libtmux>=0.62.0,<1.0", "fastmcp>=3.4.2,<4.0.0", ] diff --git a/uv.lock b/uv.lock index 127cf321..3937d8a3 100644 --- a/uv.lock +++ b/uv.lock @@ -1176,11 +1176,11 @@ wheels = [ [[package]] name = "libtmux" -version = "0.61.0" +version = "0.62.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/c2/6abbac4420c35b3f1140c72607117236b160173f74fd86941f99b28375d5/libtmux-0.61.0.tar.gz", hash = "sha256:2d6081081a629b9236a36a64f874667533811d2ce5a1b0caa9c821f4d9d2e618", size = 546122, upload-time = "2026-07-04T12:57:36.401Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/b8/0410c8487f7673926d141a5aaaf60133984a564b3c90eb4f3f99e92e7740/libtmux-0.62.0.tar.gz", hash = "sha256:41e9e80602b2656fd119b13253b27bad46af5cfef32099be53cdd391e2936b61", size = 571757, upload-time = "2026-07-12T21:48:02.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/14/934d0d9076d1d46fcc71f3397daea7921363781674337084b15d1bbcbf84/libtmux-0.61.0-py3-none-any.whl", hash = "sha256:b9315db6600757f840a8f16438725103e579aaa4be3c32728c105f2c284330df", size = 118059, upload-time = "2026-07-04T12:57:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/471ac01844ee157fe794446e17568e9bf219d323575c2acc7957a9a2d8c9/libtmux-0.62.0-py3-none-any.whl", hash = "sha256:626aa6fae45e3a423e1acc81604efafa63b7262b1d67b2c7a8778b1ad691456b", size = 127700, upload-time = "2026-07-12T21:48:01.52Z" }, ] [[package]] @@ -1244,7 +1244,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=3.4.2,<4.0.0" }, - { name = "libtmux", specifier = ">=0.61.0,<1.0" }, + { name = "libtmux", specifier = ">=0.62.0,<1.0" }, ] [package.metadata.requires-dev] From f3f7252115a6a8485ebf24355e784ff9f516a353 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 08:05:48 -0500 Subject: [PATCH 2/4] middleware(fix[retry]): Do not retry what cannot succeed why: Readonly tools retry on LibTmuxException, which is right for the transient socket errors libtmux wraps -- but most of that family is not transient. A pane that does not exist will not exist on the second look. Every readonly call with a stale id was paying a second tmux round-trip and a backoff window to fail identically, and the audit log recorded one call, so the doubled traffic was invisible. libtmux#718 widens the blast radius: TmuxObjectDoesNotExist becomes a LibTmuxException, so every missing session and window joins the panes that were already being retried. what: - Add NON_RETRYABLE_EXCEPTIONS and skip them in the retry decision, walking __cause__ as fastmcp does -- handle_tool_errors presents an ExpectedToolError, so the real failure is only visible one hop down - Cover every entry, and the decorated path end to end --- src/libtmux_mcp/middleware.py | 47 ++++++++++++++++++- tests/test_middleware.py | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index d66567b8..0fd6779b 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -666,6 +666,45 @@ async def on_call_tool( DEFAULT_RESPONSE_LIMIT_BYTES = 1_000_000 +#: Failures a retry cannot fix. Each one names a thing that is not there, or a +#: request that cannot succeed as written, so re-running it buys a second tmux +#: round-trip and a backoff window in order to fail identically. They all +#: descend from :exc:`libtmux.exc.LibTmuxException`, which is the retry +#: trigger, so without this set they would all be retried. +#: +#: Order the entries most-general-first when reading: ``ObjectDoesNotExist`` +#: already covers :exc:`libtmux.exc.TmuxObjectDoesNotExist`. +NON_RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = ( + libtmux_exc.ObjectDoesNotExist, + libtmux_exc.MultipleObjectsReturned, + libtmux_exc.PaneNotFound, + libtmux_exc.NoWindowsExist, + libtmux_exc.BadSessionName, + libtmux_exc.TmuxSessionExists, + libtmux_exc.TmuxCommandNotFound, +) + + +class _SkipDeterministicFailures(RetryMiddleware): + """A :class:`RetryMiddleware` that declines to retry what cannot succeed. + + fastmcp decides whether to retry in ``_should_retry``, matching the error + and, one hop down, its ``__cause__`` -- which is where the real failure + lives, because ``handle_tool_errors`` re-raises every libtmux error as an + ``ExpectedToolError`` chained off the original. This narrows that decision + with :data:`NON_RETRYABLE_EXCEPTIONS`, checking the same two places. + """ + + def _should_retry(self, error: Exception) -> bool: + """Return ``False`` for a failure a second attempt cannot change.""" + cause = error.__cause__ + if isinstance(error, NON_RETRYABLE_EXCEPTIONS) or ( + cause is not None and isinstance(cause, NON_RETRYABLE_EXCEPTIONS) + ): + return False + return super()._should_retry(error) + + class ReadonlyRetryMiddleware(Middleware): """Retry transient libtmux failures, but only for readonly tools. @@ -684,6 +723,12 @@ class ReadonlyRetryMiddleware(Middleware): ``(ConnectionError, TimeoutError)`` does NOT match these, so the upstream defaults would be a silent no-op. + That trigger is a whole family, though, and most of it is not + transient: a pane that does not exist will not exist on the second + look. :data:`NON_RETRYABLE_EXCEPTIONS` carves those out, so a stale + id fails once instead of costing a backoff window and a second tmux + round-trip to fail again. + Place this in the middleware stack **inside** ``AuditMiddleware`` (so retried calls are audited once each) and **outside** ``SafetyMiddleware`` (so tier-denied tools never reach retry). @@ -715,7 +760,7 @@ def __init__( """ if logger_ is None: logger_ = logging.getLogger("libtmux_mcp.retry") - self._retry = RetryMiddleware( + self._retry = _SkipDeterministicFailures( max_retries=max_retries, base_delay=base_delay, max_delay=max_delay, diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 0a42a3db..6d29e251 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -9,7 +9,9 @@ import pydantic import pytest +from fastmcp.exceptions import ToolError from fastmcp.server.middleware import MiddlewareContext +from libtmux import exc as libtmux_exc from mcp.types import CallToolRequestParams from libtmux_mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY @@ -916,6 +918,90 @@ async def real_call_next(_context: t.Any) -> t.Any: ) +@pytest.mark.parametrize( + "raised", + [ + libtmux_exc.TmuxObjectDoesNotExist("@99"), + libtmux_exc.MultipleObjectsReturned(count=2, query={"pane_id": "%0"}), + libtmux_exc.PaneNotFound("%99"), + libtmux_exc.NoWindowsExist, + libtmux_exc.BadSessionName(reason="contains periods", session_name="a.b"), + libtmux_exc.TmuxSessionExists("session exists"), + ], + ids=lambda e: type(e).__name__ if isinstance(e, Exception) else e.__name__, +) +def test_readonly_retry_skips_deterministic_failures(raised: Exception) -> None: + """A failure a second attempt cannot change is not retried. + + Every one of these descends from ``LibTmuxException``, which is the retry + trigger — so without :data:`NON_RETRYABLE_EXCEPTIONS` they would all be + retried. None of them can succeed on the second look: a pane that is not + there will not appear during a backoff window, and an ambiguous match does + not become unambiguous. Retrying buys a second tmux round-trip and 100 ms + of latency in order to fail identically. + """ + middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TAG_READONLY}) + call_next = _FlakyCallNext(raises_n_times=1, exception=raised) + + with pytest.raises(libtmux_exc.LibTmuxException): + asyncio.run(middleware.on_call_tool(ctx, call_next)) + + assert call_next.calls == 1, ( + f"{type(raised).__name__} was retried. It descends from LibTmuxException, " + f"so it must be listed in NON_RETRYABLE_EXCEPTIONS." + ) + + +def test_readonly_retry_skips_not_found_on_decorated_tool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """End-to-end: a stale id is not retried through the production wrap path. + + The unit test above raises straight into the middleware. This one goes + through ``handle_tool_errors``, which re-raises every libtmux failure as an + ``ExpectedToolError`` chained off the original — so at the middleware layer + the exception is an ``ExpectedToolError`` and the real failure is only + visible one hop down, on ``__cause__``. The retry decision walks that hop + (which is what makes retries work at all), so the *skip* decision has to + walk it too, or it never fires in production. + + Guards the shape libtmux tmux-python/libtmux#718 introduced: + ``TmuxObjectDoesNotExist`` became a ``LibTmuxException``, which silently + made every stale session or window id retryable. + """ + from libtmux import Server + + from libtmux_mcp.tools.server_tools import list_sessions + + calls = {"count": 0} + + def _missing_sessions(_self: Server) -> list[t.Any]: + calls["count"] += 1 + raise libtmux_exc.TmuxObjectDoesNotExist( + obj_key="session_id", + obj_id="$99", + list_cmd="list-sessions", + list_extra_args=None, + ) + + monkeypatch.setattr(Server, "sessions", property(_missing_sessions)) + + middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TAG_READONLY}) + + async def real_call_next(_context: t.Any) -> t.Any: + return list_sessions(socket_name="retry-skip-smoke") + + with pytest.raises(ToolError): + asyncio.run(middleware.on_call_tool(ctx, real_call_next)) + + assert calls["count"] == 1, ( + f"a missing object was retried (calls={calls['count']}). The skip must " + f"walk __cause__, because handle_tool_errors wraps it in ExpectedToolError." + ) + + def test_readonly_retry_logger_uses_project_namespace() -> None: """Retry warnings route through ``libtmux_mcp.retry``, not ``fastmcp.retry``. From 1a940798c00efeb99b75144b067d43b855f033b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 08:05:48 -0500 Subject: [PATCH 3/4] _utils(fix[errors]): Say how to fix an ambiguous target why: A window shared between sessions is listed once per session holding it, so a name or index can match several rows and the lookup raises MultipleObjectsReturned. It reached the agent as an unexpected error with a stack trace and no way forward. Under libtmux#718 it would instead be reported as agent-correctable while still saying nothing about how to correct it, which is worse. what: - Map MultipleObjectsReturned to an expected error that names the fix: target the object by id - Widen the not-found arm to ObjectDoesNotExist, so a QueryList miss gets the same discovery hint as a tmux id miss --- src/libtmux_mcp/_utils.py | 11 ++++++++++- tests/test_utils.py | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 2adfa770..3611e20d 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -1001,13 +1001,22 @@ def _map_exception_to_tool_error(fn_name: str, e: BaseException) -> ToolError: return ExpectedToolError(str(e)) if isinstance(e, exc.BadSessionName): return ExpectedToolError(str(e)) - if isinstance(e, exc.TmuxObjectDoesNotExist): + if isinstance(e, exc.ObjectDoesNotExist): return ExpectedToolError( f"Object not found: {e}", suggestion=( "Call list_sessions / list_windows / list_panes to discover valid ids." ), ) + if isinstance(e, exc.MultipleObjectsReturned): + return ExpectedToolError( + f"Ambiguous target: {e}", + suggestion=( + "A window shared between sessions is listed once per session that " + "holds it, so a name or index can match more than one row. Target " + "it by id (session_id / window_id / pane_id) instead." + ), + ) if isinstance(e, exc.PaneNotFound): return ExpectedToolError( f"Pane not found: {e}", diff --git a/tests/test_utils.py b/tests/test_utils.py index dee986c1..d5ee8f3d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -875,6 +875,8 @@ async def _raiser() -> None: exc.TmuxSessionExists("session foo already exists"), exc.BadSessionName("bad name"), exc.TmuxObjectDoesNotExist("@99"), + exc.ObjectDoesNotExist(query={"window_name": "gone"}), + exc.MultipleObjectsReturned(count=2, query={"pane_id": "%0"}), exc.PaneNotFound("%99"), exc.LibTmuxException("server gone"), ], @@ -952,6 +954,10 @@ async def _call() -> None: ("raised", "expected_suggestion_fragment"), [ (exc.TmuxObjectDoesNotExist("@99"), "list_sessions / list_windows"), + ( + exc.MultipleObjectsReturned(count=2, query={"pane_id": "%0"}), + "Target it by id", + ), (exc.PaneNotFound("%99"), "list_panes"), (exc.TmuxSessionExists("dup"), None), (exc.BadSessionName("bad:name"), None), From c6b50efaa0c348158308deaa8b451f5673430b1d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 17:32:41 -0500 Subject: [PATCH 4/4] docs(CHANGES): libtmux 0.62.0 floor and lookup-retry fixes why: Record what this branch ships for a reader deciding whether to upgrade -- the libtmux floor bump and the two lookup behaviours -- at deliverable altitude rather than at the level of the exception classes involved. what: - Note the raised libtmux floor under Dependencies - State the once-not-twice retry fix and the ambiguous-target recovery hint as bold-subheading deliverables, matching the rest of the file --- CHANGES | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGES b/CHANGES index ed55b6f0..97e3c25f 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,32 @@ _Notes on upcoming releases will be added here_ +### Dependencies + +**Minimum `libtmux>=0.62.0`** (was `>=0.61.0`). Picks up libtmux 0.62.0, which +re-parents {exc}`libtmux.exc.MultipleObjectsReturned` and +{exc}`libtmux.exc.ObjectDoesNotExist` under {exc}`libtmux.exc.LibTmuxException`. +The retry and error-mapping fixes below rely on that hierarchy. (#98) + +### Fixes + +**A stale id fails once instead of twice** + +Readonly tools retried on any {exc}`libtmux.exc.LibTmuxException`, the right +trigger for the transient socket errors libtmux wraps but wrong for the rest of +that family — a pane that does not exist will not exist on the second look, and +an ambiguous match does not become unambiguous during a backoff window. A lookup +that a retry cannot change now fails on the first attempt, without the redundant +tmux round-trip and its backoff delay. (#98) + +**An ambiguous target says how to disambiguate it** + +A window shared between sessions — through a grouped session or `link-window` — +is listed once per session that holds it, so a name or index can match more than +one row and the lookup raises {exc}`libtmux.exc.MultipleObjectsReturned`. That +reached the agent with no recovery hint. It now says to target the object by id +instead. (#98) + ## libtmux-mcp 0.1.0a17 (2026-07-11) libtmux-mcp 0.1.0a17 makes best-effort shell-history suppression the default for {tooliconl}`run-command` — calls that omit `suppress_history` now inherit the server default, and setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` to `0` restores the previous behavior. Spawned shells gain the same hygiene: {tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` accept `suppress_persistent_history=true` for best-effort no-disk history controls. {tooliconl}`create-window` and {tooliconl}`split-window` also accept per-process `environment` mappings, and the MCP install picker now covers Grok CLI and Antigravity.