Skip to content

Commit 393cef4

Browse files
committed
mcp(refactor[instructions]): surface call-site rules in tool descriptions
Follow-up to 6432646 which split ``_BASE_INSTRUCTIONS`` into named gap-explainer / positive-guidance segments and named the "prefer the tool description" decision. Phase 1 of the slim-down acts on it: per-tool rules move from the global card (or invisible module docstrings) into tool descriptions an agent sees on every ``list_tools`` call. * ``show_hooks``: docstring now carries the no-set_hook rationale (write-hooks survive process death, so they belong in the tmux config file, not a transient MCP session). Previously only in the ``hook_tools`` module docstring — FastMCP doesn't surface those. * ``load_buffer``: docstring carries the no-list_buffers / clipboard-privacy rationale. Same module-docstring-only problem. * ``capture_pane``: registered with a ``description=`` override pointing at ``snapshot_pane``, ``wait_for_text``, and ``search_panes``. The function docstring stays focused on parameters for Sphinx; the override carries the agent-facing cross-references without bloating the human docstring. * ``send_keys``: explicit anti-poll guidance naming ``wait_for_text`` as the server-side blocking primitive. * ``list_panes`` / ``list_windows``: sharpened metadata-vs-content phrasing with the user-trigger language ("panes that contain X"). New parametrized ``test_tool_description_includes`` asserts each tool is registered AND its description carries the cross-reference, so a future rename that drops the rule fails loudly instead of silently. Pure addition — ``_BASE_INSTRUCTIONS`` is unchanged. The redundant card-level segments come out in a later phase once the call-site copies have shipped.
1 parent 6534251 commit 393cef4

7 files changed

Lines changed: 93 additions & 11 deletions

File tree

src/libtmux_mcp/tools/buffer_tools.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ def load_buffer(
176176
) -> BufferRef:
177177
"""Load text into a new agent-namespaced tmux paste buffer.
178178
179+
Track the returned BufferRef on subsequent paste_buffer / show_buffer
180+
/ delete_buffer calls — there is no list_buffers tool, because tmux
181+
buffers may include OS clipboard history (passwords, private
182+
snippets) and a blanket enumeration would leak that to the agent.
183+
179184
Each call allocates a fresh buffer name — two concurrent calls will
180185
land in distinct buffers even if they pass the same ``logical_name``.
181186
Agents MUST use the returned :attr:`BufferRef.buffer_name` on

src/libtmux_mcp/tools/hook_tools.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ def show_hooks(
166166
) -> HookListResult:
167167
"""List configured tmux hooks at the given scope.
168168
169+
Hooks are read-only by design: tmux hooks survive process death
170+
(kill -9, OOM, etc.), so write-hooks belong in your tmux config file,
171+
not a transient MCP session. No set_hook / unset_hook tool is exposed
172+
for that reason. Use this to inspect what is configured.
173+
169174
``scope="server"`` enumerates hooks installed via
170175
``tmux set-hook -g ...``. tmux splits those globals across two
171176
options trees by hook category: session-level hooks

src/libtmux_mcp/tools/pane_tools/__init__.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,20 @@ def register(mcp: FastMCP) -> None:
8080
mcp.tool(title="Send Keys", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})(
8181
send_keys
8282
)
83-
mcp.tool(title="Capture Pane", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
84-
capture_pane
85-
)
83+
mcp.tool(
84+
title="Capture Pane",
85+
annotations=ANNOTATIONS_RO,
86+
tags={TAG_READONLY},
87+
description=(
88+
"Capture the visible contents of a tmux pane (tail-preserving "
89+
"truncation at max_lines, default 500). For pane content + "
90+
"cursor + mode + scroll state in one call, use snapshot_pane. "
91+
"For 'send_keys then wait for output' flows, use wait_for_text "
92+
"or wait_for_content_change instead of a capture_pane retry "
93+
"loop — server-side blocking is dramatically cheaper in agent "
94+
"turns. To find text across many panes, use search_panes."
95+
),
96+
)(capture_pane)
8697
mcp.tool(
8798
title="Resize Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING}
8899
)(resize_pane)

src/libtmux_mcp/tools/pane_tools/io.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ def send_keys(
3232
) -> str:
3333
"""Send keys (commands or text) to a tmux pane.
3434
35-
After sending, use wait_for_text to block until the command completes,
36-
or capture_pane to read the result. Do not capture_pane immediately —
37-
there is a race condition.
35+
After sending, use wait_for_text to block until the command completes
36+
(server-side, turn-cheap) or capture_pane once you know it has
37+
finished. Do not capture_pane in a tight loop — that races with
38+
command execution and burns agent turns; wait_for_text is the
39+
server-side blocking primitive built for this flow.
3840
3941
Parameters
4042
----------

src/libtmux_mcp/tools/session_tools.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ def list_windows(
3939
) -> list[WindowInfo]:
4040
"""List windows in a tmux session, or all windows across sessions.
4141
42-
Only searches window metadata (name, index, layout). To search
43-
the actual text visible in terminal panes, use search_panes instead.
42+
Searches window metadata only (name, index, layout). For text
43+
visible IN terminals — when users say "panes that contain/mention/show X"
44+
— use search_panes instead.
4445
4546
Parameters
4647
----------

src/libtmux_mcp/tools/window_tools.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ def list_panes(
5050
) -> list[PaneInfo]:
5151
"""List panes in a tmux window, session, or across the entire server.
5252
53-
Only searches pane metadata (current command, title, working directory).
54-
To search the actual text visible in terminal panes, use search_panes
55-
instead.
53+
Searches pane metadata only (current command, title, working
54+
directory). For text visible IN terminals — when users say "panes
55+
that contain/mention/show X" — use search_panes instead.
5656
5757
Parameters
5858
----------

tests/test_server.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,64 @@ def test_base_instructions_document_buffer_lifecycle() -> None:
247247
assert "clipboard history" in _BASE_INSTRUCTIONS
248248

249249

250+
@pytest.mark.parametrize(
251+
("tool_name", "must_include"),
252+
[
253+
("capture_pane", "snapshot_pane"),
254+
("capture_pane", "wait_for_text"),
255+
("capture_pane", "search_panes"),
256+
("show_hooks", "tmux config file"),
257+
("load_buffer", "list_buffers"),
258+
("load_buffer", "clipboard history"),
259+
("send_keys", "wait_for_text"),
260+
("list_panes", "search_panes"),
261+
("list_windows", "search_panes"),
262+
],
263+
)
264+
def test_tool_description_includes(tool_name: str, must_include: str) -> None:
265+
"""Tool descriptions carry cross-references the agent needs at the call site.
266+
267+
Phase 1 of the BASE_INSTRUCTIONS slim-down: rules that are tool-specific
268+
live in tool descriptions (surfaced by FastMCP at every ``list_tools``
269+
call), not in the global card or in module docstrings (which FastMCP
270+
does not surface). The asserted phrases are the ones an agent would
271+
look for when deciding which tool to call:
272+
273+
* ``capture_pane`` cross-references richer alternatives
274+
(``snapshot_pane``, ``wait_for_text``) and the parallel-search tool
275+
(``search_panes``).
276+
* ``show_hooks`` carries the no-set_hook rationale ("tmux config
277+
file") that previously lived only in ``hook_tools``' module
278+
docstring.
279+
* ``load_buffer`` carries the no-list_buffers / clipboard-privacy
280+
rationale that previously lived only in ``buffer_tools``' module
281+
docstring.
282+
* ``send_keys`` points at ``wait_for_text`` instead of a poll loop.
283+
* ``list_panes`` / ``list_windows`` point at ``search_panes`` for
284+
content (vs. metadata-only) queries.
285+
286+
The "tool exists" assertion is a strict upgrade over substring tests
287+
on ``_BASE_INSTRUCTIONS``: a future rename that drops the rule fails
288+
here instead of silently losing agent-relevant guidance.
289+
"""
290+
import asyncio
291+
292+
from fastmcp import FastMCP
293+
294+
from libtmux_mcp.tools import register_tools
295+
296+
mcp = FastMCP(name="tool-description-contract")
297+
register_tools(mcp)
298+
299+
tools = asyncio.run(mcp.list_tools())
300+
by_name = {tool.name: tool for tool in tools}
301+
assert tool_name in by_name, f"{tool_name!r} is not registered"
302+
description = by_name[tool_name].description or ""
303+
assert must_include in description, (
304+
f"{tool_name!r} description missing {must_include!r}; got {description!r}"
305+
)
306+
307+
250308
def test_build_instructions_documents_is_caller_workflow_inside_tmux(
251309
monkeypatch: pytest.MonkeyPatch,
252310
) -> None:

0 commit comments

Comments
 (0)