Skip to content

Commit 6088e7f

Browse files
committed
mcp(feat[tools,resources]): Add complete MCP annotations and titles
why: The MCP spec (2025-06-18) defines 4 tool annotation hints with defaults that can be misleading — `destructiveHint` defaults to `true` and `openWorldHint` defaults to `true`. Tools that only set `readOnlyHint: true` inherited the contradictory `destructiveHint: true` default. Since all tools interact with local tmux (not external APIs), `openWorldHint` should be `false` across the board. Additionally, the MCP spec supports `title` on tools and resources for human-readable display in MCP clients, but none were set. what: - Set all 4 annotations explicitly on all 25 tools (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) - Add human-readable `title` to all 25 tools and 6 resources - Set `openWorldHint: false` everywhere (local tmux, not external APIs) - Set `idempotentHint: true` on rename/set/resize/select/kill tools - Update MockMCP in test_resources.py to accept **kwargs
1 parent 13e2032 commit 6088e7f

File tree

8 files changed

+169
-34
lines changed

8 files changed

+169
-34
lines changed

src/libtmux/mcp/resources/hierarchy.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
def register(mcp: FastMCP) -> None:
2222
"""Register hierarchy resources with the FastMCP instance."""
2323

24-
@mcp.resource("tmux://sessions")
24+
@mcp.resource("tmux://sessions", title="All Sessions")
2525
def get_sessions() -> str:
2626
"""List all tmux sessions.
2727
@@ -34,7 +34,7 @@ def get_sessions() -> str:
3434
sessions = [_serialize_session(s) for s in server.sessions]
3535
return json.dumps(sessions, indent=2)
3636

37-
@mcp.resource("tmux://sessions/{session_name}")
37+
@mcp.resource("tmux://sessions/{session_name}", title="Session Detail")
3838
def get_session(session_name: str) -> str:
3939
"""Get details of a specific tmux session.
4040
@@ -58,7 +58,7 @@ def get_session(session_name: str) -> str:
5858
result["windows"] = [_serialize_window(w) for w in session.windows]
5959
return json.dumps(result, indent=2)
6060

61-
@mcp.resource("tmux://sessions/{session_name}/windows")
61+
@mcp.resource("tmux://sessions/{session_name}/windows", title="Session Windows")
6262
def get_session_windows(session_name: str) -> str:
6363
"""List all windows in a tmux session.
6464
@@ -81,7 +81,10 @@ def get_session_windows(session_name: str) -> str:
8181
windows = [_serialize_window(w) for w in session.windows]
8282
return json.dumps(windows, indent=2)
8383

84-
@mcp.resource("tmux://sessions/{session_name}/windows/{window_index}")
84+
@mcp.resource(
85+
"tmux://sessions/{session_name}/windows/{window_index}",
86+
title="Window Detail",
87+
)
8588
def get_window(session_name: str, window_index: str) -> str:
8689
"""Get details of a specific window in a session.
8790
@@ -112,7 +115,7 @@ def get_window(session_name: str, window_index: str) -> str:
112115
result["panes"] = [_serialize_pane(p) for p in window.panes]
113116
return json.dumps(result, indent=2)
114117

115-
@mcp.resource("tmux://panes/{pane_id}")
118+
@mcp.resource("tmux://panes/{pane_id}", title="Pane Detail")
116119
def get_pane(pane_id: str) -> str:
117120
"""Get details of a specific pane.
118121
@@ -134,7 +137,7 @@ def get_pane(pane_id: str) -> str:
134137

135138
return json.dumps(_serialize_pane(pane), indent=2)
136139

137-
@mcp.resource("tmux://panes/{pane_id}/content")
140+
@mcp.resource("tmux://panes/{pane_id}/content", title="Pane Content")
138141
def get_pane_content(pane_id: str) -> str:
139142
"""Capture and return the content of a pane.
140143

src/libtmux/mcp/tools/env_tools.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,5 +97,19 @@ def set_environment(
9797

9898
def register(mcp: FastMCP) -> None:
9999
"""Register environment tools with the MCP instance."""
100-
mcp.tool(annotations={"readOnlyHint": True})(show_environment)
101-
mcp.tool(annotations={"destructiveHint": False})(set_environment)
100+
_RO = {
101+
"readOnlyHint": True,
102+
"destructiveHint": False,
103+
"idempotentHint": True,
104+
"openWorldHint": False,
105+
}
106+
mcp.tool(title="Show Environment", annotations=_RO)(show_environment)
107+
mcp.tool(
108+
title="Set Environment",
109+
annotations={
110+
"readOnlyHint": False,
111+
"destructiveHint": False,
112+
"idempotentHint": True,
113+
"openWorldHint": False,
114+
},
115+
)(set_environment)

src/libtmux/mcp/tools/option_tools.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,19 @@ def set_option(
130130

131131
def register(mcp: FastMCP) -> None:
132132
"""Register option tools with the MCP instance."""
133-
mcp.tool(annotations={"readOnlyHint": True})(show_option)
134-
mcp.tool(annotations={"destructiveHint": False})(set_option)
133+
_RO = {
134+
"readOnlyHint": True,
135+
"destructiveHint": False,
136+
"idempotentHint": True,
137+
"openWorldHint": False,
138+
}
139+
mcp.tool(title="Show Option", annotations=_RO)(show_option)
140+
mcp.tool(
141+
title="Set Option",
142+
annotations={
143+
"readOnlyHint": False,
144+
"destructiveHint": False,
145+
"idempotentHint": True,
146+
"openWorldHint": False,
147+
},
148+
)(set_option)

src/libtmux/mcp/tools/pane_tools.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -320,10 +320,38 @@ def clear_pane(
320320

321321
def register(mcp: FastMCP) -> None:
322322
"""Register pane-level tools with the MCP instance."""
323-
mcp.tool(annotations={"destructiveHint": True, "idempotentHint": False})(send_keys)
324-
mcp.tool(annotations={"readOnlyHint": True})(capture_pane)
325-
mcp.tool(annotations={"destructiveHint": False})(resize_pane)
326-
mcp.tool(annotations={"destructiveHint": True})(kill_pane)
327-
mcp.tool(annotations={"destructiveHint": False})(set_pane_title)
328-
mcp.tool(annotations={"readOnlyHint": True})(get_pane_info)
329-
mcp.tool(annotations={"destructiveHint": False})(clear_pane)
323+
_RO = {
324+
"readOnlyHint": True,
325+
"destructiveHint": False,
326+
"idempotentHint": True,
327+
"openWorldHint": False,
328+
}
329+
_IDEM = {
330+
"readOnlyHint": False,
331+
"destructiveHint": False,
332+
"idempotentHint": True,
333+
"openWorldHint": False,
334+
}
335+
mcp.tool(
336+
title="Send Keys",
337+
annotations={
338+
"readOnlyHint": False,
339+
"destructiveHint": True,
340+
"idempotentHint": False,
341+
"openWorldHint": False,
342+
},
343+
)(send_keys)
344+
mcp.tool(title="Capture Pane", annotations=_RO)(capture_pane)
345+
mcp.tool(title="Resize Pane", annotations=_IDEM)(resize_pane)
346+
mcp.tool(
347+
title="Kill Pane",
348+
annotations={
349+
"readOnlyHint": False,
350+
"destructiveHint": True,
351+
"idempotentHint": True,
352+
"openWorldHint": False,
353+
},
354+
)(kill_pane)
355+
mcp.tool(title="Set Pane Title", annotations=_IDEM)(set_pane_title)
356+
mcp.tool(title="Get Pane Info", annotations=_RO)(get_pane_info)
357+
mcp.tool(title="Clear Pane", annotations=_IDEM)(clear_pane)

src/libtmux/mcp/tools/server_tools.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,29 @@ def get_server_info(socket_name: str | None = None) -> str:
146146

147147
def register(mcp: FastMCP) -> None:
148148
"""Register server-level tools with the MCP instance."""
149-
mcp.tool(annotations={"readOnlyHint": True})(list_sessions)
150-
mcp.tool(annotations={"destructiveHint": False, "idempotentHint": False})(
151-
create_session
152-
)
153-
mcp.tool(annotations={"destructiveHint": True})(kill_server)
154-
mcp.tool(annotations={"readOnlyHint": True})(get_server_info)
149+
_RO = {
150+
"readOnlyHint": True,
151+
"destructiveHint": False,
152+
"idempotentHint": True,
153+
"openWorldHint": False,
154+
}
155+
mcp.tool(title="List Sessions", annotations=_RO)(list_sessions)
156+
mcp.tool(
157+
title="Create Session",
158+
annotations={
159+
"readOnlyHint": False,
160+
"destructiveHint": False,
161+
"idempotentHint": False,
162+
"openWorldHint": False,
163+
},
164+
)(create_session)
165+
mcp.tool(
166+
title="Kill Server",
167+
annotations={
168+
"readOnlyHint": False,
169+
"destructiveHint": True,
170+
"idempotentHint": True,
171+
"openWorldHint": False,
172+
},
173+
)(kill_server)
174+
mcp.tool(title="Get Server Info", annotations=_RO)(get_server_info)

src/libtmux/mcp/tools/session_tools.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,35 @@ def kill_session(
178178

179179
def register(mcp: FastMCP) -> None:
180180
"""Register session-level tools with the MCP instance."""
181-
mcp.tool(annotations={"readOnlyHint": True})(list_windows)
182-
mcp.tool(annotations={"destructiveHint": False})(create_window)
183-
mcp.tool(annotations={"destructiveHint": False})(rename_session)
184-
mcp.tool(annotations={"destructiveHint": True})(kill_session)
181+
_RO = {
182+
"readOnlyHint": True,
183+
"destructiveHint": False,
184+
"idempotentHint": True,
185+
"openWorldHint": False,
186+
}
187+
_IDEM = {
188+
"readOnlyHint": False,
189+
"destructiveHint": False,
190+
"idempotentHint": True,
191+
"openWorldHint": False,
192+
}
193+
mcp.tool(title="List Windows", annotations=_RO)(list_windows)
194+
mcp.tool(
195+
title="Create Window",
196+
annotations={
197+
"readOnlyHint": False,
198+
"destructiveHint": False,
199+
"idempotentHint": False,
200+
"openWorldHint": False,
201+
},
202+
)(create_window)
203+
mcp.tool(title="Rename Session", annotations=_IDEM)(rename_session)
204+
mcp.tool(
205+
title="Kill Session",
206+
annotations={
207+
"readOnlyHint": False,
208+
"destructiveHint": True,
209+
"idempotentHint": True,
210+
"openWorldHint": False,
211+
},
212+
)(kill_session)

src/libtmux/mcp/tools/window_tools.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,37 @@ def resize_window(
334334

335335
def register(mcp: FastMCP) -> None:
336336
"""Register window-level tools with the MCP instance."""
337-
mcp.tool(annotations={"readOnlyHint": True})(list_panes)
338-
mcp.tool(annotations={"destructiveHint": False})(split_window)
339-
mcp.tool(annotations={"destructiveHint": False})(rename_window)
340-
mcp.tool(annotations={"destructiveHint": True})(kill_window)
341-
mcp.tool(annotations={"destructiveHint": False})(select_layout)
342-
mcp.tool(annotations={"destructiveHint": False})(resize_window)
337+
_RO = {
338+
"readOnlyHint": True,
339+
"destructiveHint": False,
340+
"idempotentHint": True,
341+
"openWorldHint": False,
342+
}
343+
_IDEM = {
344+
"readOnlyHint": False,
345+
"destructiveHint": False,
346+
"idempotentHint": True,
347+
"openWorldHint": False,
348+
}
349+
mcp.tool(title="List Panes", annotations=_RO)(list_panes)
350+
mcp.tool(
351+
title="Split Window",
352+
annotations={
353+
"readOnlyHint": False,
354+
"destructiveHint": False,
355+
"idempotentHint": False,
356+
"openWorldHint": False,
357+
},
358+
)(split_window)
359+
mcp.tool(title="Rename Window", annotations=_IDEM)(rename_window)
360+
mcp.tool(
361+
title="Kill Window",
362+
annotations={
363+
"readOnlyHint": False,
364+
"destructiveHint": True,
365+
"idempotentHint": True,
366+
"openWorldHint": False,
367+
},
368+
)(kill_window)
369+
mcp.tool(title="Select Layout", annotations=_IDEM)(select_layout)
370+
mcp.tool(title="Resize Window", annotations=_IDEM)(resize_window)

tests/mcp/test_resources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def resource_functions(mcp_server: Server) -> dict[str, t.Any]:
2626
functions: dict[str, t.Any] = {}
2727

2828
class MockMCP:
29-
def resource(self, uri: str) -> t.Any:
29+
def resource(self, uri: str, **kwargs: t.Any) -> t.Any:
3030
def decorator(fn: t.Any) -> t.Any:
3131
functions[uri] = fn
3232
return fn

0 commit comments

Comments
 (0)