Skip to content

Commit 883cd80

Browse files
committed
mcp(feat[instructions]): make the card visibility-aware via run_server
Phase 4 of the BASE_INSTRUCTIONS slim-down. The card names specific tools by hint phrase (``snapshot_pane over capture_pane + get_pane_info``, ``wait_for_text over capture_pane in a retry loop``, ``search_panes over list_panes...``); under ``LIBTMUX_SAFETY=readonly`` some of those tools are hidden by ``mcp.enable(tags=..., only=True)``, so naming them in the card was misleading. Composition ----------- * New ``_HANDLE_HINTS: tuple[tuple[str, str], ...]`` keyed by tool name. * New ``_format_handles_section(visible_tool_names)`` renders the three-bullet handles list, filtering the Tools-handle hints by visibility. ``visible_tool_names=None`` keeps every hint (backward-compat for tests that build instructions without invoking ``mcp.enable``, and for the module-import-time placeholder). * ``_INSTR_HANDLES`` is now ``_format_handles_section(None)`` — the unfiltered baseline used by ``_BASE_INSTRUCTIONS``. Wiring ------ * ``_build_instructions`` gains a ``visible_tool_names: set[str] | None`` parameter. When provided it rebuilds the base from ``_INSTR_CARD`` + filtered handles; otherwise it uses ``_BASE_INSTRUCTIONS`` directly. Env-driven ``is_caller`` block is unchanged. * ``run_server`` now collects visible tool names via ``asyncio.run(mcp.list_tools())`` after ``mcp.enable(tags=..., only=True)`` has applied the safety-tier filter, then overwrites ``mcp.instructions`` with the visibility-filtered card. The module-import-time ``instructions=`` set on the FastMCP constructor is now an explicitly documented placeholder. Tests ----- * ``test_card_omits_invisible_tools`` (parametrized) — drops one tool from the visible set and asserts its hint phrase disappears, with a paired sanity check that the phrase IS present when the tool is visible. * ``test_build_instructions_default_visible_tool_names_emits_full_card`` — pins the backward-compat behavior so a future contributor doesn't silently break the placeholder by changing the default. End-to-end smoke: simulating ``LIBTMUX_SAFETY=readonly`` (wait_for_text hidden), the card drops the wait_for_text hint while keeping snapshot_pane and search_panes. Default placeholder still emits all three at 215 total words.
1 parent 72abdae commit 883cd80

2 files changed

Lines changed: 147 additions & 12 deletions

File tree

src/libtmux_mcp/server.py

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,22 +69,69 @@
6969
"and is the documented socket_name exception."
7070
)
7171

72-
_INSTR_HANDLES = (
73-
"Three handles cover everything the agent needs:\n"
74-
"- Tools — call list_tools; per-tool descriptions tell you which to "
75-
"prefer (e.g. snapshot_pane over capture_pane + get_pane_info, "
76-
"wait_for_text over capture_pane in a retry loop, search_panes over "
77-
'list_panes when the user says "panes that contain X").\n'
78-
"- Resources (tmux://) — browseable hierarchy plus reference cards "
79-
"(format strings).\n"
80-
"- Prompts — packaged workflows: run_and_wait, diagnose_failing_pane, "
81-
"build_dev_workspace, interrupt_gracefully."
72+
#: Tool-prefer hints in the Tools handle, keyed by the tool that
73+
#: motivates them. ``_format_handles_section`` filters these by
74+
#: ``visible_tool_names`` so the card never names a tool the agent
75+
#: cannot call (e.g. ``send_keys``-only hints under
76+
#: ``LIBTMUX_SAFETY=readonly``).
77+
_HANDLE_HINTS: tuple[tuple[str, str], ...] = (
78+
("snapshot_pane", "snapshot_pane over capture_pane + get_pane_info"),
79+
("wait_for_text", "wait_for_text over capture_pane in a retry loop"),
80+
(
81+
"search_panes",
82+
'search_panes over list_panes when the user says "panes that contain X"',
83+
),
8284
)
8385

86+
87+
def _format_handles_section(visible_tool_names: set[str] | None) -> str:
88+
"""Render the three-handles bullet list, optionally visibility-filtered.
89+
90+
When ``visible_tool_names`` is ``None`` every hint is included
91+
(backward-compat for tests that build instructions without first
92+
invoking ``mcp.enable``). Otherwise hints whose tool is not in the
93+
visible set are dropped — naming a tool the agent cannot call would
94+
be misleading.
95+
"""
96+
if visible_tool_names is None:
97+
hints = [hint for _, hint in _HANDLE_HINTS]
98+
else:
99+
hints = [hint for tool, hint in _HANDLE_HINTS if tool in visible_tool_names]
100+
101+
tools_line = (
102+
"- Tools — call list_tools; per-tool descriptions tell you which to prefer"
103+
)
104+
if hints:
105+
tools_line += " (e.g. " + ", ".join(hints) + ")."
106+
else:
107+
tools_line += "."
108+
109+
return "\n".join(
110+
(
111+
"Three handles cover everything the agent needs:",
112+
tools_line,
113+
(
114+
"- Resources (tmux://) — browseable hierarchy plus reference "
115+
"cards (format strings)."
116+
),
117+
(
118+
"- Prompts — packaged workflows: run_and_wait, "
119+
"diagnose_failing_pane, build_dev_workspace, "
120+
"interrupt_gracefully."
121+
),
122+
)
123+
)
124+
125+
126+
_INSTR_HANDLES = _format_handles_section(visible_tool_names=None)
127+
84128
_BASE_INSTRUCTIONS = "\n\n".join((_INSTR_CARD, _INSTR_HANDLES))
85129

86130

87-
def _build_instructions(safety_level: str = TAG_MUTATING) -> str:
131+
def _build_instructions(
132+
safety_level: str = TAG_MUTATING,
133+
visible_tool_names: set[str] | None = None,
134+
) -> str:
88135
"""Build server instructions with agent context and safety level.
89136
90137
When the MCP server process runs inside a tmux pane, ``TMUX_PANE`` and
@@ -95,13 +142,25 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str:
95142
----------
96143
safety_level : str
97144
Active safety tier (readonly, mutating, or destructive).
145+
visible_tool_names : set of str, optional
146+
When provided, the handles section drops hints for tools not in
147+
the set so the card never names a tool the agent cannot call.
148+
``run_server`` populates this from ``mcp.list_tools()`` after
149+
``mcp.enable(tags=..., only=True)`` has applied the safety-tier
150+
filter. Defaults to ``None`` (backward-compat: all hints emitted),
151+
which is what the module-import-time placeholder uses before
152+
``run_server`` runs.
98153
99154
Returns
100155
-------
101156
str
102157
Server instructions string, optionally with agent tmux context.
103158
"""
104-
parts: list[str] = [_BASE_INSTRUCTIONS]
159+
if visible_tool_names is None:
160+
base = _BASE_INSTRUCTIONS
161+
else:
162+
base = "\n\n".join((_INSTR_CARD, _format_handles_section(visible_tool_names)))
163+
parts: list[str] = [base]
105164

106165
# Safety tier context
107166
parts.append(
@@ -273,6 +332,8 @@ def _register_all() -> None:
273332

274333
def run_server() -> None:
275334
"""Run the MCP server."""
335+
import asyncio
336+
276337
_register_all()
277338

278339
# Use FastMCP's native visibility system as primary gate,
@@ -284,4 +345,17 @@ def run_server() -> None:
284345
allowed_tags.add(TAG_DESTRUCTIVE)
285346
mcp.enable(tags=allowed_tags, only=True)
286347

348+
# Rebuild instructions now that ``mcp.enable`` has hidden tools
349+
# outside the active safety tier. The card mentions specific tools
350+
# by name (snapshot_pane, wait_for_text, search_panes); naming a
351+
# tool the agent cannot call would be misleading. The
352+
# module-import-time ``instructions=`` set on the FastMCP
353+
# constructor was a placeholder built without a visibility filter —
354+
# this overwrite is the authoritative version.
355+
visible_tool_names = {tool.name for tool in asyncio.run(mcp.list_tools())}
356+
mcp.instructions = _build_instructions(
357+
safety_level=_safety_level,
358+
visible_tool_names=visible_tool_names,
359+
)
360+
287361
mcp.run()

tests/test_server.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,67 @@ def test_card_length_budget() -> None:
218218
)
219219

220220

221+
# Phrases the handles section emits when the corresponding tool is in
222+
# ``visible_tool_names``. Phase 4: ``_build_instructions`` filters the
223+
# Tools handle by visibility so the card never names a tool the agent
224+
# cannot call. Keep this aligned with ``_HANDLE_HINTS`` in server.py.
225+
_VISIBILITY_FILTER_CASES: list[tuple[str, str]] = [
226+
("snapshot_pane", "snapshot_pane over capture_pane"),
227+
("wait_for_text", "wait_for_text over capture_pane"),
228+
("search_panes", "search_panes over list_panes"),
229+
]
230+
231+
232+
@pytest.mark.parametrize(
233+
("omitted_tool", "phrase_that_should_drop"),
234+
_VISIBILITY_FILTER_CASES,
235+
ids=[t for t, _ in _VISIBILITY_FILTER_CASES],
236+
)
237+
def test_card_omits_invisible_tools(
238+
omitted_tool: str,
239+
phrase_that_should_drop: str,
240+
) -> None:
241+
"""The handles section drops hints for tools outside ``visible_tool_names``.
242+
243+
Each row asserts that removing one tool from the visible set drops
244+
its tool-prefer hint from the card. Sanity check: the same phrase IS
245+
present when the tool IS visible — guards against the test passing
246+
accidentally because the phrase was never there.
247+
"""
248+
full_visibility = {tool for tool, _ in _VISIBILITY_FILTER_CASES}
249+
visible = full_visibility - {omitted_tool}
250+
251+
filtered = _build_instructions(TAG_MUTATING, visible_tool_names=visible)
252+
assert phrase_that_should_drop not in filtered, (
253+
f"phrase {phrase_that_should_drop!r} stayed when {omitted_tool} "
254+
f"was filtered out — visibility filter is broken"
255+
)
256+
257+
full = _build_instructions(TAG_MUTATING, visible_tool_names=full_visibility)
258+
assert phrase_that_should_drop in full, (
259+
f"phrase {phrase_that_should_drop!r} missing even when "
260+
f"{omitted_tool} is visible — sanity check failed"
261+
)
262+
263+
264+
def test_build_instructions_default_visible_tool_names_emits_full_card() -> None:
265+
"""``visible_tool_names=None`` is the backward-compat default.
266+
267+
The module-import-time ``instructions=`` placeholder builds without
268+
invoking ``mcp.enable``, so it has no visibility set to consult. In
269+
that case ``_build_instructions`` must emit every handle hint as if
270+
the agent could call any tool — the alternative would be silently
271+
dropping hints during the (brief) window before ``run_server``
272+
overwrites the placeholder.
273+
"""
274+
full = _build_instructions(TAG_MUTATING)
275+
for _, phrase in _VISIBILITY_FILTER_CASES:
276+
assert phrase in full, (
277+
f"phrase {phrase!r} missing from default _build_instructions; "
278+
f"visible_tool_names=None should emit the full hint set"
279+
)
280+
281+
221282
def test_registered_tools_accept_socket_name() -> None:
222283
"""All registered tools (except list_servers) accept ``socket_name``.
223284

0 commit comments

Comments
 (0)