Skip to content

Commit 62f47c6

Browse files
committed
mcp(feat[list_tools]): Add filtering and scope broadening to list tools
why: LLM agents need to search across tmux objects without knowing the exact hierarchy, and filter results using QueryList's 12 lookup operators. what: - Add optional filters param to list_sessions, list_windows, list_panes - Broaden list_windows scope: omit session params to list all server windows - Broaden list_panes scope: window > session > server fallback chain - Add 9 parametrized tests for list_sessions filtering - Add 7 parametrized tests for list_windows filtering + cross-session scope - Add 7 parametrized tests for list_panes filtering + session/server scope
1 parent a3d9b69 commit 62f47c6

File tree

6 files changed

+399
-20
lines changed

6 files changed

+399
-20
lines changed

src/libtmux/mcp/tools/server_tools.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import typing as t
77

88
from libtmux.mcp._utils import (
9+
_apply_filters,
910
_get_server,
1011
_invalidate_server,
1112
_serialize_session,
@@ -17,13 +18,18 @@
1718

1819

1920
@handle_tool_errors
20-
def list_sessions(socket_name: str | None = None) -> str:
21+
def list_sessions(
22+
socket_name: str | None = None,
23+
filters: dict[str, str] | None = None,
24+
) -> str:
2125
"""List all tmux sessions.
2226
2327
Parameters
2428
----------
2529
socket_name : str, optional
2630
tmux socket name. Defaults to LIBTMUX_SOCKET env var.
31+
filters : dict, optional
32+
Django-style filters (e.g. ``{"session_name__contains": "dev"}``).
2733
2834
Returns
2935
-------
@@ -32,7 +38,7 @@ def list_sessions(socket_name: str | None = None) -> str:
3238
"""
3339
server = _get_server(socket_name=socket_name)
3440
sessions = server.sessions
35-
return json.dumps([_serialize_session(s) for s in sessions])
41+
return json.dumps(_apply_filters(sessions, filters, _serialize_session))
3642

3743

3844
@handle_tool_errors

src/libtmux/mcp/tools/session_tools.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from libtmux.constants import WindowDirection
99
from libtmux.mcp._utils import (
10+
_apply_filters,
1011
_get_server,
1112
_resolve_session,
1213
_serialize_session,
@@ -23,27 +24,36 @@ def list_windows(
2324
session_name: str | None = None,
2425
session_id: str | None = None,
2526
socket_name: str | None = None,
27+
filters: dict[str, str] | None = None,
2628
) -> str:
27-
"""List all windows in a tmux session.
29+
"""List windows in a tmux session, or all windows across sessions.
2830
2931
Parameters
3032
----------
3133
session_name : str, optional
32-
Session name to look up.
34+
Session name to look up. If omitted along with session_id,
35+
returns windows from all sessions.
3336
session_id : str, optional
3437
Session ID (e.g. '$1') to look up.
3538
socket_name : str, optional
3639
tmux socket name. Defaults to LIBTMUX_SOCKET env var.
40+
filters : dict, optional
41+
Django-style filters (e.g. ``{"window_name__contains": "dev"}``).
3742
3843
Returns
3944
-------
4045
str
4146
JSON array of window objects.
4247
"""
4348
server = _get_server(socket_name=socket_name)
44-
session = _resolve_session(server, session_name=session_name, session_id=session_id)
45-
windows = session.windows
46-
return json.dumps([_serialize_window(w) for w in windows])
49+
if session_name is not None or session_id is not None:
50+
session = _resolve_session(
51+
server, session_name=session_name, session_id=session_id
52+
)
53+
windows = session.windows
54+
else:
55+
windows = server.windows
56+
return json.dumps(_apply_filters(windows, filters, _serialize_window))
4757

4858

4959
@handle_tool_errors

src/libtmux/mcp/tools/window_tools.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from libtmux.constants import PaneDirection
99
from libtmux.mcp._utils import (
10+
_apply_filters,
1011
_get_server,
1112
_resolve_pane,
13+
_resolve_session,
1214
_resolve_window,
1315
_serialize_pane,
1416
_serialize_window,
@@ -33,36 +35,50 @@ def list_panes(
3335
window_id: str | None = None,
3436
window_index: str | None = None,
3537
socket_name: str | None = None,
38+
filters: dict[str, str] | None = None,
3639
) -> str:
37-
"""List all panes in a tmux window.
40+
"""List panes in a tmux window, session, or across the entire server.
3841
3942
Parameters
4043
----------
4144
session_name : str, optional
42-
Session name to resolve the window from.
45+
Session name. If given without window params, lists all panes
46+
in the session.
4347
session_id : str, optional
44-
Session ID to resolve the window from.
48+
Session ID. If given without window params, lists all panes
49+
in the session.
4550
window_id : str, optional
46-
Window ID (e.g. '@1').
51+
Window ID (e.g. '@1'). Scopes to a single window.
4752
window_index : str, optional
48-
Window index within the session.
53+
Window index within the session. Scopes to a single window.
4954
socket_name : str, optional
5055
tmux socket name.
56+
filters : dict, optional
57+
Django-style filters (e.g. ``{"pane_current_command__contains": "vim"}``).
5158
5259
Returns
5360
-------
5461
str
5562
JSON array of serialized pane objects.
5663
"""
5764
server = _get_server(socket_name=socket_name)
58-
window = _resolve_window(
59-
server,
60-
window_id=window_id,
61-
window_index=window_index,
62-
session_name=session_name,
63-
session_id=session_id,
64-
)
65-
return json.dumps([_serialize_pane(p) for p in window.panes])
65+
if window_id is not None or window_index is not None:
66+
window = _resolve_window(
67+
server,
68+
window_id=window_id,
69+
window_index=window_index,
70+
session_name=session_name,
71+
session_id=session_id,
72+
)
73+
panes = window.panes
74+
elif session_name is not None or session_id is not None:
75+
session = _resolve_session(
76+
server, session_name=session_name, session_id=session_id
77+
)
78+
panes = session.panes
79+
else:
80+
panes = server.panes
81+
return json.dumps(_apply_filters(panes, filters, _serialize_pane))
6682

6783

6884
@handle_tool_errors

tests/mcp/test_server_tools.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,134 @@ def test_get_server_info(mcp_server: Server, mcp_session: Session) -> None:
6969
assert data["session_count"] >= 1
7070

7171

72+
class ListSessionsFilterFixture(t.NamedTuple):
73+
"""Test fixture for list_sessions with filters."""
74+
75+
test_id: str
76+
filters: dict[str, str] | None
77+
expected_count: int | None
78+
expect_error: bool
79+
error_match: str | None
80+
81+
82+
LIST_SESSIONS_FILTER_FIXTURES: list[ListSessionsFilterFixture] = [
83+
ListSessionsFilterFixture(
84+
test_id="no_filters",
85+
filters=None,
86+
expected_count=None,
87+
expect_error=False,
88+
error_match=None,
89+
),
90+
ListSessionsFilterFixture(
91+
test_id="exact_session_name",
92+
filters={"session_name": "<session_name>"},
93+
expected_count=1,
94+
expect_error=False,
95+
error_match=None,
96+
),
97+
ListSessionsFilterFixture(
98+
test_id="contains_operator",
99+
filters={"session_name__contains": "<partial>"},
100+
expected_count=1,
101+
expect_error=False,
102+
error_match=None,
103+
),
104+
ListSessionsFilterFixture(
105+
test_id="startswith_operator",
106+
filters={"session_name__startswith": "<partial>"},
107+
expected_count=None,
108+
expect_error=False,
109+
error_match=None,
110+
),
111+
ListSessionsFilterFixture(
112+
test_id="regex_operator",
113+
filters={"session_name__regex": ".*"},
114+
expected_count=None,
115+
expect_error=False,
116+
error_match=None,
117+
),
118+
ListSessionsFilterFixture(
119+
test_id="icontains_operator",
120+
filters={"session_name__icontains": "<partial_upper>"},
121+
expected_count=1,
122+
expect_error=False,
123+
error_match=None,
124+
),
125+
ListSessionsFilterFixture(
126+
test_id="no_match",
127+
filters={"session_name": "nonexistent_xyz_999"},
128+
expected_count=0,
129+
expect_error=False,
130+
error_match=None,
131+
),
132+
ListSessionsFilterFixture(
133+
test_id="invalid_operator",
134+
filters={"session_name__badop": "test"},
135+
expected_count=None,
136+
expect_error=True,
137+
error_match="Invalid filter operator",
138+
),
139+
ListSessionsFilterFixture(
140+
test_id="multiple_filters",
141+
filters={"session_name__contains": "<partial>", "session_name__regex": ".*"},
142+
expected_count=None,
143+
expect_error=False,
144+
error_match=None,
145+
),
146+
]
147+
148+
149+
@pytest.mark.parametrize(
150+
ListSessionsFilterFixture._fields,
151+
LIST_SESSIONS_FILTER_FIXTURES,
152+
ids=[f.test_id for f in LIST_SESSIONS_FILTER_FIXTURES],
153+
)
154+
def test_list_sessions_with_filters(
155+
mcp_server: Server,
156+
mcp_session: Session,
157+
test_id: str,
158+
filters: dict[str, str] | None,
159+
expected_count: int | None,
160+
expect_error: bool,
161+
error_match: str | None,
162+
) -> None:
163+
"""list_sessions supports QueryList filtering."""
164+
from fastmcp.exceptions import ToolError
165+
166+
if filters is not None:
167+
session_name = mcp_session.session_name
168+
assert session_name is not None
169+
resolved: dict[str, str] = {}
170+
for k, v in filters.items():
171+
if v == "<session_name>":
172+
resolved[k] = session_name
173+
elif v == "<partial>":
174+
resolved[k] = session_name[:4]
175+
elif v == "<partial_upper>":
176+
resolved[k] = session_name[:4].upper()
177+
else:
178+
resolved[k] = v
179+
filters = resolved
180+
181+
if expect_error:
182+
with pytest.raises(ToolError, match=error_match):
183+
list_sessions(
184+
socket_name=mcp_server.socket_name,
185+
filters=filters,
186+
)
187+
else:
188+
result = list_sessions(
189+
socket_name=mcp_server.socket_name,
190+
filters=filters,
191+
)
192+
data = json.loads(result)
193+
assert isinstance(data, list)
194+
if expected_count is not None:
195+
assert len(data) == expected_count
196+
else:
197+
assert len(data) >= 1
198+
199+
72200
def test_kill_server(mcp_server: Server, mcp_session: Session) -> None:
73201
"""kill_server kills the tmux server."""
74202
result = kill_server(socket_name=mcp_server.socket_name)

0 commit comments

Comments
 (0)