Skip to content

Commit 2d629eb

Browse files
committed
mcp(refactor[tools,resources]): Return Pydantic models instead of JSON strings
why: Returning typed Pydantic models instead of `json.dumps()` strings gives MCP clients auto-generated `outputSchema` for result validation, lets tests assert on model attributes directly (`result.session_name`) instead of `json.loads(result)["session_name"]`, and centralizes field documentation in model `Field(description=...)` definitions. what: - Update `_serialize_*` functions to return Pydantic models - Update `_apply_filters` with generic TypeVar for typed returns - Change tool return types: `str` → `SessionInfo`, `WindowInfo`, `PaneInfo`, `ServerInfo`, `OptionResult`, `OptionSetResult`, `EnvironmentSetResult` - Keep `str` returns for message-only tools (kill_*, send_keys, clear_pane) and text output (capture_pane, show_environment) - Update resources to use `.model_dump()` before `json.dumps()` - Update all tests to assert on model attributes directly
1 parent 4175ac1 commit 2d629eb

15 files changed

Lines changed: 219 additions & 225 deletions

src/libtmux/mcp/_utils.py

Lines changed: 61 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from libtmux.server import Server
1919

2020
if t.TYPE_CHECKING:
21+
from libtmux.mcp.models import PaneInfo, SessionInfo, WindowInfo
2122
from libtmux.pane import Pane
2223
from libtmux.session import Session
2324
from libtmux.window import Window
@@ -286,11 +287,14 @@ def _resolve_pane(
286287
return panes[0]
287288

288289

290+
M = t.TypeVar("M")
291+
292+
289293
def _apply_filters(
290294
items: t.Any,
291295
filters: dict[str, str] | str | None,
292-
serializer: t.Callable[..., dict[str, t.Any]],
293-
) -> list[dict[str, t.Any]]:
296+
serializer: t.Callable[..., M],
297+
) -> list[M]:
294298
"""Apply QueryList filters and serialize results.
295299
296300
Parameters
@@ -302,11 +306,11 @@ def _apply_filters(
302306
or as a JSON string. Some MCP clients require the string form.
303307
If None or empty, all items are returned.
304308
serializer : callable
305-
Serializer function to convert each item to a dict.
309+
Serializer function to convert each item to a model.
306310
307311
Returns
308312
-------
309-
list[dict]
313+
list
310314
Serialized list of matching items.
311315
312316
Raises
@@ -349,8 +353,8 @@ def _apply_filters(
349353
return [serializer(item) for item in filtered]
350354

351355

352-
def _serialize_session(session: Session) -> dict[str, t.Any]:
353-
"""Serialize a Session to a JSON-compatible dict.
356+
def _serialize_session(session: Session) -> SessionInfo:
357+
"""Serialize a Session to a Pydantic model.
354358
355359
Parameters
356360
----------
@@ -359,20 +363,23 @@ def _serialize_session(session: Session) -> dict[str, t.Any]:
359363
360364
Returns
361365
-------
362-
dict
363-
Session data including id, name, window count, dimensions.
366+
SessionInfo
367+
Session data including id, name, window count.
364368
"""
365-
return {
366-
"session_id": session.session_id,
367-
"session_name": session.session_name,
368-
"window_count": len(session.windows),
369-
"session_attached": getattr(session, "session_attached", None),
370-
"session_created": getattr(session, "session_created", None),
371-
}
369+
from libtmux.mcp.models import SessionInfo
370+
371+
assert session.session_id is not None
372+
return SessionInfo(
373+
session_id=session.session_id,
374+
session_name=session.session_name,
375+
window_count=len(session.windows),
376+
session_attached=getattr(session, "session_attached", None),
377+
session_created=getattr(session, "session_created", None),
378+
)
372379

373380

374-
def _serialize_window(window: Window) -> dict[str, t.Any]:
375-
"""Serialize a Window to a JSON-compatible dict.
381+
def _serialize_window(window: Window) -> WindowInfo:
382+
"""Serialize a Window to a Pydantic model.
376383
377384
Parameters
378385
----------
@@ -381,25 +388,28 @@ def _serialize_window(window: Window) -> dict[str, t.Any]:
381388
382389
Returns
383390
-------
384-
dict
391+
WindowInfo
385392
Window data including id, name, index, pane count, layout.
386393
"""
387-
return {
388-
"window_id": window.window_id,
389-
"window_name": window.window_name,
390-
"window_index": window.window_index,
391-
"session_id": window.session_id,
392-
"session_name": getattr(window, "session_name", None),
393-
"pane_count": len(window.panes),
394-
"window_layout": getattr(window, "window_layout", None),
395-
"window_active": getattr(window, "window_active", None),
396-
"window_width": getattr(window, "window_width", None),
397-
"window_height": getattr(window, "window_height", None),
398-
}
399-
400-
401-
def _serialize_pane(pane: Pane) -> dict[str, t.Any]:
402-
"""Serialize a Pane to a JSON-compatible dict.
394+
from libtmux.mcp.models import WindowInfo
395+
396+
assert window.window_id is not None
397+
return WindowInfo(
398+
window_id=window.window_id,
399+
window_name=window.window_name,
400+
window_index=window.window_index,
401+
session_id=window.session_id,
402+
session_name=getattr(window, "session_name", None),
403+
pane_count=len(window.panes),
404+
window_layout=getattr(window, "window_layout", None),
405+
window_active=getattr(window, "window_active", None),
406+
window_width=getattr(window, "window_width", None),
407+
window_height=getattr(window, "window_height", None),
408+
)
409+
410+
411+
def _serialize_pane(pane: Pane) -> PaneInfo:
412+
"""Serialize a Pane to a Pydantic model.
403413
404414
Parameters
405415
----------
@@ -408,22 +418,25 @@ def _serialize_pane(pane: Pane) -> dict[str, t.Any]:
408418
409419
Returns
410420
-------
411-
dict
421+
PaneInfo
412422
Pane data including id, dimensions, current command, title.
413423
"""
414-
return {
415-
"pane_id": pane.pane_id,
416-
"pane_index": getattr(pane, "pane_index", None),
417-
"pane_width": getattr(pane, "pane_width", None),
418-
"pane_height": getattr(pane, "pane_height", None),
419-
"pane_current_command": getattr(pane, "pane_current_command", None),
420-
"pane_current_path": getattr(pane, "pane_current_path", None),
421-
"pane_pid": getattr(pane, "pane_pid", None),
422-
"pane_title": getattr(pane, "pane_title", None),
423-
"pane_active": getattr(pane, "pane_active", None),
424-
"window_id": pane.window_id,
425-
"session_id": pane.session_id,
426-
}
424+
from libtmux.mcp.models import PaneInfo
425+
426+
assert pane.pane_id is not None
427+
return PaneInfo(
428+
pane_id=pane.pane_id,
429+
pane_index=getattr(pane, "pane_index", None),
430+
pane_width=getattr(pane, "pane_width", None),
431+
pane_height=getattr(pane, "pane_height", None),
432+
pane_current_command=getattr(pane, "pane_current_command", None),
433+
pane_current_path=getattr(pane, "pane_current_path", None),
434+
pane_pid=getattr(pane, "pane_pid", None),
435+
pane_title=getattr(pane, "pane_title", None),
436+
pane_active=getattr(pane, "pane_active", None),
437+
window_id=pane.window_id,
438+
session_id=pane.session_id,
439+
)
427440

428441

429442
P = t.ParamSpec("P")

src/libtmux/mcp/resources/hierarchy.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def get_sessions() -> str:
3131
JSON array of session objects.
3232
"""
3333
server = _get_server()
34-
sessions = [_serialize_session(s) for s in server.sessions]
34+
sessions = [_serialize_session(s).model_dump() for s in server.sessions]
3535
return json.dumps(sessions, indent=2)
3636

3737
@mcp.resource("tmux://sessions/{session_name}", title="Session Detail")
@@ -54,8 +54,8 @@ def get_session(session_name: str) -> str:
5454
msg = f"Session not found: {session_name}"
5555
raise ResourceError(msg)
5656

57-
result = _serialize_session(session)
58-
result["windows"] = [_serialize_window(w) for w in session.windows]
57+
result = _serialize_session(session).model_dump()
58+
result["windows"] = [_serialize_window(w).model_dump() for w in session.windows]
5959
return json.dumps(result, indent=2)
6060

6161
@mcp.resource("tmux://sessions/{session_name}/windows", title="Session Windows")
@@ -78,7 +78,7 @@ def get_session_windows(session_name: str) -> str:
7878
msg = f"Session not found: {session_name}"
7979
raise ResourceError(msg)
8080

81-
windows = [_serialize_window(w) for w in session.windows]
81+
windows = [_serialize_window(w).model_dump() for w in session.windows]
8282
return json.dumps(windows, indent=2)
8383

8484
@mcp.resource(
@@ -111,8 +111,8 @@ def get_window(session_name: str, window_index: str) -> str:
111111
msg = f"Window not found: index {window_index}"
112112
raise ResourceError(msg)
113113

114-
result = _serialize_window(window)
115-
result["panes"] = [_serialize_pane(p) for p in window.panes]
114+
result = _serialize_window(window).model_dump()
115+
result["panes"] = [_serialize_pane(p).model_dump() for p in window.panes]
116116
return json.dumps(result, indent=2)
117117

118118
@mcp.resource("tmux://panes/{pane_id}", title="Pane Detail")
@@ -135,7 +135,7 @@ def get_pane(pane_id: str) -> str:
135135
msg = f"Pane not found: {pane_id}"
136136
raise ResourceError(msg)
137137

138-
return json.dumps(_serialize_pane(pane), indent=2)
138+
return json.dumps(_serialize_pane(pane).model_dump(), indent=2)
139139

140140
@mcp.resource("tmux://panes/{pane_id}/content", title="Pane Content")
141141
def get_pane_content(pane_id: str) -> str:

src/libtmux/mcp/tools/env_tools.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
_resolve_session,
1111
handle_tool_errors,
1212
)
13+
from libtmux.mcp.models import EnvironmentSetResult
1314

1415
if t.TYPE_CHECKING:
1516
from fastmcp import FastMCP
@@ -59,7 +60,7 @@ def set_environment(
5960
session_name: str | None = None,
6061
session_id: str | None = None,
6162
socket_name: str | None = None,
62-
) -> str:
63+
) -> EnvironmentSetResult:
6364
"""Set a tmux environment variable.
6465
6566
Parameters
@@ -77,8 +78,8 @@ def set_environment(
7778
7879
Returns
7980
-------
80-
str
81-
JSON confirming the variable was set.
81+
EnvironmentSetResult
82+
Confirmation with variable name, value, and status.
8283
"""
8384
server = _get_server(socket_name=socket_name)
8485

@@ -92,7 +93,7 @@ def set_environment(
9293
else:
9394
server.set_environment(name, value)
9495

95-
return json.dumps({"name": name, "value": value, "status": "set"})
96+
return EnvironmentSetResult(name=name, value=value, status="set")
9697

9798

9899
def register(mcp: FastMCP) -> None:

src/libtmux/mcp/tools/option_tools.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import json
65
import typing as t
76

87
from libtmux.constants import OptionScope
@@ -13,6 +12,7 @@
1312
_resolve_window,
1413
handle_tool_errors,
1514
)
15+
from libtmux.mcp.models import OptionResult, OptionSetResult
1616

1717
if t.TYPE_CHECKING:
1818
from fastmcp import FastMCP
@@ -66,7 +66,7 @@ def show_option(
6666
target: str | None = None,
6767
global_: bool = False,
6868
socket_name: str | None = None,
69-
) -> str:
69+
) -> OptionResult:
7070
"""Show a tmux option value.
7171
7272
Parameters
@@ -86,12 +86,12 @@ def show_option(
8686
8787
Returns
8888
-------
89-
str
90-
JSON with the option name and its value.
89+
OptionResult
90+
Option name and its value.
9191
"""
9292
obj, opt_scope = _resolve_option_target(socket_name, scope, target)
9393
value = obj.show_option(option, global_=global_, scope=opt_scope)
94-
return json.dumps({"option": option, "value": value})
94+
return OptionResult(option=option, value=value)
9595

9696

9797
@handle_tool_errors
@@ -102,7 +102,7 @@ def set_option(
102102
target: str | None = None,
103103
global_: bool = False,
104104
socket_name: str | None = None,
105-
) -> str:
105+
) -> OptionSetResult:
106106
"""Set a tmux option value.
107107
108108
Parameters
@@ -124,12 +124,12 @@ def set_option(
124124
125125
Returns
126126
-------
127-
str
128-
JSON confirming the option was set.
127+
OptionSetResult
128+
Confirmation with option name, value, and status.
129129
"""
130130
obj, opt_scope = _resolve_option_target(socket_name, scope, target)
131131
obj.set_option(option, value, global_=global_, scope=opt_scope)
132-
return json.dumps({"option": option, "value": value, "status": "set"})
132+
return OptionSetResult(option=option, value=value, status="set")
133133

134134

135135
def register(mcp: FastMCP) -> None:

0 commit comments

Comments
 (0)