Skip to content

Commit 5537356

Browse files
committed
mcp(fix[_utils]): Accept filters as JSON string for MCP client compat
why: Multiple MCP clients (Cursor agent CLI, older Claude Code) cannot serialize nested dict tool arguments — they either stringify the object or fail with a JSON parse error before dispatching. This is a widely reported bug across platforms. refs: - https://forum.cursor.com/t/145807 (Dec 2025) - https://forum.cursor.com/t/132571 - https://forum.cursor.com/t/151180 (Feb 2026) - makenotion/notion-mcp-server#176 (Jan 2026) - anthropics/claude-code#5504 what: - Widen _apply_filters() to accept str, parse via json.loads() - Widen tool signatures to dict | str | None for JSON Schema compat - Add 5 parametrized test cases for string coercion and error paths
1 parent 62f47c6 commit 5537356

File tree

5 files changed

+75
-15
lines changed

5 files changed

+75
-15
lines changed

src/libtmux/mcp/_utils.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import functools
10+
import json
1011
import logging
1112
import os
1213
import typing as t
@@ -276,7 +277,7 @@ def _resolve_pane(
276277

277278
def _apply_filters(
278279
items: t.Any,
279-
filters: dict[str, str] | None,
280+
filters: dict[str, str] | str | None,
280281
serializer: t.Callable[..., dict[str, t.Any]],
281282
) -> list[dict[str, t.Any]]:
282283
"""Apply QueryList filters and serialize results.
@@ -285,8 +286,9 @@ def _apply_filters(
285286
----------
286287
items : QueryList
287288
The QueryList of tmux objects to filter.
288-
filters : dict or None
289-
Django-style filter kwargs (e.g. ``{"session_name__contains": "dev"}``).
289+
filters : dict or str, optional
290+
Django-style filters as a dict (e.g. ``{"session_name__contains": "dev"}``)
291+
or as a JSON string. Some MCP clients require the string form.
290292
If None or empty, all items are returned.
291293
serializer : callable
292294
Serializer function to convert each item to a dict.
@@ -306,6 +308,20 @@ def _apply_filters(
306308

307309
from fastmcp.exceptions import ToolError
308310

311+
# Workaround: Cursor and other MCP clients serialize dict params as
312+
# JSON strings instead of objects. Accept both forms.
313+
# See: https://forum.cursor.com/t/145807
314+
# https://github.com/anthropics/claude-code/issues/5504
315+
if isinstance(filters, str):
316+
try:
317+
filters = json.loads(filters)
318+
except (json.JSONDecodeError, ValueError) as e:
319+
msg = f"Invalid filters JSON: {e}"
320+
raise ToolError(msg) from e
321+
if not isinstance(filters, dict):
322+
msg = f"filters must be a JSON object, got {type(filters).__name__}"
323+
raise ToolError(msg) from None
324+
309325
valid_ops = sorted(LOOKUP_NAME_MAP.keys())
310326
for key in filters:
311327
if "__" in key:

src/libtmux/mcp/tools/server_tools.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,17 @@
2020
@handle_tool_errors
2121
def list_sessions(
2222
socket_name: str | None = None,
23-
filters: dict[str, str] | None = None,
23+
filters: dict[str, str] | str | None = None,
2424
) -> str:
2525
"""List all tmux sessions.
2626
2727
Parameters
2828
----------
2929
socket_name : str, optional
3030
tmux socket name. Defaults to LIBTMUX_SOCKET env var.
31-
filters : dict, optional
32-
Django-style filters (e.g. ``{"session_name__contains": "dev"}``).
31+
filters : dict or str, optional
32+
Django-style filters as a dict (e.g. ``{"session_name__contains": "dev"}``)
33+
or as a JSON string. Some MCP clients require the string form.
3334
3435
Returns
3536
-------

src/libtmux/mcp/tools/session_tools.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def list_windows(
2424
session_name: str | None = None,
2525
session_id: str | None = None,
2626
socket_name: str | None = None,
27-
filters: dict[str, str] | None = None,
27+
filters: dict[str, str] | str | None = None,
2828
) -> str:
2929
"""List windows in a tmux session, or all windows across sessions.
3030
@@ -37,8 +37,9 @@ def list_windows(
3737
Session ID (e.g. '$1') to look up.
3838
socket_name : str, optional
3939
tmux socket name. Defaults to LIBTMUX_SOCKET env var.
40-
filters : dict, optional
41-
Django-style filters (e.g. ``{"window_name__contains": "dev"}``).
40+
filters : dict or str, optional
41+
Django-style filters as a dict (e.g. ``{"window_name__contains": "dev"}``)
42+
or as a JSON string. Some MCP clients require the string form.
4243
4344
Returns
4445
-------

src/libtmux/mcp/tools/window_tools.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def list_panes(
3535
window_id: str | None = None,
3636
window_index: str | None = None,
3737
socket_name: str | None = None,
38-
filters: dict[str, str] | None = None,
38+
filters: dict[str, str] | str | None = None,
3939
) -> str:
4040
"""List panes in a tmux window, session, or across the entire server.
4141
@@ -53,8 +53,10 @@ def list_panes(
5353
Window index within the session. Scopes to a single window.
5454
socket_name : str, optional
5555
tmux socket name.
56-
filters : dict, optional
57-
Django-style filters (e.g. ``{"pane_current_command__contains": "vim"}``).
56+
filters : dict or str, optional
57+
Django-style filters as a dict
58+
(e.g. ``{"pane_current_command__contains": "vim"}``)
59+
or as a JSON string. Some MCP clients require the string form.
5860
5961
Returns
6062
-------

tests/mcp/test_utils.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ class ApplyFiltersFixture(t.NamedTuple):
152152
"""Test fixture for _apply_filters."""
153153

154154
test_id: str
155-
filters: dict[str, str] | None
155+
filters: dict[str, str] | str | None
156156
expected_count: int | None # None = don't check exact count
157157
expect_error: bool
158158
error_match: str | None
@@ -201,6 +201,41 @@ class ApplyFiltersFixture(t.NamedTuple):
201201
expect_error=False,
202202
error_match=None,
203203
),
204+
ApplyFiltersFixture(
205+
test_id="string_filter_exact",
206+
filters='{"session_name": "<session_name>"}',
207+
expected_count=1,
208+
expect_error=False,
209+
error_match=None,
210+
),
211+
ApplyFiltersFixture(
212+
test_id="string_filter_contains",
213+
filters='{"session_name__contains": "<partial>"}',
214+
expected_count=1,
215+
expect_error=False,
216+
error_match=None,
217+
),
218+
ApplyFiltersFixture(
219+
test_id="string_filter_invalid_json",
220+
filters="{bad json",
221+
expected_count=None,
222+
expect_error=True,
223+
error_match="Invalid filters JSON",
224+
),
225+
ApplyFiltersFixture(
226+
test_id="string_filter_not_object",
227+
filters='"just a string"',
228+
expected_count=None,
229+
expect_error=True,
230+
error_match="filters must be a JSON object",
231+
),
232+
ApplyFiltersFixture(
233+
test_id="string_filter_array",
234+
filters='["not", "a", "dict"]',
235+
expected_count=None,
236+
expect_error=True,
237+
error_match="filters must be a JSON object",
238+
),
204239
]
205240

206241

@@ -213,14 +248,19 @@ def test_apply_filters(
213248
mcp_server: Server,
214249
mcp_session: Session,
215250
test_id: str,
216-
filters: dict[str, str] | None,
251+
filters: dict[str, str] | str | None,
217252
expected_count: int | None,
218253
expect_error: bool,
219254
error_match: str | None,
220255
) -> None:
221256
"""_apply_filters bridges dict params to QueryList.filter()."""
222257
# Substitute placeholders with real session name
223-
if filters is not None:
258+
if isinstance(filters, str):
259+
session_name = mcp_session.session_name
260+
assert session_name is not None
261+
filters = filters.replace("<session_name>", session_name)
262+
filters = filters.replace("<partial>", session_name[:4])
263+
elif filters is not None:
224264
session_name = mcp_session.session_name
225265
assert session_name is not None
226266
resolved: dict[str, str] = {}

0 commit comments

Comments
 (0)