Skip to content

Commit 05fccce

Browse files
committed
mcp(fix[list]): Fail when tmux cannot be asked, not list nothing
why: Server.sessions, windows and panes answer an empty list when the list command fails, so a socket the user may not open looked like a server with nothing on it and an agent created a duplicate workspace. what: - List through libtmux's strict fetch_sessions, fetch_windows and fetch_panes; a socket with no server behind it is still empty - Any other failure raises ListCommandFailed, mapped to a tool error that says the server is unreachable, not empty - Test both; the permission test fails on the lenient listing
1 parent 6c5f13b commit 05fccce

7 files changed

Lines changed: 89 additions & 6 deletions

File tree

‎CHANGES‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ scrollback of the new session's panes before a long job starts.
3232

3333
### Fixes
3434

35+
**A socket tmux cannot open is an error, not an empty list**
36+
37+
`list_sessions`, `list_windows` and `list_panes` used to answer `[]` when tmux
38+
could not be asked at all, such as a socket without read permission, so an
39+
agent concluded there was nothing to attach to. They now fail with the tmux
40+
message and a hint, and keep returning `[]` for a socket with no server behind
41+
it.
42+
3543
**`capture_since` reports a flood that scrolls the cursor out of a full history**
3644

3745
When a burst of output fills the scrollback and discards the row the cursor

‎src/libtmux_mcp/_utils.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from fastmcp.exceptions import ToolError
2020
from libtmux import exc
21-
from libtmux._internal.query_list import LOOKUP_NAME_MAP
21+
from libtmux._internal.query_list import LOOKUP_NAME_MAP, QueryList
2222
from libtmux.server import Server
2323

2424
if t.TYPE_CHECKING:
@@ -925,6 +925,37 @@ def _coerce_dict_arg(
925925
return value
926926

927927

928+
#: Fragments of a ``list-*`` failure that mean "no server is running here".
929+
#: That is a normal, empty state for a socket nobody started; any other
930+
#: failure (permission denied, a server that died mid-call) is not.
931+
_NO_SERVER_MARKERS = (
932+
"no server running",
933+
"No such file or directory",
934+
"Connection refused",
935+
)
936+
937+
938+
def _list_objects(fetch: t.Callable[[], list[t.Any]]) -> QueryList[t.Any]:
939+
"""Run one of libtmux's strict ``fetch_*`` listings as a ``QueryList``.
940+
941+
``Server.sessions`` and friends return an empty list when tmux cannot be
942+
asked, so a socket the user may not open looks like a server with
943+
nothing on it. A socket with no server behind it is still empty here;
944+
every other failure raises.
945+
946+
Raises
947+
------
948+
libtmux.exc.ListCommandFailed
949+
When tmux fails for a reason other than "no server is running".
950+
"""
951+
try:
952+
return QueryList(fetch())
953+
except exc.ListCommandFailed as e:
954+
if any(marker in str(e) for marker in _NO_SERVER_MARKERS):
955+
return QueryList([])
956+
raise
957+
958+
928959
def _apply_filters(
929960
items: t.Any,
930961
filters: dict[str, str] | str | None,

‎src/libtmux_mcp/tools/server_tools.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
_get_caller_identity,
2525
_get_server,
2626
_invalidate_server,
27+
_list_objects,
2728
_prepare_start_directory,
2829
_serialize_session,
2930
handle_tool_errors,
@@ -62,7 +63,7 @@ def list_sessions(
6263
List of session objects.
6364
"""
6465
server = _get_server(socket_name=socket_name)
65-
sessions = server.sessions
66+
sessions = _list_objects(server.fetch_sessions)
6667
return _apply_filters(sessions, filters, _serialize_session)
6768

6869

‎src/libtmux_mcp/tools/session_tools.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
_escape_tmux_format,
2121
_get_caller_identity,
2222
_get_server,
23+
_list_objects,
2324
_prepare_start_directory,
2425
_resolve_session,
2526
_serialize_session,
@@ -71,7 +72,7 @@ def list_windows(
7172
)
7273
windows = session.windows
7374
else:
74-
windows = server.windows
75+
windows = _list_objects(server.fetch_windows)
7576
return _apply_filters(windows, filters, _serialize_window)
7677

7778

‎src/libtmux_mcp/tools/window_tools.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
_escape_tmux_format,
2121
_get_caller_identity,
2222
_get_server,
23+
_list_objects,
2324
_prepare_start_directory,
2425
_resolve_pane,
2526
_resolve_session,
@@ -97,7 +98,7 @@ def list_panes(
9798
)
9899
panes = session.panes
99100
else:
100-
panes = server.panes
101+
panes = _list_objects(server.fetch_panes)
101102
return _apply_filters(panes, filters, _serialize_pane)
102103

103104

‎tests/test_middleware.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -658,9 +658,9 @@ def _fail(_self: Server) -> list[t.Any]:
658658
nonlocal calls
659659
calls += 1
660660
msg = "forced failure"
661-
raise libtmux_exc.LibTmuxException(msg)
661+
raise libtmux_exc.ListCommandFailed(msg, list_cmd="list-sessions")
662662

663-
monkeypatch.setattr(Server, "sessions", property(_fail))
663+
monkeypatch.setattr(Server, "fetch_sessions", _fail)
664664

665665
async def _call() -> t.Any:
666666
async with Client(build_mcp_server()) as client:

‎tests/test_server_tools.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,3 +505,44 @@ def test_create_session_history_limit_reaches_the_pane(mcp_server: Server) -> No
505505
pane = session.active_pane
506506
assert pane is not None
507507
assert pane.display_message("#{history_limit}", get_text=True) == ["123"]
508+
509+
510+
def test_list_tools_treat_a_socket_without_a_server_as_empty() -> None:
511+
"""A socket nobody started lists nothing instead of failing."""
512+
from libtmux_mcp.tools.server_tools import list_sessions
513+
from libtmux_mcp.tools.session_tools import list_windows
514+
from libtmux_mcp.tools.window_tools import list_panes
515+
516+
socket_name = "libtmux_mcp_never_started"
517+
assert list_sessions(socket_name=socket_name) == []
518+
assert list_windows(socket_name=socket_name) == []
519+
assert list_panes(socket_name=socket_name) == []
520+
521+
522+
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
523+
def test_list_sessions_reports_a_socket_it_cannot_open(
524+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
525+
) -> None:
526+
"""An unreadable socket is an error, not an empty server.
527+
528+
``Server.sessions`` answers ``[]`` here, so an agent would conclude there
529+
is nothing to attach to and create a duplicate workspace.
530+
"""
531+
from fastmcp.exceptions import ToolError
532+
533+
from libtmux_mcp._utils import _server_cache
534+
from libtmux_mcp.tools.server_tools import list_sessions
535+
536+
socket_dir = tmp_path / f"tmux-{os.geteuid()}"
537+
socket_dir.mkdir()
538+
(socket_dir / "locked").touch()
539+
socket_dir.chmod(0o000)
540+
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
541+
_server_cache.clear()
542+
try:
543+
with pytest.raises(ToolError, match="Permission denied") as excinfo:
544+
list_sessions(socket_name="locked")
545+
assert "unreachable" in (getattr(excinfo.value, "suggestion", None) or "")
546+
finally:
547+
socket_dir.chmod(0o700)
548+
_server_cache.clear()

0 commit comments

Comments
 (0)