Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### 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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
11 changes: 10 additions & 1 deletion src/libtmux_mcp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
47 changes: 46 additions & 1 deletion src/libtmux_mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``.

Expand Down
6 changes: 6 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
],
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.