From 86ddba67df6fb294b22a1a8afe8726c39133933a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:53:06 -0500 Subject: [PATCH 01/44] mcp(fix[spawn]): Resolve start_directory in Python why: tmux expands `-c` as a format before using it as a working directory (spawn.c), so `#(...)` in `start_directory` ran a shell job, and a directory whose real name held a `#` silently landed the pane in `$HOME` instead. All four spawn tools carried the sink while advertising `destructiveHint: false` and `openWorldHint: false`. what: - Resolve `start_directory` to an existing directory before tmux sees it, then escape the result for tmux's format pass - Refuse `#[`, which tmux hands to the style parser with no escaped form, rather than silently redirecting the pane - Fail on a path that does not exist instead of falling back to the client's directory - Cover `create_session`, `create_window`, `split_window` and `respawn_pane`, including directories named `#(id)` and `#{x}` - State the parameter's contract where it previously restated its own name --- src/libtmux_mcp/_utils.py | 81 +++++++++++ src/libtmux_mcp/tools/pane_tools/lifecycle.py | 7 +- src/libtmux_mcp/tools/server_tools.py | 9 +- src/libtmux_mcp/tools/session_tools.py | 9 +- src/libtmux_mcp/tools/window_tools.py | 9 +- tests/test_spawn_start_directory.py | 129 ++++++++++++++++++ 6 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/test_spawn_start_directory.py diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 587e2d85..a4131cf6 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -470,6 +470,87 @@ def _caller_is_strictly_on_server( } +def _escape_tmux_format(value: str) -> str: + """Return ``value`` escaped so tmux's format pass yields it unchanged. + + tmux expands several argument values as formats, where ``#(...)`` runs + a shell job and ``#{...}`` substitutes a variable. Doubling every ``#`` + makes the whole string literal. ``#[`` is the exception: tmux hands a + ``#`` run before ``[`` to the style parser rather than collapsing it, + so no escaping renders it literal and such a value is refused. + + Parameters + ---------- + value : str + Text to pass through a tmux format argument. + + Returns + ------- + str + ``value`` with every ``#`` doubled. + + Raises + ------ + ExpectedToolError + If ``value`` contains ``#[``, which has no escaped form. + + Examples + -------- + >>> _escape_tmux_format("/srv/app") + '/srv/app' + >>> _escape_tmux_format("/srv/#(id)") + '/srv/##(id)' + """ + if "#[" in value: + msg = ( + f"tmux reads '#[' as a style prefix with no escaped form, so " + f"this value cannot be passed through: {value!r}" + ) + raise ExpectedToolError(msg) + return value.replace("#", "##") + + +def _prepare_start_directory(start_directory: str | None) -> str | None: + """Resolve a caller path to the directory tmux will actually use. + + tmux expands ``-c`` as a format before using it as a working + directory, and silently falls back to the client's directory when the + result does not exist — so an unresolvable path lands the pane + somewhere else instead of failing. Resolving here leaves no format for + tmux to run and turns a bad path into an error the caller can correct. + + Parameters + ---------- + start_directory : str or None + Caller-supplied working directory, or ``None``. + + Returns + ------- + str or None + Absolute path escaped for tmux, or ``None`` when none was given. + + Raises + ------ + ExpectedToolError + If the path does not name an existing directory. + + Examples + -------- + >>> _prepare_start_directory(None) is None + True + >>> _prepare_start_directory("/") + '/' + """ + if start_directory is None: + return None + + resolved = pathlib.Path(start_directory).expanduser().resolve() + if not resolved.is_dir(): + msg = f"start_directory is not an existing directory: {str(resolved)!r}" + raise ExpectedToolError(msg) + return _escape_tmux_format(str(resolved)) + + def _tmux_argv(server: Server, *tmux_args: str) -> list[str]: """Build a full tmux argv list honouring ``socket_name`` and ``socket_path``. diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 56c702c4..9acea513 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -10,6 +10,7 @@ _caller_is_on_server, _get_caller_identity, _get_server, + _prepare_start_directory, _resolve_pane, _resolve_window, _serialize_pane, @@ -118,8 +119,8 @@ def respawn_pane( the ``shell`` parameter on :func:`split_window` and the eventual upstream ``Pane.respawn(shell=)`` API. start_directory : str, optional - Working directory for the relaunched command (maps to - ``respawn-pane -c``). + Existing directory to start in. ``~`` expands; a relative path + resolves against the MCP server process's directory. environment : dict or str, optional Environment variables to set for the relaunched process. Each item becomes one ``-e KEY=VALUE`` flag (tmux's @@ -165,7 +166,7 @@ def respawn_pane( raise ExpectedToolError(msg) pane.respawn( kill=kill, - start_directory=start_directory, + start_directory=_prepare_start_directory(start_directory), environment=spawn_environment, shell=shell, ) diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index d9ab55af..68f400a7 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -25,6 +25,7 @@ _get_caller_identity, _get_server, _invalidate_server, + _prepare_start_directory, _serialize_session, handle_tool_errors, ) @@ -91,7 +92,8 @@ def create_session( window_name : str, optional Name for the initial window. start_directory : str, optional - Working directory for the session. + Existing directory to start in. ``~`` expands; a relative path + resolves against the MCP server process's directory. x : int, optional Width of the initial window. y : int, optional @@ -130,8 +132,9 @@ def create_session( kwargs["session_name"] = session_name if window_name is not None: kwargs["window_name"] = window_name - if start_directory is not None: - kwargs["start_directory"] = start_directory + prepared_start_directory = _prepare_start_directory(start_directory) + if prepared_start_directory is not None: + kwargs["start_directory"] = prepared_start_directory if x is not None: kwargs["x"] = x if y is not None: diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 7805c3ff..8ce91174 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -21,6 +21,7 @@ _caller_is_on_server, _get_caller_identity, _get_server, + _prepare_start_directory, _resolve_session, _serialize_session, _serialize_window, @@ -135,7 +136,8 @@ def create_window( window_name : str, optional Name for the new window. start_directory : str, optional - Working directory for the new window. + Existing directory to start in. ``~`` expands; a relative path + resolves against the MCP server process's directory. attach : bool, optional Whether to make the new window active. direction : str, optional @@ -169,8 +171,9 @@ def create_window( kwargs: dict[str, t.Any] = {} if window_name is not None: kwargs["window_name"] = window_name - if start_directory is not None: - kwargs["start_directory"] = start_directory + prepared_start_directory = _prepare_start_directory(start_directory) + if prepared_start_directory is not None: + kwargs["start_directory"] = prepared_start_directory kwargs["attach"] = attach if direction is not None: direction_map: dict[str, WindowDirection] = { diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 0a8461b3..133c28bb 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -21,6 +21,7 @@ _caller_is_on_server, _get_caller_identity, _get_server, + _prepare_start_directory, _resolve_pane, _resolve_session, _resolve_window, @@ -190,7 +191,8 @@ def split_window( Size of the new pane. Use a string with '%%' suffix for percentage (e.g. '50%%') or an integer for lines/columns. start_directory : str, optional - Working directory for the new pane. + Existing directory to start in. ``~`` expands; a relative path + resolves against the MCP server process's directory. shell : str, optional Shell command to run in the new pane. socket_name : str, optional @@ -227,12 +229,13 @@ def split_window( msg = f"Invalid direction: {direction!r}. Valid: {valid}" raise ExpectedToolError(msg) + prepared_start_directory = _prepare_start_directory(start_directory) if pane_id is not None: pane = _resolve_pane(server, pane_id=pane_id) new_pane = pane.split( direction=pane_dir, size=size, - start_directory=start_directory, + start_directory=prepared_start_directory, shell=shell, environment=spawn_environment, ) @@ -247,7 +250,7 @@ def split_window( new_pane = window.split( direction=pane_dir, size=size, - start_directory=start_directory, + start_directory=prepared_start_directory, shell=shell, environment=spawn_environment, ) diff --git a/tests/test_spawn_start_directory.py b/tests/test_spawn_start_directory.py new file mode 100644 index 00000000..58d5316f --- /dev/null +++ b/tests/test_spawn_start_directory.py @@ -0,0 +1,129 @@ +"""Tests for ``start_directory`` handling across the tmux spawn tools.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from libtmux_mcp._utils import ExpectedToolError +from libtmux_mcp.tools.pane_tools import respawn_pane +from libtmux_mcp.tools.server_tools import create_session +from libtmux_mcp.tools.session_tools import create_window +from libtmux_mcp.tools.window_tools import split_window + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + +#: Spawn tools keyed by name, each called with only ``start_directory`` +#: and the target it needs, and each returning the resulting pane ID. +_SPAWNERS: dict[str, t.Callable[[Server, Session, Pane, str], str]] = { + "create_session": lambda server, session, pane, cwd: t.cast( + "str", + create_session( + session_name="sd-probe", + start_directory=cwd, + socket_name=server.socket_name, + ).active_pane_id, + ), + "create_window": lambda server, session, pane, cwd: t.cast( + "str", + create_window( + session_id=session.session_id, + start_directory=cwd, + socket_name=server.socket_name, + ).active_pane_id, + ), + "split_window": lambda server, session, pane, cwd: ( + split_window( + pane_id=pane.pane_id, + start_directory=cwd, + socket_name=server.socket_name, + ).pane_id + ), + "respawn_pane": lambda server, session, pane, cwd: ( + respawn_pane( + pane_id=t.cast("str", pane.pane_id), + start_directory=cwd, + socket_name=server.socket_name, + ).pane_id + ), +} + + +def _current_path(server: Server, pane_id: str) -> str: + """Return a pane's working directory as tmux reports it.""" + pane = server.panes.get(pane_id=pane_id) + assert pane is not None + pane.refresh() + return t.cast("str", pane.pane_current_path) + + +@pytest.mark.parametrize("spawner", list(_SPAWNERS)) +def test_start_directory_does_not_run_tmux_format_jobs( + mcp_server: Server, + mcp_session: Session, + mcp_pane: Pane, + tmp_path: pathlib.Path, + spawner: str, +) -> None: + """A ``#(...)`` job in ``start_directory`` is refused, not executed.""" + marker = tmp_path / "executed" + + with pytest.raises(ExpectedToolError): + _SPAWNERS[spawner](mcp_server, mcp_session, mcp_pane, f"#(touch {marker})") + + assert not marker.exists() + + +@pytest.mark.parametrize("spawner", list(_SPAWNERS)) +@pytest.mark.parametrize("name", ["plain", "has#hash", "job#(id)", "var#{x}"]) +def test_start_directory_uses_the_directory_named( + mcp_server: Server, + mcp_session: Session, + mcp_pane: Pane, + tmp_path: pathlib.Path, + spawner: str, + name: str, +) -> None: + """A directory whose real name contains ``#`` is used verbatim.""" + target = tmp_path / name + target.mkdir() + + pane_id = _SPAWNERS[spawner](mcp_server, mcp_session, mcp_pane, str(target)) + + assert _current_path(mcp_server, pane_id) == str(target) + + +def test_start_directory_rejects_a_missing_directory( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """A path that does not exist fails instead of falling back to ``$HOME``.""" + with pytest.raises(ExpectedToolError, match="not an existing directory"): + split_window( + pane_id=mcp_pane.pane_id, + start_directory=str(tmp_path / "absent"), + socket_name=mcp_server.socket_name, + ) + + +def test_start_directory_rejects_a_style_prefix( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """``#[`` has no tmux format encoding, so such a path is refused.""" + target = tmp_path / "style#[x]" + target.mkdir() + + with pytest.raises(ExpectedToolError, match=r"#\["): + split_window( + pane_id=mcp_pane.pane_id, + start_directory=str(target), + socket_name=mcp_server.socket_name, + ) From 22534443a3ee9a1cd08f2586735699759bd1d16c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:58:26 -0500 Subject: [PATCH 02/44] mcp(fix[search]): Bound search_panes match time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `search_panes` compiled a caller's pattern with the stdlib `re`, which has no execution ceiling. A pattern of eleven characters against an ordinary pane line hung the server indefinitely — `(a|a)+$` over 60 `a` characters never returned — and the tool is `readonly`, so the hang was reachable at the most restrictive safety tier. what: - Compile the caller's pattern with `regex`, which accepts a per-search wall-clock budget - Share one 2 s deadline across every line of every captured pane, so the ceiling covers the call rather than each line - Report exhaustion as a correctable error naming the cause - Keep the tmux-side `#{C:...}` fast path untouched; it never runs a Python match A literal scan of 20,000 lines costs ~3.5 ms, so 2 s is a ceiling a real search never approaches. --- pyproject.toml | 2 + src/libtmux_mcp/tools/pane_tools/search.py | 69 ++++++++++- tests/test_pane_tools.py | 25 ++++ uv.lock | 134 +++++++++++++++++++++ 4 files changed, 225 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 036f261a..0a17c3fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ include = [ dependencies = [ "libtmux>=0.62.0,<1.0", "fastmcp>=3.4.2,<4.0.0", + "regex>=2024.11.6", ] [project.urls] @@ -79,6 +80,7 @@ dev = [ # Lint "ruff>=0.16.1", "mypy", + "types-regex", ] docs = [ diff --git a/src/libtmux_mcp/tools/pane_tools/search.py b/src/libtmux_mcp/tools/pane_tools/search.py index 21569e4a..44882001 100644 --- a/src/libtmux_mcp/tools/pane_tools/search.py +++ b/src/libtmux_mcp/tools/pane_tools/search.py @@ -3,6 +3,10 @@ from __future__ import annotations import re +import time + +# Aliased: the ``regex`` tool parameter below shadows the module name. +import regex as regex_engine from libtmux_mcp._utils import ( ExpectedToolError, @@ -22,11 +26,65 @@ #: (most-recent) matches so the agent sees what's currently on screen. SEARCH_DEFAULT_MAX_LINES_PER_PANE = 50 +#: Wall-clock ceiling for matching a caller's pattern across every +#: captured line. A literal scan of 20,000 lines costs ~3.5 ms, so this +#: is a ceiling reached only by a pattern that backtracks, never a +#: budget a real search spends. +SEARCH_MATCH_MAX_SECONDS = 2.0 + #: Default maximum number of matching panes returned in one call. #: Pagination via ``offset``/``limit`` lets the caller page forward. SEARCH_DEFAULT_LIMIT = 500 +def _match_lines( + compiled: regex_engine.Pattern[str], + lines: list[str], + deadline: float, +) -> list[str]: + """Return the lines matching ``compiled``, giving up at ``deadline``. + + A caller-supplied pattern can backtrack for longer than the age of + the universe on an ordinary pane line, so every match shares one + wall-clock budget rather than each line getting its own. + + Parameters + ---------- + compiled : regex_engine.Pattern + Caller's pattern, already compiled. + lines : list of str + Captured pane content. + deadline : float + :func:`time.monotonic` value at which to give up. + + Returns + ------- + list of str + Lines that matched. + + Raises + ------ + ExpectedToolError + If the budget runs out before every line has been tried. + """ + matched: list[str] = [] + for line in lines: + # A zero budget raises TimeoutError too, so an exhausted deadline + # and a single slow line report through one path. + remaining = max(deadline - time.monotonic(), 0.0) + try: + if compiled.search(line, timeout=remaining): + matched.append(line) + except TimeoutError as e: + msg = ( + f"Matching took longer than {SEARCH_MATCH_MAX_SECONDS}s. " + f"Nested quantifiers such as '(a+)+' backtrack exponentially; " + f"anchor the pattern or search for a literal instead." + ) + raise ExpectedToolError(msg) from e + return matched + + def _pane_id_sort_key(m: PaneContentMatch) -> tuple[int, str]: """Sort panes numerically by their tmux id. @@ -143,11 +201,11 @@ def search_panes( Paginated match list with ``truncated`` / ``truncated_panes`` / ``total_panes_matched`` / ``offset`` / ``limit`` fields. """ - search_pattern = pattern if regex else re.escape(pattern) - flags = 0 if match_case else re.IGNORECASE + search_pattern = pattern if regex else regex_engine.escape(pattern) + flags = 0 if match_case else regex_engine.IGNORECASE try: - compiled = re.compile(search_pattern, flags) - except re.error as e: + compiled = regex_engine.compile(search_pattern, flags) + except regex_engine.error as e: msg = f"Invalid regex pattern: {e}" raise ExpectedToolError(msg) from e @@ -229,6 +287,7 @@ def search_panes( # tail-truncated to keep the most recent matches. all_matches: list[PaneContentMatch] = [] per_pane_truncated = False + deadline = time.monotonic() + SEARCH_MATCH_MAX_SECONDS for pane_id_str in matching_pane_ids: pane = server.panes.get(pane_id=pane_id_str, default=None) if pane is None: @@ -239,7 +298,7 @@ def search_panes( end=content_end, join_wrapped=True, ) - matched_lines = [line for line in lines if compiled.search(line)] + matched_lines = _match_lines(compiled, lines, deadline) if not matched_lines: continue diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 1e3d2ff4..6cdd66a9 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -39,6 +39,7 @@ pipe_pane, resize_pane, respawn_pane, + search, search_panes, select_pane, send_keys, @@ -2311,6 +2312,30 @@ def test_search_panes_invalid_regex(mcp_server: Server, mcp_session: Session) -> ) +def test_search_panes_bounds_matching_time( + mcp_server: Server, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pattern that backtracks exponentially stops at the deadline.""" + monkeypatch.setattr(search, "SEARCH_MATCH_MAX_SECONDS", 0.2) + mcp_pane.send_keys("echo " + "a" * 40 + "b", enter=True) + retry_until( + lambda: "a" * 40 in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + started = time.monotonic() + with pytest.raises(ExpectedToolError, match="took longer than"): + search_panes( + pattern=r"(a|a)+$", + regex=True, + socket_name=mcp_server.socket_name, + ) + assert time.monotonic() - started < 5 + + def test_search_panes_pagination_limit_and_offset( mcp_server: Server, mcp_session: Session, mcp_pane: Pane ) -> None: diff --git a/uv.lock b/uv.lock index 5cb618d2..714530b4 100644 --- a/uv.lock +++ b/uv.lock @@ -1413,6 +1413,7 @@ source = { editable = "." } dependencies = [ { name = "fastmcp" }, { name = "libtmux" }, + { name = "regex" }, ] [package.dev-dependencies] @@ -1440,6 +1441,7 @@ dev = [ { name = "sphinx-autodoc-fastmcp" }, { name = "syrupy" }, { name = "tomlkit" }, + { name = "types-regex" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ @@ -1468,6 +1470,7 @@ testing = [ requires-dist = [ { name = "fastmcp", specifier = ">=3.4.2,<4.0.0" }, { name = "libtmux", specifier = ">=0.62.0,<1.0" }, + { name = "regex", specifier = ">=2024.11.6" }, ] [package.metadata.requires-dev] @@ -1494,6 +1497,7 @@ dev = [ { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a37" }, { name = "syrupy", specifier = ">=5.1.0" }, { name = "tomlkit", specifier = ">=0.13" }, + { name = "types-regex" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ @@ -2327,6 +2331,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/13/bbf7d9d1887fe4a3693527c6caa232c197ea9da91f1212e9672eff60329d/regex-2026.7.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b", size = 494009, upload-time = "2026-07-19T00:16:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/a3/19/783688e75a2bec15d50aec0d5e7e317d363808bc82a6eb6750b897bfcd7b/regex-2026.7.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52", size = 295287, upload-time = "2026-07-19T00:16:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/cefe4f051302ca298d3f3e79ed6dbd933ac84485b9515acdd6a52d70cef7/regex-2026.7.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665", size = 290633, upload-time = "2026-07-19T00:16:17.182Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1e/1045ca2cabb12e8ec41ad0d138e9f3ff1eb079d30a6f51ccf7d709b44aad/regex-2026.7.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0", size = 785300, upload-time = "2026-07-19T00:16:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/20e5bca184e90bf1bd187efdb53363f4a7b7b34f01d54ced5740caf104bd/regex-2026.7.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951", size = 854079, upload-time = "2026-07-19T00:16:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/09/9b/5a2e59678be3b24aa6a42b2c6d66a48daa212593e9f4096fe7ba577fa9b1/regex-2026.7.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3", size = 899496, upload-time = "2026-07-19T00:16:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/48/9a/7317f14ed8ed9fd998d1978b4802b07bc4d79216353c435dbcc1ddd1301f/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c", size = 793541, upload-time = "2026-07-19T00:16:22.991Z" }, + { url = "https://files.pythonhosted.org/packages/6f/53/833c2db3e274d3c191f4c42fe5bfa358e4c8b617d5d7312d31334965fc46/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6", size = 785515, upload-time = "2026-07-19T00:16:24.654Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b9/efb2f9fa151d71db09d4015e1fb92fee47416f01c12164836bbd23e2f3c2/regex-2026.7.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175", size = 769556, upload-time = "2026-07-19T00:16:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/45610c263f8eadb84e4a1fabd904d81d5176226faa9104ef498bf8a8b285/regex-2026.7.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6", size = 774130, upload-time = "2026-07-19T00:16:27.786Z" }, + { url = "https://files.pythonhosted.org/packages/40/95/1b40d87c7a9e5480bec7a87bce9fd67fc3f14b5f106c8ee66d660249072f/regex-2026.7.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095", size = 848694, upload-time = "2026-07-19T00:16:29.412Z" }, + { url = "https://files.pythonhosted.org/packages/17/8b/bb45968addd5b394ef9cd9184bd9c65ade1a819dbb2b92b71ad52a0c7907/regex-2026.7.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0", size = 758505, upload-time = "2026-07-19T00:16:31.006Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/33386c672fbf43e21602135a0f29a97ee251a483f007fe51d10e9b2dbc93/regex-2026.7.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a", size = 836985, upload-time = "2026-07-19T00:16:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/22/f1/9112b86e9bb075619862e8e42b604794389f1958faa68fb69bde505dd90e/regex-2026.7.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902", size = 782610, upload-time = "2026-07-19T00:16:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f9/13d460d8a385ca0b0be9e6be80a90968b9293b3e30895543ad2d1d1653e4/regex-2026.7.19-cp310-cp310-win32.whl", hash = "sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e", size = 266772, upload-time = "2026-07-19T00:16:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/9f/90/29addd7a03e1aea402c1f31467e25c80caabc8c3735b88a23cf73b0aa9c2/regex-2026.7.19-cp310-cp310-win_amd64.whl", hash = "sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db", size = 277967, upload-time = "2026-07-19T00:16:36.847Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ba/ecfce06fe66c122bc6f77ae284887a9282e4411fe1e6268c5266611ca054/regex-2026.7.19-cp310-cp310-win_arm64.whl", hash = "sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6", size = 276963, upload-time = "2026-07-19T00:16:38.315Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -3188,6 +3313,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] +[[package]] +name = "types-regex" +version = "2026.7.19.20260720" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/bc/825471e08455b037c40d2c80fe9f93a574fcfef0b2e6c6fc2e248ada4adb/types_regex-2026.7.19.20260720.tar.gz", hash = "sha256:49ab8261bd46fa215245b9cb0d2fed1c1056fbdc763303714f6132208909afcf", size = 13473, upload-time = "2026-07-20T05:27:00.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1a/9bbd3eace00c2562556570d2464afe843ef5f8118df17aee5f36fed5948f/types_regex-2026.7.19.20260720-py3-none-any.whl", hash = "sha256:70e76f05d8ef60f00227e50351f1cb81552d1cdf3fdf77350f7063c911ee26da", size = 11147, upload-time = "2026-07-20T05:26:59.245Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 42ad48ba1d1dd301bcad9a6dc28d43cd043cfda2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:03:51 -0500 Subject: [PATCH 03/44] mcp(fix[hints]): Typed input is not additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: MCP defines `destructiveHint: false` as a positive claim that a tool performs only additive updates. Six tools that hand a caller's payload to a program advertised it: `send_keys`, `send_keys_batch`, `run_command`, `paste_text`, `paste_buffer`, and `pipe_pane`. Typed input can overwrite a file or end a process, so the claim was false to every connected client. what: - Advertise `destructiveHint: true` on the six input-delivering tools - Move `load_buffer` off that preset: it allocates a fresh buffer and delivers nothing, so it is additive and closed-world, which is what the module docstring already said while the code did otherwise - Assert the claim per tool against the registered surface, and drop the five tests that asserted the presets' contents instead — one of them pinned `destructiveHint: false` in place - Require every tool to advertise all four hints, so a client never falls back on a protocol default --- src/libtmux_mcp/_utils.py | 26 ++++----- src/libtmux_mcp/tools/buffer_tools.py | 11 ++-- tests/test_history.py | 2 +- tests/test_tool_annotations.py | 76 +++++++++++++++++++++++++++ tests/test_utils.py | 65 ----------------------- 5 files changed, 92 insertions(+), 88 deletions(-) create mode 100644 tests/test_tool_annotations.py diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index a4131cf6..9c00927e 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -405,26 +405,20 @@ def _caller_is_strictly_on_server( "idempotentHint": False, "openWorldHint": False, } -#: Annotations for tools that move user-supplied payloads into a shell -#: context. Six consumers today: +#: Annotations for tools whose caller-supplied payload reaches a program +#: that runs it: ``send_keys``, ``send_keys_batch``, ``run_command``, +#: ``paste_text``, ``paste_buffer``, and ``pipe_pane``. #: -#: * ``send_keys``, ``run_command``, ``paste_text``, ``pipe_pane`` — the -#: canonical shell-driving tools; caller's keys/command/text/stream -#: reaches the shell prompt or pipes into an external command -#: respectively. -#: * ``load_buffer``, ``paste_buffer`` — ``load_buffer`` stages content -#: into a tmux paste buffer; ``paste_buffer`` pushes that content -#: into a target pane where the shell receives it as input. The two -#: are split into a stage/fire pair so callers can validate before -#: paste, but both participate in the same open-world transfer. +#: ``destructiveHint`` is ``True`` because MCP defines ``False`` as a +#: claim of additive-only updates, and typed input can overwrite a file, +#: end a process, or leave a shell mid-line. ``openWorldHint`` is ``True`` +#: because the effect extends into whatever the payload runs. #: -#: Distinguished from :data:`ANNOTATIONS_CREATE` by ``openWorldHint=True``: -#: the effects of these tools extend into whatever command or content -#: the caller supplies, which is the canonical open-world MCP -#: interaction. +#: ``load_buffer`` is deliberately not here: it allocates a fresh buffer +#: and delivers nothing, so it is additive and closed-world. ANNOTATIONS_SHELL: dict[str, bool] = { "readOnlyHint": False, - "destructiveHint": False, + "destructiveHint": True, "idempotentHint": False, "openWorldHint": True, } diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 6663d7e7..7919d183 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -35,6 +35,7 @@ import uuid from libtmux_mcp._utils import ( + ANNOTATIONS_CREATE, ANNOTATIONS_MUTATING, ANNOTATIONS_RO, ANNOTATIONS_SHELL, @@ -380,14 +381,12 @@ def delete_buffer( def register(mcp: FastMCP) -> None: """Register buffer tools with the MCP instance. - ``load_buffer`` is tagged with - :data:`~libtmux_mcp._utils.ANNOTATIONS_SHELL` because its ``content`` - argument is arbitrary user text that may carry interactive-environment - side effects (commands about to be pasted into a shell). Other buffer - tools are plain mutating ops on the tmux buffer store. + ``load_buffer`` stages content into a buffer it allocates, so it is + additive and closed-world. ``paste_buffer`` is what delivers that + content to a pane's program, and carries the hints for it. """ mcp.tool( - title="Load tmux Buffer", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING} + title="Load tmux Buffer", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING} )(load_buffer) mcp.tool( title="Paste tmux Buffer", diff --git a/tests/test_history.py b/tests/test_history.py index 57ce68ff..ea73cdc6 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -428,7 +428,7 @@ async def main(): assert payload["schema"]["default"] is expected assert "anyOf" not in payload["schema"] assert payload["annotations"] == { - "destructiveHint": False, + "destructiveHint": True, "idempotentHint": False, "openWorldHint": True, "readOnlyHint": False, diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py new file mode 100644 index 00000000..74fbca82 --- /dev/null +++ b/tests/test_tool_annotations.py @@ -0,0 +1,76 @@ +"""Tests that each tool's advertised MCP hints match what it does. + +MCP defines ``destructiveHint: false`` as a positive claim of +additive-only updates and ``true`` as the cautious default, so a hint +is a statement to every connected client rather than a severity label. +These tests assert the statement per tool. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux_mcp.server import build_mcp_server + +from .conftest import wire_annotations + +#: Tools whose caller-supplied payload reaches a program that runs it — +#: a shell prompt, a pane's process, or the command ``pipe_pane`` feeds. +#: Membership is a fact about where the value lands, not about the +#: parameter's name, so it is listed rather than derived from a schema. +PANE_INPUT_TOOLS = frozenset( + { + "paste_buffer", + "paste_text", + "pipe_pane", + "run_command", + "send_keys", + "send_keys_batch", + } +) + + +@pytest.fixture(scope="module") +def advertised_tools() -> dict[str, t.Any]: + """Return every registered tool keyed by name, as clients see it.""" + import asyncio + + mcp = build_mcp_server() + tools = asyncio.run(mcp.list_tools()) + return {tool.name: tool for tool in tools} + + +def test_every_tool_advertises_all_four_hints( + advertised_tools: dict[str, t.Any], +) -> None: + """No tool leaves a client to fall back on a protocol default.""" + expected = { + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + } + for name, tool in advertised_tools.items(): + assert set(wire_annotations(tool)) >= expected, name + + +def test_every_pane_input_tool_is_registered( + advertised_tools: dict[str, t.Any], +) -> None: + """The list below names tools that exist, so a rename cannot mute it.""" + assert set(advertised_tools) >= PANE_INPUT_TOOLS + + +@pytest.mark.parametrize("name", sorted(PANE_INPUT_TOOLS)) +def test_pane_input_tools_do_not_claim_additive_updates( + advertised_tools: dict[str, t.Any], + name: str, +) -> None: + """Input a program executes is not an additive update, and never repeats.""" + hints = wire_annotations(advertised_tools[name]) + + assert hints["destructiveHint"] is True + assert hints["idempotentHint"] is False + assert hints["openWorldHint"] is True diff --git a/tests/test_utils.py b/tests/test_utils.py index d5ee8f3d..43da1eec 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,11 +10,6 @@ from libtmux import exc from libtmux_mcp._utils import ( - ANNOTATIONS_CREATE, - ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_MUTATING, - ANNOTATIONS_RO, - ANNOTATIONS_SHELL, TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, @@ -639,66 +634,6 @@ def test_serialize_pane_is_caller_requires_tmux_env_not_just_pane( assert _serialize_pane(mcp_pane).is_caller is False -# --------------------------------------------------------------------------- -# Annotation and tag constants tests -# --------------------------------------------------------------------------- - -_ANNOTATION_KEYS = { - "readOnlyHint", - "destructiveHint", - "idempotentHint", - "openWorldHint", -} - - -def test_annotation_presets_have_correct_keys() -> None: - """All annotation presets contain exactly the four MCP annotation keys.""" - for preset in ( - ANNOTATIONS_RO, - ANNOTATIONS_MUTATING, - ANNOTATIONS_CREATE, - ANNOTATIONS_SHELL, - ANNOTATIONS_DESTRUCTIVE, - ): - assert set(preset.keys()) == _ANNOTATION_KEYS - - -def test_annotations_ro_is_readonly() -> None: - """ANNOTATIONS_RO marks tools as read-only.""" - assert ANNOTATIONS_RO["readOnlyHint"] is True - assert ANNOTATIONS_RO["destructiveHint"] is False - - -def test_annotations_destructive_is_destructive() -> None: - """ANNOTATIONS_DESTRUCTIVE marks tools as destructive.""" - assert ANNOTATIONS_DESTRUCTIVE["destructiveHint"] is True - assert ANNOTATIONS_DESTRUCTIVE["readOnlyHint"] is False - - -def test_annotations_shell_is_open_world() -> None: - """ANNOTATIONS_SHELL marks shell-driving tools as open-world. - - Shell-driving tools (``send_keys``, ``paste_text``, ``pipe_pane``) - interact with arbitrary external state through whatever command the - caller runs — the canonical open-world MCP interaction. - """ - assert ANNOTATIONS_SHELL["openWorldHint"] is True - assert ANNOTATIONS_SHELL["readOnlyHint"] is False - assert ANNOTATIONS_SHELL["destructiveHint"] is False - assert ANNOTATIONS_SHELL["idempotentHint"] is False - - -def test_annotations_create_is_closed_world() -> None: - """ANNOTATIONS_CREATE does NOT set openWorldHint. - - Create-style mutating tools (``create_session``, ``create_window``, - ``split_window``, ``swap_pane``, ``enter_copy_mode``) allocate tmux - objects but do not interact with an open-ended environment. The - shell-driving case is separately handled by ``ANNOTATIONS_SHELL``. - """ - assert ANNOTATIONS_CREATE["openWorldHint"] is False - - def test_tag_constants() -> None: """Safety tier tag constants are distinct strings.""" tags = {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE} From 93f70c46fdc8401c14468dda203a036d67f12e36 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:06:38 -0500 Subject: [PATCH 04/44] mcp(fix[hints]): Spawning a pane reaches past tmux why: The four spawn tools advertised `openWorldHint: false`, but a new pane runs a process with the user's full authority. `split_window` also claimed `destructiveHint: false` while accepting a `shell` command that replaces what the pane would otherwise have run. what: - Advertise `openWorldHint: true` on `create_session`, `create_window`, `split_window` and `respawn_pane` - Move `split_window` and `respawn_pane` onto the payload-carrying hints; both take an authored command - Add a preset for the two spawns that carry no command payload, so `create_session` and `create_window` keep their additive claim - Fold the single-use mutating-destructive preset into the destructive one; the tier tag at the registration site already carried the distinction the name existed to document - Retire the pane-scoped hint tests the surface-wide invariants now cover, including a docstring narrating an earlier preset refactor --- src/libtmux_mcp/_utils.py | 48 ++++++++---------- src/libtmux_mcp/tools/pane_tools/__init__.py | 5 +- src/libtmux_mcp/tools/server_tools.py | 4 +- src/libtmux_mcp/tools/session_tools.py | 4 +- src/libtmux_mcp/tools/window_tools.py | 4 +- tests/test_pane_tools.py | 52 +------------------- tests/test_tool_annotations.py | 34 +++++++++++++ 7 files changed, 63 insertions(+), 88 deletions(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 9c00927e..7b1f3256 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -405,17 +405,28 @@ def _caller_is_strictly_on_server( "idempotentHint": False, "openWorldHint": False, } -#: Annotations for tools whose caller-supplied payload reaches a program -#: that runs it: ``send_keys``, ``send_keys_batch``, ``run_command``, -#: ``paste_text``, ``paste_buffer``, and ``pipe_pane``. +#: Annotations for tools that hand a caller-supplied payload to a program +#: that runs it — typed keys, a pasted buffer, an authored shell command, +#: or the command ``pipe_pane`` feeds. #: -#: ``destructiveHint`` is ``True`` because MCP defines ``False`` as a -#: claim of additive-only updates, and typed input can overwrite a file, -#: end a process, or leave a shell mid-line. ``openWorldHint`` is ``True`` -#: because the effect extends into whatever the payload runs. +#: ``destructiveHint`` is ``True`` because MCP defines ``False`` as a claim +#: of additive-only updates, and such a payload can overwrite a file, end a +#: process, or leave a shell mid-line. ``openWorldHint`` is ``True`` because +#: the effect extends into whatever the payload runs. #: -#: ``load_buffer`` is deliberately not here: it allocates a fresh buffer -#: and delivers nothing, so it is additive and closed-world. +#: Contrast :data:`ANNOTATIONS_SPAWN`, which starts the pane's *configured* +#: process and carries no payload. +#: Annotations for tools that start a pane's configured process without a +#: caller-supplied command. Additive at the tmux level, but ``openWorldHint`` +#: is ``True``: the new process runs with the user's authority and reaches +#: whatever that user reaches. +ANNOTATIONS_SPAWN: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": True, +} + ANNOTATIONS_SHELL: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, @@ -443,25 +454,6 @@ def _caller_is_strictly_on_server( DISCOVERY_META: dict[str, t.Any] = { "anthropic/alwaysLoad": True, } -#: Annotations for tools that stay in the ``mutating`` tier (so they remain -#: visible to default-profile agents) but whose default behaviour can -#: terminate processes or otherwise lose state. -#: -#: Canonical users include ``respawn_pane`` and ``clear_pane``: -#: tier=mutating because shell recovery and scrollback cleanup are part -#: of normal agent workflows, while the hints still disclose process -#: termination or state loss. -#: -#: Distinct from :data:`ANNOTATIONS_DESTRUCTIVE` (same hint values) because -#: the tier tag differs: ``ANNOTATIONS_DESTRUCTIVE`` is paired with -#: ``TAG_DESTRUCTIVE`` everywhere it is used; this preset is paired with -#: ``TAG_MUTATING``. The distinct name documents intent at the call site. -ANNOTATIONS_MUTATING_DESTRUCTIVE: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": False, - "openWorldHint": False, -} def _escape_tmux_format(value: str) -> str: diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index dfb86324..b338f6c0 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -15,7 +15,6 @@ ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_MUTATING, - ANNOTATIONS_MUTATING_DESTRUCTIVE, ANNOTATIONS_RO, ANNOTATIONS_SHELL, DISCOVERY_META, @@ -116,7 +115,7 @@ def register(mcp: FastMCP) -> None: )(kill_pane) mcp.tool( title="Respawn Pane", - annotations=ANNOTATIONS_MUTATING_DESTRUCTIVE, + annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING}, )(respawn_pane) mcp.tool( @@ -132,7 +131,7 @@ def register(mcp: FastMCP) -> None: )(find_pane_by_position) mcp.tool( title="Clear Pane", - annotations=ANNOTATIONS_MUTATING_DESTRUCTIVE, + annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING}, )(clear_pane) mcp.tool(title="Search Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index 68f400a7..5ee166f1 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -13,9 +13,9 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_RO, + ANNOTATIONS_SPAWN, TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, @@ -373,7 +373,7 @@ def register(mcp: FastMCP) -> None: )(list_servers) mcp.tool( title="Create tmux Session", - annotations=ANNOTATIONS_CREATE, + annotations=ANNOTATIONS_SPAWN, tags={TAG_MUTATING}, )(create_session) mcp.tool( diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 8ce91174..bd65f055 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -8,10 +8,10 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_MUTATING, ANNOTATIONS_RO, + ANNOTATIONS_SPAWN, DISCOVERY_META, TAG_DESTRUCTIVE, TAG_MUTATING, @@ -361,7 +361,7 @@ def register(mcp: FastMCP) -> None: title="Get tmux Session Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} )(get_session_info) mcp.tool( - title="Create tmux Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING} + title="Create tmux Window", annotations=ANNOTATIONS_SPAWN, tags={TAG_MUTATING} )(create_window) mcp.tool( title="Rename tmux Session", diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 133c28bb..abd0a61f 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -8,10 +8,10 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_MUTATING, ANNOTATIONS_RO, + ANNOTATIONS_SHELL, DISCOVERY_META, TAG_DESTRUCTIVE, TAG_MUTATING, @@ -509,7 +509,7 @@ def register(mcp: FastMCP) -> None: title="Get tmux Window Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} )(get_window_info) mcp.tool( - title="Split tmux Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING} + title="Split tmux Window", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING} )(split_window) mcp.tool( title="Rename tmux Window", diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 6cdd66a9..d3461fbd 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -5512,15 +5512,6 @@ def test_paste_text_does_not_leak_named_buffer( @pytest.mark.parametrize( ("tool_name", "expected_open_world"), [ - # Shell-driving tools: the command the caller sends can reach - # arbitrary external state, so the interaction is open-world. - ("send_keys", True), - ("send_keys_batch", True), - ("run_command", True), - ("paste_text", True), - ("pipe_pane", True), - # Create-style tools: allocate tmux objects only. Not open-world - # even though they share the old ANNOTATIONS_CREATE preset. ("swap_pane", False), ("enter_copy_mode", False), ], @@ -5528,15 +5519,7 @@ def test_paste_text_does_not_leak_named_buffer( def test_pane_tool_open_world_hint_registration( tool_name: str, expected_open_world: bool ) -> None: - """Pane tools advertise ``openWorldHint`` matching their real semantics. - - Regression guard for the shared-preset trap: the old - ``ANNOTATIONS_CREATE`` preset was applied to both shell-driving and - non-shell-driving tools, so every caller saw ``openWorldHint=False``. - A new ``ANNOTATIONS_SHELL`` preset now carries ``openWorldHint=True`` - for the three shell-driving tools only, leaving the other - ``ANNOTATIONS_CREATE`` users unchanged. - """ + """Pane tools that only rearrange tmux state stay closed-world.""" import asyncio from fastmcp import FastMCP @@ -5554,39 +5537,6 @@ def test_pane_tool_open_world_hint_registration( assert wire_annotations(tool).get("openWorldHint") is expected_open_world -def test_respawn_pane_advertises_destructive_non_idempotent() -> None: - """``respawn_pane`` registers as mutating-tier with destructive hints. - - Default ``kill=True`` sends ``SPAWN_KILL`` to the running process - (`cmd-respawn-pane.c:78-79`); repeated calls kill repeated processes. - The MCP spec defines ``destructiveHint`` as "may perform destructive - updates" and ``idempotentHint`` as "calling repeatedly will have no - additional effect" (`mcp/types.py:1268-1282`). The default - ``ANNOTATIONS_MUTATING`` preset (``destructiveHint=False``, - ``idempotentHint=True``) would lie to the agent. The new - ``ANNOTATIONS_MUTATING_DESTRUCTIVE`` preset stays in ``TAG_MUTATING`` - so the recovery use case remains visible to default-profile clients, - while honestly advertising destructive non-idempotent semantics. - """ - import asyncio - - from fastmcp import FastMCP - - from libtmux_mcp.tools import pane_tools - - mcp = FastMCP(name="test-respawn-annotations") - pane_tools.register(mcp) - - tool = asyncio.run(mcp.get_tool("respawn_pane")) - assert tool is not None, "respawn_pane should be registered" - assert tool.annotations is not None, ( - "respawn_pane registration should carry annotations" - ) - assert wire_annotations(tool).get("destructiveHint") is True - assert wire_annotations(tool).get("idempotentHint") is False - assert wire_annotations(tool).get("readOnlyHint") is False - - def test_clear_pane_advertises_destructive_non_idempotent() -> None: """``clear_pane`` registers as mutating-tier with destructive hints.""" import asyncio diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index 74fbca82..06d29192 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -16,6 +16,22 @@ from .conftest import wire_annotations +#: Tools that start a process. The pane's program runs with the user's +#: authority and reaches whatever that user reaches, so the effect does +#: not stop at tmux. +SPAWN_TOOLS = frozenset( + { + "create_session", + "create_window", + "respawn_pane", + "split_window", + } +) + +#: Spawn tools that additionally accept a command string to run in place +#: of the pane's configured process. +AUTHORED_COMMAND_TOOLS = frozenset({"respawn_pane", "split_window"}) + #: Tools whose caller-supplied payload reaches a program that runs it — #: a shell prompt, a pane's process, or the command ``pipe_pane`` feeds. #: Membership is a fact about where the value lands, not about the @@ -74,3 +90,21 @@ def test_pane_input_tools_do_not_claim_additive_updates( assert hints["destructiveHint"] is True assert hints["idempotentHint"] is False assert hints["openWorldHint"] is True + + +@pytest.mark.parametrize("name", sorted(SPAWN_TOOLS)) +def test_spawn_tools_are_advertised_open_world( + advertised_tools: dict[str, t.Any], + name: str, +) -> None: + """A pane's program runs with the user's authority, not inside tmux.""" + assert wire_annotations(advertised_tools[name])["openWorldHint"] is True + + +@pytest.mark.parametrize("name", sorted(AUTHORED_COMMAND_TOOLS)) +def test_authored_command_spawns_do_not_claim_additive_updates( + advertised_tools: dict[str, t.Any], + name: str, +) -> None: + """A caller-authored command replaces what the pane would have run.""" + assert wire_annotations(advertised_tools[name])["destructiveHint"] is True From d29b9dc5b36308f0337c18f13dbe7b8479c491eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:09:56 -0500 Subject: [PATCH 05/44] mcp(fix[hints]): Replacing a value is not additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: MCP reserves `destructiveHint: false` for tools that perform only additive updates. Renames, resizes, selections, moves, layouts, titles, options, and environment writes all replace a value tmux already held, and `swap_pane` and `delete_buffer` were additionally mis-preset — a swap exchanges two panes, a delete removes a buffer. what: - Advertise `destructiveHint: true` on the replacement tools, and move `swap_pane`, `enter_copy_mode` and `delete_buffer` onto the removal hints - Give `signal_channel` and `wait_for_channel` an additive preset: a `wait-for` channel latches, replacing nothing - Rename the create preset to say what its one remaining user does, now that the session and window spawns have their own - Assert the claim as a closed set: only five named tools may advertise additive-only updates, so a new tool cannot join them without saying so The coarser hint is accepted, not worked around. A client that gates on `destructiveHint` now prompts for a rename; the signal that separates a rename from a shell command is the tool name and description. --- src/libtmux_mcp/_utils.py | 21 +++++++++++++++- src/libtmux_mcp/tools/buffer_tools.py | 8 +++---- src/libtmux_mcp/tools/pane_tools/__init__.py | 9 ++++--- src/libtmux_mcp/tools/wait_for_tools.py | 6 ++--- tests/test_tool_annotations.py | 25 ++++++++++++++++++++ 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 7b1f3256..add29092 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -387,19 +387,38 @@ def _caller_is_strictly_on_server( # Reusable annotation presets for tool registration # --------------------------------------------------------------------------- +#: Annotations for tools that only read tmux or pane state. ANNOTATIONS_RO: dict[str, bool] = { "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False, } + +#: Annotations for tools that replace a value tmux already held — a name, +#: a size, a layout, a selection, an option. MCP's ``destructiveHint: false`` +#: means additive-only, which a replacement is not, so these advertise +#: ``True`` even though nothing is destroyed. Repeating the same call lands +#: on the same state, so ``idempotentHint`` stays ``True``. ANNOTATIONS_MUTATING: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, +} + +#: Annotations for tools that add state without replacing any, and land on +#: the same state when repeated. +ANNOTATIONS_ADDITIVE: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False, } -ANNOTATIONS_CREATE: dict[str, bool] = { + +#: Annotations for tools that allocate a new tmux object each call, so +#: nothing is replaced and no two calls land on the same state. +ANNOTATIONS_ALLOCATE: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 7919d183..a6a08a09 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -35,8 +35,8 @@ import uuid from libtmux_mcp._utils import ( - ANNOTATIONS_CREATE, - ANNOTATIONS_MUTATING, + ANNOTATIONS_ALLOCATE, + ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_RO, ANNOTATIONS_SHELL, TAG_MUTATING, @@ -386,7 +386,7 @@ def register(mcp: FastMCP) -> None: content to a pane's program, and carries the hints for it. """ mcp.tool( - title="Load tmux Buffer", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING} + title="Load tmux Buffer", annotations=ANNOTATIONS_ALLOCATE, tags={TAG_MUTATING} )(load_buffer) mcp.tool( title="Paste tmux Buffer", @@ -398,6 +398,6 @@ def register(mcp: FastMCP) -> None: ) mcp.tool( title="Delete tmux Buffer", - annotations=ANNOTATIONS_MUTATING, + annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING}, )(delete_buffer) diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index b338f6c0..71185e18 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -12,7 +12,6 @@ import typing as t from libtmux_mcp._utils import ( - ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_MUTATING, ANNOTATIONS_RO, @@ -153,9 +152,9 @@ def register(mcp: FastMCP) -> None: mcp.tool( title="Select Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} )(select_pane) - mcp.tool(title="Swap Pane", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( - swap_pane - ) + mcp.tool( + title="Swap Pane", annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING} + )(swap_pane) mcp.tool(title="Pipe Pane", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})( pipe_pane ) @@ -166,7 +165,7 @@ def register(mcp: FastMCP) -> None: )(display_message) mcp.tool( title="Enter Copy Mode", - annotations=ANNOTATIONS_CREATE, + annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING}, )(enter_copy_mode) mcp.tool( diff --git a/src/libtmux_mcp/tools/wait_for_tools.py b/src/libtmux_mcp/tools/wait_for_tools.py index 04699bb2..4f982be6 100644 --- a/src/libtmux_mcp/tools/wait_for_tools.py +++ b/src/libtmux_mcp/tools/wait_for_tools.py @@ -42,7 +42,7 @@ from libtmux_mcp._tmux_proc import _run_tmux_bounded from libtmux_mcp._utils import ( - ANNOTATIONS_MUTATING, + ANNOTATIONS_ADDITIVE, TAG_MUTATING, TAG_SELF_BOUNDED, ExpectedToolError, @@ -320,11 +320,11 @@ def register(mcp: FastMCP) -> None: # which is what the tag asserts. mcp.tool( title="Wait For tmux Channel", - annotations=ANNOTATIONS_MUTATING, + annotations=ANNOTATIONS_ADDITIVE, tags={TAG_MUTATING, TAG_SELF_BOUNDED}, )(wait_for_channel) mcp.tool( title="Signal tmux Channel", - annotations=ANNOTATIONS_MUTATING, + annotations=ANNOTATIONS_ADDITIVE, tags={TAG_MUTATING}, )(signal_channel) diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index 06d29192..e0dddca6 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -108,3 +108,28 @@ def test_authored_command_spawns_do_not_claim_additive_updates( ) -> None: """A caller-authored command replaces what the pane would have run.""" assert wire_annotations(advertised_tools[name])["destructiveHint"] is True + + +#: The only tools whose updates are additive-only. Everything else that +#: writes replaces or removes prior state, which MCP spells +#: ``destructiveHint: true`` however small the change. +ADDITIVE_TOOLS = frozenset( + { + "create_session", + "create_window", + "load_buffer", + "signal_channel", + "wait_for_channel", + } +) + + +def test_only_additive_tools_claim_additive_updates( + advertised_tools: dict[str, t.Any], +) -> None: + """A new tool cannot quietly claim additive-only updates.""" + for name, tool in advertised_tools.items(): + hints = wire_annotations(tool) + if hints["readOnlyHint"]: + continue + assert hints["destructiveHint"] is (name not in ADDITIVE_TOOLS), name From 748648fc2940c13771e798f54566a8a425179e5b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:11:32 -0500 Subject: [PATCH 06/44] mcp(fix[hints]): Pane text is open-world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The tools that return terminal content advertised `openWorldHint: false`, which tells a client the result came from a closed domain. A pane holds whatever was printed into it — an SSH session, a package manager, another agent — so the text crossed a trust boundary before this server read it. Being read-only says nothing about where the bytes came from. what: - Advertise `openWorldHint: true` on `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `wait_for_text` and `show_buffer` - Carry the same hint on `call_readonly_tools_batch`, which can invoke any of them under its own name - Leave structural reads closed: listings, info, options, hooks and the tmux environment report tmux's own state --- src/libtmux_mcp/_utils.py | 11 +++++++ src/libtmux_mcp/tools/batch_tools.py | 12 +++++-- src/libtmux_mcp/tools/buffer_tools.py | 10 +++--- src/libtmux_mcp/tools/pane_tools/__init__.py | 23 +++++++------- tests/test_tool_annotations.py | 33 ++++++++++++++++++++ 5 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index add29092..17cbfaab 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -395,6 +395,17 @@ def _caller_is_strictly_on_server( "openWorldHint": False, } +#: Annotations for read tools that return terminal content. Read-only, +#: but ``openWorldHint`` is ``True``: a pane holds whatever was printed +#: into it — remote sessions, package managers, other agents — so the +#: text reaching the caller crossed a trust boundary on its way in. +ANNOTATIONS_RO_CONTENT: dict[str, bool] = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, +} + #: Annotations for tools that replace a value tmux already held — a name, #: a size, a layout, a selection, an option. MCP's ``destructiveHint: false`` #: means additive-only, which a replacement is not, so these advertise diff --git a/src/libtmux_mcp/tools/batch_tools.py b/src/libtmux_mcp/tools/batch_tools.py index 2091f4b4..5ae578ea 100644 --- a/src/libtmux_mcp/tools/batch_tools.py +++ b/src/libtmux_mcp/tools/batch_tools.py @@ -11,7 +11,6 @@ from pydantic import BaseModel from libtmux_mcp._utils import ( - ANNOTATIONS_RO, TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, @@ -54,6 +53,15 @@ } ] +#: The read batch can invoke a member that returns pane text, so it +#: carries that member's ``openWorldHint``. +_ANNOTATIONS_BATCH_READ: dict[str, bool] = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, +} + _ANNOTATIONS_BATCH_SIDE_EFFECTS: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, @@ -366,7 +374,7 @@ def register(mcp: FastMCP) -> None: """Register generic MCP batch tools.""" mcp.tool( title="Call Readonly Tools Batch", - annotations=ANNOTATIONS_RO, + annotations=_ANNOTATIONS_BATCH_READ, tags={TAG_READONLY}, )(call_readonly_tools_batch) mcp.tool( diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index a6a08a09..4712b4fb 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -37,7 +37,7 @@ from libtmux_mcp._utils import ( ANNOTATIONS_ALLOCATE, ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_RO, + ANNOTATIONS_RO_CONTENT, ANNOTATIONS_SHELL, TAG_MUTATING, TAG_READONLY, @@ -393,9 +393,11 @@ def register(mcp: FastMCP) -> None: annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING}, )(paste_buffer) - mcp.tool(title="Show tmux Buffer", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - show_buffer - ) + mcp.tool( + title="Show tmux Buffer", + annotations=ANNOTATIONS_RO_CONTENT, + tags={TAG_READONLY}, + )(show_buffer) mcp.tool( title="Delete tmux Buffer", annotations=ANNOTATIONS_DESTRUCTIVE, diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index 71185e18..b45cf135 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -15,6 +15,7 @@ ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_MUTATING, ANNOTATIONS_RO, + ANNOTATIONS_RO_CONTENT, ANNOTATIONS_SHELL, DISCOVERY_META, TAG_DESTRUCTIVE, @@ -98,12 +99,12 @@ def register(mcp: FastMCP) -> None: annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING, TAG_SELF_BOUNDED}, )(run_command) - mcp.tool(title="Capture Pane", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - capture_pane - ) - mcp.tool(title="Capture Since", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - capture_since - ) + mcp.tool( + title="Capture Pane", annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY} + )(capture_pane) + mcp.tool( + title="Capture Since", annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY} + )(capture_since) mcp.tool( title="Resize Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} )(resize_pane) @@ -133,19 +134,19 @@ def register(mcp: FastMCP) -> None: annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING}, )(clear_pane) - mcp.tool(title="Search Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - search_panes - ) + mcp.tool( + title="Search Panes", annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY} + )(search_panes) # TAG_SELF_BOUNDED excludes this tool from retry and from batch # wrappers: both would multiply the wait ceiling it enforces. mcp.tool( title="Wait For Text", - annotations=ANNOTATIONS_RO, + annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY, TAG_SELF_BOUNDED}, )(wait_for_text) mcp.tool( title="Snapshot Pane", - annotations=ANNOTATIONS_RO, + annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY}, meta=DISCOVERY_META, )(snapshot_pane) diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index e0dddca6..fb0e36d1 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -133,3 +133,36 @@ def test_only_additive_tools_claim_additive_updates( if hints["readOnlyHint"]: continue assert hints["destructiveHint"] is (name not in ADDITIVE_TOOLS), name + + +#: Read tools that return terminal content. What a pane holds arrived +#: from somewhere else — an SSH session, a package manager, a remote +#: agent — so the text crossed a trust boundary before this server saw +#: it, which is what ``openWorldHint`` tells a client. +TERMINAL_CONTENT_TOOLS = frozenset( + { + "capture_pane", + "capture_since", + "search_panes", + "show_buffer", + "snapshot_pane", + "wait_for_text", + } +) + + +@pytest.mark.parametrize("name", sorted(TERMINAL_CONTENT_TOOLS)) +def test_terminal_content_reads_are_advertised_open_world( + advertised_tools: dict[str, t.Any], + name: str, +) -> None: + """Returned pane text is untrusted, however read-only the call was.""" + assert wire_annotations(advertised_tools[name])["openWorldHint"] is True + + +def test_the_read_batch_carries_its_members_open_world_hint( + advertised_tools: dict[str, t.Any], +) -> None: + """A batch advertises the worst case of what it can invoke.""" + batch = advertised_tools["call_readonly_tools_batch"] + assert wire_annotations(batch)["openWorldHint"] is True From 2ea63fd457098a8325fc74d86e148145361d35aa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:14:54 -0500 Subject: [PATCH 07/44] docs(topics[safety]): Gate the hint table why: The hint table was hand-maintained, listed 29 of 56 tools, had no `openWorldHint` column, and went stale the moment the annotations were corrected. Nothing checked it. A paragraph also named a preset that no longer exists. what: - List every tool with all four hints, generated from the registered surface - Assert the table against a freshly registered server, so a hint change that skips the docs fails the suite - Say what `destructiveHint: false` and `openWorldHint: true` claim, and that hints are presentation, not enforcement - Register into a fresh server rather than the production one, whose tier filter is fixed at import and would make the check depend on test ordering --- docs/topics/safety.md | 103 +++++++++++++++++++---------- tests/docs/test_topic_contracts.py | 37 +++++++++++ 2 files changed, 106 insertions(+), 34 deletions(-) diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 32084c28..fd4a6fdc 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -105,7 +105,7 @@ Mitigations: {tool}`respawn-pane` restarts a pane's process while preserving the pane id and layout — exactly what an agent wants when a shell wedges. Default `kill=True` terminates the running process before relaunch. The `pane_id` and layout are preserved (the point of the tool), but any unsaved REPL state, ssh session, or in-flight job in that pane is lost. Repeated calls are *not* idempotent — each call kills a new process. -Unlike other `mutating` tools, the registration carries `destructiveHint=True` and `idempotentHint=False` (via the `ANNOTATIONS_MUTATING_DESTRUCTIVE` preset) so MCP clients see honest annotations even though the tier tag stays at `mutating` for default-profile recovery. +The registration advertises `destructiveHint=True` and `idempotentHint=False` while the tier tag stays at `mutating`, so recovery remains available to default-profile clients without understating what the call does. Mitigations: @@ -145,36 +145,71 @@ Route this logger to a dedicated sink if you want a durable audit trail; it is d ## Tool annotations -Each tool carries MCP tool annotations that hint at its behavior: - -| Tool | Tier | readOnly | destructive | idempotent | -|------|------|----------|-------------|------------| -| {toolref}`list-sessions` | {badge}`readonly` | true | false | true | -| {toolref}`get-server-info` | {badge}`readonly` | true | false | true | -| {toolref}`list-windows` | {badge}`readonly` | true | false | true | -| {toolref}`list-panes` | {badge}`readonly` | true | false | true | -| {toolref}`capture-pane` | {badge}`readonly` | true | false | true | -| {toolref}`capture-since` | {badge}`readonly` | true | false | true | -| {toolref}`get-pane-info` | {badge}`readonly` | true | false | true | -| {toolref}`search-panes` | {badge}`readonly` | true | false | true | -| {toolref}`wait-for-text` | {badge}`readonly` | true | false | true | -| {toolref}`show-option` | {badge}`readonly` | true | false | true | -| {toolref}`show-environment` | {badge}`readonly` | true | false | true | -| {toolref}`create-session` | {badge}`mutating` | false | false | false | -| {toolref}`create-window` | {badge}`mutating` | false | false | false | -| {toolref}`split-window` | {badge}`mutating` | false | false | false | -| {toolref}`send-keys` | {badge}`mutating` | false | false | false | -| {toolref}`rename-session` | {badge}`mutating` | false | false | true | -| {toolref}`rename-window` | {badge}`mutating` | false | false | true | -| {toolref}`resize-pane` | {badge}`mutating` | false | false | true | -| {toolref}`resize-window` | {badge}`mutating` | false | false | true | -| {toolref}`set-pane-title` | {badge}`mutating` | false | false | true | -| {toolref}`clear-pane` | {badge}`mutating` | false | true | false | -| {toolref}`select-layout` | {badge}`mutating` | false | false | true | -| {toolref}`set-option` | {badge}`mutating` | false | false | true | -| {toolref}`set-environment` | {badge}`mutating` | false | false | true | -| {toolref}`respawn-pane` | {badge}`mutating` | false | true | false | -| {toolref}`kill-server` | {badge}`destructive` | false | true | false | -| {toolref}`kill-session` | {badge}`destructive` | false | true | false | -| {toolref}`kill-window` | {badge}`destructive` | false | true | false | -| {toolref}`kill-pane` | {badge}`destructive` | false | true | false | +Every tool advertises the four MCP annotation hints. They are hints for client +presentation, not authorization: a client may ignore them, and this server +cannot enforce them. + +`destructiveHint: false` is a claim that a tool performs **only additive +updates**, so a tool that replaces a name, a size, or a layout advertises +`true` even though nothing is destroyed. `openWorldHint: true` says the tool +reaches, or returns text from, outside tmux — a spawned process runs with your +user's authority, and a pane holds whatever was printed into it. + +| Tool | Tier | readOnly | destructive | idempotent | openWorld | +|------|------|----------|-------------|------------|-----------| +| {toolref}`call-readonly-tools-batch` | {badge}`readonly` | true | false | true | true | +| {toolref}`capture-pane` | {badge}`readonly` | true | false | true | true | +| {toolref}`capture-since` | {badge}`readonly` | true | false | true | true | +| {toolref}`display-message` | {badge}`readonly` | true | false | true | false | +| {toolref}`find-pane-by-position` | {badge}`readonly` | true | false | true | false | +| {toolref}`get-pane-info` | {badge}`readonly` | true | false | true | false | +| {toolref}`get-server-info` | {badge}`readonly` | true | false | true | false | +| {toolref}`get-session-info` | {badge}`readonly` | true | false | true | false | +| {toolref}`get-window-info` | {badge}`readonly` | true | false | true | false | +| {toolref}`list-panes` | {badge}`readonly` | true | false | true | false | +| {toolref}`list-servers` | {badge}`readonly` | true | false | true | false | +| {toolref}`list-sessions` | {badge}`readonly` | true | false | true | false | +| {toolref}`list-windows` | {badge}`readonly` | true | false | true | false | +| {toolref}`search-panes` | {badge}`readonly` | true | false | true | true | +| {toolref}`show-buffer` | {badge}`readonly` | true | false | true | true | +| {toolref}`show-environment` | {badge}`readonly` | true | false | true | false | +| {toolref}`show-hook` | {badge}`readonly` | true | false | true | false | +| {toolref}`show-hooks` | {badge}`readonly` | true | false | true | false | +| {toolref}`show-option` | {badge}`readonly` | true | false | true | false | +| {toolref}`snapshot-pane` | {badge}`readonly` | true | false | true | true | +| {toolref}`wait-for-text` | {badge}`readonly` | true | false | true | true | +| {toolref}`call-mutating-tools-batch` | {badge}`mutating` | false | true | false | true | +| {toolref}`clear-pane` | {badge}`mutating` | false | true | false | false | +| {toolref}`create-session` | {badge}`mutating` | false | false | false | true | +| {toolref}`create-window` | {badge}`mutating` | false | false | false | true | +| {toolref}`delete-buffer` | {badge}`mutating` | false | true | false | false | +| {toolref}`enter-copy-mode` | {badge}`mutating` | false | true | false | false | +| {toolref}`exit-copy-mode` | {badge}`mutating` | false | true | true | false | +| {toolref}`load-buffer` | {badge}`mutating` | false | false | false | false | +| {toolref}`move-window` | {badge}`mutating` | false | true | true | false | +| {toolref}`paste-buffer` | {badge}`mutating` | false | true | false | true | +| {toolref}`paste-text` | {badge}`mutating` | false | true | false | true | +| {toolref}`pipe-pane` | {badge}`mutating` | false | true | false | true | +| {toolref}`rename-session` | {badge}`mutating` | false | true | true | false | +| {toolref}`rename-window` | {badge}`mutating` | false | true | true | false | +| {toolref}`resize-pane` | {badge}`mutating` | false | true | true | false | +| {toolref}`resize-window` | {badge}`mutating` | false | true | true | false | +| {toolref}`respawn-pane` | {badge}`mutating` | false | true | false | true | +| {toolref}`run-command` | {badge}`mutating` | false | true | false | true | +| {toolref}`select-layout` | {badge}`mutating` | false | true | true | false | +| {toolref}`select-pane` | {badge}`mutating` | false | true | true | false | +| {toolref}`select-window` | {badge}`mutating` | false | true | true | false | +| {toolref}`send-keys` | {badge}`mutating` | false | true | false | true | +| {toolref}`send-keys-batch` | {badge}`mutating` | false | true | false | true | +| {toolref}`set-environment` | {badge}`mutating` | false | true | true | false | +| {toolref}`set-option` | {badge}`mutating` | false | true | true | false | +| {toolref}`set-pane-title` | {badge}`mutating` | false | true | true | false | +| {toolref}`signal-channel` | {badge}`mutating` | false | false | true | false | +| {toolref}`split-window` | {badge}`mutating` | false | true | false | true | +| {toolref}`swap-pane` | {badge}`mutating` | false | true | false | false | +| {toolref}`wait-for-channel` | {badge}`mutating` | false | false | true | false | +| {toolref}`call-destructive-tools-batch` | {badge}`destructive` | false | true | false | true | +| {toolref}`kill-pane` | {badge}`destructive` | false | true | false | false | +| {toolref}`kill-server` | {badge}`destructive` | false | true | false | false | +| {toolref}`kill-session` | {badge}`destructive` | false | true | false | false | +| {toolref}`kill-window` | {badge}`destructive` | false | true | false | false | diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 7577cbc9..f6a0388a 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -390,3 +390,40 @@ def test_a17_changelog_summarizes_history_features( assert "same JSON object form" in environment_entry assert "credential references, not literal credentials" in environment_entry assert "{ref}`safety`" in environment_entry + + +def test_safety_annotation_table_matches_the_registered_surface( + docs_dir: pathlib.Path, +) -> None: + """The hand-written hint table says what clients are actually told.""" + import asyncio + import re + + from fastmcp import FastMCP + + from libtmux_mcp.tools import register_tools + + # Register into a fresh server rather than reading the production one: + # its tier filter is fixed at import, so the visible surface would + # depend on which test imported it first. + mcp = FastMCP(name="test-annotation-table") + register_tools(mcp) + tools = asyncio.run(mcp.list_tools()) + assert len(tools) > 1 + + hints = ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint") + tiers = ("readonly", "mutating", "destructive") + expected = set() + for tool in tools: + annotations = tool.annotations + assert annotations is not None, tool.name + dumped = annotations.model_dump(mode="json", by_alias=True) + cells = " | ".join(str(dumped[hint]).lower() for hint in hints) + tier = next(tier for tier in tiers if tier in tool.tags) + slug = tool.name.replace("_", "-") + expected.add(f"| {{toolref}}`{slug}` | {{badge}}`{tier}` | {cells} |") + + text = (docs_dir / "topics" / "safety.md").read_text(encoding="utf-8") + documented = set(re.findall(r"^\| \{toolref\}`.+\|$", text, flags=re.MULTILINE)) + + assert documented == expected From 471ea02562ec3a5aeae3b93d3453b60e6b9e18ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:15:25 -0500 Subject: [PATCH 08/44] docs(topics[architecture]): Name the fourth hint why: The registration list named three of the four MCP hints, and the suite now requires every tool to advertise all four. what: - List `openWorldHint` alongside the other three --- docs/topics/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/architecture.md b/docs/topics/architecture.md index c24c0178..42f86fbb 100644 --- a/docs/topics/architecture.md +++ b/docs/topics/architecture.md @@ -58,7 +58,7 @@ The libtmux layer is the tmux object hierarchy: Each tool module defines a `register(mcp)` function that registers tools with metadata: - `title` — human-readable name -- `annotations` — MCP tool annotations (readOnlyHint, destructiveHint, idempotentHint) +- `annotations` — all four MCP hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), never partial - `tags` — safety tier tags for middleware filtering ### Server caching From 48e60473d8a785096b70674527e27022793a3d08 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:19:24 -0500 Subject: [PATCH 09/44] mcp(fix[spawn]): Escape tmux hash runs why: `pipe_pane` already carried a correct tmux-format escaper, measured against the expander's actual rule: a `#`-run followed by `[` is a style sequence that tmux copies through verbatim, so doubling it corrupts the value. Resolving `start_directory` added a second escaper under the same name that doubled every `#` and refused `#[` because doubling broke it. The refusal was a workaround for the wrong rule. what: - Move the run-aware escape into `_utils` as the one implementation, and have `pipe_pane` call it - Leave `pipe_pane` only the half that is its own: `%` doubling for the `strftime` pass `format_expand_time` adds, which `-c` does not get - Drop the `#[` refusal; a directory named `style#[x]` or `run##[x]` now reaches the pane intact - Cover both forms in the round-trip matrix, which goes red under the naive rule --- src/libtmux_mcp/_utils.py | 49 +++++++++------- src/libtmux_mcp/tools/pane_tools/pipe.py | 73 +++++------------------- tests/test_spawn_start_directory.py | 22 ++----- 3 files changed, 47 insertions(+), 97 deletions(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 17cbfaab..dde30d22 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -12,6 +12,7 @@ import logging import os import pathlib +import re import threading import typing as t @@ -486,14 +487,24 @@ def _caller_is_strictly_on_server( } +#: A maximal run of ``#``, plus the ``[`` that may follow it. tmux reads a +#: ``#``-run by what comes next, so the run is the unit to escape, not the +#: individual ``#``. +_TMUX_HASH_RUN = re.compile(r"(#+)(\[?)") + + def _escape_tmux_format(value: str) -> str: - """Return ``value`` escaped so tmux's format pass yields it unchanged. + """Escape ``value`` so tmux's format expander reproduces it literally. + + Doubling every ``#`` is the obvious escape and it is wrong. A ``#``-run + followed by ``[`` is a style sequence reserved for ``format_draw``, and + the expander copies the run through verbatim rather than collapsing it, + so doubling there corrupts the value. Leave those runs alone and double + the rest. - tmux expands several argument values as formats, where ``#(...)`` runs - a shell job and ``#{...}`` substitutes a variable. Doubling every ``#`` - makes the whole string literal. ``#[`` is the exception: tmux hands a - ``#`` run before ``[`` to the style parser rather than collapsing it, - so no escaping renders it literal and such a value is refused. + This covers the ``#`` expander only. A caller reaching an argument tmux + expands with ``format_expand_time`` must escape ``%`` for ``strftime`` + as well — see :func:`~libtmux_mcp.tools.pane_tools.pipe.pipe_pane`. Parameters ---------- @@ -503,27 +514,23 @@ def _escape_tmux_format(value: str) -> str: Returns ------- str - ``value`` with every ``#`` doubled. - - Raises - ------ - ExpectedToolError - If ``value`` contains ``#[``, which has no escaped form. + ``value`` escaped for one pass of tmux format expansion. Examples -------- - >>> _escape_tmux_format("/srv/app") - '/srv/app' >>> _escape_tmux_format("/srv/#(id)") '/srv/##(id)' + >>> _escape_tmux_format("/srv/#[x]") + '/srv/#[x]' + >>> _escape_tmux_format("issue #42") + 'issue ##42' """ - if "#[" in value: - msg = ( - f"tmux reads '#[' as a style prefix with no escaped form, so " - f"this value cannot be passed through: {value!r}" - ) - raise ExpectedToolError(msg) - return value.replace("#", "##") + + def escape_run(match: re.Match[str]) -> str: + run, bracket = match.group(1), match.group(2) + return f"{run}{bracket}" if bracket else run * 2 + + return _TMUX_HASH_RUN.sub(escape_run, value) def _prepare_start_directory(start_directory: str | None) -> str | None: diff --git a/src/libtmux_mcp/tools/pane_tools/pipe.py b/src/libtmux_mcp/tools/pane_tools/pipe.py index cd87ed40..686592ea 100644 --- a/src/libtmux_mcp/tools/pane_tools/pipe.py +++ b/src/libtmux_mcp/tools/pane_tools/pipe.py @@ -2,74 +2,31 @@ from __future__ import annotations -import re import shlex from libtmux_mcp._utils import ( ExpectedToolError, + _escape_tmux_format, _get_server, _resolve_pane, handle_tool_errors, ) -#: A maximal run of ``#``, plus the ``[`` that may follow it. tmux -#: treats a ``#``-run by what comes next, so the run is the unit that -#: has to be escaped -- not the individual ``#``. -_TMUX_HASH_RUN = re.compile(r"(#+)(\[?)") - - -def _escape_tmux_format(value: str) -> str: - """Escape ``value`` so tmux's format expander reproduces it literally. - - ``pipe-pane`` runs its argument through the format expander before - handing it to ``/bin/sh``, so :func:`shlex.quote` alone is not - enough -- it guards the shell layer while tmux has already rewritten - the string. - - There are TWO expansions to escape, not one. ``cmd-pipe-pane.c`` - calls ``format_expand_time()``, which runs the argument through - ``strftime`` as well as the ``#``-format expander, so a ``%`` is as - dangerous as a ``#``: ``100%done.log`` became ``10025one.log`` - (``%d`` -> day of month) and ``date-%Y.log`` became - ``date-2026.log``. ``%%`` is strftime's literal escape and is safe - to apply to every ``%``. - - Doubling every ``#`` is the obvious escape and it is wrong. A - ``#``-run followed by ``[`` is a style sequence, reserved for - ``format_draw``, and the expander copies the whole run through - verbatim without ever collapsing it. Doubling there corrupts the - path in exactly the way this function exists to prevent. Measured - against tmux 3.7b: - - ================== ================== ========================== - input expands to note - ================== ================== ========================== - ``#{pane_id}`` ``%0`` substituted - ``##{pane_id}`` ``#{pane_id}`` run doubling escapes it - ``####{a}`` ``##{a}`` composes for longer runs - ``#S`` *(session name)* legacy single-char alias - ``#(echo hi)`` *(command job)* substituted away - ``##(echo hi)`` ``#(echo hi)`` run doubling escapes it - ``#[fg=red]`` ``#[fg=red]`` verbatim - ``##[fg=red]`` ``##[fg=red]`` verbatim -- never collapses - ``issue ##42`` ``issue #42`` ordinary run collapses - ================== ================== ========================== - - The legacy aliases are the easiest to trip over by accident: a log - named ``#Session.log`` loses its ``#S`` to the session name and - lands on ``ession.log``. - - So: leave a run alone when ``[`` follows it, double it otherwise, - and double every ``%`` for strftime. - """ - def _escape_run(match: re.Match[str]) -> str: - run, bracket = match.group(1), match.group(2) - if bracket: - return f"{run}{bracket}" - return run * 2 +def _escape_pipe_target(value: str) -> str: + """Escape ``value`` for the two expansions ``pipe-pane`` applies. + + ``cmd-pipe-pane.c`` calls ``format_expand_time()``, so the argument goes + through ``strftime`` as well as the ``#``-format expander: ``%`` is as + dangerous as ``#``. ``100%done.log`` became ``10025one.log`` (``%d`` -> + day of month) and ``date-%Y.log`` became ``date-2026.log``. ``%%`` is + strftime's literal escape. - return _TMUX_HASH_RUN.sub(_escape_run, value).replace("%", "%%") + The ``#`` half is :func:`~libtmux_mcp._utils._escape_tmux_format`, which + every format-bearing argument needs; only the ``%`` half is specific to + the arguments tmux expands for time. + """ + return _escape_tmux_format(value).replace("%", "%%") @handle_tool_errors @@ -144,6 +101,6 @@ def pipe_pane( redirect = ">>" if append else ">" # Two layers rewrite this string, so it needs two escapes: tmux # expands its own formats first, then /bin/sh parses what is left. - quoted = _escape_tmux_format(shlex.quote(output_path)) + quoted = _escape_pipe_target(shlex.quote(output_path)) pane.pipe(f"cat {redirect} {quoted}") return f"Piping pane {pane.pane_id} to {output_path}" diff --git a/tests/test_spawn_start_directory.py b/tests/test_spawn_start_directory.py index 58d5316f..9c7ea108 100644 --- a/tests/test_spawn_start_directory.py +++ b/tests/test_spawn_start_directory.py @@ -80,7 +80,10 @@ def test_start_directory_does_not_run_tmux_format_jobs( @pytest.mark.parametrize("spawner", list(_SPAWNERS)) -@pytest.mark.parametrize("name", ["plain", "has#hash", "job#(id)", "var#{x}"]) +@pytest.mark.parametrize( + "name", + ["plain", "has#hash", "job#(id)", "var#{x}", "style#[x]", "run##[x]"], +) def test_start_directory_uses_the_directory_named( mcp_server: Server, mcp_session: Session, @@ -110,20 +113,3 @@ def test_start_directory_rejects_a_missing_directory( start_directory=str(tmp_path / "absent"), socket_name=mcp_server.socket_name, ) - - -def test_start_directory_rejects_a_style_prefix( - mcp_server: Server, - mcp_pane: Pane, - tmp_path: pathlib.Path, -) -> None: - """``#[`` has no tmux format encoding, so such a path is refused.""" - target = tmp_path / "style#[x]" - target.mkdir() - - with pytest.raises(ExpectedToolError, match=r"#\["): - split_window( - pane_id=mcp_pane.pane_id, - start_directory=str(target), - socket_name=mcp_server.socket_name, - ) From 4dd6d9ced35c1375699b620d72f05f5f1be3fdcd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:32:04 -0500 Subject: [PATCH 10/44] mcp(fix[format]): Escape every name tmux expands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `start_directory` was one of eight arguments tmux runs through `format_single`. The rest were missed. `create_session`'s `session_name` and `window_name`, and `create_window`'s `window_name`, execute a `#(...)` job — reproduced on tmux 3.7d at the default safety tier. `rename_session`, `rename_window` and `set_pane_title` corrupt an ordinary name instead: `w#Sx` became `wplain-namex` and a pane titled `title #H here` became `title d here`. what: - Escape the caller's text on `create_session`, `create_window`, `rename_session`, `rename_window` and `set_pane_title` - Escape the option name on `set_option` and `show_option` together; tmux expands it on both, so escaping one alone would stop a write and its read agreeing - Cover every expanding argument in one file, named for the defect rather than for the spawn tools it started with - Correct the fallback the resolver's docstring described: a cwd that cannot be entered falls back to `$HOME`, then `/`, not the client's directory - Give `search_panes` the `Raises` section its deadline needs, since the docstring is what a calling model reads Ruled out by measurement, not assumption: `-e` environment values and `set-option`'s value expand only under `-F`, which is never passed. --- src/libtmux_mcp/_utils.py | 4 +- src/libtmux_mcp/tools/option_tools.py | 7 +- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 3 +- src/libtmux_mcp/tools/pane_tools/search.py | 9 ++ src/libtmux_mcp/tools/server_tools.py | 5 +- src/libtmux_mcp/tools/session_tools.py | 5 +- src/libtmux_mcp/tools/window_tools.py | 3 +- ...ctory.py => test_tmux_format_arguments.py} | 121 +++++++++++++++++- 8 files changed, 146 insertions(+), 11 deletions(-) rename tests/{test_spawn_start_directory.py => test_tmux_format_arguments.py} (50%) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index dde30d22..f4d668f6 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -537,8 +537,8 @@ def _prepare_start_directory(start_directory: str | None) -> str | None: """Resolve a caller path to the directory tmux will actually use. tmux expands ``-c`` as a format before using it as a working - directory, and silently falls back to the client's directory when the - result does not exist — so an unresolvable path lands the pane + directory, then silently falls back to ``$HOME``, or ``/``, when the + result cannot be entered — so an unresolvable path starts the pane somewhere else instead of failing. Resolving here leaves no format for tmux to run and turns a bad path into an error the caller can correct. diff --git a/src/libtmux_mcp/tools/option_tools.py b/src/libtmux_mcp/tools/option_tools.py index e992fd8f..43098121 100644 --- a/src/libtmux_mcp/tools/option_tools.py +++ b/src/libtmux_mcp/tools/option_tools.py @@ -12,6 +12,7 @@ TAG_MUTATING, TAG_READONLY, ExpectedToolError, + _escape_tmux_format, _get_server, _resolve_pane, _resolve_session, @@ -94,7 +95,9 @@ def show_option( Option name and its value. """ obj, opt_scope = _resolve_option_target(socket_name, scope, target) - value = obj.show_option(option, global_=global_, scope=opt_scope) + value = obj.show_option( + _escape_tmux_format(option), global_=global_, scope=opt_scope + ) return OptionResult(option=option, value=value) @@ -135,7 +138,7 @@ def set_option( Confirmation with option name, value, and status. """ obj, opt_scope = _resolve_option_target(socket_name, scope, target) - obj.set_option(option, value, global_=global_, scope=opt_scope) + obj.set_option(_escape_tmux_format(option), value, global_=global_, scope=opt_scope) return OptionSetResult(option=option, value=value, status="set") diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 9acea513..fdf1f36f 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -8,6 +8,7 @@ from libtmux_mcp._utils import ( ExpectedToolError, _caller_is_on_server, + _escape_tmux_format, _get_caller_identity, _get_server, _prepare_start_directory, @@ -217,7 +218,7 @@ def set_pane_title( session_id=session_id, window_id=window_id, ) - pane.set_title(title) + pane.set_title(_escape_tmux_format(title)) return _serialize_pane(pane) diff --git a/src/libtmux_mcp/tools/pane_tools/search.py b/src/libtmux_mcp/tools/pane_tools/search.py index 44882001..fe017fa6 100644 --- a/src/libtmux_mcp/tools/pane_tools/search.py +++ b/src/libtmux_mcp/tools/pane_tools/search.py @@ -200,6 +200,15 @@ def search_panes( SearchPanesResult Paginated match list with ``truncated`` / ``truncated_panes`` / ``total_panes_matched`` / ``offset`` / ``limit`` fields. + + Raises + ------ + ExpectedToolError + If ``pattern`` does not compile, or if matching it against the + captured lines exceeds ``SEARCH_MATCH_MAX_SECONDS`` in total. A + pattern with nested quantifiers such as ``(a+)+`` can backtrack + for hours on one ordinary line; anchor it or search for a + literal. """ search_pattern = pattern if regex else regex_engine.escape(pattern) flags = 0 if match_case else regex_engine.IGNORECASE diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index 5ee166f1..c2476d55 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -22,6 +22,7 @@ ExpectedToolError, _apply_filters, _caller_is_on_server, + _escape_tmux_format, _get_caller_identity, _get_server, _invalidate_server, @@ -129,9 +130,9 @@ def create_session( server = _get_server(socket_name=socket_name) kwargs: dict[str, t.Any] = {} if session_name is not None: - kwargs["session_name"] = session_name + kwargs["session_name"] = _escape_tmux_format(session_name) if window_name is not None: - kwargs["window_name"] = window_name + kwargs["window_name"] = _escape_tmux_format(window_name) prepared_start_directory = _prepare_start_directory(start_directory) if prepared_start_directory is not None: kwargs["start_directory"] = prepared_start_directory diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index bd65f055..acd18cd7 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -19,6 +19,7 @@ ExpectedToolError, _apply_filters, _caller_is_on_server, + _escape_tmux_format, _get_caller_identity, _get_server, _prepare_start_directory, @@ -170,7 +171,7 @@ def create_window( session = _resolve_session(server, session_name=session_name, session_id=session_id) kwargs: dict[str, t.Any] = {} if window_name is not None: - kwargs["window_name"] = window_name + kwargs["window_name"] = _escape_tmux_format(window_name) prepared_start_directory = _prepare_start_directory(start_directory) if prepared_start_directory is not None: kwargs["start_directory"] = prepared_start_directory @@ -222,7 +223,7 @@ def rename_session( """ server = _get_server(socket_name=socket_name) session = _resolve_session(server, session_name=session_name, session_id=session_id) - session = session.rename_session(new_name) + session = session.rename_session(_escape_tmux_format(new_name)) return _serialize_session(session) diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index abd0a61f..4eaac34c 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -19,6 +19,7 @@ ExpectedToolError, _apply_filters, _caller_is_on_server, + _escape_tmux_format, _get_caller_identity, _get_server, _prepare_start_directory, @@ -299,7 +300,7 @@ def rename_window( session_name=session_name, session_id=session_id, ) - window.rename_window(new_name) + window.rename_window(_escape_tmux_format(new_name)) return _serialize_window(window) diff --git a/tests/test_spawn_start_directory.py b/tests/test_tmux_format_arguments.py similarity index 50% rename from tests/test_spawn_start_directory.py rename to tests/test_tmux_format_arguments.py index 9c7ea108..071658eb 100644 --- a/tests/test_spawn_start_directory.py +++ b/tests/test_tmux_format_arguments.py @@ -1,4 +1,9 @@ -"""Tests for ``start_directory`` handling across the tmux spawn tools.""" +"""Tests for caller text reaching a tmux argument that tmux expands. + +tmux expands several argument values as formats, where ``#H`` becomes the +hostname and ``#(cmd)`` runs a shell job. Every such argument is covered +here, so a new one cannot be added without a matching row. +""" from __future__ import annotations @@ -17,6 +22,7 @@ from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session + from libtmux.window import Window #: Spawn tools keyed by name, each called with only ``start_directory`` #: and the target it needs, and each returning the resulting pane ID. @@ -113,3 +119,116 @@ def test_start_directory_rejects_a_missing_directory( start_directory=str(tmp_path / "absent"), socket_name=mcp_server.socket_name, ) + + +#: Text exercising each way a tmux format rewrites an argument: a +#: single-character alias, a variable, and a command job. +FORMAT_BEARING_NAMES = ["plain", "host#Hname", "sess#Sname", "job#(id)"] + + +@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES) +def test_rename_session_stores_the_name_given( + mcp_server: Server, + mcp_session: Session, + text: str, +) -> None: + """``tmux rename-session`` expands its argument; the stored name matches.""" + from libtmux_mcp.tools.session_tools import rename_session + + renamed = rename_session( + new_name=text, + session_id=mcp_session.session_id, + socket_name=mcp_server.socket_name, + ) + + assert renamed.session_name == text + + +@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES) +def test_rename_window_stores_the_name_given( + mcp_server: Server, + mcp_window: Window, + text: str, +) -> None: + """``tmux rename-window`` expands its argument; the stored name matches.""" + from libtmux_mcp.tools.window_tools import rename_window + + renamed = rename_window( + new_name=text, + window_id=mcp_window.window_id, + socket_name=mcp_server.socket_name, + ) + + assert renamed.window_name == text + + +@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES) +def test_set_pane_title_stores_the_title_given( + mcp_server: Server, + mcp_pane: Pane, + text: str, +) -> None: + """``tmux select-pane -T`` expands its argument; the stored title matches.""" + from libtmux_mcp.tools.pane_tools import set_pane_title + + set_pane_title( + title=text, + pane_id=t.cast("str", mcp_pane.pane_id), + socket_name=mcp_server.socket_name, + ) + + mcp_pane.refresh() + assert mcp_pane.pane_title == text + + +def test_set_option_stores_the_option_named( + mcp_server: Server, + mcp_session: Session, +) -> None: + """``set-option`` and ``show-options`` both expand the option name.""" + from libtmux_mcp.tools.option_tools import set_option + + name = "@probe#Hopt" + set_option( + option=name, + value="1", + global_=True, + socket_name=mcp_server.socket_name, + ) + + stored = mcp_server.cmd("show-options", "-g").stdout + assert any(line.startswith(f"{name} ") for line in stored), stored + + +@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES) +def test_create_session_stores_the_names_given( + mcp_server: Server, + text: str, +) -> None: + """``new-session`` expands both ``-s`` and ``-n``.""" + created = create_session( + session_name=text, + window_name=text, + socket_name=mcp_server.socket_name, + ) + + assert created.session_name == text + session = mcp_server.sessions.get(session_id=created.session_id) + assert session is not None + assert session.active_window.window_name == text + + +@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES) +def test_create_window_stores_the_name_given( + mcp_server: Server, + mcp_session: Session, + text: str, +) -> None: + """``new-window`` expands ``-n``.""" + created = create_window( + session_id=mcp_session.session_id, + window_name=text, + socket_name=mcp_server.socket_name, + ) + + assert created.window_name == text From 1298466a0d901a7840d755ad843ceba519d10c30 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:34:22 -0500 Subject: [PATCH 11/44] docs(tools): State the new contracts why: The generated schema block picked up the new docstrings, but the hand-written prose an agent reads first said nothing about either new failure mode. what: - Add a gotcha covering the whole class: names are stored literally, option values are not - Say on the four spawn pages that `start_directory` must exist - Say on the search page that matching shares a two-second ceiling and which patterns exhaust it --- docs/tools/pane/respawn-pane.md | 3 +++ docs/tools/pane/search-panes.md | 5 +++++ docs/tools/server/create-session.md | 3 +++ docs/tools/session/create-window.md | 3 +++ docs/tools/window/split-window.md | 3 +++ docs/topics/gotchas.md | 13 +++++++++++++ 6 files changed, 30 insertions(+) diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 7142910f..e9287192 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -25,6 +25,9 @@ Set it to `true` and {tooliconl}`respawn-pane` copies and merges best-effort no- When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +`start_directory` must name a directory that exists. tmux would otherwise +start the pane in `$HOME` without reporting anything. + **Tip:** Call {tooliconl}`get-pane-info` first if you need to capture `pane_current_command` before respawn — the new process loses its argv. Omitting `shell` makes tmux replay the original argv (good default for diff --git a/docs/tools/pane/search-panes.md b/docs/tools/pane/search-panes.md index 2f8f277b..3bc3045d 100644 --- a/docs/tools/pane/search-panes.md +++ b/docs/tools/pane/search-panes.md @@ -12,6 +12,11 @@ for a one-shot read, or {tooliconl}`capture-since` for repeated observation. **Side effects:** None. Readonly. +Matching shares a two-second ceiling across every line of every pane. A +`regex=true` pattern with nested quantifiers such as `(a+)+` backtracks +exponentially and will exhaust it on one ordinary line; anchor the pattern or +search for a literal. + **Example:** ```json diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index bb65860b..e2e27aae 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -22,6 +22,9 @@ Set it to `true` and {tooliconl}`create-session` copies and merges best-effort n When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +`start_directory` must name a directory that exists. tmux would otherwise +start the pane in `$HOME` without reporting anything. + **Example:** ```json diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md index 2521d5b0..30ba71f7 100644 --- a/docs/tools/session/create-window.md +++ b/docs/tools/session/create-window.md @@ -19,6 +19,9 @@ Set it to `true` and {tooliconl}`create-window` copies and merges best-effort no When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +`start_directory` must name a directory that exists. tmux would otherwise +start the pane in `$HOME` without reporting anything. + **Example:** ```json diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md index 18e6514c..3060120b 100644 --- a/docs/tools/window/split-window.md +++ b/docs/tools/window/split-window.md @@ -20,6 +20,9 @@ Set it to `true` and {tooliconl}`split-window` copies and merges best-effort no- When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +`start_directory` must name a directory that exists. tmux would otherwise +start the pane in `$HOME` without reporting anything. + **Example:** ```json diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 9bc678e4..fa0200c2 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -62,6 +62,19 @@ returns a cursor, and follow-up calls return only output written or rewritten after that cursor. If tmux has already trimmed or cleared the needed history, the result marks `lines_missed=true` and gives you a fresh cursor. +## Names you pass are stored literally + +tmux expands several argument values as formats, where `#H` becomes the +hostname and `#(cmd)` runs a shell job. Session names, window names, pane +titles, option names, and `start_directory` all land in such an argument. + +This server escapes each of them, so a window named `build #2` or a directory +named `has#hash` is stored as written. Nothing has to be pre-escaped, and +doubling a `#` yourself produces a literal doubled `#`. + +Values are the exception. {tooliconl}`set-option` stores an option's value +unexpanded, so a status format keeps its `#{...}` and runs when tmux draws it. + ## Window names are not unique across sessions Two sessions can each have a window named "editor". Targeting by `window_name` alone is ambiguous — always include `session_name` or use the globally unique `window_id` (e.g., `@0`, `@1`). From 35011a674b7b75e5e52fd8b5161a35cf0db75dff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:12:28 -0500 Subject: [PATCH 12/44] test(hints): Read a surface no env var can hide why: The annotation invariants read the production server, whose tier filter is fixed at `LIBTMUX_SAFETY` at import. Under `LIBTMUX_SAFETY=readonly` 13 of them failed on missing keys rather than on any annotation defect. The docs table gate had already solved this three commits earlier; the fix was not carried across. what: - Register into a fresh server, as `test_topic_contracts` does - Assert `idempotentHint` on the spawn tools, which nothing named: the retired per-tool test had covered `respawn_pane`, and the closed-set invariant checks only `destructiveHint` --- tests/test_tool_annotations.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index fb0e36d1..71f29992 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -12,7 +12,7 @@ import pytest -from libtmux_mcp.server import build_mcp_server +from libtmux_mcp.tools import register_tools from .conftest import wire_annotations @@ -50,10 +50,18 @@ @pytest.fixture(scope="module") def advertised_tools() -> dict[str, t.Any]: - """Return every registered tool keyed by name, as clients see it.""" + """Return every registered tool keyed by name, as clients see it. + + Registers into a fresh server rather than the production one, whose + tier filter is fixed at import: reading that one would hide the + mutating and destructive tools whenever ``LIBTMUX_SAFETY`` is set. + """ import asyncio - mcp = build_mcp_server() + from fastmcp import FastMCP + + mcp = FastMCP(name="test-tool-annotations") + register_tools(mcp) tools = asyncio.run(mcp.list_tools()) return {tool.name: tool for tool in tools} @@ -110,6 +118,15 @@ def test_authored_command_spawns_do_not_claim_additive_updates( assert wire_annotations(advertised_tools[name])["destructiveHint"] is True +@pytest.mark.parametrize("name", sorted(SPAWN_TOOLS)) +def test_spawn_tools_are_not_idempotent( + advertised_tools: dict[str, t.Any], + name: str, +) -> None: + """Calling a spawn again starts another process; it does not settle.""" + assert wire_annotations(advertised_tools[name])["idempotentHint"] is False + + #: The only tools whose updates are additive-only. Everything else that #: writes replaces or removes prior state, which MCP spells #: ``destructiveHint: true`` however small the change. From c2d2a9d9b370fc9f7912a26d5ddbd1fbb3f0aae6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:14:24 -0500 Subject: [PATCH 13/44] mcp(fix[hints]): A wait-for channel is consumed why: `signal_channel` and `wait_for_channel` advertised additive, idempotent behaviour. `tmux wait-for` is a consuming latch, measured on 3.7d: after `wait-for -S ch`, the first wait returns and the second blocks, and signalling twice removes the channel so a later wait blocks too. Repeating either call changes what a subsequent wait does, which is neither additive nor idempotent. what: - Advertise both with the removal hints - Narrow the closed additive set to the three tools that earn it, and drop the preset that now has no users - Regenerate the safety table, which the docs gate caught --- docs/topics/safety.md | 4 ++-- src/libtmux_mcp/_utils.py | 9 --------- src/libtmux_mcp/tools/wait_for_tools.py | 6 +++--- tests/test_tool_annotations.py | 2 -- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/docs/topics/safety.md b/docs/topics/safety.md index fd4a6fdc..759430e4 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -204,10 +204,10 @@ user's authority, and a pane holds whatever was printed into it. | {toolref}`set-environment` | {badge}`mutating` | false | true | true | false | | {toolref}`set-option` | {badge}`mutating` | false | true | true | false | | {toolref}`set-pane-title` | {badge}`mutating` | false | true | true | false | -| {toolref}`signal-channel` | {badge}`mutating` | false | false | true | false | +| {toolref}`signal-channel` | {badge}`mutating` | false | true | false | false | | {toolref}`split-window` | {badge}`mutating` | false | true | false | true | | {toolref}`swap-pane` | {badge}`mutating` | false | true | false | false | -| {toolref}`wait-for-channel` | {badge}`mutating` | false | false | true | false | +| {toolref}`wait-for-channel` | {badge}`mutating` | false | true | false | false | | {toolref}`call-destructive-tools-batch` | {badge}`destructive` | false | true | false | true | | {toolref}`kill-pane` | {badge}`destructive` | false | true | false | false | | {toolref}`kill-server` | {badge}`destructive` | false | true | false | false | diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index f4d668f6..1e5c5cdd 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -419,15 +419,6 @@ def _caller_is_strictly_on_server( "openWorldHint": False, } -#: Annotations for tools that add state without replacing any, and land on -#: the same state when repeated. -ANNOTATIONS_ADDITIVE: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, -} - #: Annotations for tools that allocate a new tmux object each call, so #: nothing is replaced and no two calls land on the same state. ANNOTATIONS_ALLOCATE: dict[str, bool] = { diff --git a/src/libtmux_mcp/tools/wait_for_tools.py b/src/libtmux_mcp/tools/wait_for_tools.py index 4f982be6..c2ca68b8 100644 --- a/src/libtmux_mcp/tools/wait_for_tools.py +++ b/src/libtmux_mcp/tools/wait_for_tools.py @@ -42,7 +42,7 @@ from libtmux_mcp._tmux_proc import _run_tmux_bounded from libtmux_mcp._utils import ( - ANNOTATIONS_ADDITIVE, + ANNOTATIONS_DESTRUCTIVE, TAG_MUTATING, TAG_SELF_BOUNDED, ExpectedToolError, @@ -320,11 +320,11 @@ def register(mcp: FastMCP) -> None: # which is what the tag asserts. mcp.tool( title="Wait For tmux Channel", - annotations=ANNOTATIONS_ADDITIVE, + annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING, TAG_SELF_BOUNDED}, )(wait_for_channel) mcp.tool( title="Signal tmux Channel", - annotations=ANNOTATIONS_ADDITIVE, + annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING}, )(signal_channel) diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index 71f29992..f4d5c63a 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -135,8 +135,6 @@ def test_spawn_tools_are_not_idempotent( "create_session", "create_window", "load_buffer", - "signal_channel", - "wait_for_channel", } ) From 9fff401df2fecedb6d4d8ca4761e0ee2fa507238 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:15:20 -0500 Subject: [PATCH 14/44] docs(_utils): Attach each preset's comment to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The spawn preset was inserted directly above the payload preset's `#:` block, so Sphinx read the whole run as one docstring on the spawn preset — opening with a rationale about payloads it does not take — and the payload preset rendered with none. what: - Move the payload preset's comment down to sit above it --- src/libtmux_mcp/_utils.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 1e5c5cdd..33a93a52 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -427,17 +427,6 @@ def _caller_is_strictly_on_server( "idempotentHint": False, "openWorldHint": False, } -#: Annotations for tools that hand a caller-supplied payload to a program -#: that runs it — typed keys, a pasted buffer, an authored shell command, -#: or the command ``pipe_pane`` feeds. -#: -#: ``destructiveHint`` is ``True`` because MCP defines ``False`` as a claim -#: of additive-only updates, and such a payload can overwrite a file, end a -#: process, or leave a shell mid-line. ``openWorldHint`` is ``True`` because -#: the effect extends into whatever the payload runs. -#: -#: Contrast :data:`ANNOTATIONS_SPAWN`, which starts the pane's *configured* -#: process and carries no payload. #: Annotations for tools that start a pane's configured process without a #: caller-supplied command. Additive at the tmux level, but ``openWorldHint`` #: is ``True``: the new process runs with the user's authority and reaches @@ -449,6 +438,17 @@ def _caller_is_strictly_on_server( "openWorldHint": True, } +#: Annotations for tools that hand a caller-supplied payload to a program +#: that runs it — typed keys, a pasted buffer, an authored shell command, +#: or the command ``pipe_pane`` feeds. +#: +#: ``destructiveHint`` is ``True`` because MCP defines ``False`` as a claim +#: of additive-only updates, and such a payload can overwrite a file, end a +#: process, or leave a shell mid-line. ``openWorldHint`` is ``True`` because +#: the effect extends into whatever the payload runs. +#: +#: Contrast :data:`ANNOTATIONS_SPAWN`, which starts the pane's *configured* +#: process and carries no payload. ANNOTATIONS_SHELL: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, From 16779af2255ea4d4bbc84d4bf2999f37f0e5d742 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:18:58 -0500 Subject: [PATCH 15/44] mcp(fix[format]): Validate the display format why: `display_message` refused a literal `#(` in the caller's text. That cannot see what tmux expands next: `#{E:x}` runs x's *value* through the expander again, and `format_cb_current_path` returns a pane's working directory unsanitized, so any process in any pane can put `#(cmd)` where the caller never typed one. tmux neuters `#(` for names taken from pane output (`clean_name` with untrusted set) but not for a path. `display-message` does not set `FORMAT_NOJOBS`, so the expander reaches `format_job_get` on that path. what: - Accept literal text and `#{variable}` references, and refuse anything else; the name grammar has no `:`, which every modifier needs, so a second expansion cannot be requested - Advertise `openWorldHint: true`: returned values can carry text a pane chose, such as its working directory or running command - Say both in the docstring, since that is what a calling model reads Validating what is allowed replaces scanning for what is not, so a format construct nobody has thought of yet is refused by default. --- docs/tools/pane/display-message.md | 4 ++ docs/topics/safety.md | 2 +- src/libtmux_mcp/tools/pane_tools/__init__.py | 2 +- src/libtmux_mcp/tools/pane_tools/meta.py | 41 ++++++++++++++--- tests/test_tmux_format_arguments.py | 48 ++++++++++++++++++++ 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md index ffb4878a..0dd4f697 100644 --- a/docs/tools/pane/display-message.md +++ b/docs/tools/pane/display-message.md @@ -19,6 +19,10 @@ yourself. **Side effects:** None. Readonly. +Accepts literal text and `#{variable}` references only. Modifiers such as +`#{E:...}` re-expand a variable's *value*, which can arrive from a pane +rather than from you, so they are refused rather than filtered. + **Example:** ```json diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 759430e4..1b5b06ea 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -160,7 +160,7 @@ user's authority, and a pane holds whatever was printed into it. | {toolref}`call-readonly-tools-batch` | {badge}`readonly` | true | false | true | true | | {toolref}`capture-pane` | {badge}`readonly` | true | false | true | true | | {toolref}`capture-since` | {badge}`readonly` | true | false | true | true | -| {toolref}`display-message` | {badge}`readonly` | true | false | true | false | +| {toolref}`display-message` | {badge}`readonly` | true | false | true | true | | {toolref}`find-pane-by-position` | {badge}`readonly` | true | false | true | false | | {toolref}`get-pane-info` | {badge}`readonly` | true | false | true | false | | {toolref}`get-server-info` | {badge}`readonly` | true | false | true | false | diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index b45cf135..5c1a944f 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -161,7 +161,7 @@ def register(mcp: FastMCP) -> None: ) mcp.tool( title="Evaluate tmux Format String", - annotations=ANNOTATIONS_RO, + annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY}, )(display_message) mcp.tool( diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 953f4d94..374e2f6d 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re + from libtmux_mcp._utils import ( ExpectedToolError, _coerce_bool, @@ -19,6 +21,12 @@ _truncate_lines_tail, ) +#: One ``#{name}`` reference. The name grammar excludes ``:``, which every +#: tmux format modifier needs — ``#{E:x}`` re-expands ``x``'s value, where a +#: job the caller never typed can be waiting. Validating what is allowed, +#: rather than scanning for what is not, is what makes that unreachable. +_FORMAT_VARIABLE = re.compile(r"#\{[A-Za-z_@][A-Za-z0-9_]*\}") + @handle_tool_errors def display_message( @@ -29,17 +37,24 @@ def display_message( window_id: str | None = None, socket_name: str | None = None, ) -> str: - """Evaluate a tmux format string against a target and return the expanded value. + """Read tmux variables against a target and return the substituted text. - Read-only introspection tool — expands any tmux format variable - against a target pane and returns the substituted text. Use this - when no dedicated tool covers the field you want, e.g. + Use this when no dedicated tool covers the field you want, e.g. '#{window_zoomed_flag}', '#{pane_dead}', '#{client_activity}'. + Accepts literal text and '#{variable}' references. Format modifiers, + conditionals, and jobs are refused: tmux expands '#{E:x}' by running + x's *value* through the expander again, and a value can arrive from a + pane rather than from the caller. + + Returned values can carry text a pane chose, such as a working + directory or a running command. + Parameters ---------- format_string : str - tmux format string (e.g. '#{cursor_x} #{cursor_y}'). + Literal text and '#{variable}' references (e.g. + '#{cursor_x} #{cursor_y}'). pane_id : str, optional Pane ID (e.g. '%1'). session_name : str, optional @@ -55,9 +70,21 @@ def display_message( ------- str Expanded format string result. + + Raises + ------ + ExpectedToolError + If ``format_string`` carries tmux format syntax other than + ``#{variable}`` references. """ - if "#(" in format_string: - msg = "tmux format jobs (#(...)) are not allowed in display_message" + remainder = _FORMAT_VARIABLE.sub("", format_string) + if "#" in remainder: + msg = ( + "format_string accepts literal text and '#{variable}' references " + f"only; {format_string!r} carries other tmux format syntax. " + "Modifiers such as '#{E:...}' expand a variable's value a second " + "time, and a job '#(...)' runs a shell command." + ) raise ExpectedToolError(msg) server = _get_server(socket_name=socket_name) diff --git a/tests/test_tmux_format_arguments.py b/tests/test_tmux_format_arguments.py index 071658eb..d0312c7a 100644 --- a/tests/test_tmux_format_arguments.py +++ b/tests/test_tmux_format_arguments.py @@ -232,3 +232,51 @@ def test_create_window_stores_the_name_given( ) assert created.window_name == text + + +@pytest.mark.parametrize( + "format_string", + [ + "#(touch /tmp/evil)", + "#{E:@opt}", + "#{E:pane_current_path}", + "#{T:status-left}", + "#S", + "##", + ], +) +def test_display_message_accepts_only_variable_references( + mcp_server: Server, + mcp_pane: Pane, + format_string: str, +) -> None: + """Anything but ``#{name}`` is refused, including a second expansion. + + ``#{E:...}`` re-expands a variable's *value*, and a pane's own working + directory reaches ``pane_current_path`` unsanitized, so a blocklist on + the caller's text cannot see what would run. + """ + from libtmux_mcp.tools.pane_tools import display_message + + with pytest.raises(ExpectedToolError): + display_message( + format_string=format_string, + pane_id=t.cast("str", mcp_pane.pane_id), + socket_name=mcp_server.socket_name, + ) + + +def test_display_message_expands_plain_variables( + mcp_server: Server, + mcp_pane: Pane, +) -> None: + """A format of bare ``#{name}`` references still works.""" + from libtmux_mcp.tools.pane_tools import display_message + + result = display_message( + format_string="id=#{pane_id} zoomed=#{window_zoomed_flag}", + pane_id=t.cast("str", mcp_pane.pane_id), + socket_name=mcp_server.socket_name, + ) + + assert result == f"id={mcp_pane.pane_id} zoomed=0" From 40d849bed3a861dc433edad4edd891539396d393 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:48:26 -0500 Subject: [PATCH 16/44] docs(display-message): Say what capability went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The page still offered "any `#{format}` string", which the grammar made false — the one place this branch moved a description away from accuracy instead of toward it. It also left callers with nowhere to go for the modifiers and conditionals that were removed. what: - Promise variables, not formats, in the heading and the "Use when" - Route raw format syntax to `run_command`, where the caller supplies it on the surface labelled execution - Say the same in the docstring, which is what a calling model reads --- docs/tools/pane/display-message.md | 16 ++++++++++------ src/libtmux_mcp/tools/pane_tools/meta.py | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md index 0dd4f697..cea886c9 100644 --- a/docs/tools/pane/display-message.md +++ b/docs/tools/pane/display-message.md @@ -1,13 +1,12 @@ -# Evaluate tmux format string (display_message) +# Read tmux variables (display_message) ```{fastmcp-tool} pane_tools.display_message ``` -**Use when** you need to query arbitrary tmux variables — zoom state, pane -dead flag, client activity, or any `#{format}` string that isn't covered by -other tools. Despite the historical name (`display_message` is the tmux verb -it wraps), this tool does **not** display anything to the user; it expands -the format string with `display-message -p` and returns the value. +**Use when** you need to read a tmux variable no dedicated tool covers — +zoom state, pane dead flag, client activity. Despite the historical name +(`display_message` is the tmux verb it wraps), this tool does **not** display +anything to the user; it substitutes the variables and returns the value. **Avoid when** a dedicated tool already provides the information — e.g. use {tooliconl}`snapshot-pane` for cursor position and mode, @@ -23,6 +22,11 @@ Accepts literal text and `#{variable}` references only. Modifiers such as `#{E:...}` re-expand a variable's *value*, which can arrive from a pane rather than from you, so they are refused rather than filtered. +Modifiers, conditionals, and padding are therefore no longer available here. +Run `tmux display-message -p` through {tooliconl}`run-command` for those: raw +format syntax belongs on the surface labelled execution, where the caller is +the one supplying it. + **Example:** ```json diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 374e2f6d..8c5043dd 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -45,7 +45,9 @@ def display_message( Accepts literal text and '#{variable}' references. Format modifiers, conditionals, and jobs are refused: tmux expands '#{E:x}' by running x's *value* through the expander again, and a value can arrive from a - pane rather than from the caller. + pane rather than from the caller. For raw format syntax, run + 'tmux display-message -p' through run_command, where the caller + supplies it on a surface labelled execution. Returned values can carry text a pane chose, such as a working directory or a running command. From 9c288c0d2125d9f25f7bca2f4232cafc471f0159 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:46:59 -0500 Subject: [PATCH 17/44] mcp(fix[hints]): Stored values run later MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: I ruled out `set-option`'s value as a format sink because tmux expands it only under `-F`. That was true and too narrow: it rules the value out as an *immediate* sink, not as code. Measured on 3.7d — `default-command` set through this tool ran in the next pane spawned, and `status-right` holding a `#(...)` job ran under an attached client and repeated on the status interval. `set_environment` has the same shape: a shell reads `BASH_ENV` and `PROMPT_COMMAND` as code. Both advertised `openWorldHint: false`, which is the class of claim this branch exists to remove. what: - Advertise both open-world, keeping `idempotentHint: true`: the call lands on the same stored state, and it is the state that reaches out - Say in each docstring which values execute and when - Widen the gotcha: setting an option schedules execution, it does not only configure --- docs/topics/gotchas.md | 8 ++++++-- docs/topics/safety.md | 4 ++-- src/libtmux_mcp/_utils.py | 15 +++++++++++++++ src/libtmux_mcp/tools/env_tools.py | 8 ++++++-- src/libtmux_mcp/tools/option_tools.py | 13 +++++++++++-- tests/test_tool_annotations.py | 16 ++++++++++++++++ 6 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index fa0200c2..96658562 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -72,8 +72,12 @@ This server escapes each of them, so a window named `build #2` or a directory named `has#hash` is stored as written. Nothing has to be pre-escaped, and doubling a `#` yourself produces a literal doubled `#`. -Values are the exception. {tooliconl}`set-option` stores an option's value -unexpanded, so a status format keeps its `#{...}` and runs when tmux draws it. +Values are the exception, and deliberately so. {tooliconl}`set-option` stores +an option's value unexpanded, because a status format is *supposed* to keep +its `#{...}`. tmux then runs it when it draws the status line, and again on +every status interval. `default-command` and `default-shell` decide what +future panes run. Setting an option is therefore a way to schedule +execution, not only to configure. ## Window names are not unique across sessions diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 1b5b06ea..41aeb09e 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -201,8 +201,8 @@ user's authority, and a pane holds whatever was printed into it. | {toolref}`select-window` | {badge}`mutating` | false | true | true | false | | {toolref}`send-keys` | {badge}`mutating` | false | true | false | true | | {toolref}`send-keys-batch` | {badge}`mutating` | false | true | false | true | -| {toolref}`set-environment` | {badge}`mutating` | false | true | true | false | -| {toolref}`set-option` | {badge}`mutating` | false | true | true | false | +| {toolref}`set-environment` | {badge}`mutating` | false | true | true | true | +| {toolref}`set-option` | {badge}`mutating` | false | true | true | true | | {toolref}`set-pane-title` | {badge}`mutating` | false | true | true | false | | {toolref}`signal-channel` | {badge}`mutating` | false | true | false | false | | {toolref}`split-window` | {badge}`mutating` | false | true | false | true | diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 33a93a52..6c1ba708 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -438,6 +438,21 @@ def _caller_is_strictly_on_server( "openWorldHint": True, } +#: Annotations for tools that store a value tmux runs later: the status +#: formats, ``default-command`` and ``command-alias`` all execute a +#: ``#(...)`` job when the stored value is used, and a tmux environment +#: value reaches a future shell that may execute it. +#: +#: Nothing runs during the call, so ``idempotentHint`` stays ``True`` — the +#: same call lands on the same stored state. ``openWorldHint`` is ``True`` +#: because what that state later reaches does not stop at tmux. +ANNOTATIONS_DEFERRED_EXEC: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": True, +} + #: Annotations for tools that hand a caller-supplied payload to a program #: that runs it — typed keys, a pasted buffer, an authored shell command, #: or the command ``pipe_pane`` feeds. diff --git a/src/libtmux_mcp/tools/env_tools.py b/src/libtmux_mcp/tools/env_tools.py index 16407c4e..7e51c7f4 100644 --- a/src/libtmux_mcp/tools/env_tools.py +++ b/src/libtmux_mcp/tools/env_tools.py @@ -5,7 +5,7 @@ import typing as t from libtmux_mcp._utils import ( - ANNOTATIONS_MUTATING, + ANNOTATIONS_DEFERRED_EXEC, ANNOTATIONS_RO, TAG_MUTATING, TAG_READONLY, @@ -71,6 +71,10 @@ def set_environment( Use to set variables that will be inherited by new panes and windows. Changes do not affect already-running processes. + A shell reads some variables as code — ``BASH_ENV``, ``ENV`` and + ``PROMPT_COMMAND`` among them — so a value set here can run in a pane + started later. + .. warning:: Values set here propagate into **every** shell tmux later spawns in the targeted scope — including panes the user opens manually, @@ -126,6 +130,6 @@ def register(mcp: FastMCP) -> None: )(show_environment) mcp.tool( title="Set tmux Environment", - annotations=ANNOTATIONS_MUTATING, + annotations=ANNOTATIONS_DEFERRED_EXEC, tags={TAG_MUTATING}, )(set_environment) diff --git a/src/libtmux_mcp/tools/option_tools.py b/src/libtmux_mcp/tools/option_tools.py index 43098121..dd854761 100644 --- a/src/libtmux_mcp/tools/option_tools.py +++ b/src/libtmux_mcp/tools/option_tools.py @@ -7,7 +7,7 @@ from libtmux.constants import OptionScope from libtmux_mcp._utils import ( - ANNOTATIONS_MUTATING, + ANNOTATIONS_DEFERRED_EXEC, ANNOTATIONS_RO, TAG_MUTATING, TAG_READONLY, @@ -112,6 +112,13 @@ def set_option( ) -> OptionSetResult: """Set a tmux option value. + Some option values are executable. tmux runs a ``#(...)`` job inside + the status formats when it draws them, and repeats it on the status + interval; ``default-command`` and ``default-shell`` decide what every + future pane runs, and ``command-alias`` rewrites later commands. The + value is stored verbatim, so an option that interprets it later runs + what is stored, not what this call did. + Use to change tmux behavior at runtime. Common uses: adjusting history-limit, enabling mouse support, changing status bar format. @@ -148,5 +155,7 @@ def register(mcp: FastMCP) -> None: show_option ) mcp.tool( - title="Set tmux Option", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + title="Set tmux Option", + annotations=ANNOTATIONS_DEFERRED_EXEC, + tags={TAG_MUTATING}, )(set_option) diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index f4d5c63a..6a2ec138 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -16,6 +16,13 @@ from .conftest import wire_annotations +#: Tools that store a value tmux runs later. `set-option` holds the +#: status formats, `default-command` and `command-alias`, where a +#: `#(...)` job runs when the value is used and can recur; a tmux +#: environment value reaches a future shell that may execute it. +#: Nothing runs during the call, so the reach is real but deferred. +DEFERRED_EXECUTION_TOOLS = frozenset({"set_environment", "set_option"}) + #: Tools that start a process. The pane's program runs with the user's #: authority and reaches whatever that user reaches, so the effect does #: not stop at tmux. @@ -181,3 +188,12 @@ def test_the_read_batch_carries_its_members_open_world_hint( """A batch advertises the worst case of what it can invoke.""" batch = advertised_tools["call_readonly_tools_batch"] assert wire_annotations(batch)["openWorldHint"] is True + + +@pytest.mark.parametrize("name", sorted(DEFERRED_EXECUTION_TOOLS)) +def test_deferred_execution_tools_are_advertised_open_world( + advertised_tools: dict[str, t.Any], + name: str, +) -> None: + """A stored value that tmux runs later still reaches past tmux.""" + assert wire_annotations(advertised_tools[name])["openWorldHint"] is True From 3cda504828280cd39107ab99b2c9b31d39d699e0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:49:28 -0500 Subject: [PATCH 18/44] mcp(fix[search]): Bound the pattern too why: The match deadline starts after compilation, so a pattern's own size was unbounded. The tool also read as if the ceiling covered the call; capturing each pane is a tmux round-trip outside it, and the work still grows with the panes in scope. what: - Cap the pattern length, which is what bounds compilation - Say in the docstring what the deadline covers and what it does not - Record why the shared budget is asserted by construction: the first pane to exhaust it aborts the call under a per-pane budget too, so no test separates them A test asserting the shared budget was written and then removed: it passed with the budget deliberately reset per pane, so it proved nothing. --- src/libtmux_mcp/tools/pane_tools/search.py | 32 ++++++++++++++++++---- tests/test_pane_tools.py | 12 ++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/libtmux_mcp/tools/pane_tools/search.py b/src/libtmux_mcp/tools/pane_tools/search.py index fe017fa6..5564923e 100644 --- a/src/libtmux_mcp/tools/pane_tools/search.py +++ b/src/libtmux_mcp/tools/pane_tools/search.py @@ -26,6 +26,10 @@ #: (most-recent) matches so the agent sees what's currently on screen. SEARCH_DEFAULT_MAX_LINES_PER_PANE = 50 +#: Cap on a caller's pattern. Compilation happens before any match, so +#: the wall-clock ceiling below does not cover it; a length bound does. +SEARCH_MAX_PATTERN_LENGTH = 1_000 + #: Wall-clock ceiling for matching a caller's pattern across every #: captured line. A literal scan of 20,000 lines costs ~3.5 ms, so this #: is a ceiling reached only by a pattern that backtracks, never a @@ -204,12 +208,26 @@ def search_panes( Raises ------ ExpectedToolError - If ``pattern`` does not compile, or if matching it against the - captured lines exceeds ``SEARCH_MATCH_MAX_SECONDS`` in total. A - pattern with nested quantifiers such as ``(a+)+`` can backtrack - for hours on one ordinary line; anchor it or search for a - literal. + If ``pattern`` is over ``SEARCH_MAX_PATTERN_LENGTH``, does not + compile, or if matching it against the captured lines exceeds + ``SEARCH_MATCH_MAX_SECONDS`` in total. A pattern with nested + quantifiers such as ``(a+)+`` can backtrack for hours on one + ordinary line; anchor it or search for a literal. + + Notes + ----- + The deadline bounds pattern matching across the call, not the call: + capturing each pane is a separate tmux round-trip outside it, and the + work still grows with the number of panes in scope. """ + if len(pattern) > SEARCH_MAX_PATTERN_LENGTH: + msg = ( + f"pattern is longer than {SEARCH_MAX_PATTERN_LENGTH} characters. " + f"Compilation runs before the match deadline applies, so the " + f"length is bounded instead." + ) + raise ExpectedToolError(msg) + search_pattern = pattern if regex else regex_engine.escape(pattern) flags = 0 if match_case else regex_engine.IGNORECASE try: @@ -296,6 +314,10 @@ def search_panes( # tail-truncated to keep the most recent matches. all_matches: list[PaneContentMatch] = [] per_pane_truncated = False + # Computed once, so the budget spans every pane rather than resetting + # per pane. No test separates the two: the first pane to exhaust the + # budget aborts the call either way, so they differ only for a + # workload that is cheap per pane and expensive in total. deadline = time.monotonic() + SEARCH_MATCH_MAX_SECONDS for pane_id_str in matching_pane_ids: pane = server.panes.get(pane_id=pane_id_str, default=None) diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index d3461fbd..0477db21 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -2336,6 +2336,18 @@ def test_search_panes_bounds_matching_time( assert time.monotonic() - started < 5 +def test_search_panes_rejects_an_oversized_pattern( + mcp_server: Server, +) -> None: + """Compilation is bounded by length, which no match deadline covers.""" + with pytest.raises(ExpectedToolError, match="pattern is longer than"): + search_panes( + pattern="a" * (search.SEARCH_MAX_PATTERN_LENGTH + 1), + regex=True, + socket_name=mcp_server.socket_name, + ) + + def test_search_panes_pagination_limit_and_offset( mcp_server: Server, mcp_session: Session, mcp_pane: Pane ) -> None: From 6b8fce486bf2f423fa25d0da9384adfcb1519ffb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:50:25 -0500 Subject: [PATCH 19/44] test(format): Pin the escaper's boundary why: One escaper is not one contract. `pipe_pane` doubles `%` because `pipe-pane` runs its argument through `strftime`; `-c` and the name arguments do not, so the same doubling there would corrupt a literal percent. Nothing held that line, and the function is not idempotent, so a later upstream change could double-escape without a gate noticing. what: - Assert the hash escaper leaves `%` alone, doubles a run once, and compounds when applied twice --- tests/test_tmux_format_arguments.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_tmux_format_arguments.py b/tests/test_tmux_format_arguments.py index d0312c7a..63e174d0 100644 --- a/tests/test_tmux_format_arguments.py +++ b/tests/test_tmux_format_arguments.py @@ -280,3 +280,21 @@ def test_display_message_expands_plain_variables( ) assert result == f"id={mcp_pane.pane_id} zoomed=0" + + +def test_the_hash_escaper_owns_only_the_hash_expander() -> None: + """Escaping is per interpreter, and applying it twice is not a no-op. + + ``pipe_pane`` adds ``%`` doubling because ``pipe-pane`` runs its + argument through ``strftime`` as well; ``-c`` and the name arguments + do not, so a percent there is literal. Whoever owns a value escapes + it once, at the boundary that knows which expanders it will meet. + """ + from libtmux_mcp._utils import _escape_tmux_format + + assert _escape_tmux_format("100%done") == "100%done" + assert _escape_tmux_format("log-%Y.txt") == "log-%Y.txt" + assert _escape_tmux_format("a#b") == "a##b" + assert _escape_tmux_format("a##b") == "a####b" + assert _escape_tmux_format(_escape_tmux_format("a#b")) == "a####b" + assert _escape_tmux_format("") == "" From 46041fbd9e2a09c4f7917996ec81d37986db1b43 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:32:42 -0500 Subject: [PATCH 20/44] mcp(feat[toolsets]): Replace the tier ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `readonly` / `mutating` / `destructive` read as a permission ladder and was not one. It ranked unlike powers on one axis, so running a shell command and deleting a window differed by degree rather than by kind, and the tiers accumulated upward: the kill tools could not be enabled without also enabling the typing tools. `readonly` was the worst of the three — a capture returns whatever a pane holds, secrets included, so the name promised a safety the tool never had. what: - Group tools into four unordered toolsets by what they do: `inspect`, `manage`, `execute`, `teardown` - Put anything that hands input to a program or stores a value tmux later runs in `execute`, `set_option` and `set_environment` included - Replace the tier gate with set membership, still fail-closed: a tool carrying no toolset is refused - Delete `LIBTMUX_SAFETY` with a startup error naming its replacements; add `LIBTMUX_TOOLSETS`, `LIBTMUX_TOOLS`, `LIBTMUX_EXCLUDE_TOOLS`, and fail startup on an unknown name rather than falling back - Default to `inspect,manage,execute`: this server still reaches whichever tmux server the environment points at, so deletion stays something an operator asks for by name - Rename the annotation presets off tier words onto tmux effects, and `ReadonlyRetryMiddleware` onto the toolset it actually keys on `LIBTMUX_TOOLSETS=inspect,teardown` is now a legal surface. --- src/libtmux_mcp/_utils.py | 70 +++++---- src/libtmux_mcp/middleware.py | 96 +++++++----- src/libtmux_mcp/server.py | 135 +++++++++++----- src/libtmux_mcp/tools/batch_tools.py | 157 ++++--------------- src/libtmux_mcp/tools/buffer_tools.py | 28 ++-- src/libtmux_mcp/tools/env_tools.py | 12 +- src/libtmux_mcp/tools/hook_tools.py | 16 +- src/libtmux_mcp/tools/option_tools.py | 16 +- src/libtmux_mcp/tools/pane_tools/__init__.py | 107 +++++++------ src/libtmux_mcp/tools/server_tools.py | 28 ++-- src/libtmux_mcp/tools/session_tools.py | 37 +++-- src/libtmux_mcp/tools/wait_for_tools.py | 12 +- src/libtmux_mcp/tools/window_tools.py | 47 +++--- 13 files changed, 385 insertions(+), 376 deletions(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 6c1ba708..4b466412 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -353,35 +353,47 @@ def _caller_is_strictly_on_server( # --------------------------------------------------------------------------- -# Safety tier tags +# Toolsets # --------------------------------------------------------------------------- -TAG_READONLY = "readonly" -TAG_MUTATING = "mutating" -TAG_DESTRUCTIVE = "destructive" +#: Read tmux state and terminal output. Starts no process and hands no +#: caller input to one: what a client supplies is IDs, names, bounded +#: patterns, and validated variable names. +#: +#: Reading is not "safe" — a capture returns whatever a pane holds, +#: including credentials and text written by a remote process — so this +#: names what the tools *do*, not how much they are trusted. +TOOLSET_INSPECT = "inspect" -VALID_SAFETY_LEVELS = frozenset({TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE}) +#: Change tmux structure or presentation: names, sizes, layouts, +#: selections, modes. Starts no process, and takes no caller input that +#: anything later executes. +TOOLSET_MANAGE = "manage" -#: Non-tier marker tag for tools that enforce their own wall-clock -#: ceiling internally and whose cost is therefore *duration*, not -#: side effects. -#: -#: A tagged tool must never be re-driven by machinery that assumes a -#: call is cheap: -#: -#: * :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` skips it, -#: because the deadline is computed inside the tool body — a retry -#: restarts the clock and doubles the ceiling. -#: * The ``call_*_tools_batch`` wrappers reject it per-operation, -#: because the batch loop is serial with no aggregate deadline and -#: ``MAX_BATCH_OPERATIONS`` is 1000. +#: Start a pane process, deliver input to one, or store a value tmux +#: later runs. The product lives here. +TOOLSET_EXECUTE = "execute" + +#: Delete tmux objects or retained scrollback. Irreversible at the tmux +#: level. +TOOLSET_TEARDOWN = "teardown" + +#: The four toolsets, in the order startup reports them. #: -#: A TAG rather than a tool-name list on purpose: a name string is -#: exactly what ``add_tool_transformation`` can rename out from under -#: the exclusion. Tier resolution -#: (:meth:`~libtmux_mcp.middleware.SafetyMiddleware._is_allowed`, -#: ``batch_tools._tool_tier``) inspects only the three tier tags, so -#: carrying this extra tag is inert everywhere else. +#: An unordered set, deliberately. The tiers this replaced accumulated +#: upward, so the kill tools could not be enabled without also enabling +#: the typing tools; ``LIBTMUX_TOOLSETS=inspect,teardown`` is a legal +#: surface. They group tools by what they do, for inventory +#: configuration, context reduction, and client routing. They are not +#: permissions, and filtering them is not containment: an enabled +#: execute tool can type the equivalent of anything hidden. +VALID_TOOLSETS: tuple[str, ...] = ( + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_EXECUTE, + TOOLSET_TEARDOWN, +) + TAG_SELF_BOUNDED = "self-bounded" # --------------------------------------------------------------------------- @@ -389,7 +401,7 @@ def _caller_is_strictly_on_server( # --------------------------------------------------------------------------- #: Annotations for tools that only read tmux or pane state. -ANNOTATIONS_RO: dict[str, bool] = { +ANNOTATIONS_OBSERVE: dict[str, bool] = { "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, @@ -400,7 +412,7 @@ def _caller_is_strictly_on_server( #: but ``openWorldHint`` is ``True``: a pane holds whatever was printed #: into it — remote sessions, package managers, other agents — so the #: text reaching the caller crossed a trust boundary on its way in. -ANNOTATIONS_RO_CONTENT: dict[str, bool] = { +ANNOTATIONS_OBSERVE_CONTENT: dict[str, bool] = { "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, @@ -412,7 +424,7 @@ def _caller_is_strictly_on_server( #: means additive-only, which a replacement is not, so these advertise #: ``True`` even though nothing is destroyed. Repeating the same call lands #: on the same state, so ``idempotentHint`` stays ``True``. -ANNOTATIONS_MUTATING: dict[str, bool] = { +ANNOTATIONS_CHANGE: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, "idempotentHint": True, @@ -464,13 +476,13 @@ def _caller_is_strictly_on_server( #: #: Contrast :data:`ANNOTATIONS_SPAWN`, which starts the pane's *configured* #: process and carries no payload. -ANNOTATIONS_SHELL: dict[str, bool] = { +ANNOTATIONS_PANE_INPUT: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, "idempotentHint": False, "openWorldHint": True, } -ANNOTATIONS_DESTRUCTIVE: dict[str, bool] = { +ANNOTATIONS_DELETE: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, "idempotentHint": False, diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 9226bf2a..9b4ff142 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -2,9 +2,9 @@ Provides the project's middleware infrastructure, in definition order: -* :class:`SafetyMiddleware` gates tools by safety tier based on the - ``LIBTMUX_SAFETY`` environment variable. Tools tagged above the - configured tier are hidden from listing and blocked from execution. +* :class:`ToolsetMiddleware` filters tools by toolset, from + ``LIBTMUX_TOOLSETS``. A tool outside the enabled toolsets is hidden + from listing and refused on call. * :class:`ToolErrorResultMiddleware` converts tool-call failures into ``ToolResult(is_error=True)`` results that carry the clean error message plus a structured ``meta`` payload, instead of fastmcp's @@ -14,9 +14,9 @@ invocation (name, duration, outcome, client/request ids, and a summary of arguments with payload-bearing fields redacted to a length + SHA-256 prefix). -* :class:`ReadonlyRetryMiddleware` retries transient libtmux failures, - but only for readonly tools — re-running a mutating tool would - silently double side effects. +* :class:`InspectRetryMiddleware` retries transient libtmux failures, + but only for ``inspect`` tools — re-running anything else would + silently repeat its effect. * :class:`TailPreservingResponseLimitingMiddleware` is a backstop cap for oversized tool output. Unlike FastMCP's stock ``ResponseLimitingMiddleware`` it preserves the **tail** of the @@ -44,71 +44,79 @@ from pydantic import ValidationError as PydanticValidationError from libtmux_mcp._utils import ( - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, TAG_SELF_BOUNDED, + TOOLSET_INSPECT, + VALID_TOOLSETS, ExpectedToolError, ) -_TIER_LEVELS: dict[str, int] = { - TAG_READONLY: 0, - TAG_MUTATING: 1, - TAG_DESTRUCTIVE: 2, -} +class ToolsetMiddleware(Middleware): + """Filter tools to the enabled toolsets. -class SafetyMiddleware(Middleware): - """Gate tools by safety tier. + Filtering shapes what this server advertises. It is not a permission + system: an enabled ``execute`` tool can type the equivalent of any + tool this hides, so dropping a toolset reduces accidents, not + authority. Parameters ---------- - max_tier : str - Maximum allowed tier. One of ``TAG_READONLY``, ``TAG_MUTATING``, - or ``TAG_DESTRUCTIVE``. + toolsets : set of str + Enabled toolsets. A tool tagged with none of them is refused. + tools : set of str + Tool names enabled regardless of toolset. + exclude_tools : set of str + Tool names refused regardless of every enable above. """ - def __init__(self, max_tier: str = TAG_MUTATING) -> None: - self.max_level = _TIER_LEVELS.get(max_tier, 0) + def __init__( + self, + toolsets: t.AbstractSet[str], + tools: t.AbstractSet[str] = frozenset(), + exclude_tools: t.AbstractSet[str] = frozenset(), + ) -> None: + self.toolsets = frozenset(toolsets) + self.tools = frozenset(tools) + self.exclude_tools = frozenset(exclude_tools) - def _is_allowed(self, tags: set[str]) -> bool: - """Return True if the tool's tags fall within the allowed tier. + def _is_enabled(self, name: str, tags: set[str]) -> bool: + """Return whether a tool is part of the advertised surface. - Fail-closed: tools without a recognized tier tag are denied. + Fail-closed: a tool carrying no recognized toolset is refused, + so adding one without classifying it cannot expose it. """ - found_tier = False - for tier, level in _TIER_LEVELS.items(): - if tier in tags: - found_tier = True - if level > self.max_level: - return False - return found_tier + if name in self.exclude_tools: + return False + if name in self.tools: + return True + return bool(self.toolsets & (tags & set(VALID_TOOLSETS))) async def on_list_tools( self, context: MiddlewareContext, call_next: t.Any, ) -> t.Any: - """Filter tools above the safety tier from the listing.""" + """Drop tools outside the enabled surface from the listing.""" tools = await call_next(context) - return [tool for tool in tools if self._is_allowed(tool.tags)] + return [tool for tool in tools if self._is_enabled(tool.name, tool.tags)] async def on_call_tool( self, context: MiddlewareContext, call_next: t.Any, ) -> t.Any: - """Block execution of tools above the safety tier.""" + """Refuse a tool outside the enabled surface.""" if context.fastmcp_context: - tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) - if tool and not self._is_allowed(tool.tags): + name = context.message.name + tool = await context.fastmcp_context.fastmcp.get_tool(name) + if tool and not self._is_enabled(name, tool.tags): + enabled = ", ".join(sorted(self.toolsets)) or "(none)" msg = ( - f"Tool '{context.message.name}' is not available at the " - f"current safety level. Set LIBTMUX_SAFETY=destructive " - f"to enable destructive tools." + f"Tool {name!r} is not in this server's enabled " + f"toolsets ({enabled}). Set LIBTMUX_TOOLSETS to include " + f"it, or LIBTMUX_TOOLS to enable it by name." ) raise ExpectedToolError(msg) - return await call_next(context) # --------------------------------------------------------------------------- @@ -719,7 +727,7 @@ def _should_retry(self, error: Exception) -> bool: return super()._should_retry(error) -class ReadonlyRetryMiddleware(Middleware): +class InspectRetryMiddleware(Middleware): """Retry transient libtmux failures, but only for readonly tools. Wraps fastmcp's :class:`fastmcp.server.middleware.error_handling.RetryMiddleware` @@ -801,7 +809,11 @@ async def on_call_tool( """ if context.fastmcp_context: tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) - if tool and TAG_READONLY in tool.tags and TAG_SELF_BOUNDED not in tool.tags: + if ( + tool + and TOOLSET_INSPECT in tool.tags + and TAG_SELF_BOUNDED not in tool.tags + ): return await self._retry.on_request(context, call_next) return await call_next(context) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index c5772f1b..b2b93805 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -23,10 +23,10 @@ _resolve_suppress_history, ) from libtmux_mcp._utils import ( - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, - VALID_SAFETY_LEVELS, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + VALID_TOOLSETS, _server_cache, ) from libtmux_mcp._wait_policy import ( @@ -37,10 +37,10 @@ from libtmux_mcp.middleware import ( DEFAULT_RESPONSE_LIMIT_BYTES, AuditMiddleware, - ReadonlyRetryMiddleware, - SafetyMiddleware, + InspectRetryMiddleware, TailPreservingResponseLimitingMiddleware, ToolErrorResultMiddleware, + ToolsetMiddleware, install_fastmcp_validation_log_filter, ) from libtmux_mcp.tools.buffer_tools import _MCP_BUFFER_PREFIX @@ -140,9 +140,16 @@ _INSTRUCTIONS_MAX_BYTES = 2048 +#: Enabled when ``LIBTMUX_TOOLSETS`` is unset. ``teardown`` is not in it: +#: this server still reaches whichever tmux server the environment points +#: at, so deletion stays something an operator asks for by name. +DEFAULT_TOOLSETS: frozenset[str] = frozenset( + {TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_EXECUTE} +) + def _build_instructions( - safety_level: str = TAG_MUTATING, + toolsets: frozenset[str] = DEFAULT_TOOLSETS, suppress_history: bool = True, ) -> str: """Build server instructions with agent context and safety level. @@ -153,8 +160,8 @@ def _build_instructions( Parameters ---------- - safety_level : str - Active safety tier (readonly, mutating, or destructive). + toolsets : frozenset of str + Enabled toolsets. suppress_history : bool Effective MCP default for semantic shell-command suppression. @@ -167,9 +174,11 @@ def _build_instructions( # Safety tier context parts.append( - f"\n\nSafety level: {safety_level} " - "(values: readonly, mutating, destructive). " - "Set LIBTMUX_SAFETY; off-tier tools are hidden." + "\n\nToolsets enabled: " + + (", ".join(sorted(toolsets)) or "(none)") + + f" (of {', '.join(VALID_TOOLSETS)}). Set LIBTMUX_TOOLSETS; tools " + "outside them are hidden. This shapes what is advertised, not what " + "tmux or a pane's shell can do." ) history_default = "true" if suppress_history else "false" parts.append( @@ -182,7 +191,7 @@ def _build_instructions( # expensive on mutating/destructive (where kill_* is one mis-routed # query away). Reuse the existing safety axis instead of shipping a # separate LIBTMUX_DISCOVERABILITY knob. - if safety_level == TAG_READONLY: + if toolsets == frozenset({TOOLSET_INSPECT}): parts.append( "\n\nReadonly mode: probe snapshot_pane/list_panes/search_panes if unsure." ) @@ -263,21 +272,69 @@ def _build_instructions( return instructions -def _resolve_safety_level(value: str | None) -> str: - """Return the effective safety level for a ``LIBTMUX_SAFETY`` value.""" +def _resolve_toolsets(value: str | None) -> frozenset[str]: + """Return the enabled toolsets for a ``LIBTMUX_TOOLSETS`` value. + + Parameters + ---------- + value : str or None + Comma-separated toolset names. ``None`` takes the default; an + empty string enables none, which is legal. + + Returns + ------- + frozenset of str + Enabled toolsets. + + Raises + ------ + RuntimeError + If a name is not a toolset. A typo silently falling back is how + a narrowed surface quietly becomes a wider one. + """ if value is None: - return TAG_MUTATING - if value in VALID_SAFETY_LEVELS: - return value - logger.warning( - "invalid LIBTMUX_SAFETY=%r, falling back to %s", - value, - TAG_READONLY, + return DEFAULT_TOOLSETS + names = [part.strip() for part in value.split(",") if part.strip()] + unknown = [name for name in names if name not in VALID_TOOLSETS] + if unknown: + msg = ( + f"LIBTMUX_TOOLSETS names unknown toolsets: {', '.join(unknown)}. " + f"Valid toolsets: {', '.join(VALID_TOOLSETS)}." + ) + raise RuntimeError(msg) + return frozenset(names) + + +def _resolve_tool_names(value: str | None) -> frozenset[str]: + """Return a comma-separated tool-name list as a set.""" + if not value: + return frozenset() + return frozenset(part.strip() for part in value.split(",") if part.strip()) + + +def _reject_retired_safety_env() -> None: + """Fail startup when ``LIBTMUX_SAFETY`` is still set. + + The tiers it selected were an ordered ladder that read as a + permission system and was not one. Ignoring the variable would + silently widen a surface an operator believes is narrow. + """ + if "LIBTMUX_SAFETY" not in os.environ: + return + msg = ( + "LIBTMUX_SAFETY has been removed. Tools are grouped into the " + f"unordered toolsets {', '.join(VALID_TOOLSETS)}; select them with " + "LIBTMUX_TOOLSETS. The nearest equivalents are " + "LIBTMUX_TOOLSETS=inspect, LIBTMUX_TOOLSETS=inspect,manage,execute, " + "and LIBTMUX_TOOLSETS=inspect,manage,execute,teardown." ) - return TAG_READONLY + raise RuntimeError(msg) -_safety_level = _resolve_safety_level(os.environ.get("LIBTMUX_SAFETY")) +_reject_retired_safety_env() +_toolsets = _resolve_toolsets(os.environ.get("LIBTMUX_TOOLSETS")) +_extra_tools = _resolve_tool_names(os.environ.get("LIBTMUX_TOOLS")) +_excluded_tools = _resolve_tool_names(os.environ.get("LIBTMUX_EXCLUDE_TOOLS")) _suppress_history = _resolve_suppress_history( os.environ.get("LIBTMUX_SUPPRESS_HISTORY") ) @@ -357,7 +414,7 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: name="tmux", version=__version__, instructions=_build_instructions( - safety_level=_safety_level, + toolsets=_toolsets, suppress_history=_suppress_history, ), website_url="https://libtmux-mcp.git-pull.com/", @@ -378,16 +435,16 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: # denials must propagate as exceptions for audit to record # them), so converting the exception to a result any deeper # would silently break all three. - # 4. AuditMiddleware — outside SafetyMiddleware so tier-denial + # 4. AuditMiddleware — outside ToolsetMiddleware so a refusal # events (which raise ExpectedToolError before call_next inside # Safety) are still logged with outcome=error. Without this # ordering, denied access attempts would silently bypass the # audit log — a security-observability gap. - # 5. ReadonlyRetryMiddleware — inside Audit so retries are + # 5. InspectRetryMiddleware — inside Audit so retries are # audited once each, outside Safety so tier-denied tools # never reach retry. Only readonly tools are retried; # mutating/destructive tools pass straight through. - # 6. SafetyMiddleware — innermost gate (fail-closed). Denials + # 6. ToolsetMiddleware — innermost gate (fail-closed). Refusals # never reach the tool, but the audit record above captures # them for forensic review. middleware=[ @@ -398,8 +455,8 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: ), ToolErrorResultMiddleware(transform_errors=True), AuditMiddleware(), - ReadonlyRetryMiddleware(), - SafetyMiddleware(max_tier=_safety_level), + InspectRetryMiddleware(), + ToolsetMiddleware(_toolsets, _extra_tools, _excluded_tools), ], on_duplicate="error", ) @@ -431,20 +488,20 @@ def _register_all() -> None: def _enable_allowed_tools() -> None: - """Apply the native FastMCP visibility gate for the active safety tier.""" + """Apply FastMCP's visibility gate for the enabled toolsets.""" global _mcp_visibility_configured if _mcp_visibility_configured: return - # Use FastMCP's native visibility system as primary gate, - # with the SafetyMiddleware as a secondary layer for clear error messages. - allowed_tags = {TAG_READONLY} - if _safety_level in {TAG_MUTATING, TAG_DESTRUCTIVE}: - allowed_tags.add(TAG_MUTATING) - if _safety_level == TAG_DESTRUCTIVE: - allowed_tags.add(TAG_DESTRUCTIVE) + # FastMCP's tag visibility is the primary filter; ToolsetMiddleware + # repeats the decision so a direct call gets an error naming the + # variable rather than an unknown-tool error. mcp.disable(components={"tool"}) - mcp.enable(tags=allowed_tags, components={"tool"}) + if _toolsets: + mcp.enable(tags=set(_toolsets), components={"tool"}) + for name in _extra_tools: + with contextlib.suppress(Exception): + mcp.enable(components={"tool"}, names={name}) _mcp_visibility_configured = True diff --git a/src/libtmux_mcp/tools/batch_tools.py b/src/libtmux_mcp/tools/batch_tools.py index 5ae578ea..7a278787 100644 --- a/src/libtmux_mcp/tools/batch_tools.py +++ b/src/libtmux_mcp/tools/batch_tools.py @@ -11,10 +11,8 @@ from pydantic import BaseModel from libtmux_mcp._utils import ( - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, TAG_SELF_BOUNDED, + TOOLSET_INSPECT, ExpectedToolError, handle_tool_errors_async, ) @@ -30,19 +28,7 @@ _OnError: t.TypeAlias = t.Literal["stop", "continue"] -_TIER_LEVELS: dict[str, int] = { - TAG_READONLY: 0, - TAG_MUTATING: 1, - TAG_DESTRUCTIVE: 2, -} - -_BATCH_TOOL_NAMES: frozenset[str] = frozenset( - { - "call_readonly_tools_batch", - "call_mutating_tools_batch", - "call_destructive_tools_batch", - } -) +_BATCH_TOOL_NAMES: frozenset[str] = frozenset({"call_read_tools_batch"}) MAX_BATCH_OPERATIONS = 1_000 @@ -62,13 +48,6 @@ "openWorldHint": True, } -_ANNOTATIONS_BATCH_SIDE_EFFECTS: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": False, - "openWorldHint": True, -} - def _content_block_to_dict(block: t.Any) -> dict[str, t.Any]: """Return a JSON-ready representation of an MCP content block.""" @@ -95,38 +74,12 @@ def _result_error_text(result: ToolResult) -> str | None: return None -def _tool_tier(tool_name: str, tags: set[str]) -> str: - """Return the highest recognized safety tier for a registered tool.""" - found = [tier for tier in _TIER_LEVELS if tier in tags] - if not found: - msg = f"Tool {tool_name!r} has no recognized safety tier tag." - raise ExpectedToolError(msg) - return max(found, key=lambda tier: _TIER_LEVELS[tier]) - - -def _check_operation_allowed( - *, - tool_name: str, - tool_tier: str, - max_tier: str, -) -> None: - """Raise when a nested tool exceeds this batch wrapper's tier.""" - if _TIER_LEVELS[tool_tier] <= _TIER_LEVELS[max_tier]: - return - msg = ( - f"Tool {tool_name!r} has tier {tool_tier!r}, which exceeds " - f"batch tier {max_tier}." - ) - raise ExpectedToolError(msg) - - -async def _get_allowed_tool_tier( +async def _check_operation_allowed( *, fastmcp: FastMCP, operation: ToolCallOperation, - max_tier: str, ) -> None: - """Validate that one nested operation targets an allowed tool.""" + """Validate that one nested operation targets an ``inspect`` tool.""" if operation.tool in _BATCH_TOOL_NAMES: msg = "Batch tools cannot call batch tools recursively." raise ExpectedToolError(msg) @@ -136,13 +89,11 @@ async def _get_allowed_tool_tier( msg = f"Unknown tool: {operation.tool!r}" raise ExpectedToolError(msg) - # ``max_tier`` is a CEILING, so a readonly tool is reachable through - # every batch wrapper, not only the readonly one. The batch loop is - # serial with no aggregate deadline and ``MAX_BATCH_OPERATIONS`` is - # 1000, so a self-bounded wait batched N times costs N x its - # ceiling. Reject per-operation (not pre-loop) so the raise becomes - # a ``success=False`` row and ``on_error='continue'`` isolation is - # preserved. + # The batch loop is serial with no aggregate deadline and + # ``MAX_BATCH_OPERATIONS`` is 1000, so a self-bounded wait batched N + # times costs N x its ceiling. Reject per-operation, not pre-loop, so + # the raise becomes a ``success=False`` row and ``on_error='continue'`` + # isolation is preserved. if TAG_SELF_BOUNDED in tool.tags: msg = ( f"Tool {operation.tool!r} enforces its own wait ceiling and " @@ -151,12 +102,12 @@ async def _get_allowed_tool_tier( ) raise ExpectedToolError(msg) - tool_tier = _tool_tier(operation.tool, tool.tags) - _check_operation_allowed( - tool_name=operation.tool, - tool_tier=tool_tier, - max_tier=max_tier, - ) + if TOOLSET_INSPECT not in tool.tags: + msg = ( + f"Tool {operation.tool!r} is not an 'inspect' tool, so it " + "cannot run in a read batch. Call it directly." + ) + raise ExpectedToolError(msg) def _ensure_tool_result(tool_name: str, result: t.Any) -> ToolResult: @@ -220,15 +171,13 @@ async def _call_one_tool( fastmcp: FastMCP, operation: ToolCallOperation, index: int, - max_tier: str, ) -> ToolCallOperationResult: """Call one nested tool and convert its outcome to a batch result row.""" start = time.monotonic() try: - await _get_allowed_tool_tier( + await _check_operation_allowed( fastmcp=fastmcp, operation=operation, - max_tier=max_tier, ) result = _ensure_tool_result( @@ -265,7 +214,6 @@ async def _call_tools_batch( *, operations: list[ToolCallOperation], on_error: _OnError, - max_tier: str, ctx: Context | None, ) -> ToolCallBatchResult: """Execute nested MCP tool calls serially through FastMCP.""" @@ -289,7 +237,6 @@ async def _call_tools_batch( fastmcp=ctx.fastmcp, operation=operation, index=index, - max_tier=max_tier, ) results.append(result) if not result.success and on_error == "stop": @@ -309,63 +256,25 @@ async def _call_tools_batch( @handle_tool_errors_async -async def call_readonly_tools_batch( - operations: list[ToolCallOperation], - on_error: _OnError = "stop", - ctx: Context | None = None, -) -> ToolCallBatchResult: - """Call readonly MCP tools serially and return per-tool results. - - Use when several read-only observations should be made in one agent - turn. Each nested call still goes through FastMCP validation, - middleware, and safety checks. Mutating and destructive tools are - rejected even if the server process itself is running at a higher - safety tier. - """ - return await _call_tools_batch( - operations=operations, - on_error=on_error, - max_tier=TAG_READONLY, - ctx=ctx, - ) - - -@handle_tool_errors_async -async def call_mutating_tools_batch( +async def call_read_tools_batch( operations: list[ToolCallOperation], on_error: _OnError = "stop", ctx: Context | None = None, ) -> ToolCallBatchResult: - """Call readonly or mutating MCP tools serially and return per-tool results. + """Call several `inspect` tools serially and return per-tool results. - Use for ordered tmux workflows where every step is still an existing - typed MCP tool. Destructive tools are rejected regardless of the - process-wide safety tier. - """ - return await _call_tools_batch( - operations=operations, - on_error=on_error, - max_tier=TAG_MUTATING, - ctx=ctx, - ) - - -@handle_tool_errors_async -async def call_destructive_tools_batch( - operations: list[ToolCallOperation], - on_error: _OnError = "stop", - ctx: Context | None = None, -) -> ToolCallBatchResult: - """Call readonly, mutating, or destructive MCP tools serially. + Use when one agent turn needs several observations. Each nested call + still goes through FastMCP validation and this server's middleware. + Only `inspect` tools are accepted; anything that changes tmux state + is refused, whatever this server has enabled. - This wrapper preserves the normal per-tool schemas and middleware - but its tier permits destructive nested operations. Prefer the - narrower readonly or mutating wrappers whenever possible. + This wrapper aggregates authority under its own name: a client rule + keyed on a nested tool's name does not fire for a call made through + it. Read it as authority to invoke any `inspect` tool. """ return await _call_tools_batch( operations=operations, on_error=on_error, - max_tier=TAG_DESTRUCTIVE, ctx=ctx, ) @@ -373,17 +282,7 @@ async def call_destructive_tools_batch( def register(mcp: FastMCP) -> None: """Register generic MCP batch tools.""" mcp.tool( - title="Call Readonly Tools Batch", + title="Call Read Tools Batch", annotations=_ANNOTATIONS_BATCH_READ, - tags={TAG_READONLY}, - )(call_readonly_tools_batch) - mcp.tool( - title="Call Mutating Tools Batch", - annotations=_ANNOTATIONS_BATCH_SIDE_EFFECTS, - tags={TAG_MUTATING}, - )(call_mutating_tools_batch) - mcp.tool( - title="Call Destructive Tools Batch", - annotations=_ANNOTATIONS_BATCH_SIDE_EFFECTS, - tags={TAG_DESTRUCTIVE}, - )(call_destructive_tools_batch) + tags={TOOLSET_INSPECT}, + )(call_read_tools_batch) diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 4712b4fb..5f9e4d28 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -36,11 +36,13 @@ from libtmux_mcp._utils import ( ANNOTATIONS_ALLOCATE, - ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_RO_CONTENT, - ANNOTATIONS_SHELL, - TAG_MUTATING, - TAG_READONLY, + ANNOTATIONS_DELETE, + ANNOTATIONS_OBSERVE_CONTENT, + ANNOTATIONS_PANE_INPUT, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, ExpectedToolError, _get_server, _resolve_pane, @@ -386,20 +388,22 @@ def register(mcp: FastMCP) -> None: content to a pane's program, and carries the hints for it. """ mcp.tool( - title="Load tmux Buffer", annotations=ANNOTATIONS_ALLOCATE, tags={TAG_MUTATING} + title="Load tmux Buffer", + annotations=ANNOTATIONS_ALLOCATE, + tags={TOOLSET_MANAGE}, )(load_buffer) mcp.tool( title="Paste tmux Buffer", - annotations=ANNOTATIONS_SHELL, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_PANE_INPUT, + tags={TOOLSET_EXECUTE}, )(paste_buffer) mcp.tool( title="Show tmux Buffer", - annotations=ANNOTATIONS_RO_CONTENT, - tags={TAG_READONLY}, + annotations=ANNOTATIONS_OBSERVE_CONTENT, + tags={TOOLSET_INSPECT}, )(show_buffer) mcp.tool( title="Delete tmux Buffer", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_TEARDOWN}, )(delete_buffer) diff --git a/src/libtmux_mcp/tools/env_tools.py b/src/libtmux_mcp/tools/env_tools.py index 7e51c7f4..9acce4e9 100644 --- a/src/libtmux_mcp/tools/env_tools.py +++ b/src/libtmux_mcp/tools/env_tools.py @@ -6,9 +6,9 @@ from libtmux_mcp._utils import ( ANNOTATIONS_DEFERRED_EXEC, - ANNOTATIONS_RO, - TAG_MUTATING, - TAG_READONLY, + ANNOTATIONS_OBSERVE, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, _get_server, _resolve_session, handle_tool_errors, @@ -125,11 +125,11 @@ def register(mcp: FastMCP) -> None: """Register environment tools with the MCP instance.""" mcp.tool( title="Show tmux Environment", - annotations=ANNOTATIONS_RO, - tags={TAG_READONLY}, + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, )(show_environment) mcp.tool( title="Set tmux Environment", annotations=ANNOTATIONS_DEFERRED_EXEC, - tags={TAG_MUTATING}, + tags={TOOLSET_EXECUTE}, )(set_environment) diff --git a/src/libtmux_mcp/tools/hook_tools.py b/src/libtmux_mcp/tools/hook_tools.py index c7821dad..141d21ac 100644 --- a/src/libtmux_mcp/tools/hook_tools.py +++ b/src/libtmux_mcp/tools/hook_tools.py @@ -34,8 +34,8 @@ from libtmux.constants import OptionScope from libtmux_mcp._utils import ( - ANNOTATIONS_RO, - TAG_READONLY, + ANNOTATIONS_OBSERVE, + TOOLSET_INSPECT, ExpectedToolError, _get_server, _resolve_pane, @@ -261,9 +261,9 @@ def show_hook( def register(mcp: FastMCP) -> None: """Register read-only hook tools with the MCP instance.""" - mcp.tool(title="Show tmux Hooks", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - show_hooks - ) - mcp.tool(title="Show tmux Hook", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - show_hook - ) + mcp.tool( + title="Show tmux Hooks", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + )(show_hooks) + mcp.tool( + title="Show tmux Hook", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + )(show_hook) diff --git a/src/libtmux_mcp/tools/option_tools.py b/src/libtmux_mcp/tools/option_tools.py index dd854761..c31f156f 100644 --- a/src/libtmux_mcp/tools/option_tools.py +++ b/src/libtmux_mcp/tools/option_tools.py @@ -8,9 +8,9 @@ from libtmux_mcp._utils import ( ANNOTATIONS_DEFERRED_EXEC, - ANNOTATIONS_RO, - TAG_MUTATING, - TAG_READONLY, + ANNOTATIONS_OBSERVE, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, ExpectedToolError, _escape_tmux_format, _get_server, @@ -151,11 +151,13 @@ def set_option( def register(mcp: FastMCP) -> None: """Register option tools with the MCP instance.""" - mcp.tool(title="Show tmux Option", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - show_option - ) + mcp.tool( + title="Show tmux Option", + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, + )(show_option) mcp.tool( title="Set tmux Option", annotations=ANNOTATIONS_DEFERRED_EXEC, - tags={TAG_MUTATING}, + tags={TOOLSET_EXECUTE}, )(set_option) diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index 5c1a944f..5c27d552 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -12,16 +12,17 @@ import typing as t from libtmux_mcp._utils import ( - ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_MUTATING, - ANNOTATIONS_RO, - ANNOTATIONS_RO_CONTENT, - ANNOTATIONS_SHELL, + ANNOTATIONS_CHANGE, + ANNOTATIONS_DELETE, + ANNOTATIONS_OBSERVE, + ANNOTATIONS_OBSERVE_CONTENT, + ANNOTATIONS_PANE_INPUT, DISCOVERY_META, - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, TAG_SELF_BOUNDED, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, ) from libtmux_mcp.tools.pane_tools.capture_since import capture_since from libtmux_mcp.tools.pane_tools.copy_mode import enter_copy_mode, exit_copy_mode @@ -82,13 +83,13 @@ def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" - mcp.tool(title="Send Keys", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})( - send_keys - ) + mcp.tool( + title="Send Keys", annotations=ANNOTATIONS_PANE_INPUT, tags={TOOLSET_EXECUTE} + )(send_keys) mcp.tool( title="Send Keys Batch", - annotations=ANNOTATIONS_SHELL, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_PANE_INPUT, + tags={TOOLSET_EXECUTE}, )(send_keys_batch) # run_command blocks on ``tmux wait-for`` under the same wait # ceiling as the wait tools, so TAG_SELF_BOUNDED excludes it from @@ -96,84 +97,90 @@ def register(mcp: FastMCP) -> None: # the operation count. Use send_keys_batch for command sequences. mcp.tool( title="Run Command", - annotations=ANNOTATIONS_SHELL, - tags={TAG_MUTATING, TAG_SELF_BOUNDED}, + annotations=ANNOTATIONS_PANE_INPUT, + tags={TOOLSET_EXECUTE, TAG_SELF_BOUNDED}, )(run_command) mcp.tool( - title="Capture Pane", annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY} + title="Capture Pane", + annotations=ANNOTATIONS_OBSERVE_CONTENT, + tags={TOOLSET_INSPECT}, )(capture_pane) mcp.tool( - title="Capture Since", annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY} + title="Capture Since", + annotations=ANNOTATIONS_OBSERVE_CONTENT, + tags={TOOLSET_INSPECT}, )(capture_since) mcp.tool( - title="Resize Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + title="Resize Pane", annotations=ANNOTATIONS_CHANGE, tags={TOOLSET_MANAGE} )(resize_pane) mcp.tool( title="Kill Pane", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_DESTRUCTIVE}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_TEARDOWN}, )(kill_pane) mcp.tool( title="Respawn Pane", - annotations=ANNOTATIONS_SHELL, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_PANE_INPUT, + tags={TOOLSET_EXECUTE}, )(respawn_pane) mcp.tool( - title="Set Pane Title", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + title="Set Pane Title", annotations=ANNOTATIONS_CHANGE, tags={TOOLSET_MANAGE} )(set_pane_title) - mcp.tool(title="Get Pane Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - get_pane_info - ) + mcp.tool( + title="Get Pane Info", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + )(get_pane_info) mcp.tool( title="Find Pane By Position", - annotations=ANNOTATIONS_RO, - tags={TAG_READONLY}, + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, )(find_pane_by_position) mcp.tool( title="Clear Pane", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_TEARDOWN}, )(clear_pane) mcp.tool( - title="Search Panes", annotations=ANNOTATIONS_RO_CONTENT, tags={TAG_READONLY} + title="Search Panes", + annotations=ANNOTATIONS_OBSERVE_CONTENT, + tags={TOOLSET_INSPECT}, )(search_panes) # TAG_SELF_BOUNDED excludes this tool from retry and from batch # wrappers: both would multiply the wait ceiling it enforces. mcp.tool( title="Wait For Text", - annotations=ANNOTATIONS_RO_CONTENT, - tags={TAG_READONLY, TAG_SELF_BOUNDED}, + annotations=ANNOTATIONS_OBSERVE_CONTENT, + tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED}, )(wait_for_text) mcp.tool( title="Snapshot Pane", - annotations=ANNOTATIONS_RO_CONTENT, - tags={TAG_READONLY}, + annotations=ANNOTATIONS_OBSERVE_CONTENT, + tags={TOOLSET_INSPECT}, meta=DISCOVERY_META, )(snapshot_pane) mcp.tool( - title="Select Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + title="Select Pane", annotations=ANNOTATIONS_CHANGE, tags={TOOLSET_MANAGE} )(select_pane) - mcp.tool( - title="Swap Pane", annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_MUTATING} - )(swap_pane) - mcp.tool(title="Pipe Pane", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})( - pipe_pane + mcp.tool(title="Swap Pane", annotations=ANNOTATIONS_DELETE, tags={TOOLSET_MANAGE})( + swap_pane ) + mcp.tool( + title="Pipe Pane", annotations=ANNOTATIONS_PANE_INPUT, tags={TOOLSET_EXECUTE} + )(pipe_pane) mcp.tool( title="Evaluate tmux Format String", - annotations=ANNOTATIONS_RO_CONTENT, - tags={TAG_READONLY}, + annotations=ANNOTATIONS_OBSERVE_CONTENT, + tags={TOOLSET_INSPECT}, )(display_message) mcp.tool( title="Enter Copy Mode", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_MANAGE}, )(enter_copy_mode) mcp.tool( title="Exit Copy Mode", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, )(exit_copy_mode) - mcp.tool(title="Paste Text", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})( - paste_text - ) + mcp.tool( + title="Paste Text", annotations=ANNOTATIONS_PANE_INPUT, tags={TOOLSET_EXECUTE} + )(paste_text) diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index c2476d55..fa62d7d9 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -13,12 +13,12 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_RO, + ANNOTATIONS_DELETE, + ANNOTATIONS_OBSERVE, ANNOTATIONS_SPAWN, - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_TEARDOWN, ExpectedToolError, _apply_filters, _caller_is_on_server, @@ -367,21 +367,27 @@ def list_servers( def register(mcp: FastMCP) -> None: """Register server-level tools with the MCP instance.""" mcp.tool( - title="List tmux Sessions", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} + title="List tmux Sessions", + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, )(list_sessions) mcp.tool( - title="List tmux Servers", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} + title="List tmux Servers", + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, )(list_servers) mcp.tool( title="Create tmux Session", annotations=ANNOTATIONS_SPAWN, - tags={TAG_MUTATING}, + tags={TOOLSET_EXECUTE}, )(create_session) mcp.tool( title="Kill tmux Server", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_DESTRUCTIVE}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_TEARDOWN}, )(kill_server) mcp.tool( - title="Get tmux Server Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} + title="Get tmux Server Info", + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, )(get_server_info) diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index acd18cd7..e6918ae4 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -8,14 +8,15 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_MUTATING, - ANNOTATIONS_RO, + ANNOTATIONS_CHANGE, + ANNOTATIONS_DELETE, + ANNOTATIONS_OBSERVE, ANNOTATIONS_SPAWN, DISCOVERY_META, - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, ExpectedToolError, _apply_filters, _caller_is_on_server, @@ -354,28 +355,32 @@ def register(mcp: FastMCP) -> None: """Register session-level tools with the MCP instance.""" mcp.tool( title="List tmux Windows", - annotations=ANNOTATIONS_RO, - tags={TAG_READONLY}, + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, meta=DISCOVERY_META, )(list_windows) mcp.tool( - title="Get tmux Session Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} + title="Get tmux Session Info", + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, )(get_session_info) mcp.tool( - title="Create tmux Window", annotations=ANNOTATIONS_SPAWN, tags={TAG_MUTATING} + title="Create tmux Window", + annotations=ANNOTATIONS_SPAWN, + tags={TOOLSET_EXECUTE}, )(create_window) mcp.tool( title="Rename tmux Session", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, )(rename_session) mcp.tool( title="Kill tmux Session", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_DESTRUCTIVE}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_TEARDOWN}, )(kill_session) mcp.tool( title="Select tmux Window", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, )(select_window) diff --git a/src/libtmux_mcp/tools/wait_for_tools.py b/src/libtmux_mcp/tools/wait_for_tools.py index c2ca68b8..be2722b9 100644 --- a/src/libtmux_mcp/tools/wait_for_tools.py +++ b/src/libtmux_mcp/tools/wait_for_tools.py @@ -42,9 +42,9 @@ from libtmux_mcp._tmux_proc import _run_tmux_bounded from libtmux_mcp._utils import ( - ANNOTATIONS_DESTRUCTIVE, - TAG_MUTATING, + ANNOTATIONS_DELETE, TAG_SELF_BOUNDED, + TOOLSET_MANAGE, ExpectedToolError, _get_server, _tmux_argv, @@ -320,11 +320,11 @@ def register(mcp: FastMCP) -> None: # which is what the tag asserts. mcp.tool( title="Wait For tmux Channel", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_MUTATING, TAG_SELF_BOUNDED}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_MANAGE, TAG_SELF_BOUNDED}, )(wait_for_channel) mcp.tool( title="Signal tmux Channel", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_MANAGE}, )(signal_channel) diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 4eaac34c..b8795b5f 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -8,14 +8,15 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_MUTATING, - ANNOTATIONS_RO, - ANNOTATIONS_SHELL, + ANNOTATIONS_CHANGE, + ANNOTATIONS_DELETE, + ANNOTATIONS_OBSERVE, + ANNOTATIONS_PANE_INPUT, DISCOVERY_META, - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, ExpectedToolError, _apply_filters, _caller_is_on_server, @@ -502,38 +503,42 @@ def register(mcp: FastMCP) -> None: """Register window-level tools with the MCP instance.""" mcp.tool( title="List tmux Panes", - annotations=ANNOTATIONS_RO, - tags={TAG_READONLY}, + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, meta=DISCOVERY_META, )(list_panes) mcp.tool( - title="Get tmux Window Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} + title="Get tmux Window Info", + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT}, )(get_window_info) mcp.tool( - title="Split tmux Window", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING} + title="Split tmux Window", + annotations=ANNOTATIONS_PANE_INPUT, + tags={TOOLSET_EXECUTE}, )(split_window) mcp.tool( title="Rename tmux Window", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, )(rename_window) mcp.tool( title="Kill tmux Window", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_DESTRUCTIVE}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_TEARDOWN}, )(kill_window) mcp.tool( title="Select tmux Layout", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, )(select_layout) mcp.tool( title="Resize tmux Window", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, )(resize_window) mcp.tool( title="Move tmux Window", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, )(move_window) From 6f9e243e0388b72063e9e61db902336352ef6f45 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:55:37 -0500 Subject: [PATCH 21/44] mcp(feat[toolsets]): Retire the tiers tree-wide why: The tests and docs still taught the ladder the code no longer has. A reader who found `readonly` in the glossary or the landing grid would have learned the wrong model, and the docs contract asserted the old table, so the vocabulary could not be half-removed. what: - Rewrite the safety page as a trust page: what the toolsets group, that `inspect` means "does not interpret your input as a command" rather than "safe", and that dropping a toolset is not containment - Retire the mutating and destructive batch pages, and rename the read batch to match the tool - Sweep the glossary, landing grid, configuration, architecture, troubleshooting, logging, prompting, gotchas and demo pages - Point `LIBTMUX_SAFETY`'s entry at the three variables that replaced it, and the section badge map at the toolsets - Rewrite the tests that encoded a ladder: membership instead of a ceiling, and a spawn refused by the read batch instead of a spawn batched through a wrapper that no longer exists --- docs/conf.py | 6 +- docs/configuration.md | 31 ++- docs/demo.md | 25 +- docs/glossary.md | 4 +- docs/index.md | 24 +- docs/quickstart.md | 2 +- docs/redirects.txt | 3 +- .../batch/call-destructive-tools-batch.md | 32 --- docs/tools/batch/call-mutating-tools-batch.md | 41 ---- ...ools-batch.md => call-read-tools-batch.md} | 8 +- docs/tools/batch/index.md | 6 +- docs/tools/index.md | 22 +- docs/tools/pane/respawn-pane.md | 4 +- docs/tools/pane/run-command.md | 2 +- docs/tools/server/create-session.md | 4 +- docs/tools/session/create-window.md | 4 +- docs/tools/window/split-window.md | 4 +- docs/topics/architecture.md | 20 +- docs/topics/concepts.md | 4 +- docs/topics/gotchas.md | 2 +- docs/topics/history-suppression.md | 4 +- docs/topics/index.md | 8 +- docs/topics/logging.md | 12 +- docs/topics/prompting.md | 8 +- docs/topics/troubleshooting.md | 8 +- docs/topics/{safety.md => trust.md} | 215 ++++++++++------- src/libtmux_mcp/_utils.py | 2 +- src/libtmux_mcp/middleware.py | 3 +- src/libtmux_mcp/server.py | 20 +- src/libtmux_mcp/tools/env_tools.py | 2 +- src/libtmux_mcp/tools/pane_tools/pipe.py | 2 +- src/libtmux_mcp/tools/server_tools.py | 7 +- tests/docs/test_topic_contracts.py | 17 +- tests/test_batch_tools.py | 200 +++++----------- tests/test_history.py | 4 +- tests/test_middleware.py | 212 ++++++++--------- tests/test_server.py | 220 +++++++----------- tests/test_spawn_tools_history.py | 59 ++--- tests/test_tool_annotations.py | 2 +- tests/test_utils.py | 24 +- 40 files changed, 536 insertions(+), 741 deletions(-) delete mode 100644 docs/tools/batch/call-destructive-tools-batch.md delete mode 100644 docs/tools/batch/call-mutating-tools-batch.md rename docs/tools/batch/{call-readonly-tools-batch.md => call-read-tools-batch.md} (80%) rename docs/topics/{safety.md => trust.md} (57%) diff --git a/docs/conf.py b/docs/conf.py index 288b6a78..89b98796 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -174,9 +174,9 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any: "BufferContent", ) conf["fastmcp_section_badge_map"] = { - "Inspect": "readonly", - "Act": "mutating", - "Destroy": "destructive", + "Inspect": "inspect", + "Execute": "execute", + "Teardown": "teardown", } conf["fastmcp_section_badge_pages"] = ("tools/index", "index") diff --git a/docs/configuration.md b/docs/configuration.md index 6eec03e8..76c37520 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,14 +30,33 @@ Path to tmux binary. Useful for testing with different tmux versions. - **Type:** string - **Default:** `tmux` -```{envvar} LIBTMUX_SAFETY +```{envvar} LIBTMUX_TOOLSETS ``` -Safety tier controlling which tools are available. See {ref}`safety`. +Comma list of toolsets to advertise. See {ref}`trust`. - **Type:** string -- **Default:** `mutating` -- **Values:** `readonly`, `mutating`, `destructive` +- **Default:** `inspect,manage,execute` +- **Values:** any of `inspect`, `manage`, `execute`, `teardown`; may be empty + +An unknown name fails startup rather than being ignored. Filtering shapes +what this server advertises, not what tmux or a pane's shell can do. + +```{envvar} LIBTMUX_TOOLS +``` + +Comma list of tool names to advertise regardless of toolset. + +- **Type:** string +- **Default:** empty + +```{envvar} LIBTMUX_EXCLUDE_TOOLS +``` + +Comma list of tool names to refuse, beating every enable above. + +- **Type:** string +- **Default:** empty ```{envvar} LIBTMUX_MCP_WAIT_MAX_SECONDS ``` @@ -79,7 +98,7 @@ Process creation uses a separate control. {toolref}`create-session`, {toolref}`c Leaving it `false` adds no history controls. That choice cannot remove inherited, session, or startup-file controls; the process can still receive them from tmux, your supplied `environment`, or a shell startup file. The startup default never changes the raw-input behavior of {toolref}`send-keys`, {toolref}`send-keys-batch`, {toolref}`paste-text`, or {toolref}`paste-buffer`. -The server resolves {envvar}`LIBTMUX_SUPPRESS_HISTORY` once during startup. Restart the MCP server only after changing this startup setting, usually by reconnecting or restarting the MCP client. Per-call arguments take effect without a restart. See {ref}`history-hygiene` for shell-specific limits and {ref}`safety` for surfaces that history suppression does not hide. +The server resolves {envvar}`LIBTMUX_SUPPRESS_HISTORY` once during startup. Restart the MCP server only after changing this startup setting, usually by reconnecting or restarting the MCP client. Per-call arguments take effect without a restart. See {ref}`history-hygiene` for shell-specific limits and {ref}`trust` for surfaces that history suppression does not hide. ## Setting environment variables @@ -93,7 +112,7 @@ Set environment variables in your MCP client config: "args": ["libtmux-mcp"], "env": { "LIBTMUX_SOCKET": "ai_workspace", - "LIBTMUX_SAFETY": "readonly", + "LIBTMUX_TOOLSETS": "inspect", "LIBTMUX_SUPPRESS_HISTORY": "1" } } diff --git a/docs/demo.md b/docs/demo.md index 6d4eeb63..38fd3a11 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -6,13 +6,14 @@ orphan: true A showcase of the custom Sphinx roles and visual elements available in libtmux-mcp documentation. -## Safety badges +## Toolset badges Standalone badges via `{badge}`: -- {badge}`readonly` — green, read-only operations -- {badge}`mutating` — amber, state-changing operations -- {badge}`destructive` — red, irreversible operations +- {badge}`inspect` — read tmux state and terminal output +- {badge}`manage` — change tmux structure or presentation +- {badge}`execute` — start or drive pane processes +- {badge}`teardown` — delete tmux objects or retained scrollback ## Tool references @@ -50,19 +51,19 @@ Standalone badges via `{badge}`: These are the actual tool headings as they render on tool pages: -> `capture_pane` {badge}`readonly` +> `capture_pane` {badge}`inspect` -> `split_window` {badge}`mutating` +> `split_window` {badge}`manage` -> `kill_session` {badge}`destructive` +> `kill_session` {badge}`teardown` ### In a table | Tool | Tier | Description | |------|------|-------------| -| {toolref}`list-sessions` | {badge}`readonly` | List all sessions | -| {toolref}`send-keys` | {badge}`mutating` | Send commands to a pane | -| {toolref}`kill-pane` | {badge}`destructive` | Destroy a pane | +| {toolref}`list-sessions` | {badge}`inspect` | List all sessions | +| {toolref}`send-keys` | {badge}`manage` | Send commands to a pane | +| {toolref}`kill-pane` | {badge}`teardown` | Destroy a pane | ### In prose @@ -78,7 +79,7 @@ The fundamental command pattern: {toolref}`run-command` → inspect `exit_status ## Glossary terms -{term}`SIGINT` · {term}`SIGQUIT` · {term}`MCP` · {term}`Safety tier` · {term}`Pane` · {term}`Session` +{term}`SIGINT` · {term}`SIGQUIT` · {term}`MCP` · {term}`Toolset` · {term}`Pane` · {term}`Session` ## Admonitions @@ -101,7 +102,7 @@ Each badge renders as: ```html + aria-label="Toolset: readonly"> 🔍 readonly ``` diff --git a/docs/glossary.md b/docs/glossary.md index 34eee36e..af9fd737 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -28,8 +28,8 @@ Window Pane A tmux pane within a window. A pseudoterminal that runs a single process. Has an ID (e.g. `%1`) that is globally unique within a server. -Safety tier - A level controlling which MCP tools are available: `readonly`, `mutating`, or `destructive`. Set via the {envvar}`LIBTMUX_SAFETY` env var. +Toolset + One of four groups a tool belongs to by what it does: `inspect`, `manage`, `execute`, or `teardown`. Unordered — `inspect,teardown` is a legal surface. Selected with the {envvar}`LIBTMUX_TOOLSETS` env var. Filtering them shapes what this server advertises, not what a pane can run. Socket The Unix socket used to communicate with a tmux server. Can be specified by name (`-L`) or path (`-S`). diff --git a/docs/index.md b/docs/index.md index 162ca864..3df4dfbe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ Install, connect, get a first result. Under 2 minutes. :link: tools/index :link-type: doc -Every tool, grouped by intent and safety tier. +Every tool, grouped by intent and toolset. ::: :::{grid-item-card} Prompts @@ -47,11 +47,11 @@ Four workflow recipes the client renders for the model. Snapshot views of the tmux hierarchy via `tmux://` URIs. ::: -:::{grid-item-card} Safety tiers -:link: topics/safety +:::{grid-item-card} Trust model +:link: topics/trust :link-type: doc -Readonly, mutating, destructive. Know what changes state. +What the toolsets group, and what they do not bound. ::: :::{grid-item-card} Client setup @@ -67,23 +67,23 @@ Config blocks for Claude Desktop, Claude Code, Cursor, and others. ## What you can do -### Inspect (readonly) +### Inspect Read tmux state without changing anything. -{toolref}`list-sessions` · {toolref}`capture-pane` · {toolref}`capture-since` · {toolref}`snapshot-pane` · {toolref}`get-pane-info` · {toolref}`find-pane-by-position` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`display-message` · {toolref}`call-readonly-tools-batch` +{toolref}`list-sessions` · {toolref}`capture-pane` · {toolref}`capture-since` · {toolref}`snapshot-pane` · {toolref}`get-pane-info` · {toolref}`find-pane-by-position` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`display-message` · {toolref}`call-read-tools-batch` -### Act (mutating) +### Execute Create or modify tmux objects. -{toolref}`create-session` · {toolref}`send-keys` · {toolref}`send-keys-batch` · {toolref}`run-command` · {toolref}`paste-text` · {toolref}`create-window` · {toolref}`split-window` · {toolref}`select-pane` · {toolref}`select-window` · {toolref}`move-window` · {toolref}`resize-pane` · {toolref}`pipe-pane` · {toolref}`set-option` · {toolref}`call-mutating-tools-batch` +{toolref}`create-session` · {toolref}`send-keys` · {toolref}`send-keys-batch` · {toolref}`run-command` · {toolref}`paste-text` · {toolref}`create-window` · {toolref}`split-window` · {toolref}`select-pane` · {toolref}`select-window` · {toolref}`move-window` · {toolref}`resize-pane` · {toolref}`pipe-pane` · {toolref}`set-option` -### Destroy (destructive) +### Teardown Tear down tmux objects. Not reversible. -{toolref}`kill-session` · {toolref}`kill-window` · {toolref}`kill-pane` · {toolref}`kill-server` · {toolref}`call-destructive-tools-batch` +{toolref}`kill-session` · {toolref}`kill-window` · {toolref}`kill-pane` · {toolref}`kill-server` ### Example: keep test runs out of persistent history @@ -108,7 +108,7 @@ The agent calls {tooliconl}`create-session` with These controls reduce history noise; they do not make commands secret. See {ref}`history-suppression` for shell-specific behavior, -{ref}`configuration` for the server default, and {ref}`safety` for other +{ref}`configuration` for the server default, and {ref}`trust` for other observation surfaces. [Browse all tools →](tools/index) @@ -118,7 +118,7 @@ observation surfaces. ## Mental model - **Object hierarchy** — sessions contain windows, windows contain panes ({doc}`topics/concepts`) -- **Read vs. mutate** — some tools observe, some act, some destroy ({doc}`topics/safety`) +- **Toolsets** — inspect, manage, execute, teardown; what each groups ({doc}`topics/trust`) - **tmux is the source of truth** — the server reads from it and writes to it, never caches or abstracts --- diff --git a/docs/quickstart.md b/docs/quickstart.md index 5487dcb3..fcaa2a74 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -74,5 +74,5 @@ return only new pane output. - {ref}`concepts` — Understand the tmux hierarchy and how tools target panes - {ref}`configuration` — Environment variables and socket isolation -- {ref}`safety` — Control which tools are available +- {ref}`trust` — Control which tools are available - {ref}`Tools ` — Browse all available tools diff --git a/docs/redirects.txt b/docs/redirects.txt index 2c28f11b..2acdcd74 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -13,7 +13,8 @@ "api/utils" "reference/api/utils" "architecture" "topics/architecture" "concepts" "topics/concepts" -"safety" "topics/safety" +"safety" "topics/trust" +"topics/safety" "topics/trust" "guides/troubleshooting" "topics/troubleshooting" "tools/panes" "tools/pane/index" "tools/sessions" "tools/server/index" diff --git a/docs/tools/batch/call-destructive-tools-batch.md b/docs/tools/batch/call-destructive-tools-batch.md deleted file mode 100644 index aad382c8..00000000 --- a/docs/tools/batch/call-destructive-tools-batch.md +++ /dev/null @@ -1,32 +0,0 @@ -# Call destructive tools batch - -```{fastmcp-tool} batch_tools.call_destructive_tools_batch -``` - -**Use when** a reviewed workflow intentionally includes destructive -tools and should still return one per-operation result envelope. - -**Avoid when** the workflow can fit inside -{tooliconl}`call-mutating-tools-batch`. This wrapper can invoke -destructive nested tools when the server safety tier permits them. - -**Side effects:** Runs readonly, mutating, and destructive nested tools -in order. Recursive batch calls are rejected. - -**Example:** - -```json -{ - "tool": "call_destructive_tools_batch", - "arguments": { - "operations": [ - {"tool": "kill_pane", "arguments": {"pane_id": "%7"}}, - {"tool": "list_panes", "arguments": {"window_id": "@3"}} - ], - "on_error": "stop" - } -} -``` - -```{fastmcp-tool-input} batch_tools.call_destructive_tools_batch -``` diff --git a/docs/tools/batch/call-mutating-tools-batch.md b/docs/tools/batch/call-mutating-tools-batch.md deleted file mode 100644 index ceb34bc5..00000000 --- a/docs/tools/batch/call-mutating-tools-batch.md +++ /dev/null @@ -1,41 +0,0 @@ -# Call mutating tools batch - -```{fastmcp-tool} batch_tools.call_mutating_tools_batch -``` - -**Use when** you need an ordered workflow made from existing typed MCP -tools, such as renaming and splitting a known window, while preserving -each tool's own schema and safety checks. - -**Avoid when** you need tmux's native semicolon command parsing. This -tool batches MCP tools; it does not create one tmux command sequence. -For shell commands with completion and output, prefer -{tooliconl}`run-command`. - -**Side effects:** Runs readonly and mutating nested tools in order. -Destructive nested tools are rejected even when the server process is -running with `LIBTMUX_SAFETY=destructive`. - -**Example:** - -```json -{ - "tool": "call_mutating_tools_batch", - "arguments": { - "operations": [ - { - "tool": "rename_window", - "arguments": {"window_id": "@2", "new_name": "logs"} - }, - { - "tool": "split_window", - "arguments": {"window_id": "@2", "direction": "right"} - } - ], - "on_error": "stop" - } -} -``` - -```{fastmcp-tool-input} batch_tools.call_mutating_tools_batch -``` diff --git a/docs/tools/batch/call-readonly-tools-batch.md b/docs/tools/batch/call-read-tools-batch.md similarity index 80% rename from docs/tools/batch/call-readonly-tools-batch.md rename to docs/tools/batch/call-read-tools-batch.md index 8d32190c..1564d799 100644 --- a/docs/tools/batch/call-readonly-tools-batch.md +++ b/docs/tools/batch/call-read-tools-batch.md @@ -1,6 +1,6 @@ -# Call readonly tools batch +# Call Read Tools Batch -```{fastmcp-tool} batch_tools.call_readonly_tools_batch +```{fastmcp-tool} batch_tools.call_read_tools_batch ``` **Use when** you need several read-only observations in one ordered @@ -19,7 +19,7 @@ running with a higher safety tier. ```json { - "tool": "call_readonly_tools_batch", + "tool": "call_read_tools_batch", "arguments": { "operations": [ {"tool": "list_sessions", "arguments": {}}, @@ -30,5 +30,5 @@ running with a higher safety tier. } ``` -```{fastmcp-tool-input} batch_tools.call_readonly_tools_batch +```{fastmcp-tool-input} batch_tools.call_read_tools_batch ``` diff --git a/docs/tools/batch/index.md b/docs/tools/batch/index.md index be5684cf..b5fc4a70 100644 --- a/docs/tools/batch/index.md +++ b/docs/tools/batch/index.md @@ -7,7 +7,7 @@ including `socket_name` when needed. ::::{grid} 1 1 2 3 :gutter: 2 2 3 3 -:::{grid-item-card} {tooliconl}`call-readonly-tools-batch` +:::{grid-item-card} {tooliconl}`call-read-tools-batch` Call readonly tools in order. ::: @@ -25,7 +25,5 @@ Call readonly, mutating, or destructive tools in order. :hidden: :maxdepth: 1 -call-readonly-tools-batch -call-mutating-tools-batch -call-destructive-tools-batch +call-read-tools-batch ``` diff --git a/docs/tools/index.md b/docs/tools/index.md index 6bde2301..af573b8f 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -55,9 +55,9 @@ leave socket selection inside each nested tool's arguments. See - Signal a waiter → {tool}`signal-channel` **Batching typed tool calls?** -- Read-only observations → {tool}`call-readonly-tools-batch` -- Ordered readonly + mutating workflows → {tool}`call-mutating-tools-batch` -- Reviewed workflows that include destructive steps → {tool}`call-destructive-tools-batch` +- Read-only observations → {tool}`call-read-tools-batch` +- Anything that writes → call the tool directly; a batch would hide its + name from a client rule keyed on it **Staging multi-line input?** - Stage content → {tool}`load-buffer` @@ -159,8 +159,8 @@ Wait for text to appear in a pane. Get tmux server info. ::: -:::{grid-item-card} call_readonly_tools_batch -:link: call-readonly-tools-batch +:::{grid-item-card} call_read_tools_batch +:link: call-read-tools-batch :link-type: ref Call typed readonly tools in order. ::: @@ -258,12 +258,6 @@ Send several ordered raw-input operations. Run a shell command and report exit status. ::: -:::{grid-item-card} call_mutating_tools_batch -:link: call-mutating-tools-batch -:link-type: ref -Call typed readonly or mutating tools in order. -::: - :::{grid-item-card} rename_session :link: rename-session :link-type: ref @@ -429,12 +423,6 @@ Destroy a pane. Kill the entire tmux server. ::: -:::{grid-item-card} call_destructive_tools_batch -:link: call-destructive-tools-batch -:link-type: ref -Call typed tools including destructive steps. -::: - :::{grid-item-card} delete_buffer :link: delete-buffer :link-type: ref diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index e9287192..d6899011 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -23,7 +23,7 @@ point of the tool. `pane_pid` updates to the new process. Set it to `true` and {tooliconl}`respawn-pane` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment or affect later panes. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries. `start_directory` must name a directory that exists. tmux would otherwise start the pane in `$HOME` without reporting anything. @@ -74,7 +74,7 @@ time). } ``` -Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, sha256_prefix}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details. +Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, sha256_prefix}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`trust` for details. Response ({class}`~libtmux_mcp.models.PaneInfo`): diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md index c2e669d9..41ff1882 100644 --- a/docs/tools/pane/run-command.md +++ b/docs/tools/pane/run-command.md @@ -15,7 +15,7 @@ names, and partial commands. For MCP calls, lightweight suppression is enabled by default. The {ref}`configuration ` setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` controls only omitted MCP `suppress_history` arguments, and an explicit `suppress_history` value wins. Direct Python calls default to `False`. `suppress_history=false` permits intentional multiline input. -Suppression is best effort: {tooliconl}`run-command` prefixes one space to the grouped event that carries your single-line command, but the existing shell must be configured to ignore space-prefixed commands. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input because one prefix cannot protect multiple shell events. This control does not change the shell's environment or startup configuration, clear its memory or pane scrollback, or hide the command from other observers. See {ref}`history-hygiene` for shell behavior and {ref}`safety` before handling credentials. +Suppression is best effort: {tooliconl}`run-command` prefixes one space to the grouped event that carries your single-line command, but the existing shell must be configured to ignore space-prefixed commands. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input because one prefix cannot protect multiple shell events. This control does not change the shell's environment or startup configuration, clear its memory or pane scrollback, or hide the command from other observers. See {ref}`history-hygiene` for shell behavior and {ref}`trust` before handling credentials. **Example:** diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index e2e27aae..d9c42e5a 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -14,13 +14,13 @@ container — create one before creating windows or panes. **Do not pass credentials directly in `environment`.** Values persist in the new session, can be inspected with {tooliconl}`show-environment`, and reach its initial pane and future panes. Pass credential references instead; see -{ref}`safety` for details. +{ref}`trust` for details. `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. Set it to `true` and {tooliconl}`create-session` copies and merges best-effort no-disk history controls into the tmux session environment. They reach the initial pane and future panes in that session. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries. `start_directory` must name a directory that exists. tmux would otherwise start the pane in `$HOME` without reporting anything. diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md index 30ba71f7..370ae5aa 100644 --- a/docs/tools/session/create-window.md +++ b/docs/tools/session/create-window.md @@ -11,13 +11,13 @@ process started in the new window; it does not change the tmux session environment. Values can remain visible in the tmux client argv and child environment even though the MCP audit record is redacted. Pass credential -references, not literal credentials; see {ref}`safety`. +references, not literal credentials; see {ref}`trust`. `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. Set it to `true` and {tooliconl}`create-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so future windows and panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries. `start_directory` must name a directory that exists. tmux would otherwise start the pane in `$HOME` without reporting anything. diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md index 3060120b..c3fe07c2 100644 --- a/docs/tools/window/split-window.md +++ b/docs/tools/window/split-window.md @@ -12,13 +12,13 @@ window. process started in the new pane; it does not change the tmux session environment. Values can remain visible in the tmux client argv and child environment even though the MCP audit record is redacted. Pass credential -references, not literal credentials; see {ref}`safety`. +references, not literal credentials; see {ref}`trust`. `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. Set it to `true` and {tooliconl}`split-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so later panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries. `start_directory` must name a directory that exists. tmux would otherwise start the pane in `$HOME` without reporting anything. diff --git a/docs/topics/architecture.md b/docs/topics/architecture.md index 42f86fbb..2c074b31 100644 --- a/docs/topics/architecture.md +++ b/docs/topics/architecture.md @@ -13,9 +13,9 @@ src/libtmux_mcp/ server.py # FastMCP instance and configuration _utils.py # Server caching, resolvers, serializers, error handling models.py # Pydantic output models - middleware.py # Safety, audit, retry, and error-result middleware + middleware.py # Toolset, audit, retry, and error-result middleware tools/ - batch_tools.py # call_readonly_tools_batch, call_mutating_tools_batch, call_destructive_tools_batch + batch_tools.py # call_read_tools_batch server_tools.py # list_servers, list_sessions, create_session, kill_server, get_server_info session_tools.py # list_windows, create_window, rename_session, kill_session window_tools.py # list_panes, split_window, rename_window, kill_window, select_layout, resize_window @@ -41,8 +41,8 @@ MCP Client (Claude, Cursor, etc.) → TailPreservingResponseLimitingMiddleware (response size backstop) → ToolErrorResultMiddleware (exceptions → is_error results) → AuditMiddleware (one log record per call) - → ReadonlyRetryMiddleware (retries readonly tools only) - → SafetyMiddleware (tier gate, fail-closed) + → InspectRetryMiddleware (retries inspect tools only) + → ToolsetMiddleware (membership, fail-closed) → Tool function (tools/*.py) → libtmux Python objects → tmux binary (via subprocess) @@ -59,7 +59,7 @@ The libtmux layer is the tmux object hierarchy: Each tool module defines a `register(mcp)` function that registers tools with metadata: - `title` — human-readable name - `annotations` — all four MCP hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), never partial -- `tags` — safety tier tags for middleware filtering +- `tags` — the tool's toolset, which drives filtering ### Server caching @@ -76,9 +76,9 @@ targeting parameters and resolve to the correct {external+libtmux:doc}`libtmux ` object. Resolution follows a priority chain: direct ID → name lookup → error. -### Safety middleware +### Toolset middleware -{class}`~libtmux_mcp.middleware.SafetyMiddleware` implements +{class}`~libtmux_mcp.middleware.ToolsetMiddleware` implements [FastMCP](https://gofastmcp.com)'s middleware interface. It operates as a secondary gate behind FastMCP's native tag visibility system, providing clear error messages when a tool above the configured tier @@ -88,11 +88,11 @@ is invoked. Three boundaries split the work: -1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e`, which is what lets {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` match transient {exc}`~libtmux.exc.LibTmuxException` causes. +1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e`, which is what lets {class}`~libtmux_mcp.middleware.InspectRetryMiddleware` match transient {exc}`~libtmux.exc.LibTmuxException` causes. 2. **Schema classification** — FastMCP validates tool arguments before tool code runs, so [Pydantic](https://docs.pydantic.dev/) validation failures never reach the decorator. {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` classifies those schema-validation errors as expected, agent-correctable WARNINGs before converting them. -3. **Conversion** — {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` catches the exception once it has cleared the audit/retry/safety trio and returns an error `ToolResult` carrying the message exactly as raised, plus a `_meta` payload (`error_type`, `expected`, and an optional agent-facing `suggestion` for recovery hints such as discovery tools or rejected-argument fixes). +3. **Conversion** — {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` catches the exception once it has cleared the audit/retry/toolset trio and returns an error `ToolResult` carrying the message exactly as raised, plus a `_meta` payload (`error_type`, `expected`, and an optional agent-facing `suggestion` for recovery hints such as discovery tools or rejected-argument fixes). -Errors must stay exceptions through the audit/retry/safety trio — audit detects failures by catching, retry matches via `__cause__` — so conversion happens only in the outermost error layer. The response limiter sits outside conversion and may truncate large success or error results on the return path; its truncation path preserves `is_error` and `_meta` so oversized expected failures stay tool errors. Level policy lives in {doc}`/topics/logging`. +Errors must stay exceptions through the audit/retry/toolset trio — audit detects failures by catching, retry matches via `__cause__` — so conversion happens only in the outermost error layer. The response limiter sits outside conversion and may truncate large success or error results on the return path; its truncation path preserves `is_error` and `_meta` so oversized expected failures stay tool errors. Level policy lives in {doc}`/topics/logging`. ## References diff --git a/docs/topics/concepts.md b/docs/topics/concepts.md index 4fa68d0a..e2e211cf 100644 --- a/docs/topics/concepts.md +++ b/docs/topics/concepts.md @@ -62,7 +62,7 @@ Tools fall into three categories: - **Destruction** — Remove tmux objects: {toolref}`kill-server`, {toolref}`kill-session`, {toolref}`kill-window`, {toolref}`kill-pane` -These map to {ref}`safety tiers `. +These map to {ref}`toolsets `. ## Agent self-awareness @@ -70,7 +70,7 @@ When the MCP server runs inside a tmux pane (detected via the `TMUX_PANE` enviro - Includes the caller's pane context in server instructions - Annotates the caller's own pane with `is_caller=true` in tool results -- Prevents destructive tools from killing the caller's own pane, window, session, or server +- Prevents the `teardown` tools from killing the caller's own pane, window, session, or server This means agents can safely explore and manage tmux without accidentally terminating themselves. diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 96658562..509115cf 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -114,4 +114,4 @@ This has been observed with stock Gemini CLI behavior (no extensions involved). Tool schemas are strict (`additionalProperties: false`), so the call is rejected with a validation error — classified as expected (WARNING log, `expected: true` in the result's `_meta`) and carrying a suggestion that names the rejected argument and identifies `wait_for_previous` as a client scheduling flag to retry without. Gemini's model reads it, drops the flag, and retries successfully on its own. -The visible symptom is harmless noise: `Error executing tool mcp_tmux_: ... reported an error` lines in Gemini's output for calls that then succeed on retry, and matching WARNING records in the server log. Similar reports in other MCP servers have handled this injected key by stripping it or whitelisting arguments against the schema ([MemPalace/mempalace#816](https://github.com/MemPalace/mempalace/issues/816)). This server deliberately keeps the rejection: silently dropping unknown arguments would also swallow genuine argument typos from every client — on a server with mutating and destructive tools, a mis-named flag (`enter` on {toolref}`send-keys`, say) must fail loudly, not run with defaults. +The visible symptom is harmless noise: `Error executing tool mcp_tmux_: ... reported an error` lines in Gemini's output for calls that then succeed on retry, and matching WARNING records in the server log. Similar reports in other MCP servers have handled this injected key by stripping it or whitelisting arguments against the schema ([MemPalace/mempalace#816](https://github.com/MemPalace/mempalace/issues/816)). This server deliberately keeps the rejection: silently dropping unknown arguments would also swallow genuine argument typos from every client — on a server that can type into panes and delete them, a mis-named flag (`enter` on {toolref}`send-keys`, say) must fail loudly, not run with defaults. diff --git a/docs/topics/history-suppression.md b/docs/topics/history-suppression.md index e6a014bf..88902007 100644 --- a/docs/topics/history-suppression.md +++ b/docs/topics/history-suppression.md @@ -11,7 +11,7 @@ best-effort no-disk controls, opt in with `suppress_persistent_history=true`. Neither control makes a command secret. Shell configuration can override the request, in-memory history can remain available, and terminal output or other -observers can still record the command. See {ref}`safety` before handling +observers can still record the command. See {ref}`trust` before handling credentials. ## Why raw input stays explicit @@ -139,5 +139,5 @@ History suppression does not clear pane echo, scrollback, in-memory history, process arguments, tmux environment state, MCP client transcripts, hooks, or logs. Prefer credential references that the child process resolves over literal credentials in `command`, `keys`, `text`, `shell`, or `environment`. -See {ref}`safety` for the full observation boundary and {ref}`logging` for +See {ref}`trust` for the full observation boundary and {ref}`logging` for audit-record behavior. diff --git a/docs/topics/index.md b/docs/topics/index.md index 2b927654..c7f5a7ef 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -17,10 +17,10 @@ Source layout, request flow, and extension points. tmux hierarchy, MCP protocol, and the mental model. ::: -:::{grid-item-card} Safety Tiers -:link: safety +:::{grid-item-card} Trust model +:link: trust :link-type: doc -Three-tier safety system for controlling tool access. +What the four toolsets group, and what filtering them does not bound. ::: :::{grid-item-card} History Suppression @@ -92,7 +92,7 @@ observation cursors. architecture concepts -safety +trust history-suppression waiting gotchas diff --git a/docs/topics/logging.md b/docs/topics/logging.md index a4bcd01f..34396ea1 100644 --- a/docs/topics/logging.md +++ b/docs/topics/logging.md @@ -14,10 +14,10 @@ No manual wiring needed. All loggers are children of ``libtmux_mcp``. The primary streams are: -- ``libtmux_mcp.audit`` — one structured line per tool call, emitted by {class}`~libtmux_mcp.middleware.AuditMiddleware`. Includes tool name, digest-redacted arguments, latency, and outcome. See {doc}`/topics/safety` for the argument-redaction rules. It does not record tool return values. +- ``libtmux_mcp.audit`` — one structured line per tool call, emitted by {class}`~libtmux_mcp.middleware.AuditMiddleware`. Includes tool name, digest-redacted arguments, latency, and outcome. See {doc}`/topics/trust` for the argument-redaction rules. It does not record tool return values. - ``libtmux_mcp.retry`` — warnings from - {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` when a - readonly tool retried after a transient + {class}`~libtmux_mcp.middleware.InspectRetryMiddleware` when an + `inspect` tool retried after a transient {exc}`~libtmux.exc.LibTmuxException`. - ``libtmux_mcp.server`` / ``libtmux_mcp.tools.*`` / etc. — ad-hoc warnings and debug messages from the codebase. @@ -29,7 +29,7 @@ are: Tool failures are logged at a level matching who needs to act: - **WARNING** — expected, agent-correctable failures: unknown - pane/window/session ids, invalid arguments, safety-tier denials, + pane/window/session ids, invalid arguments, toolset refusals, transient tmux errors. The calling agent receives the message and can correct course; operators don't need to. - **ERROR** — operator faults and potential bugs: a missing ``tmux`` @@ -61,7 +61,7 @@ the Python logger name, which the protocol doesn't model. ## History controls stay observable -`suppress_history` and `suppress_persistent_history` affect shell-history behavior only. They do not disable audit logging and do not clear pane echo or scrollback. The audit logger still summarizes the call's arguments and outcome, other library or application loggers keep their own behavior, and an MCP client can still retain the original request and response. See {ref}`safety` for every observation boundary that remains outside history suppression. +`suppress_history` and `suppress_persistent_history` affect shell-history behavior only. They do not disable audit logging and do not clear pane echo or scrollback. The audit logger still summarizes the call's arguments and outcome, other library or application loggers keep their own behavior, and an MCP client can still retain the original request and response. See {ref}`trust` for every observation boundary that remains outside history suppression. ```{tip} If a tool call has no user-visible error or side effect, the ``libtmux_mcp.audit`` log shows the invocation and whether it returned or raised, not the tool's return value. Use the MCP response and current tmux state to determine what the tool returned or changed. @@ -70,6 +70,6 @@ If a tool call has no user-visible error or side effect, the ``libtmux_mcp.audit ## Further reading - [MCP logging spec](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) -- {doc}`/topics/safety` — audit log redaction rules +- {doc}`/topics/trust` — audit log redaction rules - {class}`~libtmux_mcp.middleware.AuditMiddleware` — the primary audit emitter diff --git a/docs/topics/prompting.md b/docs/topics/prompting.md index e0493c3f..ebb011c1 100644 --- a/docs/topics/prompting.md +++ b/docs/topics/prompting.md @@ -29,7 +29,7 @@ known pane, or capture_pane for a one-shot manual inspection. ``` The server also dynamically adds: -- **Safety tier context**: Which tier is active and what tools are available +- **Toolset context**: Which toolsets are enabled and what tools they carry - **Caller pane awareness**: If the server runs inside tmux, it tells the agent which pane is its own (via `TMUX_PANE`). See {ref}`concepts` "Agent self-awareness" for details. ## Activation and discovery @@ -79,7 +79,7 @@ These natural-language prompts reliably trigger the right tool sequences: |--------|---------|---------------| | [Run this command]{.prompt} | Ambiguous — agent may use its own shell instead of tmux | [Run `make test` in a tmux pane]{.prompt} | | [Check my terminal]{.prompt} | Which pane? Agent must discover first | [Check the pane running `npm dev`]{.prompt} or [Search all panes for errors]{.prompt} | -| [Clean up everything]{.prompt} | Too broad for destructive operations | [Kill the `ci-test` session]{.prompt} | +| [Clean up everything]{.prompt} | Too broad for a call that deletes | [Kill the `ci-test` session]{.prompt} | | [Show me the output]{.prompt} | Capture immediately? Or wait? | [Wait for the command to finish, then show me the output]{.prompt} | ## System prompt fragments @@ -113,7 +113,7 @@ command may still be running. Before creating tmux sessions, check list_sessions to avoid duplicates. Always use pane_id for targeting — it is globally unique. Never run -destructive operations (kill_session, kill_server) without confirming +deletions (kill_session, kill_server) without confirming the target with the user first. ``` @@ -148,4 +148,4 @@ When an agent is unsure which tool to use, these rules help: 2. **Prefer IDs**: Once you have a `pane_id`, use it for all subsequent calls — it never changes during the pane's lifetime 3. **Run, wait, or observe deliberately**: For commands the agent authors, prefer {toolref}`run-command`. Use {toolref}`wait-for-channel` only for custom shell composition outside that shape. Use {toolref}`capture-since` for repeated observation, and fall back to {toolref}`wait-for-text` for output the agent doesn't author. Never call {toolref}`capture-pane` in a retry loop. 4. **Content vs. metadata**: If looking for text *in* a terminal, use {toolref}`search-panes`. If looking for pane *properties* (name, PID, path), use {toolref}`list-panes` or {toolref}`get-pane-info` -5. **Destructive tools are opt-in**: Never kill sessions, windows, or panes unless the user explicitly asks +5. **Deletion is opt-in**: Never kill sessions, windows, or panes unless the user explicitly asks diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md index 21f47af7..9ad7d7ac 100644 --- a/docs/topics/troubleshooting.md +++ b/docs/topics/troubleshooting.md @@ -107,13 +107,13 @@ what you expect. $ python --version ``` -## Safety tier blocking tools +## A toolset is hiding tools -**Symptoms**: Some tools are missing from the tool list, or return "blocked by safety tier" errors. +**Symptoms**: Some tools are missing from the tool list, or a call returns "not in this server's enabled toolsets". -**Cause**: `LIBTMUX_SAFETY` is set to a restrictive tier. +**Cause**: `LIBTMUX_TOOLSETS` does not include the tool's toolset. -**Fix**: Check the configured tier. Default is `mutating`, which includes most tools. Only `destructive` enables kill commands. See {ref}`safety`. +**Fix**: The default is `inspect,manage,execute`. Add `teardown` for the kill commands, or name the tool in `LIBTMUX_TOOLS`. See {ref}`trust`. ## How to see logs diff --git a/docs/topics/safety.md b/docs/topics/trust.md similarity index 57% rename from docs/topics/safety.md rename to docs/topics/trust.md index 41aeb09e..035ed7d8 100644 --- a/docs/topics/safety.md +++ b/docs/topics/trust.md @@ -1,22 +1,58 @@ ```{eval-rst} -.. _safety: +.. _trust: ``` -# Safety tiers +# Trust model -libtmux-mcp uses a three-tier safety system to control which tools are available to AI agents. +This server gives an agent a terminal. What follows is what that does and +does not bound. -## Overview +## Toolsets are an inventory, not a permission system -| Tier | Label | Access | Use case | -|------|-------|--------|----------| -| `readonly` | {badge}`readonly` | List, capture, search, info, readonly batches | Monitoring, browsing | -| `mutating` (default) | {badge}`mutating` | + create, {toolref}`send-keys`, {toolref}`send-keys-batch`, mutating batches, rename, resize | Normal agent workflow | -| `destructive` | {badge}`destructive` | + destructive batches, {toolref}`kill-server`, {toolref}`kill-session`, {toolref}`kill-window`, {toolref}`kill-pane` | Full control | +Tools are grouped into four sets by what they do: -## Configuration +`inspect` +: Read tmux state and terminal output. Starts no process, and hands no + caller input to one — what you supply is IDs, names, bounded patterns, + and validated variable names. + +`manage` +: Change tmux structure or presentation: names, sizes, layouts, selections, + modes. Starts no process, and takes no caller input that anything later + executes. + +`execute` +: Start a pane process, deliver input to one, or store a value tmux later + runs. {tooliconl}`set-option` is here, not in `manage`: a `#(...)` job in + a status format runs when tmux draws it and repeats on the status + interval, and `default-command` decides what every future pane runs. + +`teardown` +: Delete tmux objects or retained scrollback. Irreversible at the tmux + level. + +The sets are unordered. `LIBTMUX_TOOLSETS=inspect,teardown` is a legal +surface — an agent that can look and clean up, but not type. + +**Dropping a toolset is not containment.** It changes what this server +advertises. An enabled `execute` tool can type the equivalent of anything +you hid, because a pane's shell runs with your user's authority. Treat the +toolsets as inventory configuration and accident reduction. OS accounts, +containers, and separate tmux sockets are the isolation boundaries. + +## `inspect` does not mean safe -Set the safety tier via the {envvar}`LIBTMUX_SAFETY` environment variable: +An `inspect` tool does not interpret what you give it as a command. That is +a property of these implementations, and it is the only thing the name +claims. + +It is not a claim that the result is harmless. A capture returns whatever +the pane holds: credentials someone typed, a command line with a token in +it, output from a remote host, text written by another agent. Those reads +advertise `openWorldHint: true` for that reason. Auto-approving the whole +set is a decision to make with that in mind, not one the name endorses. + +## Configuration ```json { @@ -25,36 +61,37 @@ Set the safety tier via the {envvar}`LIBTMUX_SAFETY` environment variable: "command": "uvx", "args": ["libtmux-mcp"], "env": { - "LIBTMUX_SAFETY": "readonly" + "LIBTMUX_TOOLSETS": "inspect" } } } } ``` -## How it works - -### Dual-layer gating - -1. **[FastMCP](https://gofastmcp.com) tag visibility**: Tools are tagged with their tier. Only tags at or below the configured tier are enabled via `mcp.enable(tags=..., only=True)`. - -2. **Safety middleware**: A secondary middleware layer hides tools from listings and blocks execution with clear error messages if a tool above the tier is somehow invoked. - -### Tool tags +{envvar}`LIBTMUX_TOOLSETS` is a comma list, defaulting to +`inspect,manage,execute`. `teardown` is not in the default: this server +reaches whichever tmux server the environment points at, so deletion is +something you ask for by name. {envvar}`LIBTMUX_TOOLS` enables individual +tools regardless of toolset, and {envvar}`LIBTMUX_EXCLUDE_TOOLS` refuses +them regardless of every enable above. -Every tool is tagged with exactly one safety tier: +An unknown toolset or tool name fails startup rather than being ignored. +A typo that silently widened a surface you believed was narrow is worse +than a server that will not start. -- {badge}`readonly` `readonly` — Read-only operations that don't modify tmux state -- {badge}`mutating` `mutating` — Operations that create, modify, or send input to tmux objects -- {badge}`destructive` `destructive` — Operations that destroy tmux objects (kill commands) +### How it works -### Fail-closed design +Two layers, both keyed on the same tags. [FastMCP](https://gofastmcp.com) +tag visibility filters the listing; a middleware repeats the decision on +call so a direct invocation gets an error naming the variable rather than +an unknown-tool error. -Tools without a recognized tier tag are **denied by default**. This prevents accidentally exposing new tools without explicit safety classification. +Both fail closed: a tool carrying no recognized toolset is refused, so +adding one without classifying it cannot expose it by accident. ## Self-kill protection -Destructive tools include safeguards against self-harm: +The `teardown` tools include safeguards against self-harm: - {tool}`kill-server` refuses to run if the MCP server is inside the target server - {tool}`kill-session` refuses to kill the session containing the MCP pane @@ -75,9 +112,9 @@ steps ({func}`~libtmux_mcp._utils._effective_socket_path` in The structural fix shipped in 0.1.x; setting {envvar}`TMUX_TMPDIR` explicitly is no longer required for the guard to work, though it remains a useful diagnostic when investigating mismatched-path bug reports. -## Footguns inside the `mutating` tier +## Footguns inside `execute` -Most `mutating` tools are bounded: {toolref}`resize-pane` only +Most `manage` tools are bounded: {toolref}`resize-pane` only resizes, {toolref}`rename-window` only renames. A few have broader reach because tmux itself exposes broader reach. Treat these as elevated risk even though they share the default tier: @@ -89,7 +126,7 @@ elevated risk even though they share the default tier: Mitigations: - Run the server as an unprivileged user with a scoped home directory. -- Consider `LIBTMUX_SAFETY=readonly` for untrusted MCP clients. +- Consider `LIBTMUX_TOOLSETS=inspect` for untrusted MCP clients. - Audit log records (see below) capture the `output_path` argument so reviewers can spot unexpected destinations. ### Setting tmux environment @@ -112,7 +149,7 @@ Mitigations: - `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of an unintended kill — resolve via {tool}`list-panes` first. - Any `shell` argument is briefly visible in the OS process table and tmux's `pane_current_command` metadata before the spawned shell takes over; the audit log redacts `shell` payloads (see below), but do not pass credentials directly even with redaction. - The optional `environment` argument accepts either a mapping of string keys and values or a JSON object string, then maps each item to one tmux `-e KEY=VALUE` flag. For a mapping, the audit log keeps each *key* visible and replaces each *value* with a `{len, sha256_prefix}` digest. A JSON string is redacted as one scalar digest, so its keys are not retained in the audit record. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env. -- The same self-pane guard that protects the destructive kill commands also refuses to respawn the pane running the MCP server. +- The same self-pane guard that protects the kill commands also refuses to respawn the pane running the MCP server. ### Raw pane input @@ -155,61 +192,59 @@ updates**, so a tool that replaces a name, a size, or a layout advertises reaches, or returns text from, outside tmux — a spawned process runs with your user's authority, and a pane holds whatever was printed into it. -| Tool | Tier | readOnly | destructive | idempotent | openWorld | -|------|------|----------|-------------|------------|-----------| -| {toolref}`call-readonly-tools-batch` | {badge}`readonly` | true | false | true | true | -| {toolref}`capture-pane` | {badge}`readonly` | true | false | true | true | -| {toolref}`capture-since` | {badge}`readonly` | true | false | true | true | -| {toolref}`display-message` | {badge}`readonly` | true | false | true | true | -| {toolref}`find-pane-by-position` | {badge}`readonly` | true | false | true | false | -| {toolref}`get-pane-info` | {badge}`readonly` | true | false | true | false | -| {toolref}`get-server-info` | {badge}`readonly` | true | false | true | false | -| {toolref}`get-session-info` | {badge}`readonly` | true | false | true | false | -| {toolref}`get-window-info` | {badge}`readonly` | true | false | true | false | -| {toolref}`list-panes` | {badge}`readonly` | true | false | true | false | -| {toolref}`list-servers` | {badge}`readonly` | true | false | true | false | -| {toolref}`list-sessions` | {badge}`readonly` | true | false | true | false | -| {toolref}`list-windows` | {badge}`readonly` | true | false | true | false | -| {toolref}`search-panes` | {badge}`readonly` | true | false | true | true | -| {toolref}`show-buffer` | {badge}`readonly` | true | false | true | true | -| {toolref}`show-environment` | {badge}`readonly` | true | false | true | false | -| {toolref}`show-hook` | {badge}`readonly` | true | false | true | false | -| {toolref}`show-hooks` | {badge}`readonly` | true | false | true | false | -| {toolref}`show-option` | {badge}`readonly` | true | false | true | false | -| {toolref}`snapshot-pane` | {badge}`readonly` | true | false | true | true | -| {toolref}`wait-for-text` | {badge}`readonly` | true | false | true | true | -| {toolref}`call-mutating-tools-batch` | {badge}`mutating` | false | true | false | true | -| {toolref}`clear-pane` | {badge}`mutating` | false | true | false | false | -| {toolref}`create-session` | {badge}`mutating` | false | false | false | true | -| {toolref}`create-window` | {badge}`mutating` | false | false | false | true | -| {toolref}`delete-buffer` | {badge}`mutating` | false | true | false | false | -| {toolref}`enter-copy-mode` | {badge}`mutating` | false | true | false | false | -| {toolref}`exit-copy-mode` | {badge}`mutating` | false | true | true | false | -| {toolref}`load-buffer` | {badge}`mutating` | false | false | false | false | -| {toolref}`move-window` | {badge}`mutating` | false | true | true | false | -| {toolref}`paste-buffer` | {badge}`mutating` | false | true | false | true | -| {toolref}`paste-text` | {badge}`mutating` | false | true | false | true | -| {toolref}`pipe-pane` | {badge}`mutating` | false | true | false | true | -| {toolref}`rename-session` | {badge}`mutating` | false | true | true | false | -| {toolref}`rename-window` | {badge}`mutating` | false | true | true | false | -| {toolref}`resize-pane` | {badge}`mutating` | false | true | true | false | -| {toolref}`resize-window` | {badge}`mutating` | false | true | true | false | -| {toolref}`respawn-pane` | {badge}`mutating` | false | true | false | true | -| {toolref}`run-command` | {badge}`mutating` | false | true | false | true | -| {toolref}`select-layout` | {badge}`mutating` | false | true | true | false | -| {toolref}`select-pane` | {badge}`mutating` | false | true | true | false | -| {toolref}`select-window` | {badge}`mutating` | false | true | true | false | -| {toolref}`send-keys` | {badge}`mutating` | false | true | false | true | -| {toolref}`send-keys-batch` | {badge}`mutating` | false | true | false | true | -| {toolref}`set-environment` | {badge}`mutating` | false | true | true | true | -| {toolref}`set-option` | {badge}`mutating` | false | true | true | true | -| {toolref}`set-pane-title` | {badge}`mutating` | false | true | true | false | -| {toolref}`signal-channel` | {badge}`mutating` | false | true | false | false | -| {toolref}`split-window` | {badge}`mutating` | false | true | false | true | -| {toolref}`swap-pane` | {badge}`mutating` | false | true | false | false | -| {toolref}`wait-for-channel` | {badge}`mutating` | false | true | false | false | -| {toolref}`call-destructive-tools-batch` | {badge}`destructive` | false | true | false | true | -| {toolref}`kill-pane` | {badge}`destructive` | false | true | false | false | -| {toolref}`kill-server` | {badge}`destructive` | false | true | false | false | -| {toolref}`kill-session` | {badge}`destructive` | false | true | false | false | -| {toolref}`kill-window` | {badge}`destructive` | false | true | false | false | +| Tool | Toolset | readOnly | destructive | idempotent | openWorld | +|------|---------|----------|-------------|------------|-----------| +| {toolref}`call-read-tools-batch` | {badge}`inspect` | true | false | true | true | +| {toolref}`capture-pane` | {badge}`inspect` | true | false | true | true | +| {toolref}`capture-since` | {badge}`inspect` | true | false | true | true | +| {toolref}`display-message` | {badge}`inspect` | true | false | true | true | +| {toolref}`find-pane-by-position` | {badge}`inspect` | true | false | true | false | +| {toolref}`get-pane-info` | {badge}`inspect` | true | false | true | false | +| {toolref}`get-server-info` | {badge}`inspect` | true | false | true | false | +| {toolref}`get-session-info` | {badge}`inspect` | true | false | true | false | +| {toolref}`get-window-info` | {badge}`inspect` | true | false | true | false | +| {toolref}`list-panes` | {badge}`inspect` | true | false | true | false | +| {toolref}`list-servers` | {badge}`inspect` | true | false | true | false | +| {toolref}`list-sessions` | {badge}`inspect` | true | false | true | false | +| {toolref}`list-windows` | {badge}`inspect` | true | false | true | false | +| {toolref}`search-panes` | {badge}`inspect` | true | false | true | true | +| {toolref}`show-buffer` | {badge}`inspect` | true | false | true | true | +| {toolref}`show-environment` | {badge}`inspect` | true | false | true | false | +| {toolref}`show-hook` | {badge}`inspect` | true | false | true | false | +| {toolref}`show-hooks` | {badge}`inspect` | true | false | true | false | +| {toolref}`show-option` | {badge}`inspect` | true | false | true | false | +| {toolref}`snapshot-pane` | {badge}`inspect` | true | false | true | true | +| {toolref}`wait-for-text` | {badge}`inspect` | true | false | true | true | +| {toolref}`enter-copy-mode` | {badge}`manage` | false | true | false | false | +| {toolref}`exit-copy-mode` | {badge}`manage` | false | true | true | false | +| {toolref}`load-buffer` | {badge}`manage` | false | false | false | false | +| {toolref}`move-window` | {badge}`manage` | false | true | true | false | +| {toolref}`rename-session` | {badge}`manage` | false | true | true | false | +| {toolref}`rename-window` | {badge}`manage` | false | true | true | false | +| {toolref}`resize-pane` | {badge}`manage` | false | true | true | false | +| {toolref}`resize-window` | {badge}`manage` | false | true | true | false | +| {toolref}`select-layout` | {badge}`manage` | false | true | true | false | +| {toolref}`select-pane` | {badge}`manage` | false | true | true | false | +| {toolref}`select-window` | {badge}`manage` | false | true | true | false | +| {toolref}`set-pane-title` | {badge}`manage` | false | true | true | false | +| {toolref}`signal-channel` | {badge}`manage` | false | true | false | false | +| {toolref}`swap-pane` | {badge}`manage` | false | true | false | false | +| {toolref}`wait-for-channel` | {badge}`manage` | false | true | false | false | +| {toolref}`create-session` | {badge}`execute` | false | false | false | true | +| {toolref}`create-window` | {badge}`execute` | false | false | false | true | +| {toolref}`paste-buffer` | {badge}`execute` | false | true | false | true | +| {toolref}`paste-text` | {badge}`execute` | false | true | false | true | +| {toolref}`pipe-pane` | {badge}`execute` | false | true | false | true | +| {toolref}`respawn-pane` | {badge}`execute` | false | true | false | true | +| {toolref}`run-command` | {badge}`execute` | false | true | false | true | +| {toolref}`send-keys` | {badge}`execute` | false | true | false | true | +| {toolref}`send-keys-batch` | {badge}`execute` | false | true | false | true | +| {toolref}`set-environment` | {badge}`execute` | false | true | true | true | +| {toolref}`set-option` | {badge}`execute` | false | true | true | true | +| {toolref}`split-window` | {badge}`execute` | false | true | false | true | +| {toolref}`clear-pane` | {badge}`teardown` | false | true | false | false | +| {toolref}`delete-buffer` | {badge}`teardown` | false | true | false | false | +| {toolref}`kill-pane` | {badge}`teardown` | false | true | false | false | +| {toolref}`kill-server` | {badge}`teardown` | false | true | false | false | +| {toolref}`kill-session` | {badge}`teardown` | false | true | false | false | +| {toolref}`kill-window` | {badge}`teardown` | false | true | false | false | diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 4b466412..af8b25d2 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -225,7 +225,7 @@ def _effective_socket_path(server: Server) -> str | None: server — authoritative because tmux itself reports the path it is actually using, regardless of our process environment. Necessary on macOS where ``$TMUX_TMPDIR`` under launchd diverges - from the interactive shell (see ``docs/topics/safety.md`` for + from the interactive shell (see ``docs/topics/trust.md`` for the self-kill guard gap this closes). 3. Fallback: reconstruct from ``$TMUX_TMPDIR`` + euid + socket name. This path is reached only when the target server is unreachable diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 9b4ff142..43f9712c 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -117,6 +117,7 @@ async def on_call_tool( f"it, or LIBTMUX_TOOLS to enable it by name." ) raise ExpectedToolError(msg) + return await call_next(context) # --------------------------------------------------------------------------- @@ -471,7 +472,7 @@ async def on_call_tool( #: audit log only. ``respawn_pane(shell="env SECRET=... bash")`` and #: ``environment={"AWS_SECRET_KEY": "..."}`` may briefly expose the values #: via the OS process table and tmux's ``pane_current_command`` metadata -#: until the spawned shell takes over — see ``docs/topics/safety.md``. +#: until the spawned shell takes over — see ``docs/topics/trust.md``. _SENSITIVE_ARG_NAMES: frozenset[str] = frozenset( {"keys", "text", "command", "value", "content", "shell", "environment"} ) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index b2b93805..22e869a5 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -174,11 +174,10 @@ def _build_instructions( # Safety tier context parts.append( - "\n\nToolsets enabled: " + "\n\nToolsets: " + (", ".join(sorted(toolsets)) or "(none)") - + f" (of {', '.join(VALID_TOOLSETS)}). Set LIBTMUX_TOOLSETS; tools " - "outside them are hidden. This shapes what is advertised, not what " - "tmux or a pane's shell can do." + + f" (of {', '.join(VALID_TOOLSETS)}), set by LIBTMUX_TOOLSETS. " + "Hiding one shapes this list, not what a pane can run." ) history_default = "true" if suppress_history else "false" parts.append( @@ -186,15 +185,12 @@ def _build_instructions( "raw send/batch/paste and spawn do not." ) - # Tier-conditioned discoverability hint. False-positive activation is - # cheap on readonly (worst case: an extra list_panes call) and - # expensive on mutating/destructive (where kill_* is one mis-routed - # query away). Reuse the existing safety axis instead of shipping a - # separate LIBTMUX_DISCOVERABILITY knob. + # Only when nothing but inspect is enabled: a wrong guess costs one + # extra capture, where the same nudge on a surface holding kill_* or + # send_keys could cost a pane. Keyed on the enabled toolsets rather + # than a separate discoverability variable. if toolsets == frozenset({TOOLSET_INSPECT}): - parts.append( - "\n\nReadonly mode: probe snapshot_pane/list_panes/search_panes if unsure." - ) + parts.append("\n\nProbe snapshot_pane/list_panes/search_panes if unsure.") instructions = "".join(parts) if len(instructions.encode("utf-8")) > _INSTRUCTIONS_MAX_BYTES: diff --git a/src/libtmux_mcp/tools/env_tools.py b/src/libtmux_mcp/tools/env_tools.py index 9acce4e9..ab80e05e 100644 --- a/src/libtmux_mcp/tools/env_tools.py +++ b/src/libtmux_mcp/tools/env_tools.py @@ -86,7 +86,7 @@ def set_environment( disk/memory until tmux is restarted. Prefer ``env VAR=value command`` via :func:`~libtmux_mcp.tools.pane_tools.send_keys` when you only need the override for a single command. See - :doc:`/topics/safety`. + :doc:`/topics/trust`. Parameters ---------- diff --git a/src/libtmux_mcp/tools/pane_tools/pipe.py b/src/libtmux_mcp/tools/pane_tools/pipe.py index 686592ea..68e582fe 100644 --- a/src/libtmux_mcp/tools/pane_tools/pipe.py +++ b/src/libtmux_mcp/tools/pane_tools/pipe.py @@ -56,7 +56,7 @@ def pipe_pane( tier — it is the broadest-reach tool in that tier. If you run libtmux-mcp on untrusted input, consider ``LIBTMUX_SAFETY=readonly`` or run the server under a user with - a scoped home directory. See :doc:`/topics/safety` for the full + a scoped home directory. See :doc:`/topics/trust` for the full footgun list. Parameters diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index fa62d7d9..d7477d87 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -292,12 +292,7 @@ def _probe_server_by_path(socket_path: pathlib.Path) -> ServerInfo | None: #: discovery-style tool, append it here AND update the prose in #: ``_BASE_INSTRUCTIONS`` so the two stay in lockstep. SOCKET_NAME_EXEMPT: frozenset[str] = frozenset( - { - "call_destructive_tools_batch", - "call_mutating_tools_batch", - "call_readonly_tools_batch", - "list_servers", - } + {"call_read_tools_batch", "list_servers"} ) diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index f6a0388a..6b0a2b20 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -212,7 +212,7 @@ def test_create_session_page_warns_against_literal_credentials( assert "Values persist in the new session" in normalized assert "initial pane and future panes" in normalized assert "Pass credential references instead" in normalized - assert "{ref}`safety`" in caution_block + assert "{ref}`trust`" in caution_block assert "```{fastmcp-tool-input} server_tools.create_session" in text @@ -238,11 +238,11 @@ def test_run_command_page_documents_effective_history_policy( assert "`true` unless {envvar}`LIBTMUX_SUPPRESS_HISTORY` is `0`" in text -def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( +def test_trust_docs_name_history_non_goals_and_secret_reference_guidance( docs_dir: pathlib.Path, ) -> None: """Safety guidance does not present history suppression as secret transport.""" - text = (docs_dir / "topics" / "safety.md").read_text(encoding="utf-8") + text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8") for surface in ( "pane echo", @@ -392,7 +392,7 @@ def test_a17_changelog_summarizes_history_features( assert "{ref}`safety`" in environment_entry -def test_safety_annotation_table_matches_the_registered_surface( +def test_annotation_table_matches_the_registered_surface( docs_dir: pathlib.Path, ) -> None: """The hand-written hint table says what clients are actually told.""" @@ -412,18 +412,19 @@ def test_safety_annotation_table_matches_the_registered_surface( assert len(tools) > 1 hints = ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint") - tiers = ("readonly", "mutating", "destructive") + from libtmux_mcp._utils import VALID_TOOLSETS + expected = set() for tool in tools: annotations = tool.annotations assert annotations is not None, tool.name dumped = annotations.model_dump(mode="json", by_alias=True) cells = " | ".join(str(dumped[hint]).lower() for hint in hints) - tier = next(tier for tier in tiers if tier in tool.tags) + toolset = next(name for name in VALID_TOOLSETS if name in tool.tags) slug = tool.name.replace("_", "-") - expected.add(f"| {{toolref}}`{slug}` | {{badge}}`{tier}` | {cells} |") + expected.add(f"| {{toolref}}`{slug}` | {{badge}}`{toolset}` | {cells} |") - text = (docs_dir / "topics" / "safety.md").read_text(encoding="utf-8") + text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8") documented = set(re.findall(r"^\| \{toolref\}`.+\|$", text, flags=re.MULTILINE)) assert documented == expected diff --git a/tests/test_batch_tools.py b/tests/test_batch_tools.py index 61d2ffc7..e961b741 100644 --- a/tests/test_batch_tools.py +++ b/tests/test_batch_tools.py @@ -9,13 +9,15 @@ import pytest from libtmux_mcp._utils import ( - ANNOTATIONS_DESTRUCTIVE, - ANNOTATIONS_MUTATING, - ANNOTATIONS_RO, - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, + ANNOTATIONS_CHANGE, + ANNOTATIONS_DELETE, + ANNOTATIONS_OBSERVE, TAG_SELF_BOUNDED, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, + VALID_TOOLSETS, ) from tests.conftest import wire_annotations @@ -66,19 +68,11 @@ class BatchAnnotationFixture(t.NamedTuple): BATCH_ANNOTATION_FIXTURES: list[BatchAnnotationFixture] = [ BatchAnnotationFixture( - test_id="mutating_batch_warns_destructive_open_world", - tool_name="call_mutating_tools_batch", - read_only_hint=False, - destructive_hint=True, - idempotent_hint=False, - open_world_hint=True, - ), - BatchAnnotationFixture( - test_id="destructive_batch_warns_destructive_open_world", - tool_name="call_destructive_tools_batch", - read_only_hint=False, - destructive_hint=True, - idempotent_hint=False, + test_id="read_batch_carries_its_members_open_world", + tool_name="call_read_tools_batch", + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, open_world_hint=True, ), ] @@ -104,42 +98,44 @@ def _batch_probe_server() -> FastMCP: """Build a small FastMCP server with batch tools and tiered probes.""" from fastmcp import FastMCP - from libtmux_mcp.middleware import SafetyMiddleware, ToolErrorResultMiddleware + from libtmux_mcp.middleware import ToolErrorResultMiddleware, ToolsetMiddleware from libtmux_mcp.tools.batch_tools import register as register_batch_tools mcp = FastMCP( name="batch-probe", middleware=[ ToolErrorResultMiddleware(transform_errors=True), - SafetyMiddleware(max_tier=TAG_DESTRUCTIVE), + ToolsetMiddleware(set(VALID_TOOLSETS)), ], ) register_batch_tools(mcp) - @mcp.tool(title="Readonly Probe", annotations=ANNOTATIONS_RO, tags={TAG_READONLY}) + @mcp.tool( + title="Readonly Probe", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + ) def readonly_probe(value: str) -> dict[str, str]: return {"value": value} @mcp.tool( title="Mutating Probe", - annotations=ANNOTATIONS_MUTATING, - tags={TAG_MUTATING}, + annotations=ANNOTATIONS_CHANGE, + tags={TOOLSET_MANAGE}, ) def mutating_probe(value: str) -> dict[str, str]: return {"value": value} @mcp.tool( title="Destructive Probe", - annotations=ANNOTATIONS_DESTRUCTIVE, - tags={TAG_DESTRUCTIVE}, + annotations=ANNOTATIONS_DELETE, + tags={TOOLSET_TEARDOWN}, ) def destructive_probe(value: str) -> dict[str, str]: return {"value": value} @mcp.tool( title="Self Bounded Probe", - annotations=ANNOTATIONS_RO, - tags={TAG_READONLY, TAG_SELF_BOUNDED}, + annotations=ANNOTATIONS_OBSERVE, + tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED}, ) def self_bounded_probe(value: str) -> dict[str, str]: return {"value": value} @@ -154,7 +150,7 @@ def _self_bounded_batch_call(wrapper: str, on_error: str = "stop") -> t.Any: async def _call() -> t.Any: async with Client(_batch_probe_server()) as client: return await client.call_tool( - wrapper, + "call_read_tools_batch", { "on_error": on_error, "operations": [ @@ -174,16 +170,8 @@ async def _call() -> t.Any: return asyncio.run(_call()) -@pytest.mark.parametrize( - "wrapper", - [ - "call_readonly_tools_batch", - "call_mutating_tools_batch", - "call_destructive_tools_batch", - ], -) -def test_batch_rejects_self_bounded_tool_in_every_wrapper(wrapper: str) -> None: - """A ``TAG_SELF_BOUNDED`` tool is rejected by ALL three batch wrappers. +def test_batch_rejects_a_self_bounded_tool() -> None: + """A ``TAG_SELF_BOUNDED`` tool is rejected by the batch wrapper. ``max_tier`` is a *ceiling* (``_TIER_LEVELS[tool_tier] <= _TIER_LEVELS[max_tier]``), so a readonly tool is reachable through @@ -192,7 +180,7 @@ def test_batch_rejects_self_bounded_tool_in_every_wrapper(wrapper: str) -> None: a wait tool batched N times would cost N x its ceiling — the batch wrapper is a cap amplifier unless every wrapper rejects it. """ - result = _self_bounded_batch_call(wrapper) + result = _self_bounded_batch_call("call_read_tools_batch") assert result.structured_content["failed"] == 1 rows = result.structured_content["results"] @@ -209,7 +197,7 @@ def test_batch_self_bounded_rejection_preserves_continue_isolation() -> None: the request. The raise happens inside ``_call_one_tool``'s try block, so it becomes a ``success=False`` row instead. """ - result = _self_bounded_batch_call("call_readonly_tools_batch", on_error="continue") + result = _self_bounded_batch_call("call_read_tools_batch", on_error="continue") assert result.is_error is False assert result.structured_content["failed"] == 1 @@ -220,39 +208,29 @@ def test_batch_self_bounded_rejection_preserves_continue_isolation() -> None: def test_run_command_is_registered_self_bounded_and_unbatchable() -> None: - """``run_command`` carries ``TAG_SELF_BOUNDED`` on the real server. - - ``run_command`` clamps its ``timeout`` to the same wait ceiling as - the wait tools, so batching it amplifies that ceiling exactly the - same way. Assert against the real registration rather than a probe, - and drive ``_get_allowed_tool_tier`` at every wrapper's ``max_tier`` - because ``max_tier`` is a ceiling: a mutating tool is reachable - through the mutating and destructive wrappers both. + """``run_command`` enforces its own ceiling, so a batch cannot multiply it. + + Assert against the real registration rather than a probe: the tag is + what keeps the batch loop, which has no aggregate deadline, from + running it a thousand times. """ from fastmcp import FastMCP from libtmux_mcp._utils import ExpectedToolError from libtmux_mcp.models import ToolCallOperation from libtmux_mcp.tools import register_tools - from libtmux_mcp.tools.batch_tools import _get_allowed_tool_tier + from libtmux_mcp.tools.batch_tools import _check_operation_allowed mcp = FastMCP(name="run-command-self-bounded-audit") register_tools(mcp) tool = asyncio.run(mcp.get_tool("run_command")) assert tool is not None - assert TAG_MUTATING in tool.tags + assert TOOLSET_EXECUTE in tool.tags assert TAG_SELF_BOUNDED in tool.tags operation = ToolCallOperation(tool="run_command", arguments={}) - for max_tier in (TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE): - with pytest.raises(ExpectedToolError, match="cannot be batched"): - asyncio.run( - _get_allowed_tool_tier( - fastmcp=mcp, - operation=operation, - max_tier=max_tier, - ) - ) + with pytest.raises(ExpectedToolError, match="cannot be batched"): + asyncio.run(_check_operation_allowed(fastmcp=mcp, operation=operation)) def test_call_readonly_tools_batch_preserves_structured_results() -> None: @@ -262,7 +240,7 @@ def test_call_readonly_tools_batch_preserves_structured_results() -> None: async def _call() -> t.Any: async with Client(_batch_probe_server()) as client: return await client.call_tool( - "call_readonly_tools_batch", + "call_read_tools_batch", { "operations": [ { @@ -329,7 +307,7 @@ def test_call_readonly_tools_batch_caps_aggregate_response( async def _call() -> t.Any: async with Client(_batch_probe_server()) as client: return await client.call_tool( - "call_readonly_tools_batch", + "call_read_tools_batch", { "operations": [ { @@ -403,7 +381,7 @@ def test_call_readonly_tools_batch_rejects_oversized_operation_count( async def _call() -> t.Any: async with Client(_batch_probe_server()) as client: return await client.call_tool( - "call_readonly_tools_batch", + "call_read_tools_batch", { "operations": [ { @@ -430,97 +408,27 @@ async def _call() -> t.Any: assert "operations must contain at most" in serialized -def test_call_readonly_tools_batch_rejects_mutating_inner_tool() -> None: - """Readonly batching does not tunnel a mutating tool call.""" - from fastmcp import Client - - async def _call() -> t.Any: - async with Client(_batch_probe_server()) as client: - return await client.call_tool( - "call_readonly_tools_batch", - { - "operations": [ - { - "tool": "mutating_probe", - "arguments": {"value": "changed"}, - } - ], - }, - raise_on_error=False, - ) - - result = asyncio.run(_call()) - - assert result.is_error is False - assert result.structured_content["succeeded"] == 0 - assert result.structured_content["failed"] == 1 - assert result.structured_content["stopped_at"] == 0 - [operation] = result.structured_content["results"] - assert operation["success"] is False - assert "exceeds batch tier readonly" in operation["error"] - - -def test_call_mutating_tools_batch_rejects_destructive_inner_tool() -> None: - """Mutating batching does not tunnel a destructive tool call.""" - from fastmcp import Client - - async def _call() -> t.Any: - async with Client(_batch_probe_server()) as client: - return await client.call_tool( - "call_mutating_tools_batch", - { - "operations": [ - { - "tool": "destructive_probe", - "arguments": {"value": "destroy"}, - } - ], - }, - raise_on_error=False, - ) - - result = asyncio.run(_call()) - - assert result.is_error is False - [operation] = result.structured_content["results"] - assert operation["success"] is False - assert "exceeds batch tier mutating" in operation["error"] - +def test_the_read_batch_rejects_a_tool_outside_inspect() -> None: + """A batch that could carry a write would launder it past client policy. -def test_call_mutating_tools_batch_continues_after_error() -> None: - """Continue mode attempts later operations after a failed tool call.""" + The wrapper aggregates authority under its own name, so a rule keyed + on a nested tool's name never fires. Keeping the batch to `inspect` + is what stops that mattering. + """ from fastmcp import Client async def _call() -> t.Any: async with Client(_batch_probe_server()) as client: return await client.call_tool( - "call_mutating_tools_batch", - { - "on_error": "continue", - "operations": [ - { - "tool": "missing_probe", - "arguments": {}, - }, - { - "tool": "mutating_probe", - "arguments": {"value": "kept-going"}, - }, - ], - }, + "call_read_tools_batch", + {"operations": [{"tool": "mutating_probe", "arguments": {}}]}, raise_on_error=False, ) - result = asyncio.run(_call()) + payload = asyncio.run(_call()).structured_content - assert result.is_error is False - assert result.structured_content["succeeded"] == 1 - assert result.structured_content["failed"] == 1 - assert result.structured_content["stopped_at"] is None - first, second = result.structured_content["results"] - assert first["success"] is False - assert second["success"] is True - assert second["structured_content"] == {"value": "kept-going"} + assert payload["succeeded"] == 0 + assert "not an 'inspect' tool" in payload["results"][0]["error"] def test_call_tools_batch_rejects_self_invocation() -> None: @@ -530,11 +438,11 @@ def test_call_tools_batch_rejects_self_invocation() -> None: async def _call() -> t.Any: async with Client(_batch_probe_server()) as client: return await client.call_tool( - "call_destructive_tools_batch", + "call_read_tools_batch", { "operations": [ { - "tool": "call_destructive_tools_batch", + "tool": "call_read_tools_batch", "arguments": {"operations": []}, } ], diff --git a/tests/test_history.py b/tests/test_history.py index ea73cdc6..04d27d30 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -433,7 +433,7 @@ async def main(): "openWorldHint": True, "readOnlyHint": False, } - assert sorted(payload["tags"]) == ["mutating", "self-bounded"] + assert sorted(payload["tags"]) == ["execute", "self-bounded"] assert payload["raw_defaults"] == { "send_keys": False, "send_keys_batch": False, @@ -589,7 +589,7 @@ async def _exercise() -> None: assert output.read_text() == f"{marker}\n" batch = await client.call_tool( - "call_mutating_tools_batch", + "call_read_tools_batch", { "operations": [ { diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 495e1532..2f42dbd9 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -14,124 +14,101 @@ from libtmux import exc as libtmux_exc from mcp.types import CallToolRequestParams -from libtmux_mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY +from libtmux_mcp._utils import ( + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, +) from libtmux_mcp.middleware import ( AuditMiddleware, - ReadonlyRetryMiddleware, - SafetyMiddleware, + InspectRetryMiddleware, + ToolsetMiddleware, _client_label, _redact_digest, _summarize_args, ) -class SafetyAllowedFixture(t.NamedTuple): - """Test fixture for SafetyMiddleware._is_allowed.""" +class ToolsetEnabledFixture(t.NamedTuple): + """One membership decision for :class:`ToolsetMiddleware`.""" test_id: str - max_tier: str + toolsets: set[str] + tool_name: str tool_tags: set[str] - expected_allowed: bool + expected_enabled: bool -SAFETY_ALLOWED_FIXTURES: list[SafetyAllowedFixture] = [ - # readonly tier: only readonly tools allowed - SafetyAllowedFixture( - test_id="readonly_allows_readonly", - max_tier=TAG_READONLY, - tool_tags={TAG_READONLY}, - expected_allowed=True, - ), - SafetyAllowedFixture( - test_id="readonly_blocks_mutating", - max_tier=TAG_READONLY, - tool_tags={TAG_MUTATING}, - expected_allowed=False, - ), - SafetyAllowedFixture( - test_id="readonly_blocks_destructive", - max_tier=TAG_READONLY, - tool_tags={TAG_DESTRUCTIVE}, - expected_allowed=False, - ), - # mutating tier: readonly + mutating allowed - SafetyAllowedFixture( - test_id="mutating_allows_readonly", - max_tier=TAG_MUTATING, - tool_tags={TAG_READONLY}, - expected_allowed=True, +TOOLSET_ENABLED_FIXTURES: list[ToolsetEnabledFixture] = [ + ToolsetEnabledFixture( + test_id="tool_in_an_enabled_toolset", + toolsets={TOOLSET_INSPECT}, + tool_name="capture_pane", + tool_tags={TOOLSET_INSPECT}, + expected_enabled=True, ), - SafetyAllowedFixture( - test_id="mutating_allows_mutating", - max_tier=TAG_MUTATING, - tool_tags={TAG_MUTATING}, - expected_allowed=True, + ToolsetEnabledFixture( + test_id="tool_outside_the_enabled_toolsets", + toolsets={TOOLSET_INSPECT}, + tool_name="send_keys", + tool_tags={TOOLSET_EXECUTE}, + expected_enabled=False, ), - SafetyAllowedFixture( - test_id="mutating_blocks_destructive", - max_tier=TAG_MUTATING, - tool_tags={TAG_DESTRUCTIVE}, - expected_allowed=False, + # An ordered ladder could not express this: teardown without execute. + ToolsetEnabledFixture( + test_id="teardown_without_execute", + toolsets={TOOLSET_INSPECT, TOOLSET_TEARDOWN}, + tool_name="kill_pane", + tool_tags={TOOLSET_TEARDOWN}, + expected_enabled=True, ), - # destructive tier: all allowed - SafetyAllowedFixture( - test_id="destructive_allows_readonly", - max_tier=TAG_DESTRUCTIVE, - tool_tags={TAG_READONLY}, - expected_allowed=True, + ToolsetEnabledFixture( + test_id="execute_stays_out_of_that_surface", + toolsets={TOOLSET_INSPECT, TOOLSET_TEARDOWN}, + tool_name="run_command", + tool_tags={TOOLSET_EXECUTE}, + expected_enabled=False, ), - SafetyAllowedFixture( - test_id="destructive_allows_mutating", - max_tier=TAG_DESTRUCTIVE, - tool_tags={TAG_MUTATING}, - expected_allowed=True, - ), - SafetyAllowedFixture( - test_id="destructive_allows_destructive", - max_tier=TAG_DESTRUCTIVE, - tool_tags={TAG_DESTRUCTIVE}, - expected_allowed=True, - ), - # untagged tools are denied (fail-closed) - SafetyAllowedFixture( - test_id="untagged_denied_at_readonly", - max_tier=TAG_READONLY, + ToolsetEnabledFixture( + test_id="untagged_tool_is_refused", + toolsets={TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_EXECUTE}, + tool_name="mystery", tool_tags=set(), - expected_allowed=False, + expected_enabled=False, ), ] @pytest.mark.parametrize( - SafetyAllowedFixture._fields, - SAFETY_ALLOWED_FIXTURES, - ids=[f.test_id for f in SAFETY_ALLOWED_FIXTURES], + ToolsetEnabledFixture._fields, + TOOLSET_ENABLED_FIXTURES, + ids=[f.test_id for f in TOOLSET_ENABLED_FIXTURES], ) -def test_safety_middleware_is_allowed( +def test_toolset_middleware_membership( test_id: str, - max_tier: str, + toolsets: set[str], + tool_name: str, tool_tags: set[str], - expected_allowed: bool, + expected_enabled: bool, ) -> None: - """SafetyMiddleware._is_allowed gates tools by tier.""" - mw = SafetyMiddleware(max_tier=max_tier) - assert mw._is_allowed(tool_tags) is expected_allowed + """A tool is advertised when one of its toolsets is enabled.""" + mw = ToolsetMiddleware(toolsets) + assert mw._is_enabled(tool_name, tool_tags) is expected_enabled -def test_safety_middleware_default_tier() -> None: - """SafetyMiddleware defaults to mutating tier.""" - mw = SafetyMiddleware() - assert mw._is_allowed({TAG_READONLY}) is True - assert mw._is_allowed({TAG_MUTATING}) is True - assert mw._is_allowed({TAG_DESTRUCTIVE}) is False +def test_named_tools_join_and_exclusions_win() -> None: + """`LIBTMUX_TOOLS` adds by name; `LIBTMUX_EXCLUDE_TOOLS` beats it.""" + mw = ToolsetMiddleware( + {TOOLSET_INSPECT}, + tools={"send_keys"}, + exclude_tools={"capture_pane", "send_keys"}, + ) -def test_safety_middleware_invalid_tier_falls_back() -> None: - """SafetyMiddleware falls back to readonly for unknown tiers.""" - mw = SafetyMiddleware(max_tier="nonexistent") - assert mw._is_allowed({TAG_READONLY}) is True - assert mw._is_allowed({TAG_MUTATING}) is False - assert mw._is_allowed({TAG_DESTRUCTIVE}) is False + assert mw._is_enabled("send_keys", {TOOLSET_EXECUTE}) is False + assert mw._is_enabled("capture_pane", {TOOLSET_INSPECT}) is False + assert mw._is_enabled("list_panes", {TOOLSET_INSPECT}) is True # --------------------------------------------------------------------------- @@ -644,7 +621,7 @@ def test_server_middleware_stack_order() -> None: security observability without an obvious test failure, so pin the sequence explicitly. - ReadonlyRetryMiddleware sits between Audit and Safety so retried + InspectRetryMiddleware sits between Audit and Safety so retried calls are audited once each (Audit wraps the retry loop) and tier-denied tools never reach retry (Safety stops them first). """ @@ -652,10 +629,10 @@ def test_server_middleware_stack_order() -> None: from libtmux_mcp.middleware import ( AuditMiddleware, - ReadonlyRetryMiddleware, - SafetyMiddleware, + InspectRetryMiddleware, TailPreservingResponseLimitingMiddleware, ToolErrorResultMiddleware, + ToolsetMiddleware, ) from libtmux_mcp.server import mcp @@ -668,8 +645,8 @@ def test_server_middleware_stack_order() -> None: TailPreservingResponseLimitingMiddleware, ToolErrorResultMiddleware, AuditMiddleware, - ReadonlyRetryMiddleware, - SafetyMiddleware, + InspectRetryMiddleware, + ToolsetMiddleware, ] @@ -737,7 +714,7 @@ async def _safety_denial(_ctx: t.Any) -> None: # --------------------------------------------------------------------------- -# ReadonlyRetryMiddleware tests +# InspectRetryMiddleware tests # --------------------------------------------------------------------------- @@ -801,8 +778,8 @@ def test_readonly_retry_recovers_from_libtmux_exception() -> None: """ from libtmux import exc as libtmux_exc - middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TAG_READONLY}) + middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TOOLSET_INSPECT}) call_next = _FlakyCallNext( raises_n_times=1, exception=libtmux_exc.LibTmuxException("transient socket error"), @@ -824,8 +801,8 @@ def test_readonly_retry_skips_mutating_tool() -> None: """ from libtmux import exc as libtmux_exc - middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TAG_MUTATING}) + middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TOOLSET_MANAGE}) call_next = _FlakyCallNext( raises_n_times=1, exception=libtmux_exc.LibTmuxException("transient socket error"), @@ -853,8 +830,8 @@ def test_readonly_retry_skips_self_bounded_tool() -> None: from libtmux_mcp._utils import TAG_SELF_BOUNDED - middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TAG_READONLY, TAG_SELF_BOUNDED}) + middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED}) call_next = _FlakyCallNext( raises_n_times=1, exception=libtmux_exc.LibTmuxException("transient socket error"), @@ -874,8 +851,8 @@ def test_readonly_retry_skips_non_libtmux_exception() -> None: programming error, not a transient socket hiccup, and retrying it would just delay the real failure. """ - middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TAG_READONLY}) + middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TOOLSET_INSPECT}) call_next = _FlakyCallNext( raises_n_times=1, exception=ValueError("bad caller input"), @@ -929,8 +906,8 @@ def _flaky_sessions(_self: Server) -> list[t.Any]: monkeypatch.setattr(Server, "sessions", property(_flaky_sessions)) - middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TAG_READONLY}) + middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TOOLSET_INSPECT}) async def real_call_next(_context: t.Any) -> t.Any: # ``list_sessions`` is sync + ``@handle_tool_errors`` decorated. @@ -970,8 +947,8 @@ def test_readonly_retry_skips_deterministic_failures(raised: Exception) -> None: not become unambiguous. Retrying buys a second tmux round-trip and 100 ms of latency in order to fail identically. """ - middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TAG_READONLY}) + middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TOOLSET_INSPECT}) call_next = _FlakyCallNext(raises_n_times=1, exception=raised) with pytest.raises(libtmux_exc.LibTmuxException): @@ -1017,8 +994,8 @@ def _missing_sessions(_self: Server) -> list[t.Any]: monkeypatch.setattr(Server, "sessions", property(_missing_sessions)) - middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TAG_READONLY}) + middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) + ctx = _retry_context(tags={TOOLSET_INSPECT}) async def real_call_next(_context: t.Any) -> t.Any: return list_sessions(socket_name="retry-skip-smoke") @@ -1042,7 +1019,7 @@ def test_readonly_retry_logger_uses_project_namespace() -> None: explicit override, retry warnings would silently bypass any project-namespace audit-stream routing. """ - middleware = ReadonlyRetryMiddleware() + middleware = InspectRetryMiddleware() assert middleware._retry.logger.name == "libtmux_mcp.retry" @@ -1621,28 +1598,29 @@ def _probe_fn(count: int) -> int: assert "some clients (e.g. Gemini CLI)" in meta["suggestion"] -def test_wait_for_text_is_registered_self_bounded_and_still_readonly() -> None: - """``wait_for_text`` carries both tags, and the extra tag is inert. +def test_wait_for_text_carries_a_marker_tag_that_does_not_gate_it() -> None: + """``wait_for_text`` is in ``inspect``; its extra marker tag is inert. - ``SafetyMiddleware._is_allowed`` inspects only the three tier tags, - so an additional marker tag must not change tier visibility at any - safety level. + ``ToolsetMiddleware`` intersects a tool's tags with the toolsets, so + a marker tag that names no toolset cannot change what is advertised. """ from fastmcp import FastMCP from libtmux_mcp._utils import TAG_SELF_BOUNDED - from libtmux_mcp.middleware import SafetyMiddleware + from libtmux_mcp.middleware import ToolsetMiddleware from libtmux_mcp.tools import register_tools mcp = FastMCP(name="self-bounded-audit") register_tools(mcp) tool = asyncio.run(mcp.get_tool("wait_for_text")) assert tool is not None - assert TAG_READONLY in tool.tags + assert TOOLSET_INSPECT in tool.tags assert TAG_SELF_BOUNDED in tool.tags - for tier in (TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE): - assert SafetyMiddleware(max_tier=tier)._is_allowed(set(tool.tags)) is True + enabled = ToolsetMiddleware({TOOLSET_INSPECT}) + assert enabled._is_enabled("wait_for_text", set(tool.tags)) is True + without = ToolsetMiddleware({TOOLSET_MANAGE, TOOLSET_TEARDOWN}) + assert without._is_enabled("wait_for_text", set(tool.tags)) is False class ClientLabelFieldFixture(t.NamedTuple): diff --git a/tests/test_server.py b/tests/test_server.py index 43218da3..888d684e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import os import subprocess import sys @@ -11,8 +10,12 @@ import pytest -from libtmux_mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY -from libtmux_mcp.server import _BASE_INSTRUCTIONS, _build_instructions +from libtmux_mcp._utils import TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN +from libtmux_mcp.server import ( + _BASE_INSTRUCTIONS, + DEFAULT_TOOLSETS, + _build_instructions, +) if t.TYPE_CHECKING: from libtmux.server import Server @@ -24,188 +27,137 @@ class BuildInstructionsFixture(t.NamedTuple): """Test fixture for _build_instructions.""" test_id: str - safety_level: str + toolsets: frozenset[str] tmux_pane_env: str | None tmux_env: str | None expect_agent_context: bool expect_pane_id_in_text: str | None expect_socket_name: str | None - expect_safety_in_text: str | None + expect_toolsets_in_text: str BUILD_INSTRUCTIONS_FIXTURES: list[BuildInstructionsFixture] = [ BuildInstructionsFixture( test_id="inside_tmux_full_context", - safety_level=TAG_MUTATING, + toolsets=DEFAULT_TOOLSETS, tmux_pane_env="%42", tmux_env="/tmp/tmux-1000/default,12345,0", expect_agent_context=True, expect_pane_id_in_text="%42", expect_socket_name="default", - expect_safety_in_text="mutating", + expect_toolsets_in_text="execute, inspect, manage", ), BuildInstructionsFixture( test_id="outside_tmux_no_context", - safety_level=TAG_MUTATING, + toolsets=DEFAULT_TOOLSETS, tmux_pane_env=None, tmux_env=None, expect_agent_context=False, expect_pane_id_in_text=None, expect_socket_name=None, - expect_safety_in_text="mutating", + expect_toolsets_in_text="execute, inspect, manage", ), BuildInstructionsFixture( test_id="pane_only_no_tmux_env", - safety_level=TAG_MUTATING, + toolsets=DEFAULT_TOOLSETS, tmux_pane_env="%99", tmux_env=None, expect_agent_context=True, expect_pane_id_in_text="%99", expect_socket_name=None, - expect_safety_in_text="mutating", + expect_toolsets_in_text="execute, inspect, manage", ), BuildInstructionsFixture( - test_id="readonly_safety_level", - safety_level=TAG_READONLY, + test_id="inspect_only", + toolsets=frozenset({TOOLSET_INSPECT}), tmux_pane_env=None, tmux_env=None, expect_agent_context=False, expect_pane_id_in_text=None, expect_socket_name=None, - expect_safety_in_text="readonly", + expect_toolsets_in_text="inspect", ), + # The ladder could not express this: deletion without the typing tools. BuildInstructionsFixture( - test_id="destructive_safety_level", - safety_level=TAG_DESTRUCTIVE, + test_id="inspect_and_teardown", + toolsets=frozenset({TOOLSET_INSPECT, TOOLSET_TEARDOWN}), tmux_pane_env=None, tmux_env=None, expect_agent_context=False, expect_pane_id_in_text=None, expect_socket_name=None, - expect_safety_in_text="destructive", + expect_toolsets_in_text="inspect, teardown", ), ] -class SafetyLevelFixture(t.NamedTuple): - """Test fixture for server safety-level resolution.""" +class ToolsetsFixture(t.NamedTuple): + """One ``LIBTMUX_TOOLSETS`` value and the surface it selects.""" test_id: str env_value: str | None - expected_level: str + expected: frozenset[str] -SAFETY_LEVEL_FIXTURES: list[SafetyLevelFixture] = [ - SafetyLevelFixture("unset_defaults_mutating", None, TAG_MUTATING), - SafetyLevelFixture("valid_readonly", TAG_READONLY, TAG_READONLY), - SafetyLevelFixture("valid_mutating", TAG_MUTATING, TAG_MUTATING), - SafetyLevelFixture("valid_destructive", TAG_DESTRUCTIVE, TAG_DESTRUCTIVE), - SafetyLevelFixture("invalid_fails_closed", "read", TAG_READONLY), +TOOLSETS_FIXTURES: list[ToolsetsFixture] = [ + ToolsetsFixture("unset_takes_the_default", None, DEFAULT_TOOLSETS), + ToolsetsFixture("single", "inspect", frozenset({"inspect"})), + ToolsetsFixture( + "teardown_without_execute", + "inspect,teardown", + frozenset({"inspect", "teardown"}), + ), + ToolsetsFixture( + "whitespace_tolerated", " inspect , manage ", frozenset({"inspect", "manage"}) + ), + ToolsetsFixture("empty_is_legal", "", frozenset()), ] @pytest.mark.parametrize( - BuildInstructionsFixture._fields, - BUILD_INSTRUCTIONS_FIXTURES, - ids=[f.test_id for f in BUILD_INSTRUCTIONS_FIXTURES], + ToolsetsFixture._fields, + TOOLSETS_FIXTURES, + ids=[f.test_id for f in TOOLSETS_FIXTURES], ) -def test_build_instructions( - monkeypatch: pytest.MonkeyPatch, +def test_resolve_toolsets( test_id: str, - safety_level: str, - tmux_pane_env: str | None, - tmux_env: str | None, - expect_agent_context: bool, - expect_pane_id_in_text: str | None, - expect_socket_name: str | None, - expect_safety_in_text: str | None, + env_value: str | None, + expected: frozenset[str], ) -> None: - """_build_instructions includes agent context and safety level.""" - if tmux_pane_env is not None: - monkeypatch.setenv("TMUX_PANE", tmux_pane_env) - else: - monkeypatch.delenv("TMUX_PANE", raising=False) - - if tmux_env is not None: - monkeypatch.setenv("TMUX", tmux_env) - else: - monkeypatch.delenv("TMUX", raising=False) - - result = _build_instructions(safety_level=safety_level) - - # Base instructions are always present - assert _BASE_INSTRUCTIONS in result - - if expect_agent_context: - assert "Agent context" in result - else: - assert "Agent context" not in result + """``LIBTMUX_TOOLSETS`` selects an unordered surface.""" + from libtmux_mcp.server import _resolve_toolsets - if expect_pane_id_in_text is not None: - assert expect_pane_id_in_text in result - - if expect_socket_name is not None: - assert expect_socket_name in result + assert test_id + assert _resolve_toolsets(env_value) == expected - if expect_safety_in_text is not None: - assert f"Safety level: {expect_safety_in_text}" in result +def test_an_unknown_toolset_fails_startup() -> None: + """A typo must not silently widen or narrow the surface.""" + from libtmux_mcp.server import _resolve_toolsets -@pytest.mark.parametrize( - SafetyLevelFixture._fields, - SAFETY_LEVEL_FIXTURES, - ids=[f.test_id for f in SAFETY_LEVEL_FIXTURES], -) -def test_resolve_safety_level( - test_id: str, - env_value: str | None, - expected_level: str, -) -> None: - """Safety env values resolve to the server's effective tier.""" - from libtmux_mcp.server import _resolve_safety_level + with pytest.raises(RuntimeError, match="unknown toolsets: bogus"): + _resolve_toolsets("inspect,bogus") - assert test_id - assert _resolve_safety_level(env_value) == expected_level - -def test_invalid_safety_env_hides_mutating_tools() -> None: - """Invalid ``LIBTMUX_SAFETY`` values expose readonly tools only.""" +def test_the_retired_safety_variable_fails_startup() -> None: + """`LIBTMUX_SAFETY` is gone; ignoring it could widen a surface.""" code = textwrap.dedent( """ - import asyncio - import json - - from libtmux_mcp.server import build_mcp_server - - async def main(): - tools = await build_mcp_server().list_tools() - names = {tool.name for tool in tools} - print(json.dumps({ - "list_sessions": "list_sessions" in names, - "send_keys": "send_keys" in names, - "send_keys_batch": "send_keys_batch" in names, - "kill_pane": "kill_pane" in names, - })) - - asyncio.run(main()) + import libtmux_mcp.server """ ) - env = {**os.environ, "LIBTMUX_SAFETY": "read"} + env = {**os.environ, "LIBTMUX_SAFETY": "destructive"} proc = subprocess.run( [sys.executable, "-c", code], - check=True, + check=False, capture_output=True, - env=env, text=True, + env=env, ) - result = json.loads(proc.stdout) - assert result == { - "list_sessions": True, - "send_keys": False, - "send_keys_batch": False, - "kill_pane": False, - } + assert proc.returncode != 0 + assert "LIBTMUX_SAFETY has been removed" in proc.stderr + assert "LIBTMUX_TOOLSETS" in proc.stderr def test_run_server_pins_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: @@ -385,24 +337,30 @@ def test_build_instructions_documents_is_caller_workflow_inside_tmux( # Outside tmux: the workflow sentence must NOT appear. monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) - outside = _build_instructions(safety_level=TAG_MUTATING) + outside = _build_instructions(toolsets=frozenset({TOOLSET_MANAGE})) assert "whoami tool" not in outside assert "is_caller=true" not in outside # Inside tmux: the workflow sentence appears. monkeypatch.setenv("TMUX_PANE", "%42") monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,12345,0") - inside = _build_instructions(safety_level=TAG_MUTATING) + inside = _build_instructions(toolsets=frozenset({TOOLSET_MANAGE})) assert "is_caller=true" in inside assert "whoami tool" in inside assert "list_panes" in inside -def test_build_instructions_always_includes_safety() -> None: - """_build_instructions always includes the safety level.""" - result = _build_instructions(safety_level=TAG_MUTATING) - assert "Safety level:" in result - assert "LIBTMUX_SAFETY" in result +def test_build_instructions_always_names_the_toolsets() -> None: + """Instructions name the enabled toolsets and how to change them. + + They also say what hiding one does not do, because a model reading a + short list is otherwise free to infer that the rest is unreachable. + """ + result = _build_instructions(toolsets=frozenset({TOOLSET_MANAGE})) + + assert "Toolsets: manage" in result + assert "LIBTMUX_TOOLSETS" in result + assert "not what a pane can run" in result @pytest.mark.parametrize( @@ -441,12 +399,12 @@ def test_build_instructions_defaults_semantic_history_suppression_on() -> None: @pytest.mark.parametrize( ("tier", "tmux_pane", "tmux_env"), [ - (TAG_READONLY, "%42", "/tmp/tmux-1000/default,12345,0"), - (TAG_MUTATING, "%42", "/tmp/tmux-1000/default,12345,0"), - (TAG_DESTRUCTIVE, "%42", "/tmp/tmux-1000/default,12345,0"), - (TAG_READONLY, "", ""), - (TAG_MUTATING, "", ""), - (TAG_DESTRUCTIVE, "", ""), + (TOOLSET_INSPECT, "%42", "/tmp/tmux-1000/default,12345,0"), + (TOOLSET_MANAGE, "%42", "/tmp/tmux-1000/default,12345,0"), + (TOOLSET_TEARDOWN, "%42", "/tmp/tmux-1000/default,12345,0"), + (TOOLSET_INSPECT, "", ""), + (TOOLSET_MANAGE, "", ""), + (TOOLSET_TEARDOWN, "", ""), # Variable-length stress: longer socket name + multi-digit pane id. # Guards against future text additions tipping a realistic case # over the 2KB budget. Exercises BOTH axes — a multi-digit pane id @@ -455,7 +413,7 @@ def test_build_instructions_defaults_semantic_history_suppression_on() -> None: # further or fall back to a tighter compression form (drop spaces # around ``/`` in HOOKS, drop spaces after colons in the safety # paragraph) for additional bytes of margin. - (TAG_READONLY, "%99", "/tmp/tmux-1000/dev-prod,12345,0"), + (TOOLSET_INSPECT, "%99", "/tmp/tmux-1000/dev-prod,12345,0"), ], ) def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( @@ -488,7 +446,7 @@ def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( monkeypatch.delenv("TMUX", raising=False) instructions = _build_instructions( - safety_level=tier, + toolsets=frozenset({tier}), suppress_history=suppress_history, ) size = len(instructions.encode()) @@ -512,7 +470,7 @@ def test_instruction_budget_drops_oversized_socket_before_required_text( monkeypatch.setenv("TMUX", f"/tmp/tmux-1000/{socket_name},12345,0") instructions = _build_instructions( - safety_level=TAG_READONLY, + toolsets=frozenset({TOOLSET_INSPECT}), suppress_history=True, ) @@ -536,7 +494,7 @@ def test_instruction_budget_can_drop_all_oversized_optional_context( monkeypatch.setenv("TMUX", f"/tmp/tmux-1000/{'s' * 4096},12345,0") instructions = _build_instructions( - safety_level=TAG_READONLY, + toolsets=frozenset({TOOLSET_INSPECT}), suppress_history=False, ) @@ -591,8 +549,8 @@ def test_scope_segment_carries_anti_triggers() -> None: assert "clarifying question" in _INSTR_SCOPE -@pytest.mark.parametrize("tier", [TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE]) -def test_readonly_hint_visible_only_on_readonly_tier( +@pytest.mark.parametrize("tier", [TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN]) +def test_probe_hint_visible_only_on_an_inspect_only_surface( monkeypatch: pytest.MonkeyPatch, tier: str ) -> None: """The 'Readonly mode:' investigation hint appears only on readonly. @@ -604,11 +562,11 @@ def test_readonly_hint_visible_only_on_readonly_tier( """ monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) - instructions = _build_instructions(safety_level=tier) - if tier == TAG_READONLY: - assert "Readonly mode:" in instructions + instructions = _build_instructions(toolsets=frozenset({tier})) + if tier == TOOLSET_INSPECT: + assert "Probe snapshot_pane" in instructions else: - assert "Readonly mode:" not in instructions + assert "Probe snapshot_pane" not in instructions # --------------------------------------------------------------------------- diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py index b6387bf5..3065541e 100644 --- a/tests/test_spawn_tools_history.py +++ b/tests/test_spawn_tools_history.py @@ -370,65 +370,46 @@ def _spawn_history_server(enabled: bool) -> FastMCP: return mcp -def test_generic_batch_spawn_ignores_command_default_unless_opted_in( +def test_a_spawn_cannot_be_laundered_through_the_read_batch( mcp_server: Server, mcp_session: Session, ) -> None: - """Nested spawn calls retain their explicit persistent-history option.""" - supplied = "private-nested-history-path" - before = {window.window_id for window in mcp_session.windows} - base = { - "session_name": mcp_session.session_name, - "environment": {"HISTFILE": supplied}, - "socket_name": mcp_server.socket_name, - } + """The batch refuses a spawn instead of running it under its own name. + + A batch gives every nested call the wrapper's name, so a client rule + keyed on ``create_window`` would not fire. Keeping the wrapper to + ``inspect`` is what makes that unreachable. + """ + from fastmcp import Client async def _exercise() -> t.Any: async with Client(_spawn_history_server(True)) as client: return await client.call_tool( - "call_mutating_tools_batch", + "call_read_tools_batch", { - "on_error": "continue", "operations": [ { "tool": "create_window", "arguments": { - **base, - "window_name": "batch_spawn_omitted", + "session_name": mcp_session.session_name, + "socket_name": mcp_server.socket_name, + "window_name": "batch_spawn_refused", }, - }, - { - "tool": "create_window", - "arguments": { - **base, - "window_name": "batch_spawn_explicit_true_conflict", - "suppress_persistent_history": True, - }, - }, - ], + } + ] }, raise_on_error=False, ) + before = {window.window_id for window in mcp_session.windows} result = asyncio.run(_exercise()) - assert result.is_error is False assert result.structured_content is not None - assert result.structured_content["succeeded"] == 1 - assert result.structured_content["failed"] == 1 - assert result.structured_content["stopped_at"] is None - succeeded, failed = result.structured_content["results"] - assert succeeded["success"] is True - assert succeeded["structured_content"]["window_name"] == "batch_spawn_omitted" - assert failed["success"] is False - assert failed["error"] == ( - "environment variable HISTFILE conflicts with " - "suppress_persistent_history=True; " - "omit it or set it to an empty string" - ) - assert supplied not in failed["error"] - created = {window.window_id for window in mcp_session.windows} - before - assert len(created) == 1 + assert result.structured_content["succeeded"] == 0 + [operation] = result.structured_content["results"] + assert "not an 'inspect' tool" in operation["error"] + mcp_session.refresh() + assert {window.window_id for window in mcp_session.windows} == before def test_spawn_conflict_is_absent_from_tool_results_and_logs( diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index 6a2ec138..e9d8e1a1 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -186,7 +186,7 @@ def test_the_read_batch_carries_its_members_open_world_hint( advertised_tools: dict[str, t.Any], ) -> None: """A batch advertises the worst case of what it can invoke.""" - batch = advertised_tools["call_readonly_tools_batch"] + batch = advertised_tools["call_read_tools_batch"] assert wire_annotations(batch)["openWorldHint"] is True diff --git a/tests/test_utils.py b/tests/test_utils.py index 43da1eec..58f81079 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,10 +10,11 @@ from libtmux import exc from libtmux_mcp._utils import ( - TAG_DESTRUCTIVE, - TAG_MUTATING, - TAG_READONLY, - VALID_SAFETY_LEVELS, + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, + VALID_TOOLSETS, _apply_filters, _get_server, _invalidate_server, @@ -636,13 +637,20 @@ def test_serialize_pane_is_caller_requires_tmux_env_not_just_pane( def test_tag_constants() -> None: """Safety tier tag constants are distinct strings.""" - tags = {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE} + tags = {TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN} assert len(tags) == 3 -def test_valid_safety_levels_matches_tags() -> None: - """VALID_SAFETY_LEVELS contains all tag constants.""" - assert {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE} == VALID_SAFETY_LEVELS +def test_valid_toolsets_lists_every_toolset() -> None: + """The advertised set is exactly the four toolset constants.""" + assert set(VALID_TOOLSETS) == { + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_EXECUTE, + TOOLSET_TEARDOWN, + } + # Order is reported at startup, so it is part of the contract. + assert VALID_TOOLSETS[0] == TOOLSET_INSPECT # --------------------------------------------------------------------------- From 66f0d1d5bd3963e272638dfefacf067ca5bf5e0c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:03:43 -0500 Subject: [PATCH 22/44] mcp(feat[toolsets]): Gate the old words out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The sweep was done by reading, so it missed 39 files — the README inventory, the contributor guide, thirty tool pages saying "Readonly." under Side effects. A word this easy to reintroduce by habit needs a gate, not a careful reviewer. what: - Add a test asserting no source or page names the retired tiers, with the four MCP hint fields excluded because those are the protocol's vocabulary and stay - Exempt only what must name what it replaced: `CHANGES`, `MIGRATION`, the startup error refusing `LIBTMUX_SAFETY`, the test proving it fires, and the docs redirect - Fix everything it found: README, AGENTS, the per-tool side-effect lines, the batch index, the glossary term, the badge demo, and the test names and docstrings that still described a ladder The gate found more than the sweep did, which is the argument for it. --- AGENTS.md | 8 ++- README.md | 13 +++-- docs/demo.md | 6 +- docs/tools/batch/call-read-tools-batch.md | 9 +-- docs/tools/batch/index.md | 10 +--- docs/tools/buffer/show-buffer.md | 2 +- docs/tools/hook/index.md | 2 +- docs/tools/hook/show-hook.md | 2 +- docs/tools/hook/show-hooks.md | 2 +- docs/tools/index.md | 2 +- docs/tools/pane/capture-pane.md | 2 +- docs/tools/pane/capture-since.md | 2 +- docs/tools/pane/display-message.md | 2 +- docs/tools/pane/find-pane-by-position.md | 2 +- docs/tools/pane/get-pane-info.md | 2 +- docs/tools/pane/index.md | 2 +- docs/tools/pane/search-panes.md | 2 +- docs/tools/pane/snapshot-pane.md | 2 +- docs/tools/pane/wait-for-text.md | 2 +- docs/tools/server/get-server-info.md | 2 +- docs/tools/server/index.md | 2 +- docs/tools/server/list-servers.md | 2 +- docs/tools/server/list-sessions.md | 2 +- docs/tools/server/show-environment.md | 2 +- docs/tools/server/show-option.md | 2 +- docs/tools/session/get-session-info.md | 2 +- docs/tools/session/index.md | 2 +- docs/tools/session/list-windows.md | 2 +- docs/tools/window/get-window-info.md | 2 +- docs/tools/window/index.md | 2 +- docs/tools/window/list-panes.md | 2 +- docs/topics/trust.md | 6 +- src/libtmux_mcp/_utils.py | 20 +++---- src/libtmux_mcp/_wait_policy.py | 2 +- src/libtmux_mcp/middleware.py | 22 +++---- src/libtmux_mcp/server.py | 16 +++--- src/libtmux_mcp/tools/buffer_tools.py | 2 +- src/libtmux_mcp/tools/env_tools.py | 2 +- src/libtmux_mcp/tools/hook_tools.py | 2 +- src/libtmux_mcp/tools/pane_tools/pipe.py | 7 +-- src/libtmux_mcp/tools/wait_for_tools.py | 2 +- tests/test_batch_tools.py | 49 ++++++++-------- tests/test_mcp_swap.py | 13 +++-- tests/test_middleware.py | 38 ++++++------ tests/test_pane_tools.py | 4 +- tests/test_retired_vocabulary.py | 70 +++++++++++++++++++++++ tests/test_server.py | 8 +-- tests/test_tool_annotations.py | 2 +- tests/test_utils.py | 2 +- 49 files changed, 217 insertions(+), 148 deletions(-) create mode 100644 tests/test_retired_vocabulary.py diff --git a/AGENTS.md b/AGENTS.md index 534862c9..2429d481 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,9 +49,11 @@ be stated twice, the file listed above is the one that governs. - A passing gate is evidence only once it has been shown capable of failing. Pair a new test with a deliberate break that proves it bites. -Tools are tagged `readonly`, `mutating`, or `destructive`; `LIBTMUX_SAFETY` -caps which tier is exposed (default `mutating`), and the middleware denies -any tool whose tag it does not recognize. All tmux access goes through +Tools are grouped into the unordered toolsets `inspect`, `manage`, +`execute`, and `teardown`; `LIBTMUX_TOOLSETS` selects which are exposed +(default `inspect,manage,execute`), and the middleware refuses any tool +carrying none of them. Filtering shapes what is advertised, not what a +pane can run. All tmux access goes through libtmux's `cmd()` on `Server`/`Session`/`Window`/`Pane`, returning a `CommandResult` with `stdout`/`stderr`; a libtmux object can go stale when tmux state changes externally, so call `.refresh()` before trusting a diff --git a/README.md b/README.md index c2636bb9..72d10b8c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ commands, read output, orchestrate panes. | Module | Tools | |--------|-------| | **Server** | `list_servers`, `list_sessions`, `create_session`, `kill_server`, `get_server_info` | -| **Batch** | `call_readonly_tools_batch`, `call_mutating_tools_batch`, `call_destructive_tools_batch` | +| **Batch** | `call_read_tools_batch` | | **Session** | `list_windows`, `get_session_info`, `create_window`, `rename_session`, `select_window`, `kill_session` | | **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` | | **Pane** | `run_command`, `send_keys`, `send_keys_batch`, `paste_text`, `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `find_pane_by_position`, `get_pane_info`, `wait_for_text`, `wait_for_channel`, `signal_channel`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `respawn_pane`, `kill_pane` | @@ -132,11 +132,14 @@ newly written or rewritten rows on follow-up calls. The alternative is re-sending the same scrollback to the model on every check. **Guarding.** The server detects the agent's own pane across sockets -and declines self-destructive operations — [`kill_session`](https://libtmux-mcp.git-pull.com/tools/session/kill-session/) +and declines to end its own — [`kill_session`](https://libtmux-mcp.git-pull.com/tools/session/kill-session/) on itself fails loudly instead of silently terminating the host -environment the agent is running in. [`LIBTMUX_SAFETY`](https://libtmux-mcp.git-pull.com/configuration/#envvar-LIBTMUX_SAFETY) -(`readonly`, `mutating`, `destructive`) hides whole tiers from the -client's tool list before any prompt is built. +environment the agent is running in. [`LIBTMUX_TOOLSETS`](https://libtmux-mcp.git-pull.com/configuration/#envvar-LIBTMUX_TOOLSETS) +(`inspect`, `manage`, `execute`, `teardown`) drops whole toolsets from the +client's tool list before any prompt is built. The sets are unordered, so +`inspect,teardown` is a legal surface. Dropping one is inventory +configuration, not containment: an enabled `execute` tool can type the +equivalent of anything it hides. ## Documentation diff --git a/docs/demo.md b/docs/demo.md index 38fd3a11..80eddba7 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -102,13 +102,13 @@ Each badge renders as: ```html - 🔍 readonly + aria-label="Toolset: inspect"> + 🔍 inspect ``` Features: -- **Emoji icon** — 🔍 readonly, ✏️ mutating, 💣 destructive (native system emoji, no filters) +- **Emoji icon** — 🔍 inspect, ✏️ manage, 💣 teardown (native system emoji, no filters) - **Matte colors** — forest green, smoky amber, matte crimson with 1px border - **Accessible** — `role="note"` + `aria-label` for screen readers - **Non-selectable** — `user-select: none` so copying tool names skips badge text diff --git a/docs/tools/batch/call-read-tools-batch.md b/docs/tools/batch/call-read-tools-batch.md index 1564d799..2ceb6654 100644 --- a/docs/tools/batch/call-read-tools-batch.md +++ b/docs/tools/batch/call-read-tools-batch.md @@ -7,13 +7,14 @@ MCP turn, such as listing sessions and then reading server metadata. **Avoid when** any nested operation changes tmux state — use -{tooliconl}`call-mutating-tools-batch` for readonly + mutating +a direct call for anything that writes workflows, or call the individual tools when each result should be reviewed before choosing the next action. -**Side effects:** None beyond the nested readonly tools. Mutating and -destructive nested tools are rejected even when the server process is -running with a higher safety tier. +**Side effects:** None beyond the nested `inspect` tools. Anything outside +that toolset is rejected, whatever this server has enabled — a batch gives +every nested call the wrapper's name, so a client rule keyed on the inner +tool would not fire. **Example:** diff --git a/docs/tools/batch/index.md b/docs/tools/batch/index.md index b5fc4a70..ab5929e4 100644 --- a/docs/tools/batch/index.md +++ b/docs/tools/batch/index.md @@ -8,15 +8,7 @@ including `socket_name` when needed. :gutter: 2 2 3 3 :::{grid-item-card} {tooliconl}`call-read-tools-batch` -Call readonly tools in order. -::: - -:::{grid-item-card} {tooliconl}`call-mutating-tools-batch` -Call readonly or mutating tools in order. -::: - -:::{grid-item-card} {tooliconl}`call-destructive-tools-batch` -Call readonly, mutating, or destructive tools in order. +Call several `inspect` tools in order. ::: :::: diff --git a/docs/tools/buffer/show-buffer.md b/docs/tools/buffer/show-buffer.md index 07a3893d..bd8f765e 100644 --- a/docs/tools/buffer/show-buffer.md +++ b/docs/tools/buffer/show-buffer.md @@ -7,7 +7,7 @@ to read back a buffer between modifications. Restricted to MCP-namespaced buffers — non-agent buffers are rejected. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. ```{fastmcp-tool-input} buffer_tools.show_buffer ``` diff --git a/docs/tools/hook/index.md b/docs/tools/hook/index.md index 63af7d6b..3c5ad1f8 100644 --- a/docs/tools/hook/index.md +++ b/docs/tools/hook/index.md @@ -11,7 +11,7 @@ OOM-kill, and C-extension-fault crashes. Any cleanup registry in Python could be silently bypassed, leaking agent-installed shell hooks into the user's persistent tmux server where they would fire forever. Three plausible future paths exist (a tmux-side `client-detached` -meta-hook for self-cleanup, requiring `LIBTMUX_SAFETY=destructive`, or +meta-hook for self-cleanup, requiring `teardown` in `LIBTMUX_TOOLSETS`, or exposing one-shot `run_hook` only); none is in scope. Until one of those paths is implemented, the surface here is visibility only. diff --git a/docs/tools/hook/show-hook.md b/docs/tools/hook/show-hook.md index 33fa3af7..f5cd2832 100644 --- a/docs/tools/hook/show-hook.md +++ b/docs/tools/hook/show-hook.md @@ -9,7 +9,7 @@ empty when the hook is unset; raises an unknown hook names (typos, wrong scope) so input mistakes don't masquerade as "nothing configured". -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. ```{fastmcp-tool-input} hook_tools.show_hook ``` diff --git a/docs/tools/hook/show-hooks.md b/docs/tools/hook/show-hooks.md index 62159bb9..702900c3 100644 --- a/docs/tools/hook/show-hooks.md +++ b/docs/tools/hook/show-hooks.md @@ -7,7 +7,7 @@ target — the human user's tmux config, an inherited team setup, or a session that another tool may have touched. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. ```{fastmcp-tool-input} hook_tools.show_hooks ``` diff --git a/docs/tools/index.md b/docs/tools/index.md index af573b8f..b1ba4087 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -162,7 +162,7 @@ Get tmux server info. :::{grid-item-card} call_read_tools_batch :link: call-read-tools-batch :link-type: ref -Call typed readonly tools in order. +Call typed `inspect` tools in order. ::: :::{grid-item-card} list_servers diff --git a/docs/tools/pane/capture-pane.md b/docs/tools/pane/capture-pane.md index 757bdcf4..85099918 100644 --- a/docs/tools/pane/capture-pane.md +++ b/docs/tools/pane/capture-pane.md @@ -11,7 +11,7 @@ after running a command, checking output, or verifying state. {tooliconl}`capture-since` with its cursor so unchanged scrollback is not sent again. If you only need pane metadata (not content), use {tooliconl}`get-pane-info`. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/pane/capture-since.md b/docs/tools/pane/capture-since.md index 5e1b95e7..4864418e 100644 --- a/docs/tools/pane/capture-since.md +++ b/docs/tools/pane/capture-since.md @@ -15,7 +15,7 @@ output in one typed result. If you need a one-shot content + metadata view, use {tooliconl}`snapshot-pane`; if you do not know which pane contains text, use {tooliconl}`search-panes`. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md index cea886c9..0841250a 100644 --- a/docs/tools/pane/display-message.md +++ b/docs/tools/pane/display-message.md @@ -16,7 +16,7 @@ anything to the user; it substitutes the variables and returns the value. {class}`~libtmux_mcp.models.PaneInfo` without parsing `#{pane_at_bottom}` / `#{pane_at_right}` yourself. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. Accepts literal text and `#{variable}` references only. Modifiers such as `#{E:...}` re-expand a variable's *value*, which can arrive from a pane diff --git a/docs/tools/pane/find-pane-by-position.md b/docs/tools/pane/find-pane-by-position.md index 974bb823..fc33cdfa 100644 --- a/docs/tools/pane/find-pane-by-position.md +++ b/docs/tools/pane/find-pane-by-position.md @@ -10,7 +10,7 @@ computing geometry yourself. **Avoid when** you already know the `pane_id`. Use {tooliconl}`get-pane-info` or {tooliconl}`select-pane` directly. -**Side effects:** None. Read-only. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/pane/get-pane-info.md b/docs/tools/pane/get-pane-info.md index 74e2bc50..bd3f1eaf 100644 --- a/docs/tools/pane/get-pane-info.md +++ b/docs/tools/pane/get-pane-info.md @@ -8,7 +8,7 @@ other metadata without reading the terminal content. **Avoid when** you need the actual text — use {tooliconl}`capture-pane`. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md index 85e8a75a..d1842e4a 100644 --- a/docs/tools/pane/index.md +++ b/docs/tools/pane/index.md @@ -98,7 +98,7 @@ Restart a pane's process in place, preserving pane_id. ::: :::{grid-item-card} {tooliconl}`kill-pane` -Terminate a pane. Destructive. +Terminate a pane. Not reversible. ::: :::: diff --git a/docs/tools/pane/search-panes.md b/docs/tools/pane/search-panes.md index 3bc3045d..38cb9a5b 100644 --- a/docs/tools/pane/search-panes.md +++ b/docs/tools/pane/search-panes.md @@ -10,7 +10,7 @@ without knowing which pane to look in. **Avoid when** you already know the target pane — use {tooliconl}`capture-pane` for a one-shot read, or {tooliconl}`capture-since` for repeated observation. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. Matching shares a two-second ceiling across every line of every pane. A `regex=true` pattern with nested quantifiers such as `(a+)+` backtracks diff --git a/docs/tools/pane/snapshot-pane.md b/docs/tools/pane/snapshot-pane.md index 89c0d873..991a6bb9 100644 --- a/docs/tools/pane/snapshot-pane.md +++ b/docs/tools/pane/snapshot-pane.md @@ -11,7 +11,7 @@ terminal mode. **Avoid when** you only need raw text — {tooliconl}`capture-pane` is lighter. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/pane/wait-for-text.md b/docs/tools/pane/wait-for-text.md index d77b2c5b..81ed3081 100644 --- a/docs/tools/pane/wait-for-text.md +++ b/docs/tools/pane/wait-for-text.md @@ -11,7 +11,7 @@ server to start, a build to complete, or a prompt to return. {tooliconl}`capture-since`; for command completion you control, use {tooliconl}`wait-for-channel`. -**Side effects:** None. Readonly. Blocks until text appears or timeout. +**Side effects:** None. Reads only. Blocks until text appears or timeout. **Example:** diff --git a/docs/tools/server/get-server-info.md b/docs/tools/server/get-server-info.md index 5b59a48e..1b827d68 100644 --- a/docs/tools/server/get-server-info.md +++ b/docs/tools/server/get-server-info.md @@ -8,7 +8,7 @@ or inspect server-level state before creating sessions. **Avoid when** you only need session names — use {tooliconl}`list-sessions`. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/server/index.md b/docs/tools/server/index.md index c5350f84..299d5bbb 100644 --- a/docs/tools/server/index.md +++ b/docs/tools/server/index.md @@ -22,7 +22,7 @@ Create a new tmux session. ::: :::{grid-item-card} {tooliconl}`kill-server` -Terminate the tmux daemon. Destructive. +Terminate the tmux daemon. Not reversible. ::: :::{grid-item-card} {tooliconl}`show-option` diff --git a/docs/tools/server/list-servers.md b/docs/tools/server/list-servers.md index cfe681ea..ae930f4e 100644 --- a/docs/tools/server/list-servers.md +++ b/docs/tools/server/list-servers.md @@ -11,7 +11,7 @@ side project. **Avoid when** you already know the socket name or path you want to target — pass it directly to the tool that needs it via `socket_name`. -**Side effects:** None. Readonly. Stale socket files are filtered +**Side effects:** None. Reads only. Stale socket files are filtered via a kernel-fast UNIX `connect()` probe so the call stays under one second even on machines with thousands of orphaned `tmux-/` inodes. diff --git a/docs/tools/server/list-sessions.md b/docs/tools/server/list-sessions.md index 2d791776..d632340a 100644 --- a/docs/tools/server/list-sessions.md +++ b/docs/tools/server/list-sessions.md @@ -9,7 +9,7 @@ which session to target. **Avoid when** you need window or pane details — use {tooliconl}`list-windows` or {tooliconl}`list-panes` instead. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/server/show-environment.md b/docs/tools/server/show-environment.md index a9d617f2..b9f2013d 100644 --- a/docs/tools/server/show-environment.md +++ b/docs/tools/server/show-environment.md @@ -5,7 +5,7 @@ **Use when** you need to inspect tmux environment variables. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/server/show-option.md b/docs/tools/server/show-option.md index f6718bec..ceebb794 100644 --- a/docs/tools/server/show-option.md +++ b/docs/tools/server/show-option.md @@ -6,7 +6,7 @@ **Use when** you need to check a tmux configuration value — buffer limits, history size, status bar settings, etc. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/session/get-session-info.md b/docs/tools/session/get-session-info.md index 15460f09..7743c4fd 100644 --- a/docs/tools/session/get-session-info.md +++ b/docs/tools/session/get-session-info.md @@ -10,7 +10,7 @@ count, attachment status, activity timestamp) and you already know its **Avoid when** you need every session — call {tooliconl}`list-sessions` or iterate via the `tmux://sessions` resource. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/session/index.md b/docs/tools/session/index.md index b15697e3..0dd7fe81 100644 --- a/docs/tools/session/index.md +++ b/docs/tools/session/index.md @@ -26,7 +26,7 @@ Rename an existing session. ::: :::{grid-item-card} {tooliconl}`kill-session` -Terminate a session. Destructive. +Terminate a session. Not reversible. ::: :::: diff --git a/docs/tools/session/list-windows.md b/docs/tools/session/list-windows.md index c4b99e31..d27898e6 100644 --- a/docs/tools/session/list-windows.md +++ b/docs/tools/session/list-windows.md @@ -8,7 +8,7 @@ session before selecting a window to work with. **Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/window/get-window-info.md b/docs/tools/window/get-window-info.md index bfaa32d3..e18c7c95 100644 --- a/docs/tools/window/get-window-info.md +++ b/docs/tools/window/get-window-info.md @@ -10,7 +10,7 @@ dimensions, pane count) and you already know the `window_id` or **Avoid when** you need every window in a session — call {tooliconl}`list-windows` with `session_id` or iterate via the `tmux://sessions/{name}/windows` resource. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/tools/window/index.md b/docs/tools/window/index.md index a14131ee..8a1f3cae 100644 --- a/docs/tools/window/index.md +++ b/docs/tools/window/index.md @@ -34,7 +34,7 @@ Reorder a window or move it across sessions. ::: :::{grid-item-card} {tooliconl}`kill-window` -Terminate a window. Destructive. +Terminate a window. Not reversible. ::: :::: diff --git a/docs/tools/window/list-panes.md b/docs/tools/window/list-panes.md index e08754e6..4d0644c3 100644 --- a/docs/tools/window/list-panes.md +++ b/docs/tools/window/list-panes.md @@ -6,7 +6,7 @@ **Use when** you need to discover which panes exist in a window before sending keys or capturing output. -**Side effects:** None. Readonly. +**Side effects:** None. Reads only. **Example:** diff --git a/docs/topics/trust.md b/docs/topics/trust.md index 035ed7d8..bbec54dd 100644 --- a/docs/topics/trust.md +++ b/docs/topics/trust.md @@ -142,7 +142,7 @@ Mitigations: {tool}`respawn-pane` restarts a pane's process while preserving the pane id and layout — exactly what an agent wants when a shell wedges. Default `kill=True` terminates the running process before relaunch. The `pane_id` and layout are preserved (the point of the tool), but any unsaved REPL state, ssh session, or in-flight job in that pane is lost. Repeated calls are *not* idempotent — each call kills a new process. -The registration advertises `destructiveHint=True` and `idempotentHint=False` while the tier tag stays at `mutating`, so recovery remains available to default-profile clients without understating what the call does. +The registration advertises `destructiveHint=True` and `idempotentHint=False` while staying in `manage`, so recovery remains available by default without understating what the call does. Mitigations: @@ -192,8 +192,8 @@ updates**, so a tool that replaces a name, a size, or a layout advertises reaches, or returns text from, outside tmux — a spawned process runs with your user's authority, and a pane holds whatever was printed into it. -| Tool | Toolset | readOnly | destructive | idempotent | openWorld | -|------|---------|----------|-------------|------------|-----------| +| Tool | Toolset | readOnlyHint | destructiveHint | idempotentHint | openWorldHint | +|------|---------|--------------|-----------------|----------------|---------------| | {toolref}`call-read-tools-batch` | {badge}`inspect` | true | false | true | true | | {toolref}`capture-pane` | {badge}`inspect` | true | false | true | true | | {toolref}`capture-since` | {badge}`inspect` | true | false | true | true | diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index af8b25d2..6f30f65a 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -195,9 +195,9 @@ def _compute_is_caller(pane: Pane) -> bool | None: Uses :func:`_caller_is_strictly_on_server` rather than :func:`_caller_is_on_server`: the kill-guard comparator is - conservative-True-when-uncertain (right for blocking destructive - actions, wrong for an informational annotation that should - demand a positive match). The strict variant declines the + conservative-True-when-uncertain (right for blocking a kill, wrong + for an informational annotation that should demand a positive + match). The strict variant declines the basename fallback, the unresolvable-target branch, and the socket-path-unset branch so ambiguous cases resolve to ``False``. """ @@ -274,8 +274,8 @@ def _caller_is_on_server(server: Server, caller: CallerIdentity | None) -> bool: is possible. * caller has a pane id but no socket path (e.g. ``TMUX_PANE`` set without ``TMUX``) → ``True``. We can't rule out that the caller - is on the target server, so err on the side of blocking a - destructive action. + is on the target server, so err on the side of blocking the + kill. * target server has no resolvable socket path → ``True``. Same conservative reasoning. * realpath of caller's socket path matches target's effective path @@ -285,7 +285,7 @@ def _caller_is_on_server(server: Server, caller: CallerIdentity | None) -> bool: last-chance block for env-mismatch scenarios where reconstruction produced a wrong path but the name was authoritative on both sides. Trades off one exotic false positive (two daemons with - identical socket_name under different tmpdirs) for a real safety + identical socket_name under different tmpdirs) for a real correctness property. * Otherwise → ``False``. @@ -320,7 +320,7 @@ def _caller_is_strictly_on_server( Counterpart to :func:`_caller_is_on_server` for the informational :attr:`~libtmux_mcp.models.PaneInfo.is_caller` annotation. The - destructive-action guard is biased toward True-when-uncertain so a + kill guard is biased toward True-when-uncertain so a macOS ``$TMUX_TMPDIR`` divergence cannot fool it into permitting self-kill; the annotation cannot absorb that bias — ambiguous cases are exactly the cross-socket false positives documented by @@ -1210,10 +1210,10 @@ def handle_tool_errors( (logged at ERROR). The re-raise chains the original exception via ``from e``. Keep it - single-level: :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` + single-level: :class:`~libtmux_mcp.middleware.InspectRetryMiddleware` matches :exc:`libtmux.exc.LibTmuxException` by inspecting exactly one ``__cause__`` hop, so wrapping the mapped error again would - silently disable readonly retries. + silently disable ``inspect`` retries. Use :func:`handle_tool_errors_async` for ``async def`` tools — this wrapper only supports plain sync callables. @@ -1246,7 +1246,7 @@ def handle_tool_errors_async( :class:`ExpectedToolError` at WARNING, the unexpected catch-all as stock ``ToolError`` at ERROR) by delegating to a shared helper, and chains the original exception via the same single-level - ``from e`` that readonly retries depend on. + ``from e`` that ``inspect`` retries depend on. """ @functools.wraps(fn) diff --git a/src/libtmux_mcp/_wait_policy.py b/src/libtmux_mcp/_wait_policy.py index 5460958c..a3998f44 100644 --- a/src/libtmux_mcp/_wait_policy.py +++ b/src/libtmux_mcp/_wait_policy.py @@ -61,7 +61,7 @@ def _resolve_wait_max_seconds(value: str | None) -> float: """Return the effective wait ceiling for a ``LIBTMUX_MCP_WAIT_MAX_SECONDS``. - Mirrors :func:`libtmux_mcp.server._resolve_safety_level`: never + Mirrors :func:`libtmux_mcp.server._resolve_toolsets`: never raises, warns on a bad value, falls back to a safe default. Parameters diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 43f9712c..38a2e2f8 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -228,7 +228,7 @@ def install_fastmcp_validation_log_filter() -> None: #: from other clients stay loud. Contrast MemPalace/mempalace#322, #: which strips the key, and #647, which whitelists arguments against #: the schema — silent dropping would let a mis-named flag on a -#: mutating tool (e.g. ``enter`` on send_keys) run with defaults. +#: writing tool (e.g. ``enter`` on send_keys) run with defaults. _CLIENT_SCHEDULING_FLAG = "wait_for_previous" @@ -385,9 +385,9 @@ class ToolErrorResultMiddleware(ErrorHandlingMiddleware): fix (see :func:`_error_tool_result`). Ordering invariant: must sit **outside** ``AuditMiddleware``, - ``ReadonlyRetryMiddleware``, and ``SafetyMiddleware``. All three + ``InspectRetryMiddleware``, and ``ToolsetMiddleware``. All three depend on exception semantics — audit detects failures by catching, - retry matches ``LibTmuxException`` via ``__cause__``, and safety's + retry matches ``LibTmuxException`` via ``__cause__``, and the toolset gate's tier denials must propagate as exceptions for audit to record them — so converting the exception to a result any deeper in the stack would silently break all three. @@ -729,16 +729,16 @@ def _should_retry(self, error: Exception) -> bool: class InspectRetryMiddleware(Middleware): - """Retry transient libtmux failures, but only for readonly tools. + """Retry transient libtmux failures, but only for ``inspect`` tools. Wraps fastmcp's :class:`fastmcp.server.middleware.error_handling.RetryMiddleware` - so retries are bounded by the safety tier the tool is registered - under. Mutating and destructive tools (``send_keys``, + so retries are bounded by the toolset the tool is registered + under. Tools in any other toolset (``send_keys``, ``create_session``, ``kill_server``, …) pass straight through — re-running them on a transient socket error would silently double - side effects, which is unacceptable. Readonly tools + side effects, which is unacceptable. ``inspect`` tools (``list_sessions``, ``capture_pane``, ``snapshot_pane``, …) are - safe to retry because they observe state without mutating it. + safe to retry because they observe state without changing it. Default retry trigger is :exc:`libtmux.exc.LibTmuxException` — libtmux wraps the subprocess failures we actually want to retry @@ -754,7 +754,7 @@ class InspectRetryMiddleware(Middleware): Place this in the middleware stack **inside** ``AuditMiddleware`` (so retried calls are audited once each) and **outside** - ``SafetyMiddleware`` (so tier-denied tools never reach retry). + ``ToolsetMiddleware`` (so refused tools never reach retry). """ def __init__( @@ -797,10 +797,10 @@ async def on_call_tool( context: MiddlewareContext, call_next: t.Any, ) -> t.Any: - """Delegate to the upstream retry only for retry-eligible readonly tools. + """Delegate to the upstream retry only for retry-eligible ``inspect`` tools. ``TAG_SELF_BOUNDED`` tools are excluded even though they are - readonly. Their deadline is computed inside the tool body, so a + ``inspect``. Their deadline is computed inside the tool body, so a retry restarts the clock: a transient ``LibTmuxException`` at t=29s of a 30s wait would produce a ~59s call and make the wait ceiling a lie. :class:`_SkipDeterministicFailures` cannot cover diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 22e869a5..3c747822 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -152,7 +152,7 @@ def _build_instructions( toolsets: frozenset[str] = DEFAULT_TOOLSETS, suppress_history: bool = True, ) -> str: - """Build server instructions with agent context and safety level. + """Build server instructions with agent context and toolsets. When the MCP server process runs inside a tmux pane, ``TMUX_PANE`` and ``TMUX`` environment variables are available. This function appends that @@ -172,7 +172,7 @@ def _build_instructions( """ parts: list[str] = [_BASE_INSTRUCTIONS] - # Safety tier context + # Toolset context parts.append( "\n\nToolsets: " + (", ".join(sorted(toolsets)) or "(none)") @@ -425,21 +425,21 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: # 3. ToolErrorResultMiddleware — converts tool-call failures to # rich ToolResult(is_error=True) results and transforms # resource errors to MCP code -32002. Must stay OUTSIDE the - # audit + retry + safety trio: all three depend on exception + # audit + retry + toolset trio: all three depend on exception # semantics (audit catches to record outcome=error, retry - # matches LibTmuxException via __cause__, and safety's tier + # matches LibTmuxException via __cause__, and the toolset # denials must propagate as exceptions for audit to record # them), so converting the exception to a result any deeper # would silently break all three. # 4. AuditMiddleware — outside ToolsetMiddleware so a refusal # events (which raise ExpectedToolError before call_next inside - # Safety) are still logged with outcome=error. Without this + # Toolset) are still logged with outcome=error. Without this # ordering, denied access attempts would silently bypass the # audit log — a security-observability gap. # 5. InspectRetryMiddleware — inside Audit so retries are - # audited once each, outside Safety so tier-denied tools - # never reach retry. Only readonly tools are retried; - # mutating/destructive tools pass straight through. + # audited once each, outside Toolset so refused tools + # never reach retry. Only inspect tools are retried; + # everything else passes straight through. # 6. ToolsetMiddleware — innermost gate (fail-closed). Refusals # never reach the tool, but the audit record above captures # them for forensic review. diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 5f9e4d28..a668be91 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -17,7 +17,7 @@ :func:`~libtmux_mcp.tools.buffer_tools.show_buffer`, and :func:`~libtmux_mcp.tools.buffer_tools.delete_buffer` without ambiguity. -``list_buffers`` is **not** exposed in the default safety tier — +``list_buffers`` is **not** exposed at all — buffer contents often include the user's OS clipboard history (passwords, private snippets), and a blanket enumeration would leak that to the agent. Callers track the buffers they own via the diff --git a/src/libtmux_mcp/tools/env_tools.py b/src/libtmux_mcp/tools/env_tools.py index ab80e05e..f7178a15 100644 --- a/src/libtmux_mcp/tools/env_tools.py +++ b/src/libtmux_mcp/tools/env_tools.py @@ -81,7 +81,7 @@ def set_environment( not just panes the agent drives. A caller that writes ``PATH``, ``LD_PRELOAD``, or ``AWS_*`` variables can influence future commands the human user types directly. Treat this as - elevated-risk within the ``mutating`` safety tier. The audit log + elevated-risk within ``execute``. The audit log redacts the ``value`` argument, but the side effects persist on disk/memory until tmux is restarted. Prefer ``env VAR=value command`` via :func:`~libtmux_mcp.tools.pane_tools.send_keys` diff --git a/src/libtmux_mcp/tools/hook_tools.py b/src/libtmux_mcp/tools/hook_tools.py index 141d21ac..c037c65c 100644 --- a/src/libtmux_mcp/tools/hook_tools.py +++ b/src/libtmux_mcp/tools/hook_tools.py @@ -17,7 +17,7 @@ * Install a tmux-side meta-hook on ``client-detached`` that self-cleans all ``libtmux_mcp_*``-namespaced hooks when the MCP client disconnects. Survives hard crashes because tmux enforces it. -* Require ``LIBTMUX_SAFETY=destructive`` for write-hooks so leakage is +* Require ``LIBTMUX_TOOLSETS`` to include ``teardown`` for write-hooks so leakage is an explicit opt-in with user awareness. * Expose ``run_hook`` (one-shot fire) but not ``set_hook`` (persistent install) — narrows the risk surface to transient events. diff --git a/src/libtmux_mcp/tools/pane_tools/pipe.py b/src/libtmux_mcp/tools/pane_tools/pipe.py index 68e582fe..91716b9b 100644 --- a/src/libtmux_mcp/tools/pane_tools/pipe.py +++ b/src/libtmux_mcp/tools/pane_tools/pipe.py @@ -52,10 +52,9 @@ def pipe_pane( This tool writes to arbitrary filesystem paths chosen by the MCP client. There is no allow-list; the server will create files anywhere the server process has write access. Treat this as - elevated-risk even though it sits in the ``mutating`` safety - tier — it is the broadest-reach tool in that tier. If you run - libtmux-mcp on untrusted input, consider - ``LIBTMUX_SAFETY=readonly`` or run the server under a user with + elevated-risk: it is the broadest-reach tool in ``execute``. If + you run libtmux-mcp on untrusted input, consider + ``LIBTMUX_TOOLSETS=inspect`` or run the server under a user with a scoped home directory. See :doc:`/topics/trust` for the full footgun list. diff --git a/src/libtmux_mcp/tools/wait_for_tools.py b/src/libtmux_mcp/tools/wait_for_tools.py index be2722b9..67639edd 100644 --- a/src/libtmux_mcp/tools/wait_for_tools.py +++ b/src/libtmux_mcp/tools/wait_for_tools.py @@ -8,7 +8,7 @@ then calls :func:`wait_for_channel` which blocks server-side until the signal fires. -Wait channel safety +Wait channel scope ------------------- ``tmux wait-for`` without a timeout blocks indefinitely at the OS level. If the shell command that was supposed to emit the signal crashes diff --git a/tests/test_batch_tools.py b/tests/test_batch_tools.py index e961b741..bac92eae 100644 --- a/tests/test_batch_tools.py +++ b/tests/test_batch_tools.py @@ -34,7 +34,7 @@ class BatchResponseLimitFixture(t.NamedTuple): BATCH_RESPONSE_LIMIT_FIXTURES: list[BatchResponseLimitFixture] = [ BatchResponseLimitFixture( - test_id="two_large_readonly_results", + test_id="two_large_read_results", payload_size=300_000, ), ] @@ -111,25 +111,25 @@ def _batch_probe_server() -> FastMCP: register_batch_tools(mcp) @mcp.tool( - title="Readonly Probe", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + title="Inspect Probe", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} ) - def readonly_probe(value: str) -> dict[str, str]: + def inspect_probe(value: str) -> dict[str, str]: return {"value": value} @mcp.tool( - title="Mutating Probe", + title="Manage Probe", annotations=ANNOTATIONS_CHANGE, tags={TOOLSET_MANAGE}, ) - def mutating_probe(value: str) -> dict[str, str]: + def manage_probe(value: str) -> dict[str, str]: return {"value": value} @mcp.tool( - title="Destructive Probe", + title="Teardown Probe", annotations=ANNOTATIONS_DELETE, tags={TOOLSET_TEARDOWN}, ) - def destructive_probe(value: str) -> dict[str, str]: + def teardown_probe(value: str) -> dict[str, str]: return {"value": value} @mcp.tool( @@ -159,7 +159,7 @@ async def _call() -> t.Any: "arguments": {"value": "should-not-run"}, }, { - "tool": "readonly_probe", + "tool": "inspect_probe", "arguments": {"value": "kept-going"}, }, ], @@ -174,11 +174,10 @@ def test_batch_rejects_a_self_bounded_tool() -> None: """A ``TAG_SELF_BOUNDED`` tool is rejected by the batch wrapper. ``max_tier`` is a *ceiling* (``_TIER_LEVELS[tool_tier] <= - _TIER_LEVELS[max_tier]``), so a readonly tool is reachable through - the mutating and destructive wrappers too. The batch loop is serial - with no aggregate deadline and ``MAX_BATCH_OPERATIONS`` is 1000, so - a wait tool batched N times would cost N x its ceiling — the batch - wrapper is a cap amplifier unless every wrapper rejects it. + The batch loop is serial with no aggregate deadline and + ``MAX_BATCH_OPERATIONS`` is 1000, so a wait tool batched N times + would cost N x its ceiling — the wrapper is a cap amplifier unless + it rejects one. """ result = _self_bounded_batch_call("call_read_tools_batch") @@ -233,8 +232,8 @@ def test_run_command_is_registered_self_bounded_and_unbatchable() -> None: asyncio.run(_check_operation_allowed(fastmcp=mcp, operation=operation)) -def test_call_readonly_tools_batch_preserves_structured_results() -> None: - """The readonly batch wrapper returns per-tool structured content.""" +def test_call_read_tools_batch_preserves_structured_results() -> None: + """The read batch wrapper returns per-tool structured content.""" from fastmcp import Client async def _call() -> t.Any: @@ -244,11 +243,11 @@ async def _call() -> t.Any: { "operations": [ { - "tool": "readonly_probe", + "tool": "inspect_probe", "arguments": {"value": "alpha"}, }, { - "tool": "readonly_probe", + "tool": "inspect_probe", "arguments": {"value": "beta"}, }, ], @@ -265,7 +264,7 @@ async def _call() -> t.Any: first, second = result.structured_content["results"] assert first == { "index": 0, - "tool": "readonly_probe", + "tool": "inspect_probe", "success": True, "error": None, "content": [{"type": "text", "text": '{"value":"alpha"}'}], @@ -275,7 +274,7 @@ async def _call() -> t.Any: } assert second == { "index": 1, - "tool": "readonly_probe", + "tool": "inspect_probe", "success": True, "error": None, "content": [{"type": "text", "text": '{"value":"beta"}'}], @@ -292,7 +291,7 @@ async def _call() -> t.Any: BATCH_RESPONSE_LIMIT_FIXTURES, ids=[fixture.test_id for fixture in BATCH_RESPONSE_LIMIT_FIXTURES], ) -def test_call_readonly_tools_batch_caps_aggregate_response( +def test_call_read_tools_batch_caps_aggregate_response( test_id: str, payload_size: int, ) -> None: @@ -311,11 +310,11 @@ async def _call() -> t.Any: { "operations": [ { - "tool": "readonly_probe", + "tool": "inspect_probe", "arguments": {"value": first_payload}, }, { - "tool": "readonly_probe", + "tool": "inspect_probe", "arguments": {"value": second_payload}, }, ], @@ -344,7 +343,7 @@ async def _call() -> t.Any: first, second = structured["results"] assert first["index"] == 0 - assert first["tool"] == "readonly_probe" + assert first["tool"] == "inspect_probe" assert first["success"] is True assert first["structured_content"] is None assert first["content"] == [ @@ -367,7 +366,7 @@ async def _call() -> t.Any: BATCH_OPERATION_LIMIT_FIXTURES, ids=[fixture.test_id for fixture in BATCH_OPERATION_LIMIT_FIXTURES], ) -def test_call_readonly_tools_batch_rejects_oversized_operation_count( +def test_call_read_tools_batch_rejects_oversized_operation_count( test_id: str, operation_count: int, ) -> None: @@ -421,7 +420,7 @@ async def _call() -> t.Any: async with Client(_batch_probe_server()) as client: return await client.call_tool( "call_read_tools_batch", - {"operations": [{"tool": "mutating_probe", "arguments": {}}]}, + {"operations": [{"tool": "manage_probe", "arguments": {}}]}, raise_on_error=False, ) diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index 75c7b853..1a587de8 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -270,7 +270,7 @@ def test_use_local_preserves_existing_env_when_replacing( "libtmux": { "command": "uvx", "args": ["libtmux-mcp==0.1.0a2"], - "env": {"LIBTMUX_SAFETY": "readonly", "FOO": "bar"}, + "env": {"LIBTMUX_TOOLSETS": "inspect", "FOO": "bar"}, } } }, @@ -289,7 +289,7 @@ def test_use_local_preserves_existing_env_when_replacing( "run", "libtmux-mcp", ] - assert entry["env"] == {"LIBTMUX_SAFETY": "readonly", "FOO": "bar"} + assert entry["env"] == {"LIBTMUX_TOOLSETS": "inspect", "FOO": "bar"} def test_use_local_with_no_prior_entry_writes_empty_env( @@ -1552,7 +1552,7 @@ def test_use_local_env_flag_wins_over_preserved_env( "libtmux": { "command": "uvx", "args": ["libtmux-mcp==0.1.0a2"], - "env": {"LIBTMUX_SAFETY": "readonly", "KEEP": "me"}, + "env": {"LIBTMUX_TOOLSETS": "inspect", "KEEP": "me"}, } } }, @@ -1566,13 +1566,16 @@ def test_use_local_env_flag_wins_over_preserved_env( "--cli", "cursor", "--env", - "LIBTMUX_SAFETY=destructive", + "LIBTMUX_TOOLSETS=inspect,manage,execute,teardown", ] ) assert mcp_swap.cmd_use_local(args) == 0 entry = json.loads(info.config_path.read_text())["mcpServers"]["libtmux"] - assert entry["env"] == {"LIBTMUX_SAFETY": "destructive", "KEEP": "me"} + assert entry["env"] == { + "LIBTMUX_TOOLSETS": "inspect,manage,execute,teardown", + "KEEP": "me", + } def test_env_pair_rejects_malformed() -> None: diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 2f42dbd9..d15700e8 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -670,7 +670,7 @@ def test_error_handling_middleware_transforms_errors() -> None: assert err_mw.transform_errors is True -def test_audit_records_safety_denial( +def test_audit_records_a_toolset_refusal( caplog: pytest.LogCaptureFixture, ) -> None: """A tool denied by SafetyMiddleware still appears in the audit log. @@ -696,16 +696,16 @@ def test_audit_records_safety_denial( # would when blocking an over-tier call. The test's invariant is # that the AuditMiddleware sitting *outside* Safety still records # the attempt with outcome=error. - msg = "Tool 'kill_server' is not available at the current safety level." + msg = "Tool 'kill_server' is not in this server's enabled toolsets." - async def _safety_denial(_ctx: t.Any) -> None: + async def _toolset_refusal(_ctx: t.Any) -> None: raise ExpectedToolError(msg) with ( caplog.at_level(logging.INFO, logger="libtmux_mcp.audit"), - pytest.raises(ExpectedToolError, match="not available"), + pytest.raises(ExpectedToolError, match="not in this server's enabled"), ): - asyncio.run(audit.on_call_tool(ctx, _safety_denial)) + asyncio.run(audit.on_call_tool(ctx, _toolset_refusal)) rendered = "\n".join(rec.getMessage() for rec in caplog.records) assert "tool=kill_server" in rendered @@ -767,8 +767,8 @@ async def __call__(self, _context: t.Any) -> str: return "ok" -def test_readonly_retry_recovers_from_libtmux_exception() -> None: - """Readonly tool is retried once on ``LibTmuxException`` and succeeds. +def test_inspect_retry_recovers_from_libtmux_exception() -> None: + """An ``inspect`` tool is retried once on ``LibTmuxException``. Models the production scenario the middleware exists to fix: a transient socket error from libtmux on the first call, then a @@ -791,13 +791,13 @@ def test_readonly_retry_recovers_from_libtmux_exception() -> None: assert call_next.calls == 2 # initial failure + one retry -def test_readonly_retry_skips_mutating_tool() -> None: - """Mutating tool is NOT retried on ``LibTmuxException``. +def test_inspect_retry_skips_a_writing_tool() -> None: + """A writing tool is NOT retried on ``LibTmuxException``. Critical safety property: re-running ``send_keys``, - ``create_session``, or any other mutating call on a transient + ``create_session``, or any other writing call on a transient error would silently double the side effect. This test pins the - "no retry for non-readonly" gate. + "only inspect is retried" gate. """ from libtmux import exc as libtmux_exc @@ -814,8 +814,8 @@ def test_readonly_retry_skips_mutating_tool() -> None: assert call_next.calls == 1 # no retry — fail on first call -def test_readonly_retry_skips_self_bounded_tool() -> None: - """A readonly + self-bounded tool is NOT retried. +def test_inspect_retry_skips_self_bounded_tool() -> None: + """An ``inspect`` + self-bounded tool is NOT retried. ``wait_for_text`` computes its deadline inside the tool body, so a retry restarts the clock: a transient ``LibTmuxException`` at t=29s @@ -843,8 +843,8 @@ def test_readonly_retry_skips_self_bounded_tool() -> None: assert call_next.calls == 1 # no retry — the wait budget is not doubled -def test_readonly_retry_skips_non_libtmux_exception() -> None: - """Even readonly tools don't retry on exceptions outside the trigger set. +def test_inspect_retry_skips_non_libtmux_exception() -> None: + """Even ``inspect`` tools do not retry outside the trigger set. Default ``retry_exceptions=(LibTmuxException,)`` is narrow on purpose — a ``ValueError`` from caller-side input is a @@ -864,7 +864,7 @@ def test_readonly_retry_skips_non_libtmux_exception() -> None: assert call_next.calls == 1 # no retry — wrong exception type -def test_readonly_retry_recovers_on_decorated_tool( +def test_inspect_retry_recovers_on_decorated_tool( monkeypatch: pytest.MonkeyPatch, ) -> None: """End-to-end: retry fires through the production decorator wrap path. @@ -937,7 +937,7 @@ async def real_call_next(_context: t.Any) -> t.Any: ], ids=lambda e: type(e).__name__ if isinstance(e, Exception) else e.__name__, ) -def test_readonly_retry_skips_deterministic_failures(raised: Exception) -> None: +def test_inspect_retry_skips_deterministic_failures(raised: Exception) -> None: """A failure a second attempt cannot change is not retried. Every one of these descends from ``LibTmuxException``, which is the retry @@ -960,7 +960,7 @@ def test_readonly_retry_skips_deterministic_failures(raised: Exception) -> None: ) -def test_readonly_retry_skips_not_found_on_decorated_tool( +def test_inspect_retry_skips_not_found_on_decorated_tool( monkeypatch: pytest.MonkeyPatch, ) -> None: """End-to-end: a stale id is not retried through the production wrap path. @@ -1009,7 +1009,7 @@ async def real_call_next(_context: t.Any) -> t.Any: ) -def test_readonly_retry_logger_uses_project_namespace() -> None: +def test_inspect_retry_logger_uses_project_namespace() -> None: """Retry warnings route through ``libtmux_mcp.retry``, not ``fastmcp.retry``. Operators routing logs by the ``libtmux_mcp.*`` namespace prefix diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 0477db21..11d76f0c 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -5549,8 +5549,8 @@ def test_pane_tool_open_world_hint_registration( assert wire_annotations(tool).get("openWorldHint") is expected_open_world -def test_clear_pane_advertises_destructive_non_idempotent() -> None: - """``clear_pane`` registers as mutating-tier with destructive hints.""" +def test_clear_pane_advertises_removal_hints() -> None: + """``clear_pane`` is in ``teardown`` and says what it removes.""" import asyncio from fastmcp import FastMCP diff --git a/tests/test_retired_vocabulary.py b/tests/test_retired_vocabulary.py new file mode 100644 index 00000000..35da5d76 --- /dev/null +++ b/tests/test_retired_vocabulary.py @@ -0,0 +1,70 @@ +"""The tier vocabulary must not come back. + +`readonly` / `mutating` / `destructive` described an ordered ladder this +server does not have. Tools are grouped into unordered toolsets instead. +The words are easy to reintroduce by habit, so a gate holds the line. +""" + +from __future__ import annotations + +import pathlib +import re + +import pytest + +#: Words that named the retired tiers. Matched whole and case-insensitively. +RETIRED = ("readonly", "mutating", "destructive", "safety tier", "safety level") + +#: MCP defines these fields and their meanings; they are the protocol's +#: vocabulary, not ours, and they stay. +PROTOCOL_NAMES = ( + "readOnlyHint", + "destructiveHint", + "read_only_hint", + "destructive_hint", +) + +#: Files allowed to name what they replace. +EXEMPT = ( + "CHANGES", + "MIGRATION.md", + "tests/test_retired_vocabulary.py", + # The startup error has to say the variable it is refusing, and the + # test that proves it fires has to set it. + "src/libtmux_mcp/server.py", + "tests/test_server.py", + # A redirect must name the old path to serve it. + "docs/redirects.txt", +) + +_ROOT = pathlib.Path(__file__).resolve().parent.parent +_PATTERN = re.compile("|".join(re.escape(word) for word in RETIRED), re.IGNORECASE) + + +def _tracked_sources() -> list[pathlib.Path]: + """Return the files this gate covers.""" + paths: list[pathlib.Path] = [] + for pattern in ("src/**/*.py", "tests/**/*.py", "docs/**/*.md", "*.md"): + paths.extend( + path + for path in _ROOT.glob(pattern) + if "_build" not in path.parts and str(path.relative_to(_ROOT)) not in EXEMPT + ) + return paths + + +@pytest.mark.parametrize("path", _tracked_sources(), ids=lambda p: str(p.name)) +def test_no_file_reintroduces_the_tier_vocabulary(path: pathlib.Path) -> None: + """No source or page names a tier that no longer exists.""" + text = path.read_text(encoding="utf-8") + for name in PROTOCOL_NAMES: + text = text.replace(name, "") + + offenders = sorted({match.group(0).lower() for match in _PATTERN.finditer(text)}) + + assert not offenders, ( + f"{path.relative_to(_ROOT)} names the retired tiers {offenders}. " + f"Tools belong to unordered toolsets: inspect, manage, execute, " + f"teardown. If this file must name what it replaced, add it to " + f"EXEMPT with a reason." + ) diff --git a/tests/test_server.py b/tests/test_server.py index 888d684e..3df36eb3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -427,7 +427,7 @@ def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( The static ``_BASE_INSTRUCTIONS`` length is not the contract — ``_build_instructions`` appends a safety-tier block, an optional - readonly-tier hint, and an optional ``$TMUX_PANE`` agent-context + `inspect`-only hint, and an optional ``$TMUX_PANE`` agent-context block. The full transmitted string must be ≤ 2048 bytes for every (tier, tmux_pane) combination, otherwise Claude Code silently truncates the agent-context block — the only server-side fix for @@ -553,10 +553,10 @@ def test_scope_segment_carries_anti_triggers() -> None: def test_probe_hint_visible_only_on_an_inspect_only_surface( monkeypatch: pytest.MonkeyPatch, tier: str ) -> None: - """The 'Readonly mode:' investigation hint appears only on readonly. + """The investigation hint appears only when `inspect` is all there is. - False-positive activation is cheap on readonly (worst case: an - extra ``list_panes`` call) and expensive on mutating/destructive + A wrong guess is cheap there (worst case: an + extra ``list_panes`` call) and expensive on a surface holding (where ``kill_*`` is one mis-routed query away). Reuse the existing safety axis instead of shipping a separate discoverability knob. """ diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index e9d8e1a1..7f58f7ca 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -61,7 +61,7 @@ def advertised_tools() -> dict[str, t.Any]: Registers into a fresh server rather than the production one, whose tier filter is fixed at import: reading that one would hide the - mutating and destructive tools whenever ``LIBTMUX_SAFETY`` is set. + the write tools whenever ``LIBTMUX_TOOLSETS`` is set. """ import asyncio diff --git a/tests/test_utils.py b/tests/test_utils.py index 58f81079..c4e8f55e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -636,7 +636,7 @@ def test_serialize_pane_is_caller_requires_tmux_env_not_just_pane( def test_tag_constants() -> None: - """Safety tier tag constants are distinct strings.""" + """Toolset constants are distinct strings.""" tags = {TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN} assert len(tags) == 3 From d816d944a5d42960b1a88fd75d96c343c774a30c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:06:01 -0500 Subject: [PATCH 23/44] test(docs): Follow the retargeted refs why: Two a17 entry contracts pinned the label the trust page replaced. what: - Assert the reference those entries now carry --- tests/docs/test_topic_contracts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 6b0a2b20..6979dfc0 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -377,7 +377,7 @@ def test_a17_changelog_summarizes_history_features( assert "`suppress_persistent_history=true`" in history_entry assert "Session controls reach the initial and future panes" in history_entry assert "{ref}`history-hygiene`" in history_entry - assert "{ref}`safety`" in history_entry + assert "{ref}`trust`" in history_entry assert "space-prefixed" not in history_entry assert "{tooliconl}`create-window`" in environment_entry @@ -389,7 +389,7 @@ def test_a17_changelog_summarizes_history_features( assert "{tooliconl}`respawn-pane`" in environment_entry assert "same JSON object form" in environment_entry assert "credential references, not literal credentials" in environment_entry - assert "{ref}`safety`" in environment_entry + assert "{ref}`trust`" in environment_entry def test_annotation_table_matches_the_registered_surface( From 13e018a34194bfc1d6fe273818041abfab62df57 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:07:51 -0500 Subject: [PATCH 24/44] docs(MIGRATION): Map the tiers onto the toolsets why: A rename has to reach every instance, and an operator upgrading needs the old-to-new map in one place rather than assembled from a changelog entry. what: - Add `MIGRATION.md`: the three environment variables, the tool names, which toolset each tool is in, and a surface the ladder could not express - Say why `set_option` and `set_environment` are in `execute`: tmux runs some stored values later - Say what did not change, so nobody withdraws the MCP hints too --- MIGRATION.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..0fcad482 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,84 @@ +# Migration + +Breaking changes and how to move through them, newest first. + +## Safety tiers become toolsets + +`readonly`, `mutating` and `destructive` are gone. Tools belong to four +unordered toolsets named for what they do. + +### Environment variables + +| Before | After | +| --- | --- | +| `LIBTMUX_SAFETY=readonly` | `LIBTMUX_TOOLSETS=inspect` | +| `LIBTMUX_SAFETY=mutating` (the default) | `LIBTMUX_TOOLSETS=inspect,manage,execute` (the default) | +| `LIBTMUX_SAFETY=destructive` | `LIBTMUX_TOOLSETS=inspect,manage,execute,teardown` | + +A server started with `LIBTMUX_SAFETY` set now fails at startup naming the +replacement. It is not ignored: a variable that silently stopped working +would leave you believing a surface was narrower than it is. + +Two variables are new. `LIBTMUX_TOOLS` enables individual tools regardless +of toolset, and `LIBTMUX_EXCLUDE_TOOLS` refuses them regardless of every +enable above. An unknown name in any of the three fails startup. + +### Surfaces the tiers could not express + +The tiers accumulated upward, so every surface was a prefix of the ladder. +The toolsets are a set, so this is now legal: + +```console +$ LIBTMUX_TOOLSETS=inspect,teardown libtmux-mcp +``` + +An agent that can look and clean up, but not type. + +### Tool names + +| Before | After | +| --- | --- | +| `call_readonly_tools_batch` | `call_read_tools_batch` | +| `call_mutating_tools_batch` | removed — call the tool directly | +| `call_destructive_tools_batch` | removed — call the tool directly | + +A batch gives every nested call the wrapper's name, so a client rule keyed +on `kill_session` never fires for a `kill_session` run inside one. That is +tolerable for reads and not for writes. + +### Which toolset a tool is in + +`inspect` +: Every `list_*`, `get_*`, `show_*`, `capture_*`, `snapshot_pane`, + `search_panes`, `find_pane_by_position`, `display_message`, + `wait_for_text`, `call_read_tools_batch`. + +`manage` +: `rename_*`, `select_*`, `resize_*`, `move_window`, `swap_pane`, + `set_pane_title`, `enter_copy_mode`, `exit_copy_mode`, + `wait_for_channel`, `signal_channel`, `load_buffer`. + +`execute` +: `create_session`, `create_window`, `split_window`, `respawn_pane`, + `run_command`, `send_keys`, `send_keys_batch`, `paste_text`, + `paste_buffer`, `pipe_pane`, `set_option`, `set_environment`. + +`set_option` and `set_environment` are here rather than in `manage` +because tmux runs some stored values later: a `#(...)` job in a status +format runs when tmux draws it and repeats on the status interval, and +`default-command` decides what every future pane runs. + +`teardown` +: `kill_pane`, `kill_window`, `kill_session`, `kill_server`, `clear_pane`, + `delete_buffer`. + +### Documentation + +The safety topic is now the trust page. The old URL redirects. + +### What did not change + +The MCP annotation hints. `readOnlyHint`, `destructiveHint`, +`idempotentHint` and `openWorldHint` are the protocol's fields with the +protocol's meanings. The tiers were this project's own invention, and only +they are withdrawn. From fdaa2c69bee06d1006b55110cebf55097c378e59 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:21:43 -0500 Subject: [PATCH 25/44] docs(toolsets): Declare the vocabulary the badges need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: gp-sphinx no longer ships a default vocabulary, so a project that declares none renders every tool without a toolset badge. That is the right default there — a docs tool should not badge a project's tools with words it never used — and it makes declaring one this project's job. The pin also moves to an exact rev. A branch ref resolves to whatever that branch pointed at when the cache was warmed, which is not a thing a lockfile should depend on. what: - Declare `fastmcp_toolsets` in `docs/conf.py`, in precedence order, with the tooltip and icon each badge carries - Retitle the hand-written tool groups from Inspect/Act/Destroy to the four toolsets, splitting Act into `manage` and `execute` - Pin `sphinx-autodoc-fastmcp` to a rev rather than a branch --- docs/conf.py | 25 +++++++++++++++++++++++++ docs/tools/index.md | 16 +++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 89b98796..91be0adc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -173,6 +173,31 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any: "BufferRef", "BufferContent", ) +# sphinx-autodoc-fastmcp ships no default vocabulary: it documents +# projects whose tags it does not choose. Declaration order is +# precedence, and the summary tables follow it. +conf["fastmcp_toolsets"] = ( + { + "tag": "teardown", + "tooltip": "Teardown \u2014 deletes tmux objects or scrollback", + "icon": "\U0001f4a3", + }, + { + "tag": "execute", + "tooltip": "Execute \u2014 starts or drives a pane process", + "icon": "\u270f\ufe0f", + }, + { + "tag": "manage", + "tooltip": "Manage \u2014 changes tmux structure or presentation", + "icon": "\U0001f527", + }, + { + "tag": "inspect", + "tooltip": "Inspect \u2014 reads tmux state and terminal output", + "icon": "\U0001f50d", + }, +) conf["fastmcp_section_badge_map"] = { "Inspect": "inspect", "Execute": "execute", diff --git a/docs/tools/index.md b/docs/tools/index.md index b1ba4087..cc43c1fe 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -82,7 +82,8 @@ shell-agnostic guidance. ## Inspect -Read tmux state without changing anything. +Read tmux state and terminal output. Starts no process, and hands no input +of yours to one. ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 @@ -215,9 +216,14 @@ Inspect a single tmux hook by name. :::: -## Act +## Manage -Create or modify tmux objects. +Change tmux structure or presentation. Takes no input of yours that anything +later executes. + +## Execute + +Start a pane process, deliver input to one, or store a value tmux later runs. ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 @@ -392,9 +398,9 @@ Wake clients blocked on a ``wait-for`` channel. :::: -## Destroy +## Teardown -Tear down tmux objects. Not reversible. +Delete tmux objects or retained scrollback. Not reversible. ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 From b30adaa47908efb34e25cdae52ecd9816c8230a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:33:19 -0500 Subject: [PATCH 26/44] docs(toolsets): Give each toolset a badge tone why: The badges rendered transparent with inherited link-blue. The stylesheet keyed its colours on the old tag names, so nothing matched once the tags were renamed. gp-sphinx now ships tones and a project maps its tags onto them. what: - Declare a tone per toolset: inspect green, manage blue, execute amber, teardown red - Move the gp-sphinx pin to the commit carrying tones --- docs/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 91be0adc..2bc2e887 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -179,21 +179,25 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any: conf["fastmcp_toolsets"] = ( { "tag": "teardown", + "tone": "red", "tooltip": "Teardown \u2014 deletes tmux objects or scrollback", "icon": "\U0001f4a3", }, { "tag": "execute", + "tone": "amber", "tooltip": "Execute \u2014 starts or drives a pane process", "icon": "\u270f\ufe0f", }, { "tag": "manage", + "tone": "blue", "tooltip": "Manage \u2014 changes tmux structure or presentation", "icon": "\U0001f527", }, { "tag": "inspect", + "tone": "green", "tooltip": "Inspect \u2014 reads tmux state and terminal output", "icon": "\U0001f50d", }, From dc1018b7500671c3438bff4837c132f45f345e15 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:08:17 -0500 Subject: [PATCH 27/44] mcp(fix[hints]): Treat tmux calls conservatively why: MCP annotations describe the whole call. A target command alias can replace argv, and an after-hook can extend it. Direct operation hints promised more than an inherited tmux server can guarantee. what: - Give each tmux-requesting tool conservative whole-call annotations - Preserve direct semantics in its toolset classification - Prove list_panes can activate a command alias and an after-hook - Pin tmux wait-for signals as a consuming toggle --- src/libtmux_mcp/_utils.py | 124 ++------- src/libtmux_mcp/tools/batch_tools.py | 12 +- src/libtmux_mcp/tools/buffer_tools.py | 18 +- src/libtmux_mcp/tools/env_tools.py | 13 +- src/libtmux_mcp/tools/hook_tools.py | 43 +-- src/libtmux_mcp/tools/option_tools.py | 7 +- src/libtmux_mcp/tools/pane_tools/__init__.py | 74 ++--- src/libtmux_mcp/tools/server_tools.py | 14 +- src/libtmux_mcp/tools/session_tools.py | 17 +- src/libtmux_mcp/tools/wait_for_tools.py | 17 +- src/libtmux_mcp/tools/window_tools.py | 21 +- tests/test_batch_tools.py | 66 +---- tests/test_hook_tools.py | 2 +- tests/test_pane_tools.py | 53 ---- tests/test_tool_annotations.py | 274 ++++++++----------- tests/test_wait_for_tools.py | 37 ++- tests/test_window_tools.py | 52 ++++ 17 files changed, 336 insertions(+), 508 deletions(-) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 6f30f65a..3b9950c3 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -36,7 +36,7 @@ class ExpectedToolError(ToolError): Defaults the error's ``log_level`` to ``WARNING`` (honored by fastmcp >= 3.3 when logging tool/resource failures) so routine - validation errors, missing objects, and tier denials do not surface + validation errors, missing objects, and toolset denials do not surface as ERROR records. Unexpected failures keep stock :class:`ToolError` and its ERROR default — those are the ones operators must see. @@ -356,22 +356,22 @@ def _caller_is_strictly_on_server( # Toolsets # --------------------------------------------------------------------------- -#: Read tmux state and terminal output. Starts no process and hands no -#: caller input to one: what a client supplies is IDs, names, bounded -#: patterns, and validated variable names. +#: Request tmux state or terminal output, or render server-local prompt +#: text. The built-in operation does not pass caller input as a tmux or +#: shell command. #: #: Reading is not "safe" — a capture returns whatever a pane holds, #: including credentials and text written by a remote process — so this #: names what the tools *do*, not how much they are trusted. TOOLSET_INSPECT = "inspect" -#: Change tmux structure or presentation: names, sizes, layouts, -#: selections, modes. Starts no process, and takes no caller input that -#: anything later executes. +#: Change tmux-managed structure, presentation, staging, or coordination +#: state. The built-in operation does not supply a shell command, pane +#: input, or a value tmux treats as executable configuration. TOOLSET_MANAGE = "manage" -#: Start a pane process, deliver input to one, or store a value tmux -#: later runs. The product lives here. +#: Start a pane process, deliver input to one, or store state that can +#: control later execution. The product lives here. TOOLSET_EXECUTE = "execute" #: Delete tmux objects or retained scrollback. Irreversible at the tmux @@ -380,13 +380,13 @@ def _caller_is_strictly_on_server( #: The four toolsets, in the order startup reports them. #: -#: An unordered set, deliberately. The tiers this replaced accumulated +#: An unordered set, deliberately. The ordered model this replaced accumulated #: upward, so the kill tools could not be enabled without also enabling #: the typing tools; ``LIBTMUX_TOOLSETS=inspect,teardown`` is a legal #: surface. They group tools by what they do, for inventory -#: configuration, context reduction, and client routing. They are not -#: permissions, and filtering them is not containment: an enabled -#: execute tool can type the equivalent of anything hidden. +#: configuration, context reduction, and client routing. They control MCP +#: tool calls, not tmux authority or containment: an enabled execute tool can +#: type the equivalent of anything hidden. VALID_TOOLSETS: tuple[str, ...] = ( TOOLSET_INSPECT, TOOLSET_MANAGE, @@ -400,95 +400,15 @@ def _caller_is_strictly_on_server( # Reusable annotation presets for tool registration # --------------------------------------------------------------------------- -#: Annotations for tools that only read tmux or pane state. -ANNOTATIONS_OBSERVE: dict[str, bool] = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, -} - -#: Annotations for read tools that return terminal content. Read-only, -#: but ``openWorldHint`` is ``True``: a pane holds whatever was printed -#: into it — remote sessions, package managers, other agents — so the -#: text reaching the caller crossed a trust boundary on its way in. -ANNOTATIONS_OBSERVE_CONTENT: dict[str, bool] = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True, -} - -#: Annotations for tools that replace a value tmux already held — a name, -#: a size, a layout, a selection, an option. MCP's ``destructiveHint: false`` -#: means additive-only, which a replacement is not, so these advertise -#: ``True`` even though nothing is destroyed. Repeating the same call lands -#: on the same state, so ``idempotentHint`` stays ``True``. -ANNOTATIONS_CHANGE: dict[str, bool] = { +#: Conservative MCP defaults for calls into programmable tmux servers. +#: Aliases and hooks can replace or extend the requested operation. +ANNOTATIONS_AMBIENT_UNKNOWN: dict[str, bool] = { "readOnlyHint": False, "destructiveHint": True, - "idempotentHint": True, - "openWorldHint": False, -} - -#: Annotations for tools that allocate a new tmux object each call, so -#: nothing is replaced and no two calls land on the same state. -ANNOTATIONS_ALLOCATE: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": False, -} -#: Annotations for tools that start a pane's configured process without a -#: caller-supplied command. Additive at the tmux level, but ``openWorldHint`` -#: is ``True``: the new process runs with the user's authority and reaches -#: whatever that user reaches. -ANNOTATIONS_SPAWN: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": False, "idempotentHint": False, "openWorldHint": True, } -#: Annotations for tools that store a value tmux runs later: the status -#: formats, ``default-command`` and ``command-alias`` all execute a -#: ``#(...)`` job when the stored value is used, and a tmux environment -#: value reaches a future shell that may execute it. -#: -#: Nothing runs during the call, so ``idempotentHint`` stays ``True`` — the -#: same call lands on the same stored state. ``openWorldHint`` is ``True`` -#: because what that state later reaches does not stop at tmux. -ANNOTATIONS_DEFERRED_EXEC: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": True, - "openWorldHint": True, -} - -#: Annotations for tools that hand a caller-supplied payload to a program -#: that runs it — typed keys, a pasted buffer, an authored shell command, -#: or the command ``pipe_pane`` feeds. -#: -#: ``destructiveHint`` is ``True`` because MCP defines ``False`` as a claim -#: of additive-only updates, and such a payload can overwrite a file, end a -#: process, or leave a shell mid-line. ``openWorldHint`` is ``True`` because -#: the effect extends into whatever the payload runs. -#: -#: Contrast :data:`ANNOTATIONS_SPAWN`, which starts the pane's *configured* -#: process and carries no payload. -ANNOTATIONS_PANE_INPUT: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": False, - "openWorldHint": True, -} -ANNOTATIONS_DELETE: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": False, - "openWorldHint": False, -} - #: Per-tool MCP ``meta`` payload that hints clients to keep this tool #: always visible (not deferred). FastMCP passes ``meta`` opaquely #: (verified vs ``~/study/python/fastmcp/src`` — no special handling); @@ -496,7 +416,7 @@ def _caller_is_strictly_on_server( #: documented at https://code.claude.com/docs/en/mcp (v2.1.121+). #: #: Best-effort by design — safe no-op for clients that don't index the -#: ``anthropic/*`` namespace. Apply only to read-tier discovery anchors +#: ``anthropic/*`` namespace. Apply only to inspect discovery anchors #: (``list_panes``, ``list_windows``, ``snapshot_pane``); each #: always-loaded tool consumes a fixed schema budget in clients that #: honour the hint, so widening the set has a real cost. @@ -1209,11 +1129,8 @@ def handle_tool_errors( at WARNING), the unexpected catch-all as stock ``ToolError`` (logged at ERROR). - The re-raise chains the original exception via ``from e``. Keep it - single-level: :class:`~libtmux_mcp.middleware.InspectRetryMiddleware` - matches :exc:`libtmux.exc.LibTmuxException` by inspecting exactly - one ``__cause__`` hop, so wrapping the mapped error again would - silently disable ``inspect`` retries. + The re-raise chains the original exception via ``from e`` so logs and + debuggers retain the libtmux cause behind the caller-facing error. Use :func:`handle_tool_errors_async` for ``async def`` tools — this wrapper only supports plain sync callables. @@ -1245,8 +1162,7 @@ def handle_tool_errors_async( error classes as the sync decorator (expected failures as :class:`ExpectedToolError` at WARNING, the unexpected catch-all as stock ``ToolError`` at ERROR) by delegating to a shared helper, - and chains the original exception via the same single-level - ``from e`` that ``inspect`` retries depend on. + and chains the original exception via ``from e`` for diagnostics. """ @functools.wraps(fn) diff --git a/src/libtmux_mcp/tools/batch_tools.py b/src/libtmux_mcp/tools/batch_tools.py index 7a278787..fbc5cfb6 100644 --- a/src/libtmux_mcp/tools/batch_tools.py +++ b/src/libtmux_mcp/tools/batch_tools.py @@ -11,6 +11,7 @@ from pydantic import BaseModel from libtmux_mcp._utils import ( + ANNOTATIONS_AMBIENT_UNKNOWN, TAG_SELF_BOUNDED, TOOLSET_INSPECT, ExpectedToolError, @@ -39,15 +40,6 @@ } ] -#: The read batch can invoke a member that returns pane text, so it -#: carries that member's ``openWorldHint``. -_ANNOTATIONS_BATCH_READ: dict[str, bool] = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": True, -} - def _content_block_to_dict(block: t.Any) -> dict[str, t.Any]: """Return a JSON-ready representation of an MCP content block.""" @@ -283,6 +275,6 @@ def register(mcp: FastMCP) -> None: """Register generic MCP batch tools.""" mcp.tool( title="Call Read Tools Batch", - annotations=_ANNOTATIONS_BATCH_READ, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(call_read_tools_batch) diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index a668be91..0707214e 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -35,10 +35,7 @@ import uuid from libtmux_mcp._utils import ( - ANNOTATIONS_ALLOCATE, - ANNOTATIONS_DELETE, - ANNOTATIONS_OBSERVE_CONTENT, - ANNOTATIONS_PANE_INPUT, + ANNOTATIONS_AMBIENT_UNKNOWN, TOOLSET_EXECUTE, TOOLSET_INSPECT, TOOLSET_MANAGE, @@ -383,27 +380,26 @@ def delete_buffer( def register(mcp: FastMCP) -> None: """Register buffer tools with the MCP instance. - ``load_buffer`` stages content into a buffer it allocates, so it is - additive and closed-world. ``paste_buffer`` is what delivers that - content to a pane's program, and carries the hints for it. + ``load_buffer`` stages inert content, while ``paste_buffer`` delivers + that content to a pane's program. Their toolsets preserve that boundary. """ mcp.tool( title="Load tmux Buffer", - annotations=ANNOTATIONS_ALLOCATE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(load_buffer) mcp.tool( title="Paste tmux Buffer", - annotations=ANNOTATIONS_PANE_INPUT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(paste_buffer) mcp.tool( title="Show tmux Buffer", - annotations=ANNOTATIONS_OBSERVE_CONTENT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(show_buffer) mcp.tool( title="Delete tmux Buffer", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_TEARDOWN}, )(delete_buffer) diff --git a/src/libtmux_mcp/tools/env_tools.py b/src/libtmux_mcp/tools/env_tools.py index f7178a15..39200947 100644 --- a/src/libtmux_mcp/tools/env_tools.py +++ b/src/libtmux_mcp/tools/env_tools.py @@ -5,8 +5,7 @@ import typing as t from libtmux_mcp._utils import ( - ANNOTATIONS_DEFERRED_EXEC, - ANNOTATIONS_OBSERVE, + ANNOTATIONS_AMBIENT_UNKNOWN, TOOLSET_EXECUTE, TOOLSET_INSPECT, _get_server, @@ -71,9 +70,9 @@ def set_environment( Use to set variables that will be inherited by new panes and windows. Changes do not affect already-running processes. - A shell reads some variables as code — ``BASH_ENV``, ``ENV`` and - ``PROMPT_COMMAND`` among them — so a value set here can run in a pane - started later. + Some variables control later shell execution. Bash sources the file named + by ``BASH_ENV``, POSIX shells may source the file named by ``ENV``, and + Bash evaluates ``PROMPT_COMMAND`` before displaying a prompt. .. warning:: Values set here propagate into **every** shell tmux later spawns @@ -125,11 +124,11 @@ def register(mcp: FastMCP) -> None: """Register environment tools with the MCP instance.""" mcp.tool( title="Show tmux Environment", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(show_environment) mcp.tool( title="Set tmux Environment", - annotations=ANNOTATIONS_DEFERRED_EXEC, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(set_environment) diff --git a/src/libtmux_mcp/tools/hook_tools.py b/src/libtmux_mcp/tools/hook_tools.py index c037c65c..17cc883f 100644 --- a/src/libtmux_mcp/tools/hook_tools.py +++ b/src/libtmux_mcp/tools/hook_tools.py @@ -1,29 +1,8 @@ -"""Read-only MCP tools for tmux hook introspection. - -Why read-only only ------------------- -Write-hooks (``set-hook`` / ``unset-hook``) are deliberately excluded. -The reason is side-effect leakage: tmux servers outlive the MCP -process, so if an MCP agent installs a hook that runs arbitrary shell -on ``pane-exited`` or ``command-error`` and then the MCP server is -``kill -9``'d, OOM'd, or crashes via a C-extension fault, the hook -**stays installed** in the user's persistent tmux server and fires -forever. - -FastMCP ``lifespan`` teardown only runs on graceful SIGTERM/SIGINT, so -a soft "track what we installed and unset on shutdown" registry cannot -close this gap. Three plausible future paths are open: - -* Install a tmux-side meta-hook on ``client-detached`` that self-cleans - all ``libtmux_mcp_*``-namespaced hooks when the MCP client disconnects. - Survives hard crashes because tmux enforces it. -* Require ``LIBTMUX_TOOLSETS`` to include ``teardown`` for write-hooks so leakage is - an explicit opt-in with user awareness. -* Expose ``run_hook`` (one-shot fire) but not ``set_hook`` (persistent - install) — narrows the risk surface to transient events. - -Until one is implemented, the surface here is deliberately visibility -only. +"""MCP tools for inspecting tmux hooks. + +Hooks can outlive the MCP process and run tmux command lists. Cleanup cannot be +guaranteed after SIGKILL, OOM, or a native crash, so this server exposes no +dedicated hook-write tool. Keep intentional persistent hooks in tmux configuration. """ from __future__ import annotations @@ -34,7 +13,7 @@ from libtmux.constants import OptionScope from libtmux_mcp._utils import ( - ANNOTATIONS_OBSERVE, + ANNOTATIONS_AMBIENT_UNKNOWN, TOOLSET_INSPECT, ExpectedToolError, _get_server, @@ -260,10 +239,14 @@ def show_hook( def register(mcp: FastMCP) -> None: - """Register read-only hook tools with the MCP instance.""" + """Register hook inspection tools with the MCP instance.""" mcp.tool( - title="Show tmux Hooks", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + title="Show tmux Hooks", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_INSPECT}, )(show_hooks) mcp.tool( - title="Show tmux Hook", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + title="Show tmux Hook", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_INSPECT}, )(show_hook) diff --git a/src/libtmux_mcp/tools/option_tools.py b/src/libtmux_mcp/tools/option_tools.py index c31f156f..1e25dc7e 100644 --- a/src/libtmux_mcp/tools/option_tools.py +++ b/src/libtmux_mcp/tools/option_tools.py @@ -7,8 +7,7 @@ from libtmux.constants import OptionScope from libtmux_mcp._utils import ( - ANNOTATIONS_DEFERRED_EXEC, - ANNOTATIONS_OBSERVE, + ANNOTATIONS_AMBIENT_UNKNOWN, TOOLSET_EXECUTE, TOOLSET_INSPECT, ExpectedToolError, @@ -153,11 +152,11 @@ def register(mcp: FastMCP) -> None: """Register option tools with the MCP instance.""" mcp.tool( title="Show tmux Option", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(show_option) mcp.tool( title="Set tmux Option", - annotations=ANNOTATIONS_DEFERRED_EXEC, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(set_option) diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index 5c27d552..f8b81d01 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -12,11 +12,7 @@ import typing as t from libtmux_mcp._utils import ( - ANNOTATIONS_CHANGE, - ANNOTATIONS_DELETE, - ANNOTATIONS_OBSERVE, - ANNOTATIONS_OBSERVE_CONTENT, - ANNOTATIONS_PANE_INPUT, + ANNOTATIONS_AMBIENT_UNKNOWN, DISCOVERY_META, TAG_SELF_BOUNDED, TOOLSET_EXECUTE, @@ -84,11 +80,13 @@ def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" mcp.tool( - title="Send Keys", annotations=ANNOTATIONS_PANE_INPUT, tags={TOOLSET_EXECUTE} + title="Send Keys", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_EXECUTE}, )(send_keys) mcp.tool( title="Send Keys Batch", - annotations=ANNOTATIONS_PANE_INPUT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(send_keys_batch) # run_command blocks on ``tmux wait-for`` under the same wait @@ -97,90 +95,104 @@ def register(mcp: FastMCP) -> None: # the operation count. Use send_keys_batch for command sequences. mcp.tool( title="Run Command", - annotations=ANNOTATIONS_PANE_INPUT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE, TAG_SELF_BOUNDED}, )(run_command) mcp.tool( title="Capture Pane", - annotations=ANNOTATIONS_OBSERVE_CONTENT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(capture_pane) mcp.tool( title="Capture Since", - annotations=ANNOTATIONS_OBSERVE_CONTENT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(capture_since) mcp.tool( - title="Resize Pane", annotations=ANNOTATIONS_CHANGE, tags={TOOLSET_MANAGE} + title="Resize Pane", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_MANAGE}, )(resize_pane) mcp.tool( title="Kill Pane", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_TEARDOWN}, )(kill_pane) mcp.tool( title="Respawn Pane", - annotations=ANNOTATIONS_PANE_INPUT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(respawn_pane) mcp.tool( - title="Set Pane Title", annotations=ANNOTATIONS_CHANGE, tags={TOOLSET_MANAGE} + title="Set Pane Title", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_MANAGE}, )(set_pane_title) mcp.tool( - title="Get Pane Info", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + title="Get Pane Info", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_INSPECT}, )(get_pane_info) mcp.tool( title="Find Pane By Position", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(find_pane_by_position) mcp.tool( title="Clear Pane", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_TEARDOWN}, )(clear_pane) mcp.tool( title="Search Panes", - annotations=ANNOTATIONS_OBSERVE_CONTENT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(search_panes) - # TAG_SELF_BOUNDED excludes this tool from retry and from batch - # wrappers: both would multiply the wait ceiling it enforces. + # TAG_SELF_BOUNDED excludes this tool from batch wrappers, which + # would multiply the wait ceiling it enforces. mcp.tool( title="Wait For Text", - annotations=ANNOTATIONS_OBSERVE_CONTENT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED}, )(wait_for_text) mcp.tool( title="Snapshot Pane", - annotations=ANNOTATIONS_OBSERVE_CONTENT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, meta=DISCOVERY_META, )(snapshot_pane) mcp.tool( - title="Select Pane", annotations=ANNOTATIONS_CHANGE, tags={TOOLSET_MANAGE} + title="Select Pane", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_MANAGE}, )(select_pane) - mcp.tool(title="Swap Pane", annotations=ANNOTATIONS_DELETE, tags={TOOLSET_MANAGE})( - swap_pane - ) mcp.tool( - title="Pipe Pane", annotations=ANNOTATIONS_PANE_INPUT, tags={TOOLSET_EXECUTE} + title="Swap Pane", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_MANAGE}, + )(swap_pane) + mcp.tool( + title="Pipe Pane", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_EXECUTE}, )(pipe_pane) mcp.tool( title="Evaluate tmux Format String", - annotations=ANNOTATIONS_OBSERVE_CONTENT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(display_message) mcp.tool( title="Enter Copy Mode", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(enter_copy_mode) mcp.tool( title="Exit Copy Mode", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(exit_copy_mode) mcp.tool( - title="Paste Text", annotations=ANNOTATIONS_PANE_INPUT, tags={TOOLSET_EXECUTE} + title="Paste Text", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_EXECUTE}, )(paste_text) diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index d7477d87..02a71313 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -13,9 +13,7 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_DELETE, - ANNOTATIONS_OBSERVE, - ANNOTATIONS_SPAWN, + ANNOTATIONS_AMBIENT_UNKNOWN, TOOLSET_EXECUTE, TOOLSET_INSPECT, TOOLSET_TEARDOWN, @@ -363,26 +361,26 @@ def register(mcp: FastMCP) -> None: """Register server-level tools with the MCP instance.""" mcp.tool( title="List tmux Sessions", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(list_sessions) mcp.tool( title="List tmux Servers", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(list_servers) mcp.tool( title="Create tmux Session", - annotations=ANNOTATIONS_SPAWN, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(create_session) mcp.tool( title="Kill tmux Server", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_TEARDOWN}, )(kill_server) mcp.tool( title="Get tmux Server Info", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(get_server_info) diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index e6918ae4..6dbfbdf0 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -8,10 +8,7 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_CHANGE, - ANNOTATIONS_DELETE, - ANNOTATIONS_OBSERVE, - ANNOTATIONS_SPAWN, + ANNOTATIONS_AMBIENT_UNKNOWN, DISCOVERY_META, TOOLSET_EXECUTE, TOOLSET_INSPECT, @@ -355,32 +352,32 @@ def register(mcp: FastMCP) -> None: """Register session-level tools with the MCP instance.""" mcp.tool( title="List tmux Windows", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, meta=DISCOVERY_META, )(list_windows) mcp.tool( title="Get tmux Session Info", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(get_session_info) mcp.tool( title="Create tmux Window", - annotations=ANNOTATIONS_SPAWN, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(create_window) mcp.tool( title="Rename tmux Session", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(rename_session) mcp.tool( title="Kill tmux Session", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_TEARDOWN}, )(kill_session) mcp.tool( title="Select tmux Window", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(select_window) diff --git a/src/libtmux_mcp/tools/wait_for_tools.py b/src/libtmux_mcp/tools/wait_for_tools.py index 67639edd..f987facc 100644 --- a/src/libtmux_mcp/tools/wait_for_tools.py +++ b/src/libtmux_mcp/tools/wait_for_tools.py @@ -42,7 +42,7 @@ from libtmux_mcp._tmux_proc import _run_tmux_bounded from libtmux_mcp._utils import ( - ANNOTATIONS_DELETE, + ANNOTATIONS_AMBIENT_UNKNOWN, TAG_SELF_BOUNDED, TOOLSET_MANAGE, ExpectedToolError, @@ -261,10 +261,11 @@ async def signal_channel( channel: str, socket_name: str | None = None, ) -> str: - """Signal a tmux ``wait-for`` channel, waking any blocked waiters. + """Signal a tmux ``wait-for`` channel, waking blocked waiters. - Signalling an unwaited channel is a no-op that still returns - successfully — safe to call defensively. + With no current waiter, tmux records one pending signal for the + next waiter to consume. Signalling that channel again clears it. + A woken waiter continues whatever work follows its wait. Parameters ---------- @@ -284,8 +285,8 @@ async def signal_channel( # Deliberately still a worker thread, unlike every other tmux call # in this package. The orphan-on-cancel defect that pushed the # waits onto ``_run_tmux_bounded`` needs a child that blocks for a - # caller-chosen duration; ``wait-for -S`` is edge-triggered and - # returns in milliseconds, so the worst case here is a 5 s child + # caller-chosen duration; ``wait-for -S`` does not block, so the + # worst case here is a 5 s child # against an already-wedged tmux — and that bound is ours, not the # caller's. Converting it would buy nothing and change this tool's # error messages. @@ -320,11 +321,11 @@ def register(mcp: FastMCP) -> None: # which is what the tag asserts. mcp.tool( title="Wait For tmux Channel", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE, TAG_SELF_BOUNDED}, )(wait_for_channel) mcp.tool( title="Signal tmux Channel", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(signal_channel) diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index b8795b5f..a170de44 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -8,10 +8,7 @@ from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( - ANNOTATIONS_CHANGE, - ANNOTATIONS_DELETE, - ANNOTATIONS_OBSERVE, - ANNOTATIONS_PANE_INPUT, + ANNOTATIONS_AMBIENT_UNKNOWN, DISCOVERY_META, TOOLSET_EXECUTE, TOOLSET_INSPECT, @@ -503,42 +500,42 @@ def register(mcp: FastMCP) -> None: """Register window-level tools with the MCP instance.""" mcp.tool( title="List tmux Panes", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, meta=DISCOVERY_META, )(list_panes) mcp.tool( title="Get tmux Window Info", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT}, )(get_window_info) mcp.tool( title="Split tmux Window", - annotations=ANNOTATIONS_PANE_INPUT, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_EXECUTE}, )(split_window) mcp.tool( title="Rename tmux Window", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(rename_window) mcp.tool( title="Kill tmux Window", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_TEARDOWN}, )(kill_window) mcp.tool( title="Select tmux Layout", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(select_layout) mcp.tool( title="Resize tmux Window", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(resize_window) mcp.tool( title="Move tmux Window", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, )(move_window) diff --git a/tests/test_batch_tools.py b/tests/test_batch_tools.py index bac92eae..fb81a1d5 100644 --- a/tests/test_batch_tools.py +++ b/tests/test_batch_tools.py @@ -9,9 +9,7 @@ import pytest from libtmux_mcp._utils import ( - ANNOTATIONS_CHANGE, - ANNOTATIONS_DELETE, - ANNOTATIONS_OBSERVE, + ANNOTATIONS_AMBIENT_UNKNOWN, TAG_SELF_BOUNDED, TOOLSET_EXECUTE, TOOLSET_INSPECT, @@ -19,7 +17,6 @@ TOOLSET_TEARDOWN, VALID_TOOLSETS, ) -from tests.conftest import wire_annotations if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -55,29 +52,6 @@ class BatchOperationLimitFixture(t.NamedTuple): ] -class BatchAnnotationFixture(t.NamedTuple): - """Test fixture for generic batch wrapper annotations.""" - - test_id: str - tool_name: str - read_only_hint: bool - destructive_hint: bool - idempotent_hint: bool - open_world_hint: bool - - -BATCH_ANNOTATION_FIXTURES: list[BatchAnnotationFixture] = [ - BatchAnnotationFixture( - test_id="read_batch_carries_its_members_open_world", - tool_name="call_read_tools_batch", - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=True, - ), -] - - def _content_block_to_wire(block: t.Any) -> dict[str, t.Any]: if hasattr(block, "model_dump"): dumped = block.model_dump(mode="json", by_alias=True, exclude_none=True) @@ -95,7 +69,7 @@ def _call_tool_result_wire(result: t.Any) -> dict[str, t.Any]: def _batch_probe_server() -> FastMCP: - """Build a small FastMCP server with batch tools and tiered probes.""" + """Build a small FastMCP server with batch tools and tagged probes.""" from fastmcp import FastMCP from libtmux_mcp.middleware import ToolErrorResultMiddleware, ToolsetMiddleware @@ -111,14 +85,16 @@ def _batch_probe_server() -> FastMCP: register_batch_tools(mcp) @mcp.tool( - title="Inspect Probe", annotations=ANNOTATIONS_OBSERVE, tags={TOOLSET_INSPECT} + title="Inspect Probe", + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, + tags={TOOLSET_INSPECT}, ) def inspect_probe(value: str) -> dict[str, str]: return {"value": value} @mcp.tool( title="Manage Probe", - annotations=ANNOTATIONS_CHANGE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_MANAGE}, ) def manage_probe(value: str) -> dict[str, str]: @@ -126,7 +102,7 @@ def manage_probe(value: str) -> dict[str, str]: @mcp.tool( title="Teardown Probe", - annotations=ANNOTATIONS_DELETE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_TEARDOWN}, ) def teardown_probe(value: str) -> dict[str, str]: @@ -134,7 +110,7 @@ def teardown_probe(value: str) -> dict[str, str]: @mcp.tool( title="Self Bounded Probe", - annotations=ANNOTATIONS_OBSERVE, + annotations=ANNOTATIONS_AMBIENT_UNKNOWN, tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED}, ) def self_bounded_probe(value: str) -> dict[str, str]: @@ -173,7 +149,6 @@ async def _call() -> t.Any: def test_batch_rejects_a_self_bounded_tool() -> None: """A ``TAG_SELF_BOUNDED`` tool is rejected by the batch wrapper. - ``max_tier`` is a *ceiling* (``_TIER_LEVELS[tool_tier] <= The batch loop is serial with no aggregate deadline and ``MAX_BATCH_OPERATIONS`` is 1000, so a wait tool batched N times would cost N x its ceiling — the wrapper is a cap amplifier unless @@ -455,28 +430,3 @@ async def _call() -> t.Any: [operation] = result.structured_content["results"] assert operation["success"] is False assert "cannot call batch tools recursively" in operation["error"] - - -@pytest.mark.parametrize( - BatchAnnotationFixture._fields, - BATCH_ANNOTATION_FIXTURES, - ids=[fixture.test_id for fixture in BATCH_ANNOTATION_FIXTURES], -) -def test_batch_wrappers_advertise_worst_case_annotations( - test_id: str, - tool_name: str, - read_only_hint: bool, - destructive_hint: bool, - idempotent_hint: bool, - open_world_hint: bool, -) -> None: - """Batch wrappers advertise the strongest hint from their allowed tools.""" - mcp = _batch_probe_server() - - tool = asyncio.run(mcp.get_tool(tool_name)) - assert tool is not None, f"{tool_name} should be registered" - assert tool.annotations is not None, f"{tool_name} should carry annotations" - assert wire_annotations(tool).get("readOnlyHint") is read_only_hint - assert wire_annotations(tool).get("destructiveHint") is destructive_hint - assert wire_annotations(tool).get("idempotentHint") is idempotent_hint - assert wire_annotations(tool).get("openWorldHint") is open_world_hint diff --git a/tests/test_hook_tools.py b/tests/test_hook_tools.py index 4545c3ed..0995ddc4 100644 --- a/tests/test_hook_tools.py +++ b/tests/test_hook_tools.py @@ -1,4 +1,4 @@ -"""Tests for read-only tmux hook introspection tools.""" +"""Tests for tmux hook inspection tools.""" from __future__ import annotations diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 11d76f0c..9c7034cd 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -48,7 +48,6 @@ swap_pane, wait_for_text, ) -from tests.conftest import wire_annotations if t.TYPE_CHECKING: from libtmux.pane import Pane @@ -5516,58 +5515,6 @@ def test_paste_text_does_not_leak_named_buffer( ) -# --------------------------------------------------------------------------- -# Registration-time annotation verification -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - ("tool_name", "expected_open_world"), - [ - ("swap_pane", False), - ("enter_copy_mode", False), - ], -) -def test_pane_tool_open_world_hint_registration( - tool_name: str, expected_open_world: bool -) -> None: - """Pane tools that only rearrange tmux state stay closed-world.""" - import asyncio - - from fastmcp import FastMCP - - from libtmux_mcp.tools import pane_tools - - mcp = FastMCP(name="test-pane-annotations") - pane_tools.register(mcp) - - tool = asyncio.run(mcp.get_tool(tool_name)) - assert tool is not None, f"{tool_name} should be registered" - assert tool.annotations is not None, ( - f"{tool_name} registration should carry annotations" - ) - assert wire_annotations(tool).get("openWorldHint") is expected_open_world - - -def test_clear_pane_advertises_removal_hints() -> None: - """``clear_pane`` is in ``teardown`` and says what it removes.""" - import asyncio - - from fastmcp import FastMCP - - from libtmux_mcp.tools import pane_tools - - mcp = FastMCP(name="test-clear-pane-annotations") - pane_tools.register(mcp) - - tool = asyncio.run(mcp.get_tool("clear_pane")) - assert tool is not None, "clear_pane should be registered" - assert tool.annotations is not None, "clear_pane should carry annotations" - assert wire_annotations(tool).get("destructiveHint") is True - assert wire_annotations(tool).get("idempotentHint") is False - assert wire_annotations(tool).get("readOnlyHint") is False - - # --------------------------------------------------------------------------- # Typed-output regression guard # --------------------------------------------------------------------------- diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py index 7f58f7ca..0159ae5e 100644 --- a/tests/test_tool_annotations.py +++ b/tests/test_tool_annotations.py @@ -1,10 +1,4 @@ -"""Tests that each tool's advertised MCP hints match what it does. - -MCP defines ``destructiveHint: false`` as a positive claim of -additive-only updates and ``true`` as the cautious default, so a hint -is a statement to every connected client rather than a severity label. -These tests assert the statement per tool. -""" +"""Tests for standard MCP hints and project-owned toolsets.""" from __future__ import annotations @@ -12,47 +6,96 @@ import pytest +from libtmux_mcp._utils import ( + TOOLSET_EXECUTE, + TOOLSET_INSPECT, + TOOLSET_MANAGE, + TOOLSET_TEARDOWN, + VALID_TOOLSETS, +) from libtmux_mcp.tools import register_tools from .conftest import wire_annotations -#: Tools that store a value tmux runs later. `set-option` holds the -#: status formats, `default-command` and `command-alias`, where a -#: `#(...)` job runs when the value is used and can recur; a tmux -#: environment value reaches a future shell that may execute it. -#: Nothing runs during the call, so the reach is real but deferred. -DEFERRED_EXECUTION_TOOLS = frozenset({"set_environment", "set_option"}) - -#: Tools that start a process. The pane's program runs with the user's -#: authority and reaches whatever that user reaches, so the effect does -#: not stop at tmux. -SPAWN_TOOLS = frozenset( - { - "create_session", - "create_window", - "respawn_pane", - "split_window", - } -) - -#: Spawn tools that additionally accept a command string to run in place -#: of the pane's configured process. -AUTHORED_COMMAND_TOOLS = frozenset({"respawn_pane", "split_window"}) - -#: Tools whose caller-supplied payload reaches a program that runs it — -#: a shell prompt, a pane's process, or the command ``pipe_pane`` feeds. -#: Membership is a fact about where the value lands, not about the -#: parameter's name, so it is listed rather than derived from a schema. -PANE_INPUT_TOOLS = frozenset( - { - "paste_buffer", - "paste_text", - "pipe_pane", - "run_command", - "send_keys", - "send_keys_batch", - } -) +CONSERVATIVE_ANNOTATIONS = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": True, +} + +EXPECTED_TOOLS_BY_TOOLSET = { + TOOLSET_INSPECT: frozenset( + { + "call_read_tools_batch", + "capture_pane", + "capture_since", + "display_message", + "find_pane_by_position", + "get_pane_info", + "get_server_info", + "get_session_info", + "get_window_info", + "list_panes", + "list_servers", + "list_sessions", + "list_windows", + "search_panes", + "show_buffer", + "show_environment", + "show_hook", + "show_hooks", + "show_option", + "snapshot_pane", + "wait_for_text", + } + ), + TOOLSET_MANAGE: frozenset( + { + "enter_copy_mode", + "exit_copy_mode", + "load_buffer", + "move_window", + "rename_session", + "rename_window", + "resize_pane", + "resize_window", + "select_layout", + "select_pane", + "select_window", + "set_pane_title", + "signal_channel", + "swap_pane", + "wait_for_channel", + } + ), + TOOLSET_EXECUTE: frozenset( + { + "create_session", + "create_window", + "paste_buffer", + "paste_text", + "pipe_pane", + "respawn_pane", + "run_command", + "send_keys", + "send_keys_batch", + "set_environment", + "set_option", + "split_window", + } + ), + TOOLSET_TEARDOWN: frozenset( + { + "clear_pane", + "delete_buffer", + "kill_pane", + "kill_server", + "kill_session", + "kill_window", + } + ), +} @pytest.fixture(scope="module") @@ -60,140 +103,57 @@ def advertised_tools() -> dict[str, t.Any]: """Return every registered tool keyed by name, as clients see it. Registers into a fresh server rather than the production one, whose - tier filter is fixed at import: reading that one would hide the - the write tools whenever ``LIBTMUX_TOOLSETS`` is set. + toolset filter is fixed at import and may hide tools. """ import asyncio - from fastmcp import FastMCP + from fastmcp import Client, FastMCP mcp = FastMCP(name="test-tool-annotations") register_tools(mcp) - tools = asyncio.run(mcp.list_tools()) - return {tool.name: tool for tool in tools} + async def _list_tools() -> list[t.Any]: + async with Client(mcp) as client: + return list(await client.list_tools()) -def test_every_tool_advertises_all_four_hints( - advertised_tools: dict[str, t.Any], -) -> None: - """No tool leaves a client to fall back on a protocol default.""" - expected = { - "readOnlyHint", - "destructiveHint", - "idempotentHint", - "openWorldHint", - } - for name, tool in advertised_tools.items(): - assert set(wire_annotations(tool)) >= expected, name - - -def test_every_pane_input_tool_is_registered( - advertised_tools: dict[str, t.Any], -) -> None: - """The list below names tools that exist, so a rename cannot mute it.""" - assert set(advertised_tools) >= PANE_INPUT_TOOLS + tools = asyncio.run(_list_tools()) + return {tool.name: tool for tool in tools} -@pytest.mark.parametrize("name", sorted(PANE_INPUT_TOOLS)) -def test_pane_input_tools_do_not_claim_additive_updates( - advertised_tools: dict[str, t.Any], - name: str, -) -> None: - """Input a program executes is not an additive update, and never repeats.""" - hints = wire_annotations(advertised_tools[name]) +def _wire_tags(tool: t.Any) -> set[str]: + dumped = tool.model_dump(mode="json", by_alias=True, exclude_none=True) + return set(dumped.get("_meta", {}).get("fastmcp", {}).get("tags", [])) - assert hints["destructiveHint"] is True - assert hints["idempotentHint"] is False - assert hints["openWorldHint"] is True - -@pytest.mark.parametrize("name", sorted(SPAWN_TOOLS)) -def test_spawn_tools_are_advertised_open_world( +def test_every_tool_advertises_conservative_mcp_hints( advertised_tools: dict[str, t.Any], - name: str, ) -> None: - """A pane's program runs with the user's authority, not inside tmux.""" - assert wire_annotations(advertised_tools[name])["openWorldHint"] is True + """No static hint promises how a programmable tmux target will behave.""" + for name, tool in advertised_tools.items(): + assert wire_annotations(tool) == CONSERVATIVE_ANNOTATIONS, name -@pytest.mark.parametrize("name", sorted(AUTHORED_COMMAND_TOOLS)) -def test_authored_command_spawns_do_not_claim_additive_updates( +def test_every_tool_keeps_one_project_owned_toolset( advertised_tools: dict[str, t.Any], - name: str, ) -> None: - """A caller-authored command replaces what the pane would have run.""" - assert wire_annotations(advertised_tools[name])["destructiveHint"] is True + """The direct-operation taxonomy remains visible on the MCP wire.""" + known = set(VALID_TOOLSETS) + for name, tool in advertised_tools.items(): + assert len(_wire_tags(tool) & known) == 1, name -@pytest.mark.parametrize("name", sorted(SPAWN_TOOLS)) -def test_spawn_tools_are_not_idempotent( - advertised_tools: dict[str, t.Any], - name: str, -) -> None: - """Calling a spawn again starts another process; it does not settle.""" - assert wire_annotations(advertised_tools[name])["idempotentHint"] is False - - -#: The only tools whose updates are additive-only. Everything else that -#: writes replaces or removes prior state, which MCP spells -#: ``destructiveHint: true`` however small the change. -ADDITIVE_TOOLS = frozenset( - { - "create_session", - "create_window", - "load_buffer", - } +@pytest.mark.parametrize( + ("toolset", "expected"), + EXPECTED_TOOLS_BY_TOOLSET.items(), + ids=EXPECTED_TOOLS_BY_TOOLSET, ) - - -def test_only_additive_tools_claim_additive_updates( +def test_toolset_memberships_match_direct_operations( advertised_tools: dict[str, t.Any], + toolset: str, + expected: frozenset[str], ) -> None: - """A new tool cannot quietly claim additive-only updates.""" - for name, tool in advertised_tools.items(): - hints = wire_annotations(tool) - if hints["readOnlyHint"]: - continue - assert hints["destructiveHint"] is (name not in ADDITIVE_TOOLS), name - - -#: Read tools that return terminal content. What a pane holds arrived -#: from somewhere else — an SSH session, a package manager, a remote -#: agent — so the text crossed a trust boundary before this server saw -#: it, which is what ``openWorldHint`` tells a client. -TERMINAL_CONTENT_TOOLS = frozenset( - { - "capture_pane", - "capture_since", - "search_panes", - "show_buffer", - "snapshot_pane", - "wait_for_text", + """Each toolset keeps its reviewed direct-operation inventory.""" + actual = { + name for name, tool in advertised_tools.items() if toolset in _wire_tags(tool) } -) - - -@pytest.mark.parametrize("name", sorted(TERMINAL_CONTENT_TOOLS)) -def test_terminal_content_reads_are_advertised_open_world( - advertised_tools: dict[str, t.Any], - name: str, -) -> None: - """Returned pane text is untrusted, however read-only the call was.""" - assert wire_annotations(advertised_tools[name])["openWorldHint"] is True - - -def test_the_read_batch_carries_its_members_open_world_hint( - advertised_tools: dict[str, t.Any], -) -> None: - """A batch advertises the worst case of what it can invoke.""" - batch = advertised_tools["call_read_tools_batch"] - assert wire_annotations(batch)["openWorldHint"] is True - - -@pytest.mark.parametrize("name", sorted(DEFERRED_EXECUTION_TOOLS)) -def test_deferred_execution_tools_are_advertised_open_world( - advertised_tools: dict[str, t.Any], - name: str, -) -> None: - """A stored value that tmux runs later still reaches past tmux.""" - assert wire_annotations(advertised_tools[name])["openWorldHint"] is True + assert actual == expected diff --git a/tests/test_wait_for_tools.py b/tests/test_wait_for_tools.py index bd81a98a..d0aa70f3 100644 --- a/tests/test_wait_for_tools.py +++ b/tests/test_wait_for_tools.py @@ -59,22 +59,51 @@ def test_channel_tools_are_coroutines() -> None: @pytest.mark.usefixtures("mcp_session") -def test_signal_channel_no_waiter_is_noop(mcp_server: Server) -> None: - """``tmux wait-for -S`` on an unwaited channel returns successfully. +def test_signal_channel_before_wait_records_one_signal(mcp_server: Server) -> None: + """One pre-signal is retained; a second clears the channel. The ``mcp_session`` fixture is required even though the test does not touch it — the bare ``mcp_server`` fixture only constructs an unstarted Server instance, so ``mcp_session`` is what actually boots the tmux process. """ - result = asyncio.run( + asyncio.run( signal_channel( - channel="wf_test_noop", + channel="wf_test_pending", + socket_name=mcp_server.socket_name, + ) + ) + result = asyncio.run( + wait_for_channel( + channel="wf_test_pending", + timeout=2.0, socket_name=mcp_server.socket_name, ) ) + assert "signalled" in result + asyncio.run( + signal_channel( + channel="wf_test_pending", + socket_name=mcp_server.socket_name, + ) + ) + asyncio.run( + signal_channel( + channel="wf_test_pending", + socket_name=mcp_server.socket_name, + ) + ) + with pytest.raises(ToolError, match="was not signalled"): + asyncio.run( + wait_for_channel( + channel="wf_test_pending", + timeout=0.1, + socket_name=mcp_server.socket_name, + ) + ) + @pytest.mark.usefixtures("mcp_session") def test_wait_for_channel_returns_when_signalled(mcp_server: Server) -> None: diff --git a/tests/test_window_tools.py b/tests/test_window_tools.py index ba893d51..4f9654fd 100644 --- a/tests/test_window_tools.py +++ b/tests/test_window_tools.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import shlex import typing as t import pytest @@ -19,6 +21,8 @@ ) if t.TYPE_CHECKING: + import pathlib + from libtmux.server import Server from libtmux.session import Session @@ -35,6 +39,54 @@ def test_list_panes(mcp_server: Server, mcp_session: Session) -> None: assert result[0].pane_id is not None +def test_list_panes_can_activate_target_alias_and_hook( + mcp_server: Server, + mcp_session: Session, + tmp_path: pathlib.Path, +) -> None: + """The target server can add effects around an inspect operation.""" + alias_marker = tmp_path / "alias" + hook_marker = tmp_path / "hook" + alias_command = shlex.join(["touch", str(alias_marker)]) + hook_command = shlex.join(["touch", str(hook_marker)]) + + mcp_server.cmd( + "set-option", + "-s", + "command-alias[999]", + f"list-panes=run-shell {shlex.quote(alias_command)} ; list-panes", + ) + mcp_server.cmd( + "set-hook", + "-g", + "after-list-panes", + f"run-shell {shlex.quote(hook_command)}", + ) + + from fastmcp import Client + + from libtmux_mcp.server import build_mcp_server + + window = mcp_session.active_window + + async def _call() -> t.Any: + async with Client(build_mcp_server()) as client: + return await client.call_tool( + "list_panes", + { + "window_id": window.window_id, + "socket_name": mcp_server.socket_name, + }, + raise_on_error=False, + ) + + result = asyncio.run(_call()) + + assert result.is_error is False + assert alias_marker.exists() + assert hook_marker.exists() + + def test_get_window_info(mcp_server: Server, mcp_session: Session) -> None: """get_window_info returns a WindowInfo for a single window.""" window = mcp_session.active_window From a3467702aca553bc4c9b2373ef5fd044c08ead3b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:22:25 -0500 Subject: [PATCH 28/44] mcp(fix[retry]): Run failed calls once why: A tmux alias or hook may act before the command reports an error. Retrying an inspect call could repeat that ambient effect. what: - Remove InspectRetryMiddleware and its retry policy - Keep failed production-wire calls to one attempt - Let the client or operator decide whether to retry --- src/libtmux_mcp/middleware.py | 158 +-------------- src/libtmux_mcp/server.py | 21 +- tests/test_middleware.py | 362 ++++------------------------------ 3 files changed, 52 insertions(+), 489 deletions(-) diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 38a2e2f8..436918d2 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -14,9 +14,6 @@ invocation (name, duration, outcome, client/request ids, and a summary of arguments with payload-bearing fields redacted to a length + SHA-256 prefix). -* :class:`InspectRetryMiddleware` retries transient libtmux failures, - but only for ``inspect`` tools — re-running anything else would - silently repeat its effect. * :class:`TailPreservingResponseLimitingMiddleware` is a backstop cap for oversized tool output. Unlike FastMCP's stock ``ResponseLimitingMiddleware`` it preserves the **tail** of the @@ -33,22 +30,13 @@ import typing as t from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.server.middleware.error_handling import ( - ErrorHandlingMiddleware, - RetryMiddleware, -) +from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware from fastmcp.tools.base import ToolResult -from libtmux import exc as libtmux_exc from mcp.types import CallToolRequestParams, TextContent from pydantic import ValidationError as PydanticValidationError -from libtmux_mcp._utils import ( - TAG_SELF_BOUNDED, - TOOLSET_INSPECT, - VALID_TOOLSETS, - ExpectedToolError, -) +from libtmux_mcp._utils import VALID_TOOLSETS, ExpectedToolError class ToolsetMiddleware(Middleware): @@ -384,13 +372,11 @@ class ToolErrorResultMiddleware(ErrorHandlingMiddleware): synthesized suggestion telling the agent which names to drop or fix (see :func:`_error_tool_result`). - Ordering invariant: must sit **outside** ``AuditMiddleware``, - ``InspectRetryMiddleware``, and ``ToolsetMiddleware``. All three - depend on exception semantics — audit detects failures by catching, - retry matches ``LibTmuxException`` via ``__cause__``, and the toolset gate's - tier denials must propagate as exceptions for audit to record them - — so converting the exception to a result any deeper in the stack - would silently break all three. + Ordering invariant: must sit **outside** ``AuditMiddleware`` and + ``ToolsetMiddleware``. Both depend on exception semantics: audit detects + failures by catching them, and toolset denials must propagate as exceptions + for audit to record them. Converting the exception to a result any deeper in + the stack would silently break both. """ def _log_error(self, error: Exception, context: MiddlewareContext) -> None: @@ -689,136 +675,6 @@ async def on_call_tool( DEFAULT_RESPONSE_LIMIT_BYTES = 1_000_000 -#: Failures a retry cannot fix. Each one names a thing that is not there, or a -#: request that cannot succeed as written, so re-running it buys a second tmux -#: round-trip and a backoff window in order to fail identically. They all -#: descend from :exc:`libtmux.exc.LibTmuxException`, which is the retry -#: trigger, so without this set they would all be retried. -#: -#: Order the entries most-general-first when reading: ``ObjectDoesNotExist`` -#: already covers :exc:`libtmux.exc.TmuxObjectDoesNotExist`. -NON_RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = ( - libtmux_exc.ObjectDoesNotExist, - libtmux_exc.MultipleObjectsReturned, - libtmux_exc.PaneNotFound, - libtmux_exc.NoWindowsExist, - libtmux_exc.BadSessionName, - libtmux_exc.TmuxSessionExists, - libtmux_exc.TmuxCommandNotFound, -) - - -class _SkipDeterministicFailures(RetryMiddleware): - """A :class:`RetryMiddleware` that declines to retry what cannot succeed. - - fastmcp decides whether to retry in ``_should_retry``, matching the error - and, one hop down, its ``__cause__`` -- which is where the real failure - lives, because ``handle_tool_errors`` re-raises every libtmux error as an - ``ExpectedToolError`` chained off the original. This narrows that decision - with :data:`NON_RETRYABLE_EXCEPTIONS`, checking the same two places. - """ - - def _should_retry(self, error: Exception) -> bool: - """Return ``False`` for a failure a second attempt cannot change.""" - cause = error.__cause__ - if isinstance(error, NON_RETRYABLE_EXCEPTIONS) or ( - cause is not None and isinstance(cause, NON_RETRYABLE_EXCEPTIONS) - ): - return False - return super()._should_retry(error) - - -class InspectRetryMiddleware(Middleware): - """Retry transient libtmux failures, but only for ``inspect`` tools. - - Wraps fastmcp's :class:`fastmcp.server.middleware.error_handling.RetryMiddleware` - so retries are bounded by the toolset the tool is registered - under. Tools in any other toolset (``send_keys``, - ``create_session``, ``kill_server``, …) pass straight through — - re-running them on a transient socket error would silently double - side effects, which is unacceptable. ``inspect`` tools - (``list_sessions``, ``capture_pane``, ``snapshot_pane``, …) are - safe to retry because they observe state without changing it. - - Default retry trigger is :exc:`libtmux.exc.LibTmuxException` — - libtmux wraps the subprocess failures we actually want to retry - (socket EAGAIN, transient connect errors). The fastmcp default - ``(ConnectionError, TimeoutError)`` does NOT match these, so the - upstream defaults would be a silent no-op. - - That trigger is a whole family, though, and most of it is not - transient: a pane that does not exist will not exist on the second - look. :data:`NON_RETRYABLE_EXCEPTIONS` carves those out, so a stale - id fails once instead of costing a backoff window and a second tmux - round-trip to fail again. - - Place this in the middleware stack **inside** ``AuditMiddleware`` - (so retried calls are audited once each) and **outside** - ``ToolsetMiddleware`` (so refused tools never reach retry). - """ - - def __init__( - self, - max_retries: int = 1, - base_delay: float = 0.1, - max_delay: float = 1.0, - backoff_multiplier: float = 2.0, - retry_exceptions: tuple[type[Exception], ...] = (libtmux_exc.LibTmuxException,), - logger_: logging.Logger | None = None, - ) -> None: - """Configure the underlying retry policy. - - Defaults are deliberately small. ``max_retries=1`` keeps audit - log noise minimal (one retry per failed call, not three). The - 100 ms / 1 s backoff window matches the expected duration of a - transient libtmux socket hiccup — a longer backoff would just - delay a real failure without adding meaningful retry headroom. - - ``logger_`` defaults to ``logging.getLogger("libtmux_mcp.retry")`` - when not supplied — keeps retry events on the project's - ``libtmux_mcp.*`` namespace so operators routing the audit - stream capture them. Without this default, fastmcp's stock - ``RetryMiddleware`` would log to ``fastmcp.retry`` and miss - any project-namespace log routing. - """ - if logger_ is None: - logger_ = logging.getLogger("libtmux_mcp.retry") - self._retry = _SkipDeterministicFailures( - max_retries=max_retries, - base_delay=base_delay, - max_delay=max_delay, - backoff_multiplier=backoff_multiplier, - retry_exceptions=retry_exceptions, - logger=logger_, - ) - - async def on_call_tool( - self, - context: MiddlewareContext, - call_next: t.Any, - ) -> t.Any: - """Delegate to the upstream retry only for retry-eligible ``inspect`` tools. - - ``TAG_SELF_BOUNDED`` tools are excluded even though they are - ``inspect``. Their deadline is computed inside the tool body, so a - retry restarts the clock: a transient ``LibTmuxException`` at - t=29s of a 30s wait would produce a ~59s call and make the wait - ceiling a lie. :class:`_SkipDeterministicFailures` cannot cover - this — it carves out failures by exception type, and the - offending exception here is a genuinely transient one that a - retry *would* fix for any other tool. - """ - if context.fastmcp_context: - tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) - if ( - tool - and TOOLSET_INSPECT in tool.tags - and TAG_SELF_BOUNDED not in tool.tags - ): - return await self._retry.on_request(context, call_next) - return await call_next(context) - - #: Header prefixed to a truncated response. Intentionally matches the #: format used by the per-tool ``capture_pane`` truncation so clients #: see a consistent marker regardless of which layer fired. diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 3c747822..41b04b0d 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -37,7 +37,6 @@ from libtmux_mcp.middleware import ( DEFAULT_RESPONSE_LIMIT_BYTES, AuditMiddleware, - InspectRetryMiddleware, TailPreservingResponseLimitingMiddleware, ToolErrorResultMiddleware, ToolsetMiddleware, @@ -425,22 +424,17 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: # 3. ToolErrorResultMiddleware — converts tool-call failures to # rich ToolResult(is_error=True) results and transforms # resource errors to MCP code -32002. Must stay OUTSIDE the - # audit + retry + toolset trio: all three depend on exception - # semantics (audit catches to record outcome=error, retry - # matches LibTmuxException via __cause__, and the toolset - # denials must propagate as exceptions for audit to record - # them), so converting the exception to a result any deeper - # would silently break all three. - # 4. AuditMiddleware — outside ToolsetMiddleware so a refusal + # audit + toolset pair: both depend on exception semantics + # (audit catches to record outcome=error, and toolset denials + # must propagate as exceptions for audit to record them), so + # converting the exception to a result any deeper would + # silently break both. + # 4. AuditMiddleware — outside ToolsetMiddleware so refusal # events (which raise ExpectedToolError before call_next inside # Toolset) are still logged with outcome=error. Without this # ordering, denied access attempts would silently bypass the # audit log — a security-observability gap. - # 5. InspectRetryMiddleware — inside Audit so retries are - # audited once each, outside Toolset so refused tools - # never reach retry. Only inspect tools are retried; - # everything else passes straight through. - # 6. ToolsetMiddleware — innermost gate (fail-closed). Refusals + # 5. ToolsetMiddleware — innermost gate (fail-closed). Refusals # never reach the tool, but the audit record above captures # them for forensic review. middleware=[ @@ -451,7 +445,6 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: ), ToolErrorResultMiddleware(transform_errors=True), AuditMiddleware(), - InspectRetryMiddleware(), ToolsetMiddleware(_toolsets, _extra_tools, _excluded_tools), ], on_duplicate="error", diff --git a/tests/test_middleware.py b/tests/test_middleware.py index d15700e8..053f5dec 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -9,9 +9,7 @@ import pydantic import pytest -from fastmcp.exceptions import ToolError from fastmcp.server.middleware import MiddlewareContext -from libtmux import exc as libtmux_exc from mcp.types import CallToolRequestParams from libtmux_mcp._utils import ( @@ -22,7 +20,6 @@ ) from libtmux_mcp.middleware import ( AuditMiddleware, - InspectRetryMiddleware, ToolsetMiddleware, _client_label, _redact_digest, @@ -613,23 +610,18 @@ def test_server_middleware_stack_order() -> None: The ordering is load-bearing (see server.py comment): TimingMiddleware must be outermost so it observes total wall - time; AuditMiddleware must sit *outside* SafetyMiddleware so - tier-denial events (which raise ``ExpectedToolError`` before + time; AuditMiddleware must sit *outside* ToolsetMiddleware so + toolset-denial events (which raise ``ExpectedToolError`` before ``call_next``) are still recorded — without this ordering, forbidden-access attempts silently bypass the audit log. A - refactor that swaps Audit and Safety would degrade + refactor that swaps Audit and Toolset would degrade security observability without an obvious test failure, so pin the sequence explicitly. - - InspectRetryMiddleware sits between Audit and Safety so retried - calls are audited once each (Audit wraps the retry loop) and - tier-denied tools never reach retry (Safety stops them first). """ from fastmcp.server.middleware.timing import TimingMiddleware from libtmux_mcp.middleware import ( AuditMiddleware, - InspectRetryMiddleware, TailPreservingResponseLimitingMiddleware, ToolErrorResultMiddleware, ToolsetMiddleware, @@ -640,16 +632,48 @@ def test_server_middleware_stack_order() -> None: # FastMCP auto-appends an internal DereferenceRefsMiddleware at the # end of the stack; we care about the ordering of the middleware # *we* configured. Slice off the suffix before comparing. - assert types[:6] == [ + assert types[:5] == [ TimingMiddleware, TailPreservingResponseLimitingMiddleware, ToolErrorResultMiddleware, AuditMiddleware, - InspectRetryMiddleware, ToolsetMiddleware, ] +def test_failed_inspect_call_is_not_retried( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed observation runs once because tmux may add effects.""" + from fastmcp import Client + from libtmux import Server, exc as libtmux_exc + + from libtmux_mcp.server import build_mcp_server + + calls = 0 + + def _fail(_self: Server) -> list[t.Any]: + nonlocal calls + calls += 1 + msg = "forced failure" + raise libtmux_exc.LibTmuxException(msg) + + monkeypatch.setattr(Server, "sessions", property(_fail)) + + async def _call() -> t.Any: + async with Client(build_mcp_server()) as client: + return await client.call_tool( + "list_sessions", + {"socket_name": "no-retry-boundary"}, + raise_on_error=False, + ) + + result = asyncio.run(_call()) + + assert result.is_error is True + assert calls == 1 + + def test_error_handling_middleware_transforms_errors() -> None: """ToolErrorResultMiddleware is configured with transform_errors=True. @@ -713,316 +737,6 @@ async def _toolset_refusal(_ctx: t.Any) -> None: assert "error_type=ExpectedToolError" in rendered -# --------------------------------------------------------------------------- -# InspectRetryMiddleware tests -# --------------------------------------------------------------------------- - - -class _StubTool: - """Minimal tool stand-in for ``get_tool`` lookups.""" - - def __init__(self, tags: set[str]) -> None: - self.tags = tags - - -class _StubFastMCP: - """Awaitable ``get_tool`` returning a single stub tool.""" - - def __init__(self, tool: _StubTool) -> None: - self._tool = tool - - async def get_tool(self, _name: str) -> _StubTool: - return self._tool - - -class _StubFastMCPContext: - """Just enough to satisfy ``context.fastmcp_context.fastmcp.get_tool``.""" - - def __init__(self, fastmcp: _StubFastMCP) -> None: - self.fastmcp = fastmcp - - -def _retry_context(tags: set[str]) -> MiddlewareContext[CallToolRequestParams]: - """Build a MiddlewareContext that returns a tool with the given tags.""" - fastmcp_context = _StubFastMCPContext(_StubFastMCP(_StubTool(tags))) - return MiddlewareContext( - message=CallToolRequestParams(name="x", arguments={}), - fastmcp_context=t.cast("t.Any", fastmcp_context), - ) - - -class _FlakyCallNext: - """Async callable that raises N times before succeeding.""" - - def __init__(self, raises_n_times: int, exception: Exception) -> None: - self.exception = exception - self.remaining = raises_n_times - self.calls = 0 - - async def __call__(self, _context: t.Any) -> str: - self.calls += 1 - if self.remaining > 0: - self.remaining -= 1 - raise self.exception - return "ok" - - -def test_inspect_retry_recovers_from_libtmux_exception() -> None: - """An ``inspect`` tool is retried once on ``LibTmuxException``. - - Models the production scenario the middleware exists to fix: a - transient socket error from libtmux on the first call, then a - successful call after the cache evicts the dead Server. Without - the retry the agent would see an expected tool error on the first - ``list_sessions``-style call. - """ - from libtmux import exc as libtmux_exc - - middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TOOLSET_INSPECT}) - call_next = _FlakyCallNext( - raises_n_times=1, - exception=libtmux_exc.LibTmuxException("transient socket error"), - ) - - result = asyncio.run(middleware.on_call_tool(ctx, call_next)) - - assert result == "ok" - assert call_next.calls == 2 # initial failure + one retry - - -def test_inspect_retry_skips_a_writing_tool() -> None: - """A writing tool is NOT retried on ``LibTmuxException``. - - Critical safety property: re-running ``send_keys``, - ``create_session``, or any other writing call on a transient - error would silently double the side effect. This test pins the - "only inspect is retried" gate. - """ - from libtmux import exc as libtmux_exc - - middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TOOLSET_MANAGE}) - call_next = _FlakyCallNext( - raises_n_times=1, - exception=libtmux_exc.LibTmuxException("transient socket error"), - ) - - with pytest.raises(libtmux_exc.LibTmuxException, match="transient"): - asyncio.run(middleware.on_call_tool(ctx, call_next)) - - assert call_next.calls == 1 # no retry — fail on first call - - -def test_inspect_retry_skips_self_bounded_tool() -> None: - """An ``inspect`` + self-bounded tool is NOT retried. - - ``wait_for_text`` computes its deadline inside the tool body, so a - retry restarts the clock: a transient ``LibTmuxException`` at t=29s - of a 30s wait would produce a ~59s call and turn the wait ceiling - into a lie. ``_SkipDeterministicFailures`` cannot cover this — it - carves out failures by exception *type*, and this exception is a - genuinely transient one a retry would fix for any other tool. The - exclusion has to be tool-level, and it is a TAG rather than a name - list because ``add_tool_transformation`` can rename a tool. - """ - from libtmux import exc as libtmux_exc - - from libtmux_mcp._utils import TAG_SELF_BOUNDED - - middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED}) - call_next = _FlakyCallNext( - raises_n_times=1, - exception=libtmux_exc.LibTmuxException("transient socket error"), - ) - - with pytest.raises(libtmux_exc.LibTmuxException, match="transient"): - asyncio.run(middleware.on_call_tool(ctx, call_next)) - - assert call_next.calls == 1 # no retry — the wait budget is not doubled - - -def test_inspect_retry_skips_non_libtmux_exception() -> None: - """Even ``inspect`` tools do not retry outside the trigger set. - - Default ``retry_exceptions=(LibTmuxException,)`` is narrow on - purpose — a ``ValueError`` from caller-side input is a - programming error, not a transient socket hiccup, and retrying - it would just delay the real failure. - """ - middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TOOLSET_INSPECT}) - call_next = _FlakyCallNext( - raises_n_times=1, - exception=ValueError("bad caller input"), - ) - - with pytest.raises(ValueError, match="bad caller input"): - asyncio.run(middleware.on_call_tool(ctx, call_next)) - - assert call_next.calls == 1 # no retry — wrong exception type - - -def test_inspect_retry_recovers_on_decorated_tool( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """End-to-end: retry fires through the production decorator wrap path. - - Regression guard for the fastmcp <3.2.4 production no-op where - ``RetryMiddleware._should_retry`` did not walk ``__cause__``. - Every libtmux-mcp tool is wrapped by ``handle_tool_errors`` / - ``handle_tool_errors_async``, which converts ``LibTmuxException`` - to ``ExpectedToolError(...) from LibTmuxException``. At the - middleware layer the exception type is ``ExpectedToolError``, not - ``LibTmuxException`` — so the retry decision must walk - ``__cause__`` to see the real failure type. - - The unit tests above use ``_FlakyCallNext`` which raises - ``LibTmuxException`` directly, bypassing the decorator. They - pass on every fastmcp version. This test invokes the real - ``list_sessions`` tool through the middleware, exercising the - decorator wrap path that broke in production: - - * On fastmcp 3.2.3: would fail with ``calls == 1`` (no retry). - * On fastmcp >= 3.2.4: passes with ``calls == 2``. - - The ``pyproject.toml`` floor (``fastmcp>=3.2.4``) keeps this - test green; an accidental downgrade would re-introduce the bug - and fail this test loudly. - """ - from libtmux import Server, exc as libtmux_exc - - from libtmux_mcp.tools.server_tools import list_sessions - - calls = {"count": 0} - - def _flaky_sessions(_self: Server) -> list[t.Any]: - calls["count"] += 1 - if calls["count"] == 1: - msg = "transient socket error" - raise libtmux_exc.LibTmuxException(msg) - return [] # second call succeeds with no tmux required - - monkeypatch.setattr(Server, "sessions", property(_flaky_sessions)) - - middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TOOLSET_INSPECT}) - - async def real_call_next(_context: t.Any) -> t.Any: - # ``list_sessions`` is sync + ``@handle_tool_errors`` decorated. - # Wrapping the sync call in an async function is enough — the - # exception path is what we care about. - return list_sessions(socket_name="retry-integration-smoke") - - result = asyncio.run(middleware.on_call_tool(ctx, real_call_next)) - - assert result == [] - assert calls["count"] == 2, ( - f"retry did not fire (calls={calls['count']}). Likely cause: " - f"fastmcp<3.2.4 RetryMiddleware._should_retry not walking " - f"__cause__. Bump pyproject.toml fastmcp pin to >=3.2.4." - ) - - -@pytest.mark.parametrize( - "raised", - [ - libtmux_exc.TmuxObjectDoesNotExist("@99"), - libtmux_exc.MultipleObjectsReturned(count=2, query={"pane_id": "%0"}), - libtmux_exc.PaneNotFound("%99"), - libtmux_exc.NoWindowsExist, - libtmux_exc.BadSessionName(reason="contains periods", session_name="a.b"), - libtmux_exc.TmuxSessionExists("session exists"), - ], - ids=lambda e: type(e).__name__ if isinstance(e, Exception) else e.__name__, -) -def test_inspect_retry_skips_deterministic_failures(raised: Exception) -> None: - """A failure a second attempt cannot change is not retried. - - Every one of these descends from ``LibTmuxException``, which is the retry - trigger — so without :data:`NON_RETRYABLE_EXCEPTIONS` they would all be - retried. None of them can succeed on the second look: a pane that is not - there will not appear during a backoff window, and an ambiguous match does - not become unambiguous. Retrying buys a second tmux round-trip and 100 ms - of latency in order to fail identically. - """ - middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TOOLSET_INSPECT}) - call_next = _FlakyCallNext(raises_n_times=1, exception=raised) - - with pytest.raises(libtmux_exc.LibTmuxException): - asyncio.run(middleware.on_call_tool(ctx, call_next)) - - assert call_next.calls == 1, ( - f"{type(raised).__name__} was retried. It descends from LibTmuxException, " - f"so it must be listed in NON_RETRYABLE_EXCEPTIONS." - ) - - -def test_inspect_retry_skips_not_found_on_decorated_tool( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """End-to-end: a stale id is not retried through the production wrap path. - - The unit test above raises straight into the middleware. This one goes - through ``handle_tool_errors``, which re-raises every libtmux failure as an - ``ExpectedToolError`` chained off the original — so at the middleware layer - the exception is an ``ExpectedToolError`` and the real failure is only - visible one hop down, on ``__cause__``. The retry decision walks that hop - (which is what makes retries work at all), so the *skip* decision has to - walk it too, or it never fires in production. - - Guards the shape libtmux tmux-python/libtmux#718 introduced: - ``TmuxObjectDoesNotExist`` became a ``LibTmuxException``, which silently - made every stale session or window id retryable. - """ - from libtmux import Server - - from libtmux_mcp.tools.server_tools import list_sessions - - calls = {"count": 0} - - def _missing_sessions(_self: Server) -> list[t.Any]: - calls["count"] += 1 - raise libtmux_exc.TmuxObjectDoesNotExist( - obj_key="session_id", - obj_id="$99", - list_cmd="list-sessions", - list_extra_args=None, - ) - - monkeypatch.setattr(Server, "sessions", property(_missing_sessions)) - - middleware = InspectRetryMiddleware(max_retries=1, base_delay=0.0) - ctx = _retry_context(tags={TOOLSET_INSPECT}) - - async def real_call_next(_context: t.Any) -> t.Any: - return list_sessions(socket_name="retry-skip-smoke") - - with pytest.raises(ToolError): - asyncio.run(middleware.on_call_tool(ctx, real_call_next)) - - assert calls["count"] == 1, ( - f"a missing object was retried (calls={calls['count']}). The skip must " - f"walk __cause__, because handle_tool_errors wraps it in ExpectedToolError." - ) - - -def test_inspect_retry_logger_uses_project_namespace() -> None: - """Retry warnings route through ``libtmux_mcp.retry``, not ``fastmcp.retry``. - - Operators routing logs by the ``libtmux_mcp.*`` namespace prefix - (matching ``libtmux_mcp.audit``) need retry events to appear on - the same channel. fastmcp's stock ``RetryMiddleware`` defaults - to ``fastmcp.retry`` (``error_handling.py:181``); without an - explicit override, retry warnings would silently bypass any - project-namespace audit-stream routing. - """ - middleware = InspectRetryMiddleware() - assert middleware._retry.logger.name == "libtmux_mcp.retry" - - # --------------------------------------------------------------------------- # ToolErrorResultMiddleware tests # --------------------------------------------------------------------------- @@ -1032,7 +746,7 @@ def _error_probe_server() -> t.Any: """Build a minimal FastMCP instance wired like the production stack. Only ``ToolErrorResultMiddleware`` is installed — the assertions - target error-result conversion, not the audit/retry/safety trio. + target error-result conversion, not the audit/toolset pair. Tools mirror the three production raise shapes: an expected failure with a chained cause (decorator-mapped libtmux error), an expected failure with a recovery suggestion, and an unexpected From 1ea073a23ffe2f2d46970e5cda749e05fe9bee0a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:23:51 -0500 Subject: [PATCH 29/44] mcp(fix[lifespan]): Leave tmux buffers alone why: A process-wide prefix does not prove buffer ownership. Shutdown cleanup could delete another instance's buffer and trigger configured tmux behavior. what: - Remove automatic buffer deletion from lifespan shutdown - Keep cache cleanup local to the MCP process - Prove shutdown issues no tmux commands --- src/libtmux_mcp/server.py | 50 ++--------------------- tests/test_server.py | 84 +++++++-------------------------------- 2 files changed, 18 insertions(+), 116 deletions(-) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 41b04b0d..8d027539 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -6,7 +6,6 @@ from __future__ import annotations import contextlib -import logging import os import shutil import typing as t @@ -14,9 +13,6 @@ from fastmcp import FastMCP from fastmcp.server.middleware.timing import TimingMiddleware -if t.TYPE_CHECKING: - from libtmux.server import Server - from libtmux_mcp.__about__ import __version__ from libtmux_mcp._history import ( _configure_history_defaults, @@ -42,16 +38,9 @@ ToolsetMiddleware, install_fastmcp_validation_log_filter, ) -from libtmux_mcp.tools.buffer_tools import _MCP_BUFFER_PREFIX -logger = logging.getLogger(__name__) install_fastmcp_validation_log_filter() -#: Cache-key shape used by :data:`_server_cache` and the GC helper. -#: ``(socket_name, socket_path, tmux_bin)`` — see -#: :func:`libtmux_mcp._utils._get_server`. -_ServerCacheKey: t.TypeAlias = tuple[str | None, str | None, str | None] - # --------------------------------------------------------------------------- # _BASE_INSTRUCTIONS — composed from named segments. # @@ -360,16 +349,10 @@ async def _lifespan(_app: FastMCP) -> t.AsyncIterator[None]: Shutdown -------- - Clears the process-wide :data:`_server_cache` so repeated test runs - don't share stale Server references and HTTP-transport reload - cycles start clean. Also best-effort GC's any leftover - ``libtmux_mcp_*`` paste buffers on every cached server — agents - are supposed to ``delete_buffer`` after use, but an interrupted - call chain can leak. Note: FastMCP lifespan teardown runs on - SIGTERM / SIGINT only; ``kill -9`` and OOM bypass it, so this path - must not be relied on for any invariant that must survive a hard - crash (see the hook_tools module docstring for why write-hooks - are explicitly NOT gated on lifespan cleanup). + Clears the process-wide :data:`_server_cache` so repeated test runs don't + share stale Server references and HTTP-transport reload cycles start clean. + Shutdown sends no tmux commands. Buffer tools expose explicit cleanup, and + a process-wide prefix does not prove which MCP instance owns a buffer. """ if shutil.which("tmux") is None: msg = "tmux binary not found on PATH" @@ -377,34 +360,9 @@ async def _lifespan(_app: FastMCP) -> t.AsyncIterator[None]: try: yield finally: - _gc_mcp_buffers(_server_cache) _server_cache.clear() -def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: - """Best-effort delete of leaked ``libtmux_mcp_*`` paste buffers. - - Iterates every cached tmux Server, lists buffer names, and deletes - anything matching the MCP prefix. Never raises: tmux may be - unreachable, buffers may vanish mid-scan, and none of that should - block lifespan shutdown. Logs at debug level so operators can - still surface leaks via verbose logging. - """ - for server in cache.values(): - try: - result = server.cmd("list-buffers", "-F", "#{buffer_name}") - except Exception as err: - logger.debug("buffer GC: list-buffers failed: %s", err) - continue - for name in result.stdout: - if not name.startswith(_MCP_BUFFER_PREFIX): - continue - try: - server.delete_buffer(buffer_name=name) - except Exception as err: - logger.debug("buffer GC: delete-buffer %s failed: %s", name, err) - - mcp = FastMCP( name="tmux", version=__version__, diff --git a/tests/test_server.py b/tests/test_server.py index 3df36eb3..453cfe84 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -17,11 +17,6 @@ _build_instructions, ) -if t.TYPE_CHECKING: - from libtmux.server import Server - - from libtmux_mcp.server import _ServerCacheKey - class BuildInstructionsFixture(t.NamedTuple): """Test fixture for _build_instructions.""" @@ -892,23 +887,31 @@ async def _enter() -> None: def test_lifespan_clears_server_cache_on_exit() -> None: - """Clean lifespan exit empties the process-wide ``_server_cache``.""" + """Clean lifespan exit drops cached clients without touching tmux.""" import asyncio from libtmux_mcp._utils import _server_cache - from libtmux_mcp.server import _lifespan + from libtmux_mcp.server import _lifespan, mcp + + class RecordingServer: + def __init__(self) -> None: + self.commands: list[tuple[object, ...]] = [] + + def cmd(self, *args: object) -> object: + self.commands.append(args) + return t.cast("t.Any", object()) - # Seed the cache with a sentinel entry — the actual value doesn't - # matter; we're checking that exit clears it. - _server_cache[("sentinel_socket", None, None)] = t.cast("t.Any", object()) + server = RecordingServer() + _server_cache[("sentinel_socket", None, None)] = t.cast("t.Any", server) async def _cycle() -> None: - async with _lifespan(_app=None): # type: ignore[arg-type] + async with _lifespan(mcp): # While the lifespan is active the cache still holds state. assert _server_cache asyncio.run(_cycle()) assert _server_cache == {} + assert server.commands == [] def test_server_constructed_with_lifespan() -> None: @@ -916,62 +919,3 @@ def test_server_constructed_with_lifespan() -> None: from libtmux_mcp.server import _lifespan, mcp assert mcp._lifespan is _lifespan - - -@pytest.mark.usefixtures("mcp_session") -def test_gc_mcp_buffers_deletes_mcp_prefixed_and_spares_others( - mcp_server: Server, -) -> None: - """``_gc_mcp_buffers`` deletes ``libtmux_mcp_*`` buffers only. - - Best-effort lifespan GC for leaked paste buffers — agents are - supposed to ``delete_buffer`` after use, but an interrupted call - chain can leak. The GC must NEVER touch non-MCP buffers (OS - clipboard sync, user-authored buffers) — those are the human user's - content. - """ - from libtmux_mcp.server import _gc_mcp_buffers - from libtmux_mcp.tools.buffer_tools import load_buffer - - # Seed: one MCP-owned buffer via the canonical path, one human-owned - # buffer directly via tmux so it is outside the MCP prefix. - ref = load_buffer( - content="agent-staged", - logical_name="leaky", - socket_name=mcp_server.socket_name, - ) - mcp_server.cmd("set-buffer", "-b", "human_buffer", "user-content") - - names_before = mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout - assert ref.buffer_name in names_before - assert "human_buffer" in names_before - - _gc_mcp_buffers({(mcp_server.socket_name, None, None): mcp_server}) - - names_after = mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout - assert ref.buffer_name not in names_after, "GC must delete MCP-namespaced buffers" - assert "human_buffer" in names_after, "GC must not touch non-MCP buffers" - - # Clean up the human buffer so the fixture teardown stays tidy. - mcp_server.cmd("delete-buffer", "-b", "human_buffer") - - -def test_gc_mcp_buffers_swallows_errors() -> None: - """GC logs but never raises when tmux is unreachable.""" - from libtmux_mcp.server import _gc_mcp_buffers - - class _BrokenServer: - def cmd(self, *_a: object, **_kw: object) -> object: - msg = "tmux is dead" - raise RuntimeError(msg) - - # Must not raise — lifespan shutdown cannot tolerate exceptions here. - # Cast is needed because _BrokenServer only implements ``cmd``; the - # real cache stores full Server instances, but GC is best-effort and - # consumes only the ``cmd`` method so a partial stub is sufficient. - _gc_mcp_buffers( - t.cast( - "t.Mapping[_ServerCacheKey, t.Any]", - {(None, None, None): _BrokenServer()}, - ) - ) From 17a820c3a15608587db230df46318e0a5bb6e73f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:24:01 -0500 Subject: [PATCH 30/44] mcp(fix[toolsets]): Validate the wire surface why: Tool filtering must govern both discovery and invocation. Unknown names and unclassified tools must fail closed instead of widening the surface. what: - Validate named includes and exclusions at startup - Make FastMCP visibility authoritative on the wire - Keep middleware as a classified defense-in-depth check - Retire misleading safety-tier wording --- src/libtmux_mcp/middleware.py | 28 +++--- src/libtmux_mcp/server.py | 41 ++++++--- tests/test_middleware.py | 28 +++--- tests/test_server.py | 168 ++++++++++++++++++++++++++++++---- 4 files changed, 206 insertions(+), 59 deletions(-) diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 436918d2..f1dab019 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -2,9 +2,9 @@ Provides the project's middleware infrastructure, in definition order: -* :class:`ToolsetMiddleware` filters tools by toolset, from - ``LIBTMUX_TOOLSETS``. A tool outside the enabled toolsets is hidden - from listing and refused on call. +* :class:`ToolsetMiddleware` rechecks the configured surface for any + tool that reaches dispatch. FastMCP visibility makes tools outside + that surface unavailable on the wire first. * :class:`ToolErrorResultMiddleware` converts tool-call failures into ``ToolResult(is_error=True)`` results that carry the clean error message plus a structured ``meta`` payload, instead of fastmcp's @@ -42,10 +42,9 @@ class ToolsetMiddleware(Middleware): """Filter tools to the enabled toolsets. - Filtering shapes what this server advertises. It is not a permission - system: an enabled ``execute`` tool can type the equivalent of any - tool this hides, so dropping a toolset reduces accidents, not - authority. + Filtering controls which MCP tool calls this server advertises and + accepts. It does not confine tmux: an enabled ``execute`` tool can type + the equivalent of any hidden tool. Parameters ---------- @@ -73,11 +72,12 @@ def _is_enabled(self, name: str, tags: set[str]) -> bool: Fail-closed: a tool carrying no recognized toolset is refused, so adding one without classifying it cannot expose it. """ - if name in self.exclude_tools: + toolsets = tags & set(VALID_TOOLSETS) + if not toolsets or name in self.exclude_tools: return False if name in self.tools: return True - return bool(self.toolsets & (tags & set(VALID_TOOLSETS))) + return bool(self.toolsets & toolsets) async def on_list_tools( self, @@ -692,13 +692,13 @@ class TailPreservingResponseLimitingMiddleware(ResponseLimitingMiddleware): drop the head instead, prefixing a single truncation-header line so callers can detect the cap fired. - Used as a global backstop for :func:`libtmux_mcp.tools.pane_tools.capture_pane`, + Used as a backstop for :func:`libtmux_mcp.tools.pane_tools.capture_pane`, :func:`libtmux_mcp.tools.pane_tools.capture_since`, :func:`libtmux_mcp.tools.pane_tools.snapshot_pane`, and - :func:`libtmux_mcp.tools.pane_tools.search_panes`. Per-tool - caps at the tool layer fire first under normal operation; this - middleware catches pathological output from future tools that - forget to declare their own bounds. + :func:`libtmux_mcp.tools.pane_tools.search_panes`, plus + :func:`libtmux_mcp.tools.buffer_tools.show_buffer`. Per-tool caps at + the tool layer fire first under normal operation; this middleware + catches a missed bound within that configured high-volume set. Error results keep their ``is_error`` flag through truncation. The stock truncation path rebuilds the result without it, which diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 8d027539..b44c2494 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -89,7 +89,7 @@ _INSTR_READ_TOOLS = ( "Prefer snapshot_pane over capture_pane + get_pane_info; capture_since " - "for repeated observation/tailing; display_message for tmux formats." + "for repeated observation/tailing; display_message for tmux variables." ) _INSTR_WAIT_NOT_POLL = ( @@ -104,7 +104,7 @@ #: comment above for when to add another ``_GAP`` segment vs. push the #: explanation into a tool description. _INSTR_HOOKS_GAP = ( - "HOOKS ARE READ-ONLY: inspect via show_hooks/show_hook. " + "NO DEDICATED HOOK-WRITE TOOLS: use show_hooks/show_hook. " "Write hooks survive process death; keep them in your tmux config file." ) @@ -299,7 +299,7 @@ def _resolve_tool_names(value: str | None) -> frozenset[str]: def _reject_retired_safety_env() -> None: """Fail startup when ``LIBTMUX_SAFETY`` is still set. - The tiers it selected were an ordered ladder that read as a + The former setting selected an ordered ladder that read as a permission system and was not one. Ignoring the variable would silently widen a surface an operator believes is narrow. """ @@ -342,10 +342,11 @@ async def _lifespan(_app: FastMCP) -> t.AsyncIterator[None]: Startup ------- - Verifies that a ``tmux`` binary is on ``PATH``. Without this - probe, tools fail at first call with a generic ``TmuxCommandNotFound`` - deep inside libtmux. Failing at server start instead surfaces a - clear cold-start error before any tool traffic arrives. + Validates named tool includes and exclusions against the transformed + catalog, then verifies that a ``tmux`` binary is on ``PATH``. Without + the binary probe, tools fail at first call with a generic + ``TmuxCommandNotFound`` deep inside libtmux. Failing at server start + instead surfaces a clear cold-start error before tool traffic arrives. Shutdown -------- @@ -354,6 +355,21 @@ async def _lifespan(_app: FastMCP) -> t.AsyncIterator[None]: Shutdown sends no tmux commands. Buffer tools expose explicit cleanup, and a process-wide prefix does not prove which MCP instance owns a buffer. """ + registered_tool_names = { + tool.name for tool in await super(FastMCP, _app).list_tools() + } + for variable, names in ( + ("LIBTMUX_TOOLS", _extra_tools), + ("LIBTMUX_EXCLUDE_TOOLS", _excluded_tools), + ): + # FastMCP.list_tools() hides disabled tools. Its provider lookup + # retains them and applies the same transforms, including optional + # prompt-as-tool adapters. + unknown = sorted(names - registered_tool_names) + if unknown: + msg = f"{variable} names unknown tools: {', '.join(unknown)}" + raise RuntimeError(msg) + if shutil.which("tmux") is None: msg = "tmux binary not found on PATH" raise RuntimeError(msg) @@ -440,15 +456,16 @@ def _enable_allowed_tools() -> None: if _mcp_visibility_configured: return - # FastMCP's tag visibility is the primary filter; ToolsetMiddleware - # repeats the decision so a direct call gets an error naming the - # variable rather than an unknown-tool error. + # FastMCP's tag and name visibility is the primary wire filter; + # ToolsetMiddleware repeats classification as defense in depth for + # tools that reach dispatch. mcp.disable(components={"tool"}) if _toolsets: mcp.enable(tags=set(_toolsets), components={"tool"}) for name in _extra_tools: - with contextlib.suppress(Exception): - mcp.enable(components={"tool"}, names={name}) + mcp.enable(components={"tool"}, names={name}) + if _excluded_tools: + mcp.disable(components={"tool"}, names=set(_excluded_tools)) _mcp_visibility_configured = True diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 053f5dec..19931ead 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,4 +1,4 @@ -"""Tests for libtmux MCP safety + audit middleware.""" +"""Tests for libtmux MCP toolset and audit middleware.""" from __future__ import annotations @@ -96,16 +96,18 @@ def test_toolset_middleware_membership( def test_named_tools_join_and_exclusions_win() -> None: - """`LIBTMUX_TOOLS` adds by name; `LIBTMUX_EXCLUDE_TOOLS` beats it.""" + """Named includes override selection, not missing classification.""" mw = ToolsetMiddleware( {TOOLSET_INSPECT}, - tools={"send_keys"}, + tools={"mystery", "run_command", "send_keys"}, exclude_tools={"capture_pane", "send_keys"}, ) assert mw._is_enabled("send_keys", {TOOLSET_EXECUTE}) is False assert mw._is_enabled("capture_pane", {TOOLSET_INSPECT}) is False assert mw._is_enabled("list_panes", {TOOLSET_INSPECT}) is True + assert mw._is_enabled("run_command", {TOOLSET_EXECUTE}) is True + assert mw._is_enabled("mystery", set()) is False # --------------------------------------------------------------------------- @@ -697,12 +699,12 @@ def test_error_handling_middleware_transforms_errors() -> None: def test_audit_records_a_toolset_refusal( caplog: pytest.LogCaptureFixture, ) -> None: - """A tool denied by SafetyMiddleware still appears in the audit log. + """A tool denied by ToolsetMiddleware still appears in the audit log. - Composes Audit and Safety in the production order (Audit outside - Safety) by manually nesting their ``on_call_tool`` handlers: the - inner ``call_next`` from Audit dispatches to Safety, which raises - ``ExpectedToolError`` for an over-tier tool. Audit should record + Composes Audit and Toolset in the production order (Audit outside + Toolset) by manually nesting their ``on_call_tool`` handlers: the + inner ``call_next`` from Audit dispatches to Toolset, which raises + ``ExpectedToolError`` for a disabled tool. Audit should record that as ``outcome=error error_type=ExpectedToolError`` rather than skipping the record. Without this ordering, denied access attempts would silently bypass forensic logging. @@ -712,13 +714,13 @@ def test_audit_records_a_toolset_refusal( audit = AuditMiddleware() ctx = _fake_context(name="kill_server", arguments={}) - # SafetyMiddleware.on_call_tool consults + # ToolsetMiddleware.on_call_tool consults # context.fastmcp_context.fastmcp.get_tool(...). With - # fastmcp_context=None the safety check short-circuits, so we + # fastmcp_context=None the toolset check short-circuits, so we # simulate the denial more directly: ``call_next`` is a coroutine - # that raises the same ``ExpectedToolError`` SafetyMiddleware - # would when blocking an over-tier call. The test's invariant is - # that the AuditMiddleware sitting *outside* Safety still records + # that raises the same ``ExpectedToolError`` ToolsetMiddleware + # would when blocking a disabled tool. The test's invariant is + # that the AuditMiddleware sitting *outside* Toolset still records # the attempt with outcome=error. msg = "Tool 'kill_server' is not in this server's enabled toolsets." diff --git a/tests/test_server.py b/tests/test_server.py index 453cfe84..a4854a24 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -134,6 +134,134 @@ def test_an_unknown_toolset_fails_startup() -> None: _resolve_toolsets("inspect,bogus") +@pytest.mark.parametrize( + ("variable", "tool_name"), + [ + ("LIBTMUX_TOOLS", "definitely_not_a_tool"), + ("LIBTMUX_EXCLUDE_TOOLS", "definitely_not_a_tool"), + ("LIBTMUX_TOOLS", "get_prompt"), + ], + ids=["include-typo", "exclude-typo", "disabled-prompt-adapter"], +) +def test_an_unknown_tool_name_fails_server_startup( + variable: str, + tool_name: str, +) -> None: + """A typo in an individual include or exclude fails closed.""" + code = textwrap.dedent( + """ + import asyncio + + from fastmcp import Client + + from libtmux_mcp.server import build_mcp_server + + + async def main(): + async with Client(build_mcp_server()): + pass + + + asyncio.run(main()) + """ + ) + env = {**os.environ, variable: tool_name} + proc = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + env=env, + ) + + assert proc.returncode != 0 + assert f"{variable} names unknown tools: {tool_name}" in proc.stderr + + +@pytest.mark.parametrize("variable", ["LIBTMUX_TOOLS", "LIBTMUX_EXCLUDE_TOOLS"]) +def test_generated_prompt_tool_names_validate_when_enabled(variable: str) -> None: + """Validation includes tools produced by the prompt adapter transform.""" + code = textwrap.dedent( + """ + import asyncio + + from fastmcp import Client + + from libtmux_mcp.server import build_mcp_server + + + async def main(): + async with Client(build_mcp_server()): + pass + + + asyncio.run(main()) + """ + ) + env = { + **os.environ, + "LIBTMUX_MCP_PROMPTS_AS_TOOLS": "1", + variable: "get_prompt", + } + proc = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + env=env, + ) + + assert proc.returncode == 0, proc.stderr + + +@pytest.mark.parametrize( + ("tool_name", "selection"), + [ + ("send_keys", {"LIBTMUX_TOOLSETS": "inspect"}), + ("list_sessions", {"LIBTMUX_EXCLUDE_TOOLS": "list_sessions"}), + ], + ids=["toolset-omission", "explicit-exclusion"], +) +def test_a_hidden_tool_is_unknown_on_the_production_wire( + tool_name: str, + selection: dict[str, str], +) -> None: + """FastMCP visibility rejects hidden tools before tool dispatch.""" + code = textwrap.dedent( + f""" + import asyncio + + from fastmcp import Client + + from libtmux_mcp.server import build_mcp_server + + + async def main(): + async with Client(build_mcp_server()) as client: + result = await client.call_tool( + {tool_name!r}, + {{}}, + raise_on_error=False, + ) + print(result.content[0].text) + + + asyncio.run(main()) + """ + ) + env = {**os.environ, **selection} + proc = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + env=env, + ) + + assert proc.returncode == 0 + assert f"Unknown tool: '{tool_name}'" in proc.stdout + + def test_the_retired_safety_variable_fails_startup() -> None: """`LIBTMUX_SAFETY` is gone; ignoring it could widen a surface.""" code = textwrap.dedent( @@ -219,13 +347,13 @@ def test_base_instructions_prefer_typed_completion_over_polling() -> None: def test_base_instructions_document_hook_boundary() -> None: - """_BASE_INSTRUCTIONS explains hooks are read-only by design. + """_BASE_INSTRUCTIONS explains hooks are inspection-only by design. Without this sentence agents waste a turn asking for ``set_hook`` or trying to write hooks through a nonexistent tool. Naming the boundary heads off the exploratory call. """ - assert "HOOKS ARE READ-ONLY" in _BASE_INSTRUCTIONS + assert "NO DEDICATED HOOK-WRITE TOOLS" in _BASE_INSTRUCTIONS assert "show_hooks" in _BASE_INSTRUCTIONS assert "tmux config file" in _BASE_INSTRUCTIONS @@ -392,7 +520,7 @@ def test_build_instructions_defaults_semantic_history_suppression_on() -> None: ids=["history-disabled", "history-enabled"], ) @pytest.mark.parametrize( - ("tier", "tmux_pane", "tmux_env"), + ("toolset", "tmux_pane", "tmux_env"), [ (TOOLSET_INSPECT, "%42", "/tmp/tmux-1000/default,12345,0"), (TOOLSET_MANAGE, "%42", "/tmp/tmux-1000/default,12345,0"), @@ -406,25 +534,25 @@ def test_build_instructions_defaults_semantic_history_suppression_on() -> None: # (TMUX_PANE) and a longer socket name (LIBTMUX_SOCKET). Margin # ~2 bytes; if a future text addition trips this, either trim # further or fall back to a tighter compression form (drop spaces - # around ``/`` in HOOKS, drop spaces after colons in the safety + # around ``/`` in HOOKS, drop spaces after colons in the toolset # paragraph) for additional bytes of margin. (TOOLSET_INSPECT, "%99", "/tmp/tmux-1000/dev-prod,12345,0"), ], ) -def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( +def test_full_instructions_under_2kb_across_toolsets_and_tmux_pane( monkeypatch: pytest.MonkeyPatch, suppress_history: bool, - tier: str, + toolset: str, tmux_pane: str, tmux_env: str, ) -> None: """The transmitted instructions= string fits Claude Code's 2KB budget. The static ``_BASE_INSTRUCTIONS`` length is not the contract — - ``_build_instructions`` appends a safety-tier block, an optional + ``_build_instructions`` appends a toolset block, an optional `inspect`-only hint, and an optional ``$TMUX_PANE`` agent-context block. The full transmitted string must be ≤ 2048 bytes for every - (tier, tmux_pane) combination, otherwise Claude Code silently + (toolset, tmux_pane) combination, otherwise Claude Code silently truncates the agent-context block — the only server-side fix for "current window" anaphora. @@ -441,12 +569,12 @@ def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( monkeypatch.delenv("TMUX", raising=False) instructions = _build_instructions( - toolsets=frozenset({tier}), + toolsets=frozenset({toolset}), suppress_history=suppress_history, ) size = len(instructions.encode()) assert size <= 2048, ( - f"tier={tier} tmux_pane={tmux_pane!r}: " + f"toolset={toolset} tmux_pane={tmux_pane!r}: " f"{size} bytes exceeds Claude Code's 2KB ceiling" ) @@ -544,21 +672,21 @@ def test_scope_segment_carries_anti_triggers() -> None: assert "clarifying question" in _INSTR_SCOPE -@pytest.mark.parametrize("tier", [TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN]) +@pytest.mark.parametrize("toolset", [TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN]) def test_probe_hint_visible_only_on_an_inspect_only_surface( - monkeypatch: pytest.MonkeyPatch, tier: str + monkeypatch: pytest.MonkeyPatch, toolset: str ) -> None: """The investigation hint appears only when `inspect` is all there is. A wrong guess is cheap there (worst case: an - extra ``list_panes`` call) and expensive on a surface holding - (where ``kill_*`` is one mis-routed query away). Reuse the existing - safety axis instead of shipping a separate discoverability knob. + extra ``list_panes`` call) and expensive on a surface holding teardown + tools (where ``kill_*`` is one mis-routed query away). Reuse the existing + toolset classification instead of shipping a separate discoverability knob. """ monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) - instructions = _build_instructions(toolsets=frozenset({tier})) - if tier == TOOLSET_INSPECT: + instructions = _build_instructions(toolsets=frozenset({toolset})) + if toolset == TOOLSET_INSPECT: assert "Probe snapshot_pane" in instructions else: assert "Probe snapshot_pane" not in instructions @@ -648,7 +776,7 @@ def test_probe_hint_visible_only_on_an_inspect_only_surface( #: Discovery anchors that carry the ``anthropic/alwaysLoad`` per-tool -#: meta hint. Read-only only — best-effort hint to Claude Code that +#: meta hint. Inspect only — best-effort hint to Claude Code that #: keeps a tiny tmux vocabulary always-visible without preloading #: every tool's schema. _ALWAYS_LOAD_ANCHORS = frozenset(["list_panes", "list_windows", "snapshot_pane"]) @@ -871,7 +999,7 @@ def test_lifespan_missing_tmux_raises_runtime_error( """Startup raises a clear RuntimeError when tmux is not on PATH.""" import asyncio - from libtmux_mcp.server import _lifespan + from libtmux_mcp.server import _lifespan, mcp def _missing_tmux(_name: str) -> None: return None @@ -879,7 +1007,7 @@ def _missing_tmux(_name: str) -> None: monkeypatch.setattr("libtmux_mcp.server.shutil.which", _missing_tmux) async def _enter() -> None: - async with _lifespan(_app=None): # type: ignore[arg-type] + async with _lifespan(mcp): pytest.fail("lifespan should have raised before yielding") with pytest.raises(RuntimeError, match="tmux binary not found"): From 2953c387f21fd00e657acd376b844dbc9eaceb51 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:09:15 -0500 Subject: [PATCH 31/44] mcp(fix[prompts]): Keep adapters visible why: FastMCP generated list_prompts and get_prompt after visibility classified the catalog. Enabled adapters then disappeared, and direct calls returned Unknown tool. what: - Decorate generated prompt adapters after PromptsAsTools runs - Classify them as inspect with pure, closed-world annotations - Prove both adapters stay visible and callable on the production wire --- src/libtmux_mcp/prompts/__init__.py | 46 +++++++++++++++++++++++++++++ tests/test_prompts.py | 39 ++++++++++++++++++++---- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/libtmux_mcp/prompts/__init__.py b/src/libtmux_mcp/prompts/__init__.py index 1ff3ee1d..dadeb46e 100644 --- a/src/libtmux_mcp/prompts/__init__.py +++ b/src/libtmux_mcp/prompts/__init__.py @@ -14,7 +14,13 @@ import os import typing as t +from collections.abc import Sequence +from fastmcp.server.transforms import GetToolNext, Transform +from fastmcp.utilities.versions import VersionSpec +from mcp.types import ToolAnnotations + +from libtmux_mcp._utils import TOOLSET_INSPECT from libtmux_mcp.prompts.recipes import ( build_dev_workspace, diagnose_failing_pane, @@ -24,15 +30,54 @@ if t.TYPE_CHECKING: from fastmcp import FastMCP + from fastmcp.tools.base import Tool #: Env-var gate that enables exposing prompts as tools for clients that #: do not speak the MCP prompts protocol. Off by default — a sprawling #: prompt catalog is not the goal. ENV_PROMPTS_AS_TOOLS = "LIBTMUX_MCP_PROMPTS_AS_TOOLS" +_PROMPT_TOOL_NAMES = frozenset({"list_prompts", "get_prompt"}) +_PROMPT_TOOL_ANNOTATIONS = ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, +) + __all__ = ["ENV_PROMPTS_AS_TOOLS", "register_prompts"] +class _PromptToolMetadata(Transform): + """Classify generated prompt adapters as server-local inspect tools.""" + + @staticmethod + def _decorate(tool: Tool) -> Tool: + if tool.name not in _PROMPT_TOOL_NAMES: + return tool + return tool.model_copy( + update={ + "tags": {*tool.tags, TOOLSET_INSPECT}, + "annotations": _PROMPT_TOOL_ANNOTATIONS, + } + ) + + async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + """Decorate generated adapters in tool listings.""" + return [self._decorate(tool) for tool in tools] + + async def get_tool( + self, + name: str, + call_next: GetToolNext, + *, + version: VersionSpec | None = None, + ) -> Tool | None: + """Decorate a generated adapter fetched for a call.""" + tool = await call_next(name, version=version) + return self._decorate(tool) if tool is not None else None + + def register_prompts(mcp: FastMCP) -> None: """Register the narrow prompt set with the MCP instance. @@ -50,3 +95,4 @@ def register_prompts(mcp: FastMCP) -> None: from fastmcp.server.transforms import PromptsAsTools mcp.add_transform(PromptsAsTools(mcp)) + mcp.add_transform(_PromptToolMetadata()) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index c3ed30de..524a4036 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -4,12 +4,16 @@ import ast import asyncio +import typing as t import pytest from fastmcp import FastMCP +from libtmux_mcp._utils import TOOLSET_INSPECT from libtmux_mcp.prompts import ENV_PROMPTS_AS_TOOLS, register_prompts +from .conftest import wire_annotations + @pytest.fixture def mcp_with_prompts() -> FastMCP: @@ -40,14 +44,37 @@ def test_prompts_as_tools_gated_off_by_default(mcp_with_prompts: FastMCP) -> Non def test_prompts_as_tools_enabled_by_env( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Setting the env var installs PromptsAsTools.""" + """The opt-in adapters survive production-style tool visibility.""" + from fastmcp import Client + + from libtmux_mcp.middleware import ToolsetMiddleware + monkeypatch.setenv(ENV_PROMPTS_AS_TOOLS, "1") - mcp = FastMCP(name="test-prompts-as-tools") + mcp = FastMCP( + name="test-prompts-as-tools", + middleware=[ToolsetMiddleware({TOOLSET_INSPECT})], + ) register_prompts(mcp) - tools = asyncio.run(mcp.list_tools()) - names = {tool.name for tool in tools} - assert "list_prompts" in names - assert "get_prompt" in names + mcp.disable(components={"tool"}) + mcp.enable(tags={TOOLSET_INSPECT}, components={"tool"}) + + tools = {tool.name: tool for tool in asyncio.run(mcp.list_tools())} + expected = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + } + for name in ("list_prompts", "get_prompt"): + assert tools[name].tags == {TOOLSET_INSPECT} + assert wire_annotations(tools[name]) == expected + + async def _call_adapter() -> t.Any: + async with Client(mcp) as client: + return await client.call_tool("list_prompts", {}, raise_on_error=False) + + result = asyncio.run(_call_adapter()) + assert result.is_error is False def test_run_and_wait_returns_string_template() -> None: From 8314b1ac97e331f9ff954dbecc537e1e3ba84243 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:09:38 -0500 Subject: [PATCH 32/44] test(respawn): Wait for child exec why: tmux 3.6 can briefly report the relaunched shell before the child crosses exec(2). An immediate pane_current_command assertion therefore flaked while the behavior was correct. what: - Poll the refreshed pane for at most one second - Require the requested sleep process after the transient shell exits --- tests/test_pane_tools.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 9c7034cd..df48ecb8 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -1957,9 +1957,16 @@ def test_respawn_pane_replaces_shell(mcp_server: Server, mcp_session: Session) - socket_name=mcp_server.socket_name, ) assert result.pane_id == new_pane.pane_id - # pane_current_command reflects the relaunched command. - assert result.pane_current_command is not None - assert "sleep" in result.pane_current_command + + # tmux reports the shell briefly while the child crosses exec(2). + command = result.pane_current_command + deadline = time.monotonic() + 1.0 + while (command is None or "sleep" not in command) and time.monotonic() < deadline: + time.sleep(0.01) + new_pane.refresh() + command = new_pane.pane_current_command + assert command is not None + assert "sleep" in command new_pane.kill() From 7a7f33e39ac650b5d6b233af73e70786b1335cd8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:10:08 -0500 Subject: [PATCH 33/44] docs(trust): Define the execution boundary why: Tool filtering was described more strongly than the architecture allows. tmux aliases and hooks can extend a call. Status jobs run without one, and a socket selects an endpoint without confining its processes. what: - Assign non-surprise, consent, configuration, and confinement ownership - Document aliases, hooks, resources, status jobs, and server provenance - Rebuild the catalog around the four direct-semantics toolsets - State visibility, stdio, prompt, buffer, retry, and channel contracts - Pin catalog and topic claims with derived documentation tests --- AGENTS.md | 2 +- MIGRATION.md | 16 +- README.md | 6 +- docs/clients.md | 3 +- docs/conf.py | 1 + docs/configuration.md | 23 ++- docs/demo.md | 18 +- docs/index.md | 37 +++- docs/prompts.md | 3 +- docs/quickstart.md | 2 +- docs/reference/api/index.md | 2 +- docs/resources.md | 14 +- docs/tools/batch/call-read-tools-batch.md | 18 +- docs/tools/buffer/show-buffer.md | 3 +- docs/tools/hook/index.md | 20 +- docs/tools/hook/show-hook.md | 3 +- docs/tools/hook/show-hooks.md | 3 +- docs/tools/index.md | 185 +++++++++--------- docs/tools/pane/capture-pane.md | 3 +- docs/tools/pane/capture-since.md | 3 +- docs/tools/pane/display-message.md | 3 +- docs/tools/pane/find-pane-by-position.md | 3 +- docs/tools/pane/get-pane-info.md | 3 +- docs/tools/pane/search-panes.md | 3 +- docs/tools/pane/signal-channel.md | 10 +- docs/tools/pane/snapshot-pane.md | 3 +- docs/tools/pane/wait-for-channel.md | 2 +- docs/tools/pane/wait-for-text.md | 4 +- docs/tools/server/get-server-info.md | 3 +- docs/tools/server/list-servers.md | 3 +- docs/tools/server/list-sessions.md | 3 +- docs/tools/server/show-environment.md | 3 +- docs/tools/server/show-option.md | 3 +- docs/tools/session/get-session-info.md | 3 +- docs/tools/session/list-windows.md | 3 +- docs/tools/window/get-window-info.md | 3 +- docs/tools/window/list-panes.md | 3 +- docs/topics/architecture.md | 23 ++- docs/topics/concepts.md | 41 ++-- docs/topics/logging.md | 4 - docs/topics/troubleshooting.md | 3 +- docs/topics/trust.md | 225 ++++++++++++---------- tests/docs/test_topic_contracts.py | 99 ++++++---- tests/test_utils.py | 2 +- 44 files changed, 464 insertions(+), 356 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2429d481..c1758b69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ what was asked for. | Path | What it is | | ---- | ---------- | | `src/libtmux_mcp/server.py` | FastMCP instance: construction, instructions, lifespan | -| `src/libtmux_mcp/middleware.py` | Safety-tier gating, response limiting, error mapping | +| `src/libtmux_mcp/middleware.py` | Toolset gating, response limiting, error mapping | | `src/libtmux_mcp/_utils.py` | Server cache, object resolvers/serializers, `handle_tool_errors` | | `src/libtmux_mcp/models.py` | Pydantic models for tool outputs | | `src/libtmux_mcp/tools/` | MCP tool implementations: one module per tmux object, plus batch/buffer/hook/wait_for | diff --git a/MIGRATION.md b/MIGRATION.md index 0fcad482..39f70aec 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -76,9 +76,13 @@ format runs when tmux draws it and repeats on the status interval, and The safety topic is now the trust page. The old URL redirects. -### What did not change - -The MCP annotation hints. `readOnlyHint`, `destructiveHint`, -`idempotentHint` and `openWorldHint` are the protocol's fields with the -protocol's meanings. The tiers were this project's own invention, and only -they are withdrawn. +### MCP annotations + +Every tool that requests a tmux operation now explicitly advertises +`readOnlyHint: false`, +`destructiveHint: true`, `idempotentHint: false`, and `openWorldHint: true`. +An existing tmux server can use aliases and hooks to replace or extend the +operation libtmux-mcp requests, so no stronger static promise holds for every +target. The optional prompt adapter tools render text without contacting tmux +and retain their narrower hints. Use the project-owned toolsets to distinguish +the direct operation libtmux-mcp requests. diff --git a/README.md b/README.md index 72d10b8c..220d8d00 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,9 @@ on itself fails loudly instead of silently terminating the host environment the agent is running in. [`LIBTMUX_TOOLSETS`](https://libtmux-mcp.git-pull.com/configuration/#envvar-LIBTMUX_TOOLSETS) (`inspect`, `manage`, `execute`, `teardown`) drops whole toolsets from the client's tool list before any prompt is built. The sets are unordered, so -`inspect,teardown` is a legal surface. Dropping one is inventory -configuration, not containment: an enabled `execute` tool can type the -equivalent of anything it hides. +`inspect,teardown` is a legal surface. Dropping one is inventory and MCP +tool-call configuration, not containment: an enabled `execute` tool can type +the equivalent of anything it hides. ## Documentation diff --git a/docs/clients.md b/docs/clients.md index 28a6b8ca..6110862e 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -119,4 +119,5 @@ $ grok mcp add \ - **Absolute paths**: Some clients require absolute paths in config. Use `$HOME/...` or the full path instead of `~/...`. - **Virtual environments**: If using pip install, ensure the venv is activated or the `libtmux-mcp` binary is on your PATH. -- **Socket isolation**: Set `LIBTMUX_SOCKET` in the `env` block to isolate the MCP server from your default tmux. See {ref}`configuration`. +- **Socket selection**: Set `LIBTMUX_SOCKET` in the `env` block to address a + separate tmux object namespace. See {ref}`configuration`. diff --git a/docs/conf.py b/docs/conf.py index 2bc2e887..2ba806bf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -204,6 +204,7 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any: ) conf["fastmcp_section_badge_map"] = { "Inspect": "inspect", + "Manage": "manage", "Execute": "execute", "Teardown": "teardown", } diff --git a/docs/configuration.md b/docs/configuration.md index 76c37520..7ddc6122 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ Runtime configuration for the libtmux-mcp server. For MCP client setup, see {ref ```{envvar} LIBTMUX_SOCKET ``` -tmux socket name (`-L`). Isolates the MCP server to a specific tmux socket. +tmux socket name (`-L`). Selects the tmux server the MCP process addresses. - **Type:** string - **Default:** (none — uses the default tmux socket) @@ -39,8 +39,9 @@ Comma list of toolsets to advertise. See {ref}`trust`. - **Default:** `inspect,manage,execute` - **Values:** any of `inspect`, `manage`, `execute`, `teardown`; may be empty -An unknown name fails startup rather than being ignored. Filtering shapes -what this server advertises, not what tmux or a pane's shell can do. +An unknown name fails startup rather than being ignored. The setting filters +tools only; an empty value still leaves `tmux://` resources and native prompts +available. Filtering does not constrain what tmux or a pane's shell can do. ```{envvar} LIBTMUX_TOOLS ``` @@ -120,16 +121,22 @@ Set environment variables in your MCP client config: } ``` -## Socket isolation +## Socket selection -By default, the MCP server connects to the default tmux socket. Set {envvar}`LIBTMUX_SOCKET` to isolate AI agent activity from your personal tmux sessions: +By default, the MCP server connects to the default tmux socket. Set +{envvar}`LIBTMUX_SOCKET` to address a separate tmux object namespace: ```json "env": { "LIBTMUX_SOCKET": "ai_workspace" } ``` -The agent will only see sessions on the `ai_workspace` socket, not your personal sessions. +The agent sees sessions on the `ai_workspace` socket through calls that use +that default. A socket does not confine processes, prevent same-user clients +from connecting, or prove which configuration started an existing server. See +{ref}`trust`. -## All tools accept `socket_name` +## Targeted tools accept `socket_name` -Every tool accepts an optional `socket_name` parameter that overrides {envvar}`LIBTMUX_SOCKET` for that call. This allows agents to work across multiple tmux servers in a single session. +Tools that address one tmux server accept an optional `socket_name` parameter +that overrides {envvar}`LIBTMUX_SOCKET` for that call. This allows agents to +work across multiple tmux servers in a single session. diff --git a/docs/demo.md b/docs/demo.md index 80eddba7..825a08aa 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -53,17 +53,17 @@ These are the actual tool headings as they render on tool pages: > `capture_pane` {badge}`inspect` -> `split_window` {badge}`manage` +> `split_window` {badge}`execute` > `kill_session` {badge}`teardown` ### In a table -| Tool | Tier | Description | -|------|------|-------------| +| Tool | Toolset | Description | +|------|---------|-------------| | {toolref}`list-sessions` | {badge}`inspect` | List all sessions | -| {toolref}`send-keys` | {badge}`manage` | Send commands to a pane | -| {toolref}`kill-pane` | {badge}`teardown` | Destroy a pane | +| {toolref}`send-keys` | {badge}`execute` | Send input to a pane | +| {toolref}`kill-pane` | {badge}`teardown` | Delete a pane | ### In prose @@ -75,7 +75,7 @@ The fundamental command pattern: {toolref}`run-command` → inspect `exit_status ## Environment variable references -{envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_SAFETY` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN` +{envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_TOOLSETS` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN` ## Glossary terms @@ -92,7 +92,7 @@ Do not call {toolref}`capture-pane` immediately after {toolref}`send-keys` — t ``` ```{note} -All tools accept an optional `socket_name` parameter for multi-server support. +Targeted tools accept an optional `socket_name` parameter for multi-server support. ``` ## Badge anatomy @@ -108,8 +108,8 @@ Each badge renders as: ``` Features: -- **Emoji icon** — 🔍 inspect, ✏️ manage, 💣 teardown (native system emoji, no filters) -- **Matte colors** — forest green, smoky amber, matte crimson with 1px border +- **Emoji icon** — 🔍 inspect, 🔧 manage, ✏️ execute, 💣 teardown (native system emoji, no filters) +- **Matte colors** — forest green, blue, smoky amber, matte crimson with 1px border - **Accessible** — `role="note"` + `aria-label` for screen readers - **Non-selectable** — `user-select: none` so copying tool names skips badge text - **Context-aware sizing** — slightly larger in headings, smaller inline diff --git a/docs/index.md b/docs/index.md index 3df4dfbe..05d3617b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,9 @@ Terminal control for AI agents, built on [libtmux](https://libtmux.git-pull.com) and [FastMCP](https://gofastmcp.com). -This server maps tmux's object hierarchy — sessions, windows, panes — into MCP tools. Some tools read state. Some mutate it. Some destroy. The distinction is explicit and enforced. +This server maps tmux's object hierarchy — sessions, windows, panes — into +MCP tools grouped by the operation they request. The server enforces the +selected groups at the MCP tool boundary. ```{warning} **Pre-alpha.** APIs may change. [Feedback welcome](https://github.com/tmux-python/libtmux-mcp/issues). @@ -69,21 +71,42 @@ Config blocks for Claude Desktop, Claude Code, Cursor, and others. ### Inspect -Read tmux state without changing anything. +Request tmux state and terminal output. -{toolref}`list-sessions` · {toolref}`capture-pane` · {toolref}`capture-since` · {toolref}`snapshot-pane` · {toolref}`get-pane-info` · {toolref}`find-pane-by-position` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`display-message` · {toolref}`call-read-tools-batch` +{toolref}`list-sessions` · {toolref}`capture-pane` · +{toolref}`capture-since` · {toolref}`snapshot-pane` · +{toolref}`get-pane-info` · {toolref}`find-pane-by-position` · +{toolref}`search-panes` · {toolref}`wait-for-text` · +{toolref}`display-message` · {toolref}`call-read-tools-batch` + +### Manage + +Change tmux structure or presentation. + +{toolref}`rename-session` · {toolref}`resize-pane` · +{toolref}`select-layout` · {toolref}`select-pane` · +{toolref}`move-window` · {toolref}`enter-copy-mode` · +{toolref}`load-buffer` · {toolref}`wait-for-channel` ### Execute -Create or modify tmux objects. +Start or drive pane processes, or store state that can control later +execution. -{toolref}`create-session` · {toolref}`send-keys` · {toolref}`send-keys-batch` · {toolref}`run-command` · {toolref}`paste-text` · {toolref}`create-window` · {toolref}`split-window` · {toolref}`select-pane` · {toolref}`select-window` · {toolref}`move-window` · {toolref}`resize-pane` · {toolref}`pipe-pane` · {toolref}`set-option` +{toolref}`create-session` · {toolref}`create-window` · +{toolref}`split-window` · {toolref}`respawn-pane` · +{toolref}`send-keys` · {toolref}`send-keys-batch` · +{toolref}`run-command` · {toolref}`paste-text` · +{toolref}`paste-buffer` · {toolref}`pipe-pane` · +{toolref}`set-option` · {toolref}`set-environment` ### Teardown -Tear down tmux objects. Not reversible. +Delete tmux objects or retained scrollback. Not reversible. -{toolref}`kill-session` · {toolref}`kill-window` · {toolref}`kill-pane` · {toolref}`kill-server` +{toolref}`clear-pane` · {toolref}`kill-session` · +{toolref}`kill-window` · {toolref}`kill-pane` · +{toolref}`kill-server` · {toolref}`delete-buffer` ### Example: keep test runs out of persistent history diff --git a/docs/prompts.md b/docs/prompts.md index fbb81320..c2c4abbf 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -45,7 +45,8 @@ Most MCP clients render prompts via a slash-command UI (``/:``). For tools-only clients that don't expose prompts, set ``LIBTMUX_MCP_PROMPTS_AS_TOOLS=1`` in the server environment to surface them as ``list_prompts`` / ``get_prompt`` -tools instead. +tools instead. These text-rendering adapters belong to `inspect`; naming them +in {envvar}`LIBTMUX_TOOLS` also enables them individually. ``` --- diff --git a/docs/quickstart.md b/docs/quickstart.md index fcaa2a74..71eb95ba 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -73,6 +73,6 @@ return only new pane output. ## Next steps - {ref}`concepts` — Understand the tmux hierarchy and how tools target panes -- {ref}`configuration` — Environment variables and socket isolation +- {ref}`configuration` — Environment variables and socket selection - {ref}`trust` — Control which tools are available - {ref}`Tools ` — Browse all available tools diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md index bd899006..255ab3ce 100644 --- a/docs/reference/api/index.md +++ b/docs/reference/api/index.md @@ -26,7 +26,7 @@ Pydantic models for requests and responses. :::{grid-item-card} Middleware :link: middleware :link-type: doc -Safety-tier enforcement and request hooks. +Toolset enforcement and request hooks. ::: :::{grid-item-card} Utils diff --git a/docs/resources.md b/docs/resources.md index aeadc264..fffbc6d6 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -5,15 +5,19 @@ MCP resources are addressable documents the server exposes at ``tmux://`` URIs. Clients read them via ``resources/read``. All libtmux-mcp resources are -[resource templates](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates) +[resource templates](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#resource-templates) — each URI includes a ``{?socket_name}`` query parameter for socket -isolation, plus structural path parameters (``{session_name}``, +selection, plus structural path parameters (``{session_name}``, ``{pane_id}``, …) so a single template covers every session, window, or pane. -Every resource delivers a snapshot of the tmux hierarchy at call -time. Agents use them for read-only inspection; any write workflow -goes through the corresponding {doc}`tools `. +Every resource requests a snapshot of the tmux hierarchy. Its handler accepts +identifiers and socket names rather than command text, but it still sends tmux +queries. A target `command-alias` or hook may add effects; see {ref}`trust`. + +`LIBTMUX_TOOLSETS` filters tools only. Resources remain available when no +toolsets are enabled. They do not carry ToolAnnotations or produce a +`tools/call` audit event. ## Available resources diff --git a/docs/tools/batch/call-read-tools-batch.md b/docs/tools/batch/call-read-tools-batch.md index 2ceb6654..8be427c4 100644 --- a/docs/tools/batch/call-read-tools-batch.md +++ b/docs/tools/batch/call-read-tools-batch.md @@ -3,18 +3,18 @@ ```{fastmcp-tool} batch_tools.call_read_tools_batch ``` -**Use when** you need several read-only observations in one ordered -MCP turn, such as listing sessions and then reading server metadata. +**Use when** you need several `inspect` operations in one ordered MCP turn, +such as listing sessions and then reading server metadata. -**Avoid when** any nested operation changes tmux state — use -a direct call for anything that writes -workflows, or call the individual tools when each result should be +**Avoid when** any nested operation changes tmux state — call anything that +writes directly. Call the tools individually when each result should be reviewed before choosing the next action. -**Side effects:** None beyond the nested `inspect` tools. Anything outside -that toolset is rejected, whatever this server has enabled — a batch gives -every nested call the wrapper's name, so a client rule keyed on the inner -tool would not fire. +**Side effects:** Calls only `inspect` tools. Each nested built-in request reads +data only; a configured alias or hook can add effects. Anything outside that +toolset is rejected, whatever this server has enabled. A batch gives every +nested call the wrapper's name, so a client rule keyed on the inner tool would +not fire. See {ref}`trust`. **Example:** diff --git a/docs/tools/buffer/show-buffer.md b/docs/tools/buffer/show-buffer.md index bd8f765e..da405436 100644 --- a/docs/tools/buffer/show-buffer.md +++ b/docs/tools/buffer/show-buffer.md @@ -7,7 +7,8 @@ to read back a buffer between modifications. Restricted to MCP-namespaced buffers — non-agent buffers are rejected. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. ```{fastmcp-tool-input} buffer_tools.show_buffer ``` diff --git a/docs/tools/hook/index.md b/docs/tools/hook/index.md index 3c5ad1f8..9de35115 100644 --- a/docs/tools/hook/index.md +++ b/docs/tools/hook/index.md @@ -1,20 +1,16 @@ # Hook tools -tmux hooks let you attach commands to lifecycle events — `pane-exited`, `session-renamed`, `command-error`, and so on. libtmux-mcp exposes **read-only** hook introspection so agents can audit what hooks the human user has configured before running automation that might trigger them. +tmux hooks attach commands to lifecycle events — `pane-exited`, +`session-renamed`, `command-error`, and so on. libtmux-mcp exposes hook +inspection, but no dedicated tool that installs or removes one. ## Why no `set_hook`? -Write-hooks are deliberately not exposed. tmux servers outlive the MCP -process, and [FastMCP](https://gofastmcp.com)'s `lifespan` teardown -runs only on graceful SIGTERM/SIGINT — it's bypassed on `kill -9`, -OOM-kill, and C-extension-fault crashes. Any cleanup registry in -Python could be silently bypassed, leaking agent-installed shell hooks -into the user's persistent tmux server where they would fire forever. -Three plausible future paths exist (a tmux-side `client-detached` -meta-hook for self-cleanup, requiring `teardown` in `LIBTMUX_TOOLSETS`, or -exposing one-shot `run_hook` only); none is in scope. - -Until one of those paths is implemented, the surface here is visibility only. +The hook API is deliberately inspection-only. tmux servers outlive the MCP +process and can run tmux command lists. Cleanup is not guaranteed after +SIGKILL, OOM, or a native crash, so this server exposes no dedicated +hook-write tool. +Keep intentional persistent hooks in tmux configuration. ## Inspect diff --git a/docs/tools/hook/show-hook.md b/docs/tools/hook/show-hook.md index f5cd2832..07a18d6a 100644 --- a/docs/tools/hook/show-hook.md +++ b/docs/tools/hook/show-hook.md @@ -9,7 +9,8 @@ empty when the hook is unset; raises an unknown hook names (typos, wrong scope) so input mistakes don't masquerade as "nothing configured". -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. ```{fastmcp-tool-input} hook_tools.show_hook ``` diff --git a/docs/tools/hook/show-hooks.md b/docs/tools/hook/show-hooks.md index 702900c3..b55c109a 100644 --- a/docs/tools/hook/show-hooks.md +++ b/docs/tools/hook/show-hooks.md @@ -7,7 +7,8 @@ target — the human user's tmux config, an inherited team setup, or a session that another tool may have touched. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. ```{fastmcp-tool-input} hook_tools.show_hooks ``` diff --git a/docs/tools/index.md b/docs/tools/index.md index cc43c1fe..287d85a7 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -55,7 +55,7 @@ leave socket selection inside each nested tool's arguments. See - Signal a waiter → {tool}`signal-channel` **Batching typed tool calls?** -- Read-only observations → {tool}`call-read-tools-batch` +- Several `inspect` calls → {tool}`call-read-tools-batch` - Anything that writes → call the tool directly; a batch would hide its name from a client rule keyed on it @@ -82,8 +82,9 @@ shell-agnostic guidance. ## Inspect -Read tmux state and terminal output. Starts no process, and hands no input -of yours to one. +Request tmux state or terminal output, or render server-local prompt text. The +built-in operation does not pass caller input as a tmux or shell command. +Configured tmux aliases and hooks may still execute; see {ref}`trust`. ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 @@ -193,7 +194,7 @@ Rich capture: content + cursor + mode + scroll. :::{grid-item-card} display_message :link: display-message :link-type: ref -Query arbitrary tmux format strings. +Read literal text and validated tmux variables. ::: :::{grid-item-card} show_buffer @@ -218,52 +219,14 @@ Inspect a single tmux hook by name. ## Manage -Change tmux structure or presentation. Takes no input of yours that anything -later executes. - -## Execute - -Start a pane process, deliver input to one, or store a value tmux later runs. +Change tmux-managed structure, presentation, staging, or coordination state. +The built-in operation does not supply a shell command, pane input, or a value +tmux treats as executable configuration. Configured aliases and hooks may still +execute; see {ref}`trust`. ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 -:::{grid-item-card} create_session -:link: create-session -:link-type: ref -Start a new tmux session. -::: - -:::{grid-item-card} create_window -:link: create-window -:link-type: ref -Add a window to a session. -::: - -:::{grid-item-card} split_window -:link: split-window -:link-type: ref -Split a window into panes. -::: - -:::{grid-item-card} send_keys -:link: send-keys -:link-type: ref -Send raw keystrokes to a pane. -::: - -:::{grid-item-card} send_keys_batch -:link: send-keys-batch -:link-type: ref -Send several ordered raw-input operations. -::: - -:::{grid-item-card} run_command -:link: run-command -:link-type: ref -Run a shell command and report exit status. -::: - :::{grid-item-card} rename_session :link: rename-session :link-type: ref @@ -300,30 +263,6 @@ Set window layout. Set pane title. ::: -:::{grid-item-card} clear_pane -:link: clear-pane -:link-type: ref -Clear pane content. -::: - -:::{grid-item-card} respawn_pane -:link: respawn-pane -:link-type: ref -Restart a pane's process, keeping its `pane_id`. -::: - -:::{grid-item-card} set_option -:link: set-option -:link-type: ref -Set a tmux option. -::: - -:::{grid-item-card} set_environment -:link: set-environment -:link-type: ref -Set a tmux environment variable. -::: - :::{grid-item-card} select_pane :link: select-pane :link-type: ref @@ -348,12 +287,6 @@ Exchange positions of two panes. Move window to another index or session. ::: -:::{grid-item-card} pipe_pane -:link: pipe-pane -:link-type: ref -Stream pane output to a file. -::: - :::{grid-item-card} enter_copy_mode :link: enter-copy-mode :link-type: ref @@ -366,24 +299,12 @@ Enter copy mode for scrollback. Exit copy mode. ::: -:::{grid-item-card} paste_text -:link: paste-text -:link-type: ref -Paste multi-line text via tmux buffer. -::: - :::{grid-item-card} load_buffer :link: load-buffer :link-type: ref Stage multi-line text into an MCP-namespaced tmux buffer. ::: -:::{grid-item-card} paste_buffer -:link: paste-buffer -:link-type: ref -Paste an MCP buffer into a target pane. -::: - :::{grid-item-card} wait_for_channel :link: wait-for-channel :link-type: ref @@ -398,6 +319,88 @@ Wake clients blocked on a ``wait-for`` channel. :::: +## Execute + +Start a pane process, deliver input to one, or store state that can control +later execution. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} create_session +:link: create-session +:link-type: ref +Start a new tmux session. +::: + +:::{grid-item-card} create_window +:link: create-window +:link-type: ref +Add a window to a session. +::: + +:::{grid-item-card} split_window +:link: split-window +:link-type: ref +Split a window into panes. +::: + +:::{grid-item-card} send_keys +:link: send-keys +:link-type: ref +Send raw keystrokes to a pane. +::: + +:::{grid-item-card} send_keys_batch +:link: send-keys-batch +:link-type: ref +Send several ordered raw-input operations. +::: + +:::{grid-item-card} run_command +:link: run-command +:link-type: ref +Run a shell command and report exit status. +::: + +:::{grid-item-card} respawn_pane +:link: respawn-pane +:link-type: ref +Restart a pane's process, keeping its `pane_id`. +::: + +:::{grid-item-card} set_option +:link: set-option +:link-type: ref +Set a tmux option. +::: + +:::{grid-item-card} set_environment +:link: set-environment +:link-type: ref +Set a tmux environment variable. +::: + +:::{grid-item-card} pipe_pane +:link: pipe-pane +:link-type: ref +Stream pane output to a file. +::: + +:::{grid-item-card} paste_text +:link: paste-text +:link-type: ref +Paste multi-line text via tmux buffer. +::: + +:::{grid-item-card} paste_buffer +:link: paste-buffer +:link-type: ref +Paste an MCP buffer into a target pane. +::: + +:::: + ## Teardown Delete tmux objects or retained scrollback. Not reversible. @@ -405,6 +408,12 @@ Delete tmux objects or retained scrollback. Not reversible. ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 +:::{grid-item-card} clear_pane +:link: clear-pane +:link-type: ref +Clear pane content and scrollback. +::: + :::{grid-item-card} kill_session :link: kill-session :link-type: ref diff --git a/docs/tools/pane/capture-pane.md b/docs/tools/pane/capture-pane.md index 85099918..2fe6dea1 100644 --- a/docs/tools/pane/capture-pane.md +++ b/docs/tools/pane/capture-pane.md @@ -11,7 +11,8 @@ after running a command, checking output, or verifying state. {tooliconl}`capture-since` with its cursor so unchanged scrollback is not sent again. If you only need pane metadata (not content), use {tooliconl}`get-pane-info`. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/pane/capture-since.md b/docs/tools/pane/capture-since.md index 4864418e..84ca98b9 100644 --- a/docs/tools/pane/capture-since.md +++ b/docs/tools/pane/capture-since.md @@ -15,7 +15,8 @@ output in one typed result. If you need a one-shot content + metadata view, use {tooliconl}`snapshot-pane`; if you do not know which pane contains text, use {tooliconl}`search-panes`. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md index 0841250a..828a2ce0 100644 --- a/docs/tools/pane/display-message.md +++ b/docs/tools/pane/display-message.md @@ -16,7 +16,8 @@ anything to the user; it substitutes the variables and returns the value. {class}`~libtmux_mcp.models.PaneInfo` without parsing `#{pane_at_bottom}` / `#{pane_at_right}` yourself. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. Accepts literal text and `#{variable}` references only. Modifiers such as `#{E:...}` re-expand a variable's *value*, which can arrive from a pane diff --git a/docs/tools/pane/find-pane-by-position.md b/docs/tools/pane/find-pane-by-position.md index fc33cdfa..6157de75 100644 --- a/docs/tools/pane/find-pane-by-position.md +++ b/docs/tools/pane/find-pane-by-position.md @@ -10,7 +10,8 @@ computing geometry yourself. **Avoid when** you already know the `pane_id`. Use {tooliconl}`get-pane-info` or {tooliconl}`select-pane` directly. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/pane/get-pane-info.md b/docs/tools/pane/get-pane-info.md index bd3f1eaf..89a7294e 100644 --- a/docs/tools/pane/get-pane-info.md +++ b/docs/tools/pane/get-pane-info.md @@ -8,7 +8,8 @@ other metadata without reading the terminal content. **Avoid when** you need the actual text — use {tooliconl}`capture-pane`. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/pane/search-panes.md b/docs/tools/pane/search-panes.md index 38cb9a5b..b1452e5b 100644 --- a/docs/tools/pane/search-panes.md +++ b/docs/tools/pane/search-panes.md @@ -10,7 +10,8 @@ without knowing which pane to look in. **Avoid when** you already know the target pane — use {tooliconl}`capture-pane` for a one-shot read, or {tooliconl}`capture-since` for repeated observation. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. Matching shares a two-second ceiling across every line of every pane. A `regex=true` pattern with nested quantifiers such as `(a+)+` backtracks diff --git a/docs/tools/pane/signal-channel.md b/docs/tools/pane/signal-channel.md index 02d456c8..bfe7de17 100644 --- a/docs/tools/pane/signal-channel.md +++ b/docs/tools/pane/signal-channel.md @@ -5,11 +5,13 @@ **Use when** you need to wake a blocked {tooliconl}`wait-for-channel` caller from a different MCP context (e.g. when a long-running task in -one pane completes and another pane should proceed). Signalling an -unwaited channel is a successful no-op — safe to call defensively. +one pane completes and another pane should proceed). With no current +waiter, tmux records one pending signal for the next waiter. A second +signal before that wait clears the channel. -**Side effects:** Wakes any clients blocked on the named channel. -Doesn't allocate or persist state. +**Side effects:** Wakes clients blocked on the named channel or records one +pending signal for the next waiter to consume. A waiter resumes whatever work +follows its wait. ```{fastmcp-tool-input} wait_for_tools.signal_channel ``` diff --git a/docs/tools/pane/snapshot-pane.md b/docs/tools/pane/snapshot-pane.md index 991a6bb9..c444fc66 100644 --- a/docs/tools/pane/snapshot-pane.md +++ b/docs/tools/pane/snapshot-pane.md @@ -11,7 +11,8 @@ terminal mode. **Avoid when** you only need raw text — {tooliconl}`capture-pane` is lighter. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/pane/wait-for-channel.md b/docs/tools/pane/wait-for-channel.md index 82cc8a22..9cba38db 100644 --- a/docs/tools/pane/wait-for-channel.md +++ b/docs/tools/pane/wait-for-channel.md @@ -17,7 +17,7 @@ send_keys( wait_for_channel("tests_done", timeout=60) ``` -The `; tmux wait-for -S NAME` suffix is the load-bearing safety contract — `wait-for` is edge-triggered, so a crash before the signal would deadlock until the wait's `timeout`. The shell separator `;` runs the next statement unconditionally, so the signal fires on both success and failure paths. +The `; tmux wait-for -S NAME` suffix is the load-bearing completion contract. The shell separator `;` runs the signal after either command success or failure; if the shell exits before reaching it, the wait remains bounded by `timeout`. The payload deliberately does not append `exit $?` — in an interactive shell that exits the shell itself, taking single-pane sessions down diff --git a/docs/tools/pane/wait-for-text.md b/docs/tools/pane/wait-for-text.md index 81ed3081..61e386aa 100644 --- a/docs/tools/pane/wait-for-text.md +++ b/docs/tools/pane/wait-for-text.md @@ -11,7 +11,9 @@ server to start, a build to complete, or a prompt to return. {tooliconl}`capture-since`; for command completion you control, use {tooliconl}`wait-for-channel`. -**Side effects:** None. Reads only. Blocks until text appears or timeout. +**Side effects:** Each built-in poll reads data only. The call blocks until text +appears or the timeout expires. A configured alias or hook can add effects; see +{ref}`trust`. **Example:** diff --git a/docs/tools/server/get-server-info.md b/docs/tools/server/get-server-info.md index 1b827d68..8abf9919 100644 --- a/docs/tools/server/get-server-info.md +++ b/docs/tools/server/get-server-info.md @@ -8,7 +8,8 @@ or inspect server-level state before creating sessions. **Avoid when** you only need session names — use {tooliconl}`list-sessions`. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/server/list-servers.md b/docs/tools/server/list-servers.md index ae930f4e..b0a5b19b 100644 --- a/docs/tools/server/list-servers.md +++ b/docs/tools/server/list-servers.md @@ -11,7 +11,8 @@ side project. **Avoid when** you already know the socket name or path you want to target — pass it directly to the tool that needs it via `socket_name`. -**Side effects:** None. Reads only. Stale socket files are filtered +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. Stale socket files are filtered via a kernel-fast UNIX `connect()` probe so the call stays under one second even on machines with thousands of orphaned `tmux-/` inodes. diff --git a/docs/tools/server/list-sessions.md b/docs/tools/server/list-sessions.md index d632340a..bf03b3fe 100644 --- a/docs/tools/server/list-sessions.md +++ b/docs/tools/server/list-sessions.md @@ -9,7 +9,8 @@ which session to target. **Avoid when** you need window or pane details — use {tooliconl}`list-windows` or {tooliconl}`list-panes` instead. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/server/show-environment.md b/docs/tools/server/show-environment.md index b9f2013d..a417caf3 100644 --- a/docs/tools/server/show-environment.md +++ b/docs/tools/server/show-environment.md @@ -5,7 +5,8 @@ **Use when** you need to inspect tmux environment variables. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/server/show-option.md b/docs/tools/server/show-option.md index ceebb794..7473b1cc 100644 --- a/docs/tools/server/show-option.md +++ b/docs/tools/server/show-option.md @@ -6,7 +6,8 @@ **Use when** you need to check a tmux configuration value — buffer limits, history size, status bar settings, etc. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/session/get-session-info.md b/docs/tools/session/get-session-info.md index 7743c4fd..45fefcfd 100644 --- a/docs/tools/session/get-session-info.md +++ b/docs/tools/session/get-session-info.md @@ -10,7 +10,8 @@ count, attachment status, activity timestamp) and you already know its **Avoid when** you need every session — call {tooliconl}`list-sessions` or iterate via the `tmux://sessions` resource. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/session/list-windows.md b/docs/tools/session/list-windows.md index d27898e6..9bf4072b 100644 --- a/docs/tools/session/list-windows.md +++ b/docs/tools/session/list-windows.md @@ -8,7 +8,8 @@ session before selecting a window to work with. **Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/window/get-window-info.md b/docs/tools/window/get-window-info.md index e18c7c95..2f29bdec 100644 --- a/docs/tools/window/get-window-info.md +++ b/docs/tools/window/get-window-info.md @@ -10,7 +10,8 @@ dimensions, pane count) and you already know the `window_id` or **Avoid when** you need every window in a session — call {tooliconl}`list-windows` with `session_id` or iterate via the `tmux://sessions/{name}/windows` resource. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/tools/window/list-panes.md b/docs/tools/window/list-panes.md index 4d0644c3..a1ad8a74 100644 --- a/docs/tools/window/list-panes.md +++ b/docs/tools/window/list-panes.md @@ -6,7 +6,8 @@ **Use when** you need to discover which panes exist in a window before sending keys or capturing output. -**Side effects:** None. Reads only. +**Side effects:** The built-in request reads data only. A configured alias or +hook can add effects; see {ref}`trust`. **Example:** diff --git a/docs/topics/architecture.md b/docs/topics/architecture.md index 2c074b31..44264fb7 100644 --- a/docs/topics/architecture.md +++ b/docs/topics/architecture.md @@ -13,7 +13,7 @@ src/libtmux_mcp/ server.py # FastMCP instance and configuration _utils.py # Server caching, resolvers, serializers, error handling models.py # Pydantic output models - middleware.py # Toolset, audit, retry, and error-result middleware + middleware.py # Toolset, audit, and error-result middleware tools/ batch_tools.py # call_read_tools_batch server_tools.py # list_servers, list_sessions, create_session, kill_server, get_server_info @@ -41,11 +41,10 @@ MCP Client (Claude, Cursor, etc.) → TailPreservingResponseLimitingMiddleware (response size backstop) → ToolErrorResultMiddleware (exceptions → is_error results) → AuditMiddleware (one log record per call) - → InspectRetryMiddleware (retries inspect tools only) - → ToolsetMiddleware (membership, fail-closed) - → Tool function (tools/*.py) - → libtmux Python objects - → tmux binary (via subprocess) + → ToolsetMiddleware (membership, fail-closed) + → Tool function (tools/*.py) + → libtmux Python objects + → tmux binary (via subprocess) ``` The libtmux layer is the tmux object hierarchy: @@ -80,19 +79,19 @@ priority chain: direct ID → name lookup → error. {class}`~libtmux_mcp.middleware.ToolsetMiddleware` implements [FastMCP](https://gofastmcp.com)'s middleware interface. It operates -as a secondary gate behind FastMCP's native tag visibility system, -providing clear error messages when a tool above the configured tier -is invoked. +as a secondary classification gate behind FastMCP's native tag and name +visibility. Tools hidden by FastMCP return its unknown-tool error before +dispatch reaches this middleware. ### Error handling Three boundaries split the work: -1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e`, which is what lets {class}`~libtmux_mcp.middleware.InspectRetryMiddleware` match transient {exc}`~libtmux.exc.LibTmuxException` causes. +1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e` for logs and debuggers. 2. **Schema classification** — FastMCP validates tool arguments before tool code runs, so [Pydantic](https://docs.pydantic.dev/) validation failures never reach the decorator. {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` classifies those schema-validation errors as expected, agent-correctable WARNINGs before converting them. -3. **Conversion** — {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` catches the exception once it has cleared the audit/retry/toolset trio and returns an error `ToolResult` carrying the message exactly as raised, plus a `_meta` payload (`error_type`, `expected`, and an optional agent-facing `suggestion` for recovery hints such as discovery tools or rejected-argument fixes). +3. **Conversion** — {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` catches the exception once it has cleared the audit/toolset pair and returns an error `ToolResult` carrying the message exactly as raised, plus a `_meta` payload (`error_type`, `expected`, and an optional agent-facing `suggestion` for recovery hints such as discovery tools or rejected-argument fixes). -Errors must stay exceptions through the audit/retry/toolset trio — audit detects failures by catching, retry matches via `__cause__` — so conversion happens only in the outermost error layer. The response limiter sits outside conversion and may truncate large success or error results on the return path; its truncation path preserves `is_error` and `_meta` so oversized expected failures stay tool errors. Level policy lives in {doc}`/topics/logging`. +Errors must stay exceptions through the audit/toolset pair so audit can detect failures before conversion in the outermost error layer. The response limiter sits outside conversion and may truncate large success or error results on the return path; its truncation path preserves `is_error` and `_meta` so oversized expected failures stay tool errors. Level policy lives in {doc}`/topics/logging`. ## References diff --git a/docs/topics/concepts.md b/docs/topics/concepts.md index e2e211cf..80152fed 100644 --- a/docs/topics/concepts.md +++ b/docs/topics/concepts.md @@ -41,28 +41,22 @@ Most tools accept multiple targeting parameters. The resolution order is: For pane tools, you can combine parameters to narrow the search: `session_name` + `window_id` → find the pane in that specific window. -## Discovery vs. mutation - -Tools fall into three categories: - -- **Discovery** — Read-only operations: {toolref}`list-sessions`, - {toolref}`list-windows`, {toolref}`list-panes`, - {toolref}`capture-pane`, {toolref}`capture-since`, - {toolref}`get-pane-info`, {toolref}`find-pane-by-position`, - {toolref}`search-panes`, {toolref}`wait-for-text`, - {toolref}`show-option`, {toolref}`show-environment` -- **Mutation** — Create, modify, or send input: - {toolref}`create-session`, {toolref}`create-window`, - {toolref}`split-window`, {toolref}`send-keys`, - {toolref}`send-keys-batch`, {toolref}`rename-session`, - {toolref}`rename-window`, {toolref}`resize-pane`, - {toolref}`resize-window`, {toolref}`set-pane-title`, - {toolref}`clear-pane`, {toolref}`select-layout`, - {toolref}`set-option`, {toolref}`set-environment` -- **Destruction** — Remove tmux objects: {toolref}`kill-server`, - {toolref}`kill-session`, {toolref}`kill-window`, {toolref}`kill-pane` - -These map to {ref}`toolsets `. +## Toolsets + +Tools belong to four unordered sets: + +- **Inspect** requests tmux state or terminal output, or renders server-local + prompt text. Its built-in operation does not pass caller input as a tmux or + shell command. +- **Manage** changes tmux-managed structure, presentation, staging, or + coordination state. +- **Execute** starts or drives pane processes, or stores state that can + control later execution. +- **Teardown** deletes tmux objects or retained scrollback. + +The selected sets determine which MCP tools this server advertises and +accepts. Configured tmux aliases and hooks can add effects to any tmux +request; see {ref}`trust`. ## Agent self-awareness @@ -72,7 +66,8 @@ When the MCP server runs inside a tmux pane (detected via the `TMUX_PANE` enviro - Annotates the caller's own pane with `is_caller=true` in tool results - Prevents the `teardown` tools from killing the caller's own pane, window, session, or server -This means agents can safely explore and manage tmux without accidentally terminating themselves. +These checks prevent the teardown tools from terminating their own MCP pane. +They do not constrain tmux aliases, hooks, other clients, or pane processes. ## Server caching diff --git a/docs/topics/logging.md b/docs/topics/logging.md index 34396ea1..d6489c53 100644 --- a/docs/topics/logging.md +++ b/docs/topics/logging.md @@ -15,10 +15,6 @@ All loggers are children of ``libtmux_mcp``. The primary streams are: - ``libtmux_mcp.audit`` — one structured line per tool call, emitted by {class}`~libtmux_mcp.middleware.AuditMiddleware`. Includes tool name, digest-redacted arguments, latency, and outcome. See {doc}`/topics/trust` for the argument-redaction rules. It does not record tool return values. -- ``libtmux_mcp.retry`` — warnings from - {class}`~libtmux_mcp.middleware.InspectRetryMiddleware` when an - `inspect` tool retried after a transient - {exc}`~libtmux.exc.LibTmuxException`. - ``libtmux_mcp.server`` / ``libtmux_mcp.tools.*`` / etc. — ad-hoc warnings and debug messages from the codebase. - ``fastmcp.errors`` — one record per failed tool call, emitted by diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md index 9ad7d7ac..4d127037 100644 --- a/docs/topics/troubleshooting.md +++ b/docs/topics/troubleshooting.md @@ -54,7 +54,8 @@ can't find targets. **Symptoms**: Server sees different sessions than expected, or sees nothing. -**Cause**: `LIBTMUX_SOCKET` in the MCP config isolates the server to a specific socket. Your personal sessions are on the default socket. +**Cause**: `LIBTMUX_SOCKET` in the MCP config selects a different tmux socket. +Your personal sessions are on the default socket. **Fix**: Either remove `LIBTMUX_SOCKET` from the config to use the default socket, or ensure sessions exist on the configured socket. diff --git a/docs/topics/trust.md b/docs/topics/trust.md index bbec54dd..297af580 100644 --- a/docs/topics/trust.md +++ b/docs/topics/trust.md @@ -7,50 +7,111 @@ This server gives an agent a terminal. What follows is what that does and does not bound. -## Toolsets are an inventory, not a permission system +## Toolsets gate MCP tool calls, not tmux Tools are grouped into four sets by what they do: `inspect` -: Read tmux state and terminal output. Starts no process, and hands no - caller input to one — what you supply is IDs, names, bounded patterns, - and validated variable names. +: Request tmux state or terminal output, or render server-local prompt text. + The built-in operation does not pass caller input as a tmux or shell + command. `manage` -: Change tmux structure or presentation: names, sizes, layouts, selections, - modes. Starts no process, and takes no caller input that anything later - executes. +: Change tmux-managed structure, presentation, staging, or coordination + state. The built-in operation does not supply a shell command, pane input, + or a value tmux treats as executable configuration. `execute` -: Start a pane process, deliver input to one, or store a value tmux later - runs. {tooliconl}`set-option` is here, not in `manage`: a `#(...)` job in - a status format runs when tmux draws it and repeats on the status - interval, and `default-command` decides what every future pane runs. +: Start a pane process, deliver input to one, or store state that can control + later execution. {tooliconl}`set-option` is here, not in `manage`: a + `#(...)` job in a status format runs when tmux draws it and repeats on the + status interval, and `default-command` decides what every future pane runs. `teardown` : Delete tmux objects or retained scrollback. Irreversible at the tmux level. -The sets are unordered. `LIBTMUX_TOOLSETS=inspect,teardown` is a legal -surface — an agent that can look and clean up, but not type. +The sets are unordered. FastMCP visibility and libtmux-mcp middleware both +enforce which MCP tool calls the server advertises and accepts. +`LIBTMUX_TOOLSETS=inspect,teardown` is therefore a legal surface — an agent +that can look and clean up through this server's tools, but not type through +them. + +{envvar}`LIBTMUX_TOOLSETS`, {envvar}`LIBTMUX_TOOLS`, and +{envvar}`LIBTMUX_EXCLUDE_TOOLS` filter tools only. The `tmux://` hierarchy +resources and native prompts remain available when every toolset is disabled. **Dropping a toolset is not containment.** It changes what this server advertises. An enabled `execute` tool can type the equivalent of anything -you hid, because a pane's shell runs with your user's authority. Treat the -toolsets as inventory configuration and accident reduction. OS accounts, -containers, and separate tmux sockets are the isolation boundaries. +you hid, because a pane's shell runs with your user's authority. Existing pane +processes and other clients of the same tmux server also remain outside the MCP +call gate. + +## The tmux server is programmable + +tmux is a separate, long-lived process. A configured `command-alias` can +replace a command this server sends, and an `after-*` hook can run a command +list after many built-in commands. A nominal `inspect` call can therefore +change state or run a shell without receiving executable input from the MCP +caller. + +Hierarchy resource reads are a separate MCP surface, but they send the same +class of tmux queries as `inspect` tools. A `resources/read` request can +therefore activate aliases and hooks too. Resources have no ToolAnnotations +and do not produce this server's tool-call audit record. Native prompts only +return text and do not contact tmux. + +Execution can occur without any MCP call. A `#(...)` job in a status format +runs when tmux redraws the status line and can repeat on the status interval. +No MCP tool filter can intercept work that never passes through this server. + +libtmux-mcp startup and shutdown send no tmux commands. Failed tool calls run +once: the server does not retry them automatically because tmux may already +have applied an alias or hook effect before reporting an error. + +A toolset describes the built-in operation its tools request. It does not +describe everything the target tmux server may do around that request. + +### Responsibility by layer + +| Layer | Owns | +| --- | --- | +| libtmux-mcp | Input validation and refusal, tmux argv construction, the advertised and callable tool surface, direct-operation classification, wait ceilings, selected high-volume output caps, resource disclosure, and tool-call audit redaction. | +| Model or agent | Chooses requested calls and command text, but is not an enforcement boundary against its own errors or prompt injection. | +| MCP client and user | Whether to request and confirm a call, whether to retry it, and which credentials the agent receives. | +| tmux operator | The target socket, configuration, aliases, hooks, key bindings, status formats, pane programs, and other clients. | +| OS and deployment | Process identity and limits on filesystem, network, credentials, privileges, and resources. | + +For local stdio use, the launching client and OS account are the trust context; +FastMCP has no OAuth token to authorize. A remote HTTP deployment must +authenticate users and enforce authorization on the server as well as asking +for client-side confirmation. See [FastMCP authorization](https://gofastmcp.com/servers/authorization). + +### Guarantee by topology + +| Topology | Strongest guarantee | +| --- | --- | +| Existing or shared tmux server | The MCP tool-call gate and libtmux-mcp's input handling; tmux configuration and peer activity remain unknown and mutable. | +| Fresh, separately supervised tmux server with a minimal config | A separate tmux object namespace and known startup configuration for that daemon generation; same-user clients and pane processes can still reconfigure it. | +| OS identity, container, or VM boundary | Effects are limited by the configured process, filesystem, network, credential, privilege, and resource policy. tmux still executes processes inside that boundary. | + +A socket alone is an endpoint, not process confinement. Starting a normal tmux +client with `-f` also does not prove that configuration was used: if the server +already exists, tmux keeps the configuration from that daemon's startup. ## `inspect` does not mean safe -An `inspect` tool does not interpret what you give it as a command. That is -a property of these implementations, and it is the only thing the name -claims. +An `inspect` tool's built-in command sequence does not pass caller input as a +tmux or shell command. That is a property of these implementations, and it is +the only thing the name claims. A target tmux server may still replace or +extend the requested command through its aliases and hooks. -It is not a claim that the result is harmless. A capture returns whatever -the pane holds: credentials someone typed, a command line with a token in -it, output from a remote host, text written by another agent. Those reads -advertise `openWorldHint: true` for that reason. Auto-approving the whole -set is a decision to make with that in mind, not one the name endorses. +It is not a claim that the result is harmless. A capture returns whatever the +pane holds: credentials someone typed, a command line with a token in it, +output from a remote host, text written by another agent. Auto-approving the +whole set is a decision to make with that in mind, not one the name endorses. +Treat pane and hierarchy-resource output as untrusted data, never as +instructions. ## Configuration @@ -81,10 +142,10 @@ than a server that will not start. ### How it works -Two layers, both keyed on the same tags. [FastMCP](https://gofastmcp.com) -tag visibility filters the listing; a middleware repeats the decision on -call so a direct invocation gets an error naming the variable rather than -an unknown-tool error. +Two layers use the same tags and names. [FastMCP](https://gofastmcp.com) +visibility is the primary wire filter: omitted or excluded tools disappear +from listings, and direct calls return an unknown-tool error. Middleware +rechecks the classification for tools that reach dispatch. Both fail closed: a tool carrying no recognized toolset is refused, so adding one without classifying it cannot expose it by accident. @@ -117,16 +178,19 @@ The structural fix shipped in 0.1.x; setting {envvar}`TMUX_TMPDIR` explicitly is Most `manage` tools are bounded: {toolref}`resize-pane` only resizes, {toolref}`rename-window` only renames. A few have broader reach because tmux itself exposes broader reach. Treat these as -elevated risk even though they share the default tier: +elevated risk even though the default enables their toolset: ### Piping pane output -{tool}`pipe-pane` pipes a pane's output to a shell command that the server runs. In practice this means the caller chooses an arbitrary path or pipeline on the server host. There is no allow-list. Assume it can create files anywhere the server process can write. +{tool}`pipe-pane` pipes a pane's output through a fixed shell redirection. The +caller chooses the destination path. There is no path allow-list; assume it can +create files anywhere the server process can write. Mitigations: - Run the server as an unprivileged user with a scoped home directory. -- Consider `LIBTMUX_TOOLSETS=inspect` for untrusted MCP clients. +- Exclude `execute` when pane control is unnecessary. This narrows the direct + tool surface; it does not establish trust or confinement. - Audit log records (see below) capture the `output_path` argument so reviewers can spot unexpected destinations. ### Setting tmux environment @@ -142,7 +206,8 @@ Mitigations: {tool}`respawn-pane` restarts a pane's process while preserving the pane id and layout — exactly what an agent wants when a shell wedges. Default `kill=True` terminates the running process before relaunch. The `pane_id` and layout are preserved (the point of the tool), but any unsaved REPL state, ssh session, or in-flight job in that pane is lost. Repeated calls are *not* idempotent — each call kills a new process. -The registration advertises `destructiveHint=True` and `idempotentHint=False` while staying in `manage`, so recovery remains available by default without understating what the call does. +The tool belongs to `execute`: it terminates one pane process and starts +another, even when the replacement command is omitted. Mitigations: @@ -153,7 +218,10 @@ Mitigations: ### Raw pane input -These can execute anything the pane's shell accepts. There is no payload validation. The server audit log stores a digest of the content, not the content itself, so a secret typed via {tooliconl}`send-keys` or {tooliconl}`send-keys-batch` does not land in that audit record. +These can execute anything the pane's shell accepts. There is no shell-syntax +allow-list. The server audit log stores a digest of the content, not the +content itself, so a secret typed via {tooliconl}`send-keys` or +{tooliconl}`send-keys-batch` does not land in that audit record. ### History suppression is not secret transport @@ -182,69 +250,28 @@ Route this logger to a dedicated sink if you want a durable audit trail; it is d ## Tool annotations -Every tool advertises the four MCP annotation hints. They are hints for client -presentation, not authorization: a client may ignore them, and this server -cannot enforce them. - -`destructiveHint: false` is a claim that a tool performs **only additive -updates**, so a tool that replaces a name, a size, or a layout advertises -`true` even though nothing is destroyed. `openWorldHint: true` says the tool -reaches, or returns text from, outside tmux — a spawned process runs with your -user's authority, and a pane holds whatever was printed into it. - -| Tool | Toolset | readOnlyHint | destructiveHint | idempotentHint | openWorldHint | -|------|---------|--------------|-----------------|----------------|---------------| -| {toolref}`call-read-tools-batch` | {badge}`inspect` | true | false | true | true | -| {toolref}`capture-pane` | {badge}`inspect` | true | false | true | true | -| {toolref}`capture-since` | {badge}`inspect` | true | false | true | true | -| {toolref}`display-message` | {badge}`inspect` | true | false | true | true | -| {toolref}`find-pane-by-position` | {badge}`inspect` | true | false | true | false | -| {toolref}`get-pane-info` | {badge}`inspect` | true | false | true | false | -| {toolref}`get-server-info` | {badge}`inspect` | true | false | true | false | -| {toolref}`get-session-info` | {badge}`inspect` | true | false | true | false | -| {toolref}`get-window-info` | {badge}`inspect` | true | false | true | false | -| {toolref}`list-panes` | {badge}`inspect` | true | false | true | false | -| {toolref}`list-servers` | {badge}`inspect` | true | false | true | false | -| {toolref}`list-sessions` | {badge}`inspect` | true | false | true | false | -| {toolref}`list-windows` | {badge}`inspect` | true | false | true | false | -| {toolref}`search-panes` | {badge}`inspect` | true | false | true | true | -| {toolref}`show-buffer` | {badge}`inspect` | true | false | true | true | -| {toolref}`show-environment` | {badge}`inspect` | true | false | true | false | -| {toolref}`show-hook` | {badge}`inspect` | true | false | true | false | -| {toolref}`show-hooks` | {badge}`inspect` | true | false | true | false | -| {toolref}`show-option` | {badge}`inspect` | true | false | true | false | -| {toolref}`snapshot-pane` | {badge}`inspect` | true | false | true | true | -| {toolref}`wait-for-text` | {badge}`inspect` | true | false | true | true | -| {toolref}`enter-copy-mode` | {badge}`manage` | false | true | false | false | -| {toolref}`exit-copy-mode` | {badge}`manage` | false | true | true | false | -| {toolref}`load-buffer` | {badge}`manage` | false | false | false | false | -| {toolref}`move-window` | {badge}`manage` | false | true | true | false | -| {toolref}`rename-session` | {badge}`manage` | false | true | true | false | -| {toolref}`rename-window` | {badge}`manage` | false | true | true | false | -| {toolref}`resize-pane` | {badge}`manage` | false | true | true | false | -| {toolref}`resize-window` | {badge}`manage` | false | true | true | false | -| {toolref}`select-layout` | {badge}`manage` | false | true | true | false | -| {toolref}`select-pane` | {badge}`manage` | false | true | true | false | -| {toolref}`select-window` | {badge}`manage` | false | true | true | false | -| {toolref}`set-pane-title` | {badge}`manage` | false | true | true | false | -| {toolref}`signal-channel` | {badge}`manage` | false | true | false | false | -| {toolref}`swap-pane` | {badge}`manage` | false | true | false | false | -| {toolref}`wait-for-channel` | {badge}`manage` | false | true | false | false | -| {toolref}`create-session` | {badge}`execute` | false | false | false | true | -| {toolref}`create-window` | {badge}`execute` | false | false | false | true | -| {toolref}`paste-buffer` | {badge}`execute` | false | true | false | true | -| {toolref}`paste-text` | {badge}`execute` | false | true | false | true | -| {toolref}`pipe-pane` | {badge}`execute` | false | true | false | true | -| {toolref}`respawn-pane` | {badge}`execute` | false | true | false | true | -| {toolref}`run-command` | {badge}`execute` | false | true | false | true | -| {toolref}`send-keys` | {badge}`execute` | false | true | false | true | -| {toolref}`send-keys-batch` | {badge}`execute` | false | true | false | true | -| {toolref}`set-environment` | {badge}`execute` | false | true | true | true | -| {toolref}`set-option` | {badge}`execute` | false | true | true | true | -| {toolref}`split-window` | {badge}`execute` | false | true | false | true | -| {toolref}`clear-pane` | {badge}`teardown` | false | true | false | false | -| {toolref}`delete-buffer` | {badge}`teardown` | false | true | false | false | -| {toolref}`kill-pane` | {badge}`teardown` | false | true | false | false | -| {toolref}`kill-server` | {badge}`teardown` | false | true | false | false | -| {toolref}`kill-session` | {badge}`teardown` | false | true | false | false | -| {toolref}`kill-window` | {badge}`teardown` | false | true | false | false | +[MCP defines](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations) +four standard hints for the behavior of the whole tool call. +[Clients may use positive hints](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/) +to skip confirmation or retry a call. This server can target an +existing tmux server selected by each call, and it cannot establish that the +server has no aliases or hooks or that another client will not add them. Every +tool that requests a tmux operation therefore advertises the same conservative +static hints: + +| readOnlyHint | destructiveHint | idempotentHint | openWorldHint | +| --- | --- | --- | --- | +| false | true | false | true | + +These values are hints, not authorization. They do not say that every call +modifies state, destroys data, has an additional effect when repeated, or +reaches outside tmux. They decline to promise otherwise for every target. The +project-owned `inspect`, `manage`, `execute`, and `teardown` toolsets preserve +the direct-operation distinctions that the standard hints cannot express here. +Clients that ignore project tags cannot recover those distinctions from the +four hints alone; they must use the tool name, schema, description, or an +operator-selected tool surface. + +The optional `list_prompts` and `get_prompt` adapter tools do not contact tmux. +They belong to `inspect` because they render server-local prompt text without +changing tmux, and advertise `true`, `false`, `true`, `false` respectively. diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 6979dfc0..c9eb867f 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -2,11 +2,15 @@ from __future__ import annotations +import asyncio import pathlib import typing as t import pytest +from libtmux_mcp._utils import VALID_TOOLSETS +from libtmux_mcp.tools import register_tools + class TopicContractFixture(t.NamedTuple): """Fixture for forbidden stale docs claims.""" @@ -241,7 +245,7 @@ def test_run_command_page_documents_effective_history_policy( def test_trust_docs_name_history_non_goals_and_secret_reference_guidance( docs_dir: pathlib.Path, ) -> None: - """Safety guidance does not present history suppression as secret transport.""" + """Trust guidance does not present history suppression as secret transport.""" text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8") for surface in ( @@ -304,6 +308,61 @@ def test_trust_docs_name_history_non_goals_and_secret_reference_guidance( assert boundary in process_visibility +def test_trust_docs_state_ambient_tmux_execution( + docs_dir: pathlib.Path, +) -> None: + """The trust page separates MCP requests from programmable tmux state.""" + text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8") + heading = "## The tmux server is programmable" + assert heading in text + section = text.split(heading, 1)[1].split("\n## ", 1)[0] + + for route in ( + "command-alias", + "after-*", + "#(...)", + "Hierarchy resource reads", + "startup and shutdown", + "does not retry", + ): + assert route in section + assert "without any MCP call" in section + assert "process confinement" in section + + resources = (docs_dir / "resources.md").read_text(encoding="utf-8") + assert "`LIBTMUX_TOOLSETS` filters tools" in resources + assert "{ref}`trust`" in resources + + +def test_tool_catalog_groups_every_registered_tool_by_its_wire_tag( + docs_dir: pathlib.Path, +) -> None: + """The tool overview derives its grouping contract from MCP metadata.""" + from fastmcp import Client, FastMCP + + mcp = FastMCP(name="docs-tool-catalog") + register_tools(mcp) + + async def _list_tools() -> list[t.Any]: + async with Client(mcp) as client: + return list(await client.list_tools()) + + tools = asyncio.run(_list_tools()) + text = (docs_dir / "tools" / "index.md").read_text(encoding="utf-8") + sections = { + toolset: text.split(f"## {toolset.title()}", 1)[1].split("\n## ", 1)[0] + for toolset in VALID_TOOLSETS + } + + for tool in tools: + dumped = tool.model_dump(mode="json", by_alias=True, exclude_none=True) + tags = set(dumped.get("_meta", {}).get("fastmcp", {}).get("tags", [])) + [toolset] = tags & set(VALID_TOOLSETS) + marker = f":::{'{'}grid-item-card{'}'} {tool.name}\n" + assert text.count(marker) == 1, tool.name + assert marker in sections[toolset], tool.name + + def test_respawn_page_distinguishes_environment_audit_shapes( docs_dir: pathlib.Path, ) -> None: @@ -390,41 +449,3 @@ def test_a17_changelog_summarizes_history_features( assert "same JSON object form" in environment_entry assert "credential references, not literal credentials" in environment_entry assert "{ref}`trust`" in environment_entry - - -def test_annotation_table_matches_the_registered_surface( - docs_dir: pathlib.Path, -) -> None: - """The hand-written hint table says what clients are actually told.""" - import asyncio - import re - - from fastmcp import FastMCP - - from libtmux_mcp.tools import register_tools - - # Register into a fresh server rather than reading the production one: - # its tier filter is fixed at import, so the visible surface would - # depend on which test imported it first. - mcp = FastMCP(name="test-annotation-table") - register_tools(mcp) - tools = asyncio.run(mcp.list_tools()) - assert len(tools) > 1 - - hints = ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint") - from libtmux_mcp._utils import VALID_TOOLSETS - - expected = set() - for tool in tools: - annotations = tool.annotations - assert annotations is not None, tool.name - dumped = annotations.model_dump(mode="json", by_alias=True) - cells = " | ".join(str(dumped[hint]).lower() for hint in hints) - toolset = next(name for name in VALID_TOOLSETS if name in tool.tags) - slug = tool.name.replace("_", "-") - expected.add(f"| {{toolref}}`{slug}` | {{badge}}`{toolset}` | {cells} |") - - text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8") - documented = set(re.findall(r"^\| \{toolref\}`.+\|$", text, flags=re.MULTILINE)) - - assert documented == expected diff --git a/tests/test_utils.py b/tests/test_utils.py index c4e8f55e..2e0222ca 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -380,7 +380,7 @@ def test_effective_socket_path_prefers_display_message_query( being able to reach the server, so if the MCP process's ``$TMUX_TMPDIR`` diverges from the running tmux's, the query fails and we fall back. The full structural fix requires - consulting the caller's ``$TMUX`` path — see ``docs/topics/safety.md``. + consulting the caller's ``$TMUX`` path — see ``docs/topics/trust.md``. """ from libtmux_mcp._utils import _effective_socket_path From f06185c41800a769826f1dfe4c033429777682b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 20:10:20 -0500 Subject: [PATCH 34/44] test(toolsets): Ratchet retired names why: The earlier gate matched ordinary words and skipped contributor guidance, skills, scripts, and project configuration. Stale tier language could therefore survive a tree-wide rename. what: - Match retired identifiers, keys, headings, and exact taxonomy phrases - Cover active guidance, skills, scripts, code, tests, and config - Update remaining active guidance and fixtures to the toolset model - Keep CHANGES and MIGRATION as the historical vocabulary homes --- .../testing-mcp-with-cli-agents/SKILL.md | 10 +- .../references/cli-matrix.md | 4 +- .github/WRITING.md | 16 +- scripts/mcp_swap.py | 2 +- tests/test_mcp_swap.py | 2 +- tests/test_pane_tools.py | 4 +- tests/test_retired_vocabulary.py | 150 ++++++++++++------ tests/test_wait_policy.py | 5 +- 8 files changed, 124 insertions(+), 69 deletions(-) diff --git a/.agents/skills/testing-mcp-with-cli-agents/SKILL.md b/.agents/skills/testing-mcp-with-cli-agents/SKILL.md index ab0ca476..205ee32f 100644 --- a/.agents/skills/testing-mcp-with-cli-agents/SKILL.md +++ b/.agents/skills/testing-mcp-with-cli-agents/SKILL.md @@ -59,8 +59,8 @@ tool list, a couple of representative calls, an error path. Use this to answer "is the tool surface and result shape correct?" before spending a CLI on it. Shape-normalization gotchas seen in practice — normalize before asserting: -- Match the current `LIBTMUX_SAFETY` tier: destructive-batch wrappers are hidden - at the default tier, so don't assert they're visible. +- Match the current `LIBTMUX_TOOLSETS` selection: excluded toolsets are hidden + when omitted from the selection, so don't assert they're visible. - List-returning tools surface `structuredContent` as `{"result": [...]}`; `capture_pane` output can come back under `result` as a string. Don't assume a top-level `count`. @@ -75,7 +75,7 @@ handshake, codex's `mcp get` only parses config, and agy has no proof short of a model call. `references/cli-matrix.md` has the verified per-CLI invocation, isolation lever, and approval-bypass flag for all six. Two things that surprise people: some `mcp list`/`list-tools` subcommands read the *ambient* config and -ignore your isolated one, and a mutating tool call needs a per-CLI +ignore your isolated one, and a write-capable tool call needs a per-CLI approval-bypass flag or it hangs on a no-TTY prompt. Flags drift — re-verify with `--help`. @@ -230,7 +230,7 @@ not add new keys, so inject `LIBTMUX_SOCKET=mcp-target` via each CLI's native real CLI configs, so dry-run first, record the pre-existing swap state, and revert only what you swapped. -**Prefer zero-mutation isolation for a test.** mcp_swap is for a swap you *want* +**Prefer no-config-write isolation for a test.** mcp_swap is for a swap you *want* to persist. To just exercise a checkout, use each CLI's throwaway config-home / project-config lever instead — `references/cli-matrix.md` gives the verified one per CLI (codex `CODEX_HOME` or `-c` overrides, grok `GROK_HOME`, agy @@ -275,7 +275,7 @@ Claude, which has two layers) and preview with `--dry-run`. State is keyed by `(cli, scope)`, so two swaps of the same layer collapse into one entry — there is no chain to unwind one step at a time. Re-run `status`/`doctor` and compare against the state you recorded before starting. If you stayed on the -zero-mutation path there is nothing to revert. +no-config-write path there is nothing to revert. ## When NOT to reach for the full harness diff --git a/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md b/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md index b4763dfd..9c4ae742 100644 --- a/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md +++ b/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md @@ -41,8 +41,8 @@ below. the CLI inherits them and launches the `uv` server fine. The alternate-socket-pane PATH gap (a `-L` pane's non-login shell lacks the mise shims) only bites when you launch a CLI **TUI inside a harness pane** (Layer 2). -7. **Non-interactive mutating tool calls need an approval-bypass flag** — different per - CLI (table). Without it, a mutating call blocks on an approval prompt with no TTY +7. **Non-interactive write-capable tool calls need an approval-bypass flag** — different per + CLI (table). Without it, a write-capable call blocks on an approval prompt with no TTY and the harness hangs. 8. **Interactive send-keys submit:** send the prompt text and `Enter` as **separate `send-keys` events** — then a single Enter submits. The "needs a double Enter" diff --git a/.github/WRITING.md b/.github/WRITING.md index 349cbbe9..411a37fb 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -22,7 +22,7 @@ The most useful editing operation is deleting the introductory sentence. Lead with verbs and name concrete things. Put identifiers in backticks. Prefer short declarative sentences, one operational fact each. Do not explain Python to Python developers; do explain this project's semantics — -what a safety tier does, what a tool will and will not touch, what a stale +what a toolset does, what a tool will and will not touch, what a stale pane object means. Type annotations describe shape. Documentation describes meaning. A @@ -77,7 +77,7 @@ Rules that follow: common call, then the one argument a few will tune, then the lower-level primitive. Each step is for a smaller audience than the last. - **Name the trade-off.** If a call costs something — an extra tmux - round-trip, a stale object needing a refresh, a wider safety tier — say + round-trip, a stale object needing a refresh, a wider tool surface — say so, and say what it buys. State it; do not sell it. ## README @@ -164,7 +164,7 @@ its title is chrome, not disambiguation. The title is not part of the search corpus above; it is a human-readable label only. **`anthropic/alwaysLoad` is a scarce hint, not a default.** A handful of -high-traffic, read-only tools (list/inspect operations a session usually +high-traffic `inspect` tools (list/inspect operations a session usually starts with) carry this per-tool `meta` flag so a client can keep a small tmux vocabulary visible without preloading every tool's schema. Reserve it for tools nearly every session needs early; adding it broadly defeats the @@ -187,7 +187,7 @@ recovery suggestion rather than folding it into the sentence — "call model has to parse for the actionable part. Reserve the loud, unrecoverable category (a missing `tmux` binary, a genuine bug in this server) for failures an operator — not the calling agent — has to fix; an -agent-correctable failure like a bad id or a tier denial should not read as +agent-correctable failure like a bad id or a toolset denial should not read as loud as a crash. ## Documentation site voice @@ -226,12 +226,12 @@ answer the operator's questions: - **Use when** describes the practical workflow. - **Avoid when** names the common wrong turn and points to the better tool. -- **Side effects** states the safety consequence plainly. +- **Side effects** states the operational consequence plainly. - **Examples** stay copyable, minimal, and realistic. ### What stays precise -Warm the framing, never the facts. Safety tiers, exact tool names, +Warm the framing, never the facts. Toolsets, exact tool names, parameter names, environment variables, error strings, tmux targets, format strings, JSON/TOML examples, and class or function cross-references carry meaning in their exact form. Leave them exact and @@ -244,12 +244,12 @@ of any symbol that has a useful destination on that page: - `{class}`, `{meth}`, `{func}`, `{mod}`, `{exc}`, `{attr}` — Python objects. -- `{tool}` — code chip + full safety badge (text + icon). Use in headers, +- `{tool}` — code chip + full toolset badge (text + icon). Use in headers, bulleted lists, and tables where the badge gives scannable context. - `{tooliconl}` — code chip + small colored icon (left). Use in inline paragraph text where the full badge is too heavy. - `{toolref}` — code chip only, no badge. Use for dense inline sequences - or where the safety tier is already established. + or where the toolset is already established. - `{tooliconil}` / `{tooliconir}` — bare emoji inside a code chip. Use for compact lists and scan-heavy surfaces. - `{ref}` / `{doc}` — documentation pages and section anchors. diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index ce6f063b..d1806eab 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -1452,7 +1452,7 @@ def cmd_use_local(args: argparse.Namespace) -> int: continue # Preserve the existing entry's env on replacement. ``build_local_spec`` # writes an empty env, so without this merge a swap would silently drop - # client-side settings (LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev + # client-side settings (LIBTMUX_TOOLSETS, LIBTMUX_SOCKET, custom dev # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on # the read side. base_env = dict(current.env) if current else {} diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index 1a587de8..e0715239 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -255,7 +255,7 @@ def test_use_local_preserves_existing_env_when_replacing( Regression: ``cmd_use_local`` previously constructed the replacement spec via ``build_local_spec`` (env={}) and wrote it directly, - silently dropping client-side settings like ``LIBTMUX_SAFETY`` or + silently dropping client-side settings like ``LIBTMUX_TOOLSETS`` or ``LIBTMUX_SOCKET`` that the user had set on the prior pinned-PyPI entry. The fix merges ``current.env`` into the new spec; this test locks the behaviour by seeding env on a Cursor entry, running diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index df48ecb8..0a40e8a2 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -111,7 +111,7 @@ class RunCommandHistoryFixture(t.NamedTuple): RUN_COMMAND_STATUS_ISOLATION_FIXTURES: list[RunCommandStatusIsolationFixture] = [ RunCommandStatusIsolationFixture( - "path_mutation", + "path_change", "PATH=/tmp; printf 'RUN_COMMAND_PATH_OK\\n'", 0, "RUN_COMMAND_PATH_OK", @@ -1383,7 +1383,7 @@ def _hlimit_locked() -> bool: "for i in $(seq 1 120); do printf 'CAPTURE_SINCE_TRIM_%03d\\n' \"$i\"; done" ) _signal_after_shell_payload(mcp_server, fresh_pane, payload) - # Guarantee anchor destruction: tmux 3.6 can retain the original + # Guarantee anchor removal: tmux 3.6 can retain the original # prompt hash in scrollback even after flooding past history-limit. fresh_pane.cmd("clear-history") _signal_after_shell_payload( diff --git a/tests/test_retired_vocabulary.py b/tests/test_retired_vocabulary.py index 35da5d76..0f812703 100644 --- a/tests/test_retired_vocabulary.py +++ b/tests/test_retired_vocabulary.py @@ -1,70 +1,126 @@ -"""The tier vocabulary must not come back. - -`readonly` / `mutating` / `destructive` described an ordered ladder this -server does not have. Tools are grouped into unordered toolsets instead. -The words are easy to reintroduce by habit, so a gate holds the line. -""" +"""Retired toolset identifiers, configuration, and headings stay retired.""" from __future__ import annotations import pathlib import re -import pytest +RETIRED_IDENTIFIERS = frozenset( + { + "ANNOTATIONS_ALLOCATE", + "ANNOTATIONS_CHANGE", + "ANNOTATIONS_DEFERRED_EXEC", + "ANNOTATIONS_DELETE", + "ANNOTATIONS_OBSERVE", + "ANNOTATIONS_OBSERVE_CONTENT", + "ANNOTATIONS_PANE_INPUT", + "ANNOTATIONS_SPAWN", + "InspectRetryMiddleware", + "ReadonlyRetryMiddleware", + "SafetyMiddleware", + "TAG_DESTRUCTIVE", + "TAG_MUTATING", + "TAG_READONLY", + "VALID_SAFETY_LEVELS", + "call_destructive_tools_batch", + "call_mutating_tools_batch", + "call_readonly_tools_batch", + } +) + +RETIRED_CONFIG_KEYS = frozenset({"LIBTMUX_SAFETY"}) -#: Words that named the retired tiers. Matched whole and case-insensitively. -RETIRED = ("readonly", "mutating", "destructive", "safety tier", "safety level") +RETIRED_HEADINGS = frozenset( + { + "Discovery vs. mutation", + "Safety levels", + "Safety tiers", + } +) -#: MCP defines these fields and their meanings; they are the protocol's -#: vocabulary, not ours, and they stay. -PROTOCOL_NAMES = ( - "readOnlyHint", - "destructiveHint", - "read_only_hint", - "destructive_hint", +_RETIRED_TERM_PATTERN = re.compile( + r"\b(?:safety[ -]tiers?|mutating tools?|default tiers?)\b", + re.IGNORECASE, ) -#: Files allowed to name what they replace. -EXEMPT = ( - "CHANGES", - "MIGRATION.md", - "tests/test_retired_vocabulary.py", - # The startup error has to say the variable it is refusing, and the - # test that proves it fires has to set it. - "src/libtmux_mcp/server.py", - "tests/test_server.py", - # A redirect must name the old path to serve it. - "docs/redirects.txt", +HISTORICAL_FILES = frozenset({pathlib.Path("CHANGES"), pathlib.Path("MIGRATION.md")}) + +CONFIG_REJECTION_FILES = frozenset( + { + pathlib.Path("src/libtmux_mcp/server.py"), + pathlib.Path("tests/test_server.py"), + } ) _ROOT = pathlib.Path(__file__).resolve().parent.parent -_PATTERN = re.compile("|".join(re.escape(word) for word in RETIRED), re.IGNORECASE) +_IDENTIFIER_PATTERN = re.compile( + rf"\b(?:{'|'.join(map(re.escape, sorted(RETIRED_IDENTIFIERS)))})\b" +) +_CONFIG_PATTERN = re.compile( + rf"\b(?:{'|'.join(map(re.escape, sorted(RETIRED_CONFIG_KEYS)))})\b" +) +_HEADING_PATTERN = re.compile( + rf"^#{{1,6}}\s+(?:{'|'.join(map(re.escape, sorted(RETIRED_HEADINGS)))})\s*$", + re.IGNORECASE | re.MULTILINE, +) def _tracked_sources() -> list[pathlib.Path]: - """Return the files this gate covers.""" - paths: list[pathlib.Path] = [] - for pattern in ("src/**/*.py", "tests/**/*.py", "docs/**/*.md", "*.md"): - paths.extend( + """Return source, tests, scripts, skills, and user documentation.""" + paths: set[pathlib.Path] = set() + for pattern in ( + ".agents/**/*.md", + ".github/**/*.md", + "src/**/*.py", + "tests/**/*.py", + "scripts/**/*.py", + "docs/**/*.md", + "*.md", + ): + paths.update( path for path in _ROOT.glob(pattern) - if "_build" not in path.parts and str(path.relative_to(_ROOT)) not in EXEMPT + if "_build" not in path.parts and path != pathlib.Path(__file__).resolve() ) - return paths + paths.add(_ROOT / "CHANGES") + paths.add(_ROOT / "pyproject.toml") + return sorted(paths) + +def test_retired_contract_names_do_not_return() -> None: + """Removed API names stay gone without banning ordinary security prose.""" + offenders: list[str] = [] + + for path in _tracked_sources(): + relative = path.relative_to(_ROOT) + if relative in HISTORICAL_FILES: + continue + text = path.read_text(encoding="utf-8") + offenders.extend( + f"{relative}: term {match.group(0)}" + for match in _RETIRED_TERM_PATTERN.finditer(text) + ) + offenders.extend( + f"{relative}: identifier {match.group(0)}" + for match in _IDENTIFIER_PATTERN.finditer(text) + ) + if relative not in CONFIG_REJECTION_FILES: + offenders.extend( + f"{relative}: config {match.group(0)}" + for match in _CONFIG_PATTERN.finditer(text) + ) + if path.suffix == ".md": + offenders.extend( + f"{relative}: heading {match.group(0)}" + for match in _HEADING_PATTERN.finditer(text) + ) -@pytest.mark.parametrize("path", _tracked_sources(), ids=lambda p: str(p.name)) -def test_no_file_reintroduces_the_tier_vocabulary(path: pathlib.Path) -> None: - """No source or page names a tier that no longer exists.""" - text = path.read_text(encoding="utf-8") - for name in PROTOCOL_NAMES: - text = text.replace(name, "") + assert not offenders, "Retired contract names returned:\n" + "\n".join(offenders) - offenders = sorted({match.group(0).lower() for match in _PATTERN.finditer(text)}) - assert not offenders, ( - f"{path.relative_to(_ROOT)} names the retired tiers {offenders}. " - f"Tools belong to unordered toolsets: inspect, manage, execute, " - f"teardown. If this file must name what it replaced, add it to " - f"EXEMPT with a reason." - ) +def test_retired_term_pattern_uses_whole_tokens() -> None: + """Ordinary words containing the retired stems remain legal.""" + assert _RETIRED_TERM_PATTERN.search("a safety tier") + assert _RETIRED_TERM_PATTERN.search("a mutating tool") + assert _RETIRED_TERM_PATTERN.search("the default tier") + assert _RETIRED_TERM_PATTERN.search("entire mutations") is None diff --git a/tests/test_wait_policy.py b/tests/test_wait_policy.py index 7cfb3a82..d8030a74 100644 --- a/tests/test_wait_policy.py +++ b/tests/test_wait_policy.py @@ -50,9 +50,8 @@ def test_resolve_wait_max_seconds(raw: str | None, expected: float) -> None: """Env resolution clamps and degrades instead of raising. - Mirrors ``_resolve_safety_level``: an operator typo must not stop - the server from starting, and an out-of-range value must not - silently become an unbounded wait. + An operator typo must not stop the server from starting, and an + out-of-range value must not silently become an unbounded wait. """ assert _resolve_wait_max_seconds(raw) == expected From fad85bd7cc0ac03f1d3691a1419b88acbd655f7a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 21:24:15 -0500 Subject: [PATCH 35/44] docs(trust): Badge the toolset inventory Render the classifications with their configured icon and tone. Keep only these labels selectable so copied prose retains the names without changing badge behavior elsewhere. --- docs/_static/css/project-badges.css | 5 +++++ docs/conf.py | 1 + docs/topics/trust.md | 35 +++++++++++++---------------- tests/docs/test_topic_contracts.py | 24 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 docs/_static/css/project-badges.css diff --git a/docs/_static/css/project-badges.css b/docs/_static/css/project-badges.css new file mode 100644 index 00000000..8da50215 --- /dev/null +++ b/docs/_static/css/project-badges.css @@ -0,0 +1,5 @@ +#toolsets-gate-mcp-tool-calls-not-tmux > p + > .gp-sphinx-badge .gp-sphinx-badge__label { + user-select: text; + -webkit-user-select: text; +} diff --git a/docs/conf.py b/docs/conf.py index 2ba806bf..d8e116fc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -238,6 +238,7 @@ def setup(app: Sphinx) -> None: app.connect("autodoc-process-docstring", _convert_md_xrefs) app.add_js_file("js/prompt-copy.js", loading_method="defer") app.add_css_file("css/project-admonitions.css") + app.add_css_file("css/project-badges.css") app.add_css_file("css/project-cards.css") diff --git a/docs/topics/trust.md b/docs/topics/trust.md index 297af580..3c8737fe 100644 --- a/docs/topics/trust.md +++ b/docs/topics/trust.md @@ -11,25 +11,22 @@ does not bound. Tools are grouped into four sets by what they do: -`inspect` -: Request tmux state or terminal output, or render server-local prompt text. - The built-in operation does not pass caller input as a tmux or shell - command. - -`manage` -: Change tmux-managed structure, presentation, staging, or coordination - state. The built-in operation does not supply a shell command, pane input, - or a value tmux treats as executable configuration. - -`execute` -: Start a pane process, deliver input to one, or store state that can control - later execution. {tooliconl}`set-option` is here, not in `manage`: a - `#(...)` job in a status format runs when tmux draws it and repeats on the - status interval, and `default-command` decides what every future pane runs. - -`teardown` -: Delete tmux objects or retained scrollback. Irreversible at the tmux - level. +{badge}`inspect`: Request tmux state or terminal output, or render +server-local prompt text. The built-in operation does not pass caller input as +a tmux or shell command. + +{badge}`manage`: Change tmux-managed structure, presentation, staging, or +coordination state. The built-in operation does not supply a shell command, +pane input, or a value tmux treats as executable configuration. + +{badge}`execute`: Start a pane process, deliver input to one, or store state +that can control later execution. {tooliconl}`set-option` is here, not in +`manage`: a `#(...)` job in a status format runs when tmux draws it and repeats +on the status interval, and `default-command` decides what every future pane +runs. + +{badge}`teardown`: Delete tmux objects or retained scrollback. Irreversible +at the tmux level. The sets are unordered. FastMCP visibility and libtmux-mcp middleware both enforce which MCP tool calls the server advertises and accepts. diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index c9eb867f..955d3013 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -334,6 +334,30 @@ def test_trust_docs_state_ambient_tmux_execution( assert "{ref}`trust`" in resources +def test_trust_toolset_labels_are_badged_and_copyable( + docs_dir: pathlib.Path, +) -> None: + """Trust inventory badges keep their labels available to selection.""" + text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8") + section = text.split("## Toolsets gate MCP tool calls, not tmux", 1)[1].split( + "\n## ", 1 + )[0] + + for toolset in VALID_TOOLSETS: + assert f"{{badge}}`{toolset}`:" in section + + css = (docs_dir / "_static" / "css" / "project-badges.css").read_text( + encoding="utf-8" + ) + normalized_css = " ".join(css.split()) + assert ( + "#toolsets-gate-mcp-tool-calls-not-tmux > p" + " > .gp-sphinx-badge .gp-sphinx-badge__label" in normalized_css + ) + assert "user-select: text" in css + assert "-webkit-user-select: text" in css + + def test_tool_catalog_groups_every_registered_tool_by_its_wire_tag( docs_dir: pathlib.Path, ) -> None: From ca0eb79f486c54b25adee0a8ec7838a92ed360be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:20:48 -0500 Subject: [PATCH 36/44] mcp(fix[search_panes]): Exclude capture time why: Pane capture latency could exhaust the regex deadline before matching began, contradicting the documented matching-only budget. what: - Carry the remaining aggregate budget only while matching captured lines - Cover delayed capture and catastrophic backtracking behavior --- src/libtmux_mcp/tools/pane_tools/search.py | 14 ++++++----- tests/test_pane_tools.py | 27 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/libtmux_mcp/tools/pane_tools/search.py b/src/libtmux_mcp/tools/pane_tools/search.py index 5564923e..ea78a4d3 100644 --- a/src/libtmux_mcp/tools/pane_tools/search.py +++ b/src/libtmux_mcp/tools/pane_tools/search.py @@ -314,11 +314,7 @@ def search_panes( # tail-truncated to keep the most recent matches. all_matches: list[PaneContentMatch] = [] per_pane_truncated = False - # Computed once, so the budget spans every pane rather than resetting - # per pane. No test separates the two: the first pane to exhaust the - # budget aborts the call either way, so they differ only for a - # workload that is cheap per pane and expensive in total. - deadline = time.monotonic() + SEARCH_MATCH_MAX_SECONDS + matching_seconds_left = SEARCH_MATCH_MAX_SECONDS for pane_id_str in matching_pane_ids: pane = server.panes.get(pane_id=pane_id_str, default=None) if pane is None: @@ -329,7 +325,13 @@ def search_panes( end=content_end, join_wrapped=True, ) - matched_lines = _match_lines(compiled, lines, deadline) + match_started = time.monotonic() + matched_lines = _match_lines( + compiled, + lines, + match_started + matching_seconds_left, + ) + matching_seconds_left -= time.monotonic() - match_started if not matched_lines: continue diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 0a40e8a2..6f482b06 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -2342,6 +2342,33 @@ def test_search_panes_bounds_matching_time( assert time.monotonic() - started < 5 +def test_search_panes_excludes_capture_time_from_matching_deadline( + mcp_server: Server, + mcp_session: Session, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A delayed pane capture does not spend the regex matching budget.""" + marker = "CAPTURE_OUTSIDE_MATCH_DEADLINE" + + def delayed_capture(*args: object, **kwargs: object) -> list[str]: + time.sleep(0.05) + return [marker] + + monkeypatch.setattr(search, "SEARCH_MATCH_MAX_SECONDS", 0.02) + monkeypatch.setattr(type(mcp_pane), "capture_pane", delayed_capture) + + result = search_panes( + pattern=marker, + regex=True, + session_id=mcp_session.session_id, + content_start=0, + socket_name=mcp_server.socket_name, + ) + + assert [match.pane_id for match in result.matches] == [mcp_pane.pane_id] + + def test_search_panes_rejects_an_oversized_pattern( mcp_server: Server, ) -> None: From fec46cc76874a24c460ff9b1b8179b251842b619 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:21:49 -0500 Subject: [PATCH 37/44] mcp(fix[display_message]): Accept hyphenated names why: tmux format variables include hyphenated option and user-option names on every supported tmux version. what: - Admit hyphens after the first variable-name character - Cover built-in and user-option expansion while retaining modifier and job rejection --- src/libtmux_mcp/tools/pane_tools/meta.py | 2 +- tests/test_tmux_format_arguments.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 8c5043dd..4bfb0d78 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -25,7 +25,7 @@ #: tmux format modifier needs — ``#{E:x}`` re-expands ``x``'s value, where a #: job the caller never typed can be waiting. Validating what is allowed, #: rather than scanning for what is not, is what makes that unreachable. -_FORMAT_VARIABLE = re.compile(r"#\{[A-Za-z_@][A-Za-z0-9_]*\}") +_FORMAT_VARIABLE = re.compile(r"#\{[A-Za-z_@][A-Za-z0-9_-]*\}") @handle_tool_errors diff --git a/tests/test_tmux_format_arguments.py b/tests/test_tmux_format_arguments.py index 63e174d0..5804e4aa 100644 --- a/tests/test_tmux_format_arguments.py +++ b/tests/test_tmux_format_arguments.py @@ -266,20 +266,24 @@ def test_display_message_accepts_only_variable_references( ) -def test_display_message_expands_plain_variables( +def test_display_message_expands_plain_and_hyphenated_variables( mcp_server: Server, mcp_pane: Pane, ) -> None: """A format of bare ``#{name}`` references still works.""" from libtmux_mcp.tools.pane_tools import display_message + mcp_server.cmd("set-option", "-g", "history-limit", "4321") + mcp_server.cmd("set-option", "-g", "@plugin-option", "sentinel") result = display_message( - format_string="id=#{pane_id} zoomed=#{window_zoomed_flag}", + format_string=( + "id=#{pane_id} history=#{history-limit} plugin=#{@plugin-option}" + ), pane_id=t.cast("str", mcp_pane.pane_id), socket_name=mcp_server.socket_name, ) - assert result == f"id={mcp_pane.pane_id} zoomed=0" + assert result == f"id={mcp_pane.pane_id} history=4321 plugin=sentinel" def test_the_hash_escaper_owns_only_the_hash_expander() -> None: From b35d9557a5888d36b870a5eb4ed04f6c3d120771 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:30:41 -0500 Subject: [PATCH 38/44] mcp(fix[batch]): Honor named wrapper authority why: Enabling only call_read_tools_batch advertised the wrapper while FastMCP visibility hid every nested inspect tool. Explicit exclusions must remain authoritative. what: - Scope visibility and toolset enforcement to the validated nested read call - Keep direct calls hidden and explicit exclusions dominant - Cover the production profile with an isolated tmux namespace --- src/libtmux_mcp/middleware.py | 51 ++++++++++++++++- src/libtmux_mcp/server.py | 2 + src/libtmux_mcp/tools/batch_tools.py | 25 ++++---- tests/test_server.py | 85 ++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 13 deletions(-) diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index f1dab019..d1d807e8 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -24,6 +24,8 @@ from __future__ import annotations +import contextlib +import contextvars import hashlib import logging import time @@ -32,11 +34,56 @@ from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware +from fastmcp.server.transforms import Transform, Visibility from fastmcp.tools.base import ToolResult from mcp.types import CallToolRequestParams, TextContent from pydantic import ValidationError as PydanticValidationError -from libtmux_mcp._utils import VALID_TOOLSETS, ExpectedToolError +from libtmux_mcp._utils import TOOLSET_INSPECT, VALID_TOOLSETS, ExpectedToolError + +if t.TYPE_CHECKING: + from fastmcp.server.transforms import GetToolNext + from fastmcp.tools.base import Tool + from fastmcp.utilities.versions import VersionSpec + + +_NESTED_READ_TOOL = contextvars.ContextVar[str | None]( + "libtmux_mcp_nested_read_tool", + default=None, +) + + +@contextlib.contextmanager +def _allow_nested_read_tool(name: str) -> t.Iterator[None]: + """Scope one nested read-batch operation to ``name``.""" + token = _NESTED_READ_TOOL.set(name) + try: + yield + finally: + _NESTED_READ_TOOL.reset(token) + + +class _NestedReadToolVisibility(Transform): + """Expose the current read-batch operation to FastMCP lookup.""" + + def __init__(self, exclude_tools: t.AbstractSet[str]) -> None: + self.exclude_tools = frozenset(exclude_tools) + + async def get_tool( + self, + name: str, + call_next: GetToolNext, + *, + version: VersionSpec | None = None, + ) -> Tool | None: + """Enable only the tool scoped by the read-batch validator.""" + if name != _NESTED_READ_TOOL.get() or name in self.exclude_tools: + return await call_next(name, version=version) + return await Visibility( + True, + names={name}, + components={"tool"}, + ).get_tool(name, call_next, version=version) class ToolsetMiddleware(Middleware): @@ -77,6 +124,8 @@ def _is_enabled(self, name: str, tags: set[str]) -> bool: return False if name in self.tools: return True + if name == _NESTED_READ_TOOL.get() and TOOLSET_INSPECT in toolsets: + return True return bool(self.toolsets & toolsets) async def on_list_tools( diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index b44c2494..6168dca8 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -36,6 +36,7 @@ TailPreservingResponseLimitingMiddleware, ToolErrorResultMiddleware, ToolsetMiddleware, + _NestedReadToolVisibility, install_fastmcp_validation_log_filter, ) @@ -466,6 +467,7 @@ def _enable_allowed_tools() -> None: mcp.enable(components={"tool"}, names={name}) if _excluded_tools: mcp.disable(components={"tool"}, names=set(_excluded_tools)) + mcp.add_transform(_NestedReadToolVisibility(_excluded_tools)) _mcp_visibility_configured = True diff --git a/src/libtmux_mcp/tools/batch_tools.py b/src/libtmux_mcp/tools/batch_tools.py index fbc5cfb6..0336d7e0 100644 --- a/src/libtmux_mcp/tools/batch_tools.py +++ b/src/libtmux_mcp/tools/batch_tools.py @@ -17,7 +17,7 @@ ExpectedToolError, handle_tool_errors_async, ) -from libtmux_mcp.middleware import DEFAULT_RESPONSE_LIMIT_BYTES +from libtmux_mcp.middleware import DEFAULT_RESPONSE_LIMIT_BYTES, _allow_nested_read_tool from libtmux_mcp.models import ( ToolCallBatchResult, ToolCallOperation, @@ -167,19 +167,20 @@ async def _call_one_tool( """Call one nested tool and convert its outcome to a batch result row.""" start = time.monotonic() try: - await _check_operation_allowed( - fastmcp=fastmcp, - operation=operation, - ) + with _allow_nested_read_tool(operation.tool): + await _check_operation_allowed( + fastmcp=fastmcp, + operation=operation, + ) - result = _ensure_tool_result( - operation.tool, - await fastmcp.call_tool( + result = _ensure_tool_result( operation.tool, - operation.arguments, - run_middleware=True, - ), - ) + await fastmcp.call_tool( + operation.tool, + operation.arguments, + run_middleware=True, + ), + ) error = _result_error_text(result) return ToolCallOperationResult( diff --git a/tests/test_server.py b/tests/test_server.py index a4854a24..886a7cce 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import os +import pathlib import subprocess import sys import textwrap @@ -262,6 +264,89 @@ async def main(): assert f"Unknown tool: '{tool_name}'" in proc.stdout +@pytest.mark.parametrize( + ("excluded_tool", "nested_success"), + [("", True), ("list_servers", False)], + ids=["named-wrapper-authority", "explicit-exclusion"], +) +def test_named_read_batch_reaches_only_unexcluded_inspect_tools( + excluded_tool: str, + nested_success: bool, + tmp_path: pathlib.Path, +) -> None: + """A named batch carries inspect authority without exposing nested tools.""" + code = textwrap.dedent( + """ + import asyncio + import json + + from fastmcp import Client + + from libtmux_mcp.server import build_mcp_server + + + async def main(): + async with Client(build_mcp_server()) as client: + tools = await client.list_tools() + batch = await client.call_tool( + "call_read_tools_batch", + { + "operations": [ + { + "tool": "list_servers", + "arguments": {}, + } + ] + }, + raise_on_error=False, + ) + direct = await client.call_tool( + "list_servers", + {}, + raise_on_error=False, + ) + row = batch.structured_content["results"][0] + print( + json.dumps( + { + "tools": [tool.name for tool in tools], + "nested_success": row["success"], + "nested_error": row["error"], + "direct_error": direct.content[0].text, + } + ) + ) + + + asyncio.run(main()) + """ + ) + env = { + **os.environ, + "LIBTMUX_TOOLSETS": "", + "LIBTMUX_TOOLS": "call_read_tools_batch", + "LIBTMUX_EXCLUDE_TOOLS": excluded_tool, + "TMUX_TMPDIR": str(tmp_path), + } + proc = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + env=env, + ) + + assert proc.returncode == 0, proc.stderr + payload = json.loads(proc.stdout) + assert payload["tools"] == ["call_read_tools_batch"] + assert payload["nested_success"] is nested_success + assert "Unknown tool: 'list_servers'" in payload["direct_error"] + if excluded_tool: + assert "Unknown tool: 'list_servers'" in payload["nested_error"] + else: + assert payload["nested_error"] is None + + def test_the_retired_safety_variable_fails_startup() -> None: """`LIBTMUX_SAFETY` is gone; ignoring it could widen a surface.""" code = textwrap.dedent( From ceabd51911eb57296147fd86a134a538ff0d44ca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:32:31 -0500 Subject: [PATCH 39/44] docs(fix[redirects]): Preserve read batch URL why: Renaming the batch-tool page otherwise leaves existing public links at a deleted route after deployment. what: - Redirect the former call-readonly-tools-batch route to call-read-tools-batch --- docs/redirects.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/redirects.txt b/docs/redirects.txt index 2acdcd74..03983588 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -23,3 +23,4 @@ "tools/hooks" "tools/hook/index" "tools/options" "tools/server/index" "tools/waits" "tools/pane/index" +"tools/batch/call-readonly-tools-batch" "tools/batch/call-read-tools-batch" From 5d2d4ede1e312fdf728c76059456a36451d7d9cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:39:25 -0500 Subject: [PATCH 40/44] docs(mcp): Name the toolset grouping as an axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fastmcp_toolsets` becomes a `capability` axis, keeping the four terms with their tones, tooltips and icons unchanged. Our tags already sit on one axis — the capability name implies the risk, which is why teardown reads as more than a topic — so one axis says what the vocabulary always meant. The rendered classes now name it: `__axis-capability`, `__capability-inspect`. `fastmcp_section_badge_map` keeps naming bare terms; with a single axis declared, a bare term resolves against it. --- docs/conf.py | 57 ++++++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d8e116fc..288f705b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -173,33 +173,38 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any: "BufferRef", "BufferContent", ) -# sphinx-autodoc-fastmcp ships no default vocabulary: it documents -# projects whose tags it does not choose. Declaration order is -# precedence, and the summary tables follow it. -conf["fastmcp_toolsets"] = ( +# Our tags sit on one axis: the capability name implies the risk, so +# declaration order runs most destructive first and the summary tables +# follow it. +conf["fastmcp_axes"] = ( { - "tag": "teardown", - "tone": "red", - "tooltip": "Teardown \u2014 deletes tmux objects or scrollback", - "icon": "\U0001f4a3", - }, - { - "tag": "execute", - "tone": "amber", - "tooltip": "Execute \u2014 starts or drives a pane process", - "icon": "\u270f\ufe0f", - }, - { - "tag": "manage", - "tone": "blue", - "tooltip": "Manage \u2014 changes tmux structure or presentation", - "icon": "\U0001f527", - }, - { - "tag": "inspect", - "tone": "green", - "tooltip": "Inspect \u2014 reads tmux state and terminal output", - "icon": "\U0001f50d", + "name": "capability", + "terms": ( + { + "term": "teardown", + "tone": "red", + "tooltip": "Teardown \u2014 deletes tmux objects or scrollback", + "icon": "\U0001f4a3", + }, + { + "term": "execute", + "tone": "amber", + "tooltip": "Execute \u2014 starts or drives a pane process", + "icon": "\u270f\ufe0f", + }, + { + "term": "manage", + "tone": "blue", + "tooltip": "Manage \u2014 changes tmux structure or presentation", + "icon": "\U0001f527", + }, + { + "term": "inspect", + "tone": "green", + "tooltip": "Inspect \u2014 reads tmux state and terminal output", + "icon": "\U0001f50d", + }, + ), }, ) conf["fastmcp_section_badge_map"] = { From f640a93e3bdf17e77775112acd323d8ba1489f74 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:39:31 -0500 Subject: [PATCH 41/44] ci(docs): Invalidate every path after a full sync The S3 sync replaces the whole site with `--delete`, but the invalidation named only `/index.html`, `/objects.inv` and `/searchindex.js`. Every other page kept serving CloudFront's cached copy until its TTL expired, so a deploy landed in the bucket and stayed invisible for the better part of an hour. A wildcard covers the pages the sync actually replaced, and bills as one path rather than one per page. --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2f067317..ae8f065c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -94,7 +94,7 @@ jobs: run: | aws cloudfront create-invalidation \ --distribution-id "${{ secrets.LIBTMUX_MCP_DOCS_DISTRIBUTION }}" \ - --paths "/index.html" "/objects.inv" "/searchindex.js" + --paths "/*" - name: Purge cache on Cloudflare if: env.PUBLISH == 'true' From 784a3ea9aa0b197c9ac570e2450a3f5346fba0de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 09:16:29 -0500 Subject: [PATCH 42/44] py(deps[docs]) gp-sphinx packages to 0.1.0a38 why: The toolset badge vocabulary is available in the published docs packages, so the branch can use released artifacts. what: - Pin gp-sphinx, sphinx-autodoc-api-style, and sphinx-autodoc-fastmcp to 0.1.0a38 - Resolve all three packages from PyPI --- pyproject.toml | 12 ++++---- uv.lock | 84 +++++++++++++++++++++++++------------------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a17c3fb..fc928901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,9 +57,9 @@ libtmux-mcp = "libtmux_mcp:main" [dependency-groups] dev = [ # Docs - "gp-sphinx==0.1.0a37", - "sphinx-autodoc-api-style==0.1.0a37", - "sphinx-autodoc-fastmcp==0.1.0a37", + "gp-sphinx==0.1.0a38", + "sphinx-autodoc-api-style==0.1.0a38", + "sphinx-autodoc-fastmcp==0.1.0a38", "gp-libs>=0.0.19", "sphinx-autobuild", # Testing @@ -84,9 +84,9 @@ dev = [ ] docs = [ - "gp-sphinx==0.1.0a37", - "sphinx-autodoc-api-style==0.1.0a37", - "sphinx-autodoc-fastmcp==0.1.0a37", + "gp-sphinx==0.1.0a38", + "sphinx-autodoc-api-style==0.1.0a38", + "sphinx-autodoc-fastmcp==0.1.0a38", "gp-libs>=0.0.19", "sphinx-autobuild", ] diff --git a/uv.lock b/uv.lock index 714530b4..a0eae3bc 100644 --- a/uv.lock +++ b/uv.lock @@ -976,7 +976,7 @@ server = [ [[package]] name = "gp-furo-theme" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, @@ -986,9 +986,9 @@ dependencies = [ { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-basic-ng" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/80/94765d195ad569993be6242e257359cfe363424342153a04e97de1ec7e7d/gp_furo_theme-0.1.0a37.tar.gz", hash = "sha256:647484cc6559ff178737b95ae43747d5b1c17aabd54dbc749b985e713640d0e0", size = 34311, upload-time = "2026-07-27T01:16:51.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/46/851013714eae29f91ada1f3106426ea5bc6d5652ce008d17519708b6cb67/gp_furo_theme-0.1.0a38.tar.gz", hash = "sha256:8e2bd1c698eb43b3938862ef3bdae34a5455d35ce59c1024d4b13ecc839a4b60", size = 34335, upload-time = "2026-08-30T14:08:14.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/0d/3c26e02ed68e3fe46342964db53ebc1ed4631cc5000785e9802fbc68a4d2/gp_furo_theme-0.1.0a37-py3-none-any.whl", hash = "sha256:4d24d097aab626979c843ba73021286dc36b44ae56981451e32bcdf262e80713", size = 43661, upload-time = "2026-07-27T01:16:26.069Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/a0ac760f2d48de3907d743027d3fc5ee485c60c7633d339ea5818ea84ecd/gp_furo_theme-0.1.0a38-py3-none-any.whl", hash = "sha256:1086ba262dab3f748c10a96787d72628218c8df9390ef280d74e5c04b142da05", size = 43690, upload-time = "2026-08-30T14:07:52.466Z" }, ] [[package]] @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -1029,9 +1029,9 @@ dependencies = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-rediraffe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/d6/c0b67f83123b10b759020d68c3865027d9fbec82a6755a8b24681e5e39da/gp_sphinx-0.1.0a37.tar.gz", hash = "sha256:2333b433f004d7830b370649163e4fd9666408df60e32701d614ab85abaf5ac5", size = 20707, upload-time = "2026-07-27T01:16:51.849Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/62/815379d9d7826137eab5369666c180028420be2264310065e7f56a60ff32/gp_sphinx-0.1.0a38.tar.gz", hash = "sha256:90cdf7599f2a927626162e85331be7b5fada77334b20b2d542a9d9fd601182ce", size = 20708, upload-time = "2026-08-30T14:08:15.378Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/6b/7f16626746f7a6ab7b4c687f966a311acbc2d017e155125f0861e9d70eba/gp_sphinx-0.1.0a37-py3-none-any.whl", hash = "sha256:cca4bab96fb556e96b7cc6935b0d855a99526a11881017f6c8acf3514f55e337", size = 21299, upload-time = "2026-07-27T01:16:27.65Z" }, + { url = "https://files.pythonhosted.org/packages/49/c5/7d7bdb3604dea1fc2afd3f34b7d6ece149e6ea269a48716062bb5826684a/gp_sphinx-0.1.0a38-py3-none-any.whl", hash = "sha256:7ab1eb83570b6d206d40e7f4c3692c8af2494c2ccd69eb8f46bc08c0d4211db9", size = 21300, upload-time = "2026-08-30T14:07:53.749Z" }, ] [[package]] @@ -1483,7 +1483,7 @@ dev = [ { name = "codecov" }, { name = "coverage" }, { name = "gp-libs", specifier = ">=0.0.19" }, - { name = "gp-sphinx", specifier = "==0.1.0a37" }, + { name = "gp-sphinx", specifier = "==0.1.0a38" }, { name = "mypy" }, { name = "pytest", specifier = ">=9.1.0" }, { name = "pytest-cov" }, @@ -1493,8 +1493,8 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff", specifier = ">=0.16.1" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a37" }, - { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a37" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a38" }, + { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a38" }, { name = "syrupy", specifier = ">=5.1.0" }, { name = "tomlkit", specifier = ">=0.13" }, { name = "types-regex" }, @@ -1502,10 +1502,10 @@ dev = [ ] docs = [ { name = "gp-libs", specifier = ">=0.0.19" }, - { name = "gp-sphinx", specifier = "==0.1.0a37" }, + { name = "gp-sphinx", specifier = "==0.1.0a38" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a37" }, - { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a37" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a38" }, + { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a38" }, ] lint = [ { name = "mypy" }, @@ -2938,7 +2938,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -2946,14 +2946,14 @@ dependencies = [ { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/cd/c6180e600d4d7acc9d786eaa34f6b8f853f7f5246d261cf9db5ffdd2548c/sphinx_autodoc_api_style-0.1.0a37.tar.gz", hash = "sha256:e7cec5b2c95a514536f5c30be1e2df4351ae4d9344cab33709cb08b724826e52", size = 9451, upload-time = "2026-07-27T01:16:52.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/1a/df9f6f4ae05ebe7bc46ee60a776a63caebdd2f2e39433dc1e60c3ac1162f/sphinx_autodoc_api_style-0.1.0a38.tar.gz", hash = "sha256:f3a6c801356f49718a23008b4031208a55feac228ea3b038487543bae6df4c55", size = 9453, upload-time = "2026-08-30T14:08:16.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/f8e63d4c979c7a11180bb6a72a204cbc5a1706e5e7178ab09ae27eeb65a6/sphinx_autodoc_api_style-0.1.0a37-py3-none-any.whl", hash = "sha256:2a135e8ec86cf56e4c8883f6f07839d2ae0268d9e3f5548f8b0e7433b396fa60", size = 9569, upload-time = "2026-07-27T01:16:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3b/0245d388fde2c91c45eef6c746c39c6799c3726429bdf0efba564d4100f5/sphinx_autodoc_api_style-0.1.0a38-py3-none-any.whl", hash = "sha256:e71d75041b1f5b49c8cd3f8d0d76b49788c2aa8bf1bb0233ad8fd41212791839", size = 9568, upload-time = "2026-08-30T14:07:55.059Z" }, ] [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -2962,22 +2962,22 @@ dependencies = [ { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/51/82b02636d5e3cf0441174b500874fda415ffba22a24d6d6dd3f8a4f0245c/sphinx_autodoc_fastmcp-0.1.0a37.tar.gz", hash = "sha256:3c44e874a34371b0aa6f24bdc5a6629c5f8fd732fd306dd8b9494d5f1a1a8ec4", size = 28754, upload-time = "2026-07-27T01:16:55.262Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/f8/a773f21c4cb87cbb0a8c61e4a35067d689ca8d3571eed6212b3f185ae1cf/sphinx_autodoc_fastmcp-0.1.0a38.tar.gz", hash = "sha256:d92622a3cf54982daa36efa00bb7f07d06e29ca64673e1f2cb3de748d39415b7", size = 32821, upload-time = "2026-08-30T14:08:18.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/41/1c9bb3e976a633e3bc12a51b08b0d8b6b93251467150093ce266352594f8/sphinx_autodoc_fastmcp-0.1.0a37-py3-none-any.whl", hash = "sha256:45d537ee4940397130153b6ef1a46fcb22a5d00ddd28b54efb9cb878694532e2", size = 32595, upload-time = "2026-07-27T01:16:32.586Z" }, + { url = "https://files.pythonhosted.org/packages/4c/75/fdf982ec19eb9b7b0ebec5254558dc5880b83882b79e887750b445ed6ec1/sphinx_autodoc_fastmcp-0.1.0a38-py3-none-any.whl", hash = "sha256:39c6173d06d51158e7a8df4f3532cfb80284455badb981f41da5c96801289db2", size = 36740, upload-time = "2026-08-30T14:07:58.736Z" }, ] [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/a4/667e4a81d8752c0a103c8bc211859aed313cc857906134ad142ce128efb0/sphinx_autodoc_typehints_gp-0.1.0a37.tar.gz", hash = "sha256:d80b1714d28fc7305c58655f610b0c15db0a93ce61752eeb96ae9b5b1f19e0db", size = 52328, upload-time = "2026-07-27T01:16:58.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/ba/489c1092e46222a90a2a78bddac2199ec12111dd7e0e2370befc379ecc08/sphinx_autodoc_typehints_gp-0.1.0a38.tar.gz", hash = "sha256:d40ce89f68d47bfa2af4f031028b52315f6ca6aaa00cca8b20f486febf9d3d3c", size = 52327, upload-time = "2026-08-30T14:08:21.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/c2/1a384b17f70660c397a17e1f7787b5890ec45de20ed3a5e5eb9e12b83e17/sphinx_autodoc_typehints_gp-0.1.0a37-py3-none-any.whl", hash = "sha256:826d318b1eb968edf547fe2d68081cd81ea4028b2017273823d50c0e89917113", size = 54907, upload-time = "2026-07-27T01:16:37.278Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/ebec25c2485237d4e4bd1ae54f64c9594b8953aea70e07e209bf35d64eab/sphinx_autodoc_typehints_gp-0.1.0a38-py3-none-any.whl", hash = "sha256:4348a841fbe0644cc61063b0b00a38b1223570b82a1164aad0694b3f58ac6f9f", size = 54907, upload-time = "2026-08-30T14:08:02.494Z" }, ] [[package]] @@ -3042,68 +3042,68 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/dc/64f039ba71475e568eaa070a56168acbc7d2aa33a7027328f3e23db19c83/sphinx_fonts-0.1.0a37.tar.gz", hash = "sha256:b6b72c5516b5152ab7acb50799c99ac1cb72103d8bbfd16ce2dd2171f826026f", size = 5875, upload-time = "2026-07-27T01:16:59.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/22/9e0d99c6fa8811c117574ab4ce119c6a070c18fa5d7f69a7af66648babab/sphinx_fonts-0.1.0a38.tar.gz", hash = "sha256:ed0a8dc4c1cb3b9389ac502aad8b29410c90480a3df2830caf85b54ce76d66a2", size = 5875, upload-time = "2026-08-30T14:08:22.234Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/a2/2acc6db3faf163a8f6356ea5bfccc6ec46f435a2f42e764a7df934db9d94/sphinx_fonts-0.1.0a37-py3-none-any.whl", hash = "sha256:1fbf5cdc1bbf5f1c2961b79f0324909c910e69072b3ae5b3d38227bf4a7a3f6b", size = 4445, upload-time = "2026-07-27T01:16:38.615Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/edd6c56399a5cc479f94ef855914d084ac9c83bd5b2ef477732a95027e54/sphinx_fonts-0.1.0a38-py3-none-any.whl", hash = "sha256:cef8297682da5d48c41f4bcdbbf8db6d1fb44b55a27987823eb98e256e616671", size = 4446, upload-time = "2026-08-30T14:08:03.678Z" }, ] [[package]] name = "sphinx-gp-llms" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/5a/e1dc00eb4ef5efc007737090b930ba415e00d4f25eb78954b619d0d61be7/sphinx_gp_llms-0.1.0a37.tar.gz", hash = "sha256:1ff4ce301374d31a7d36a740db71792bcd4fa1a23f1a92d854c719ad7b3064c5", size = 9996, upload-time = "2026-07-27T01:17:00.954Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/b0/765da2b46c9b074ee4f03d8a06214edc8d348ee4486ab5abb97a78755d18/sphinx_gp_llms-0.1.0a38.tar.gz", hash = "sha256:7a8b25cc9f2df87bd5202012bd8aedb2bcf67657df9f976cbac65f13911db36d", size = 9996, upload-time = "2026-08-30T14:08:23.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/c7/189e3ff63ac0c54dfd3bb00ae690ade5dd1ae980faba8b30126ebe95aa38/sphinx_gp_llms-0.1.0a37-py3-none-any.whl", hash = "sha256:a8c437be5bdce3c15cdc83444c3c66277e0785a9f94153fa5c7dd21883680bc5", size = 12730, upload-time = "2026-07-27T01:16:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/da/10/d08a192e740eeb68692854c8479b9a0a10f7bfcaa66c6bcb5fc0c1cadd37/sphinx_gp_llms-0.1.0a38-py3-none-any.whl", hash = "sha256:01e174d9da450851361707cb422dbbf53d6cbce85d718cb26f9c972e2b813b1a", size = 12728, upload-time = "2026-08-30T14:08:05.742Z" }, ] [[package]] name = "sphinx-gp-opengraph" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/e9/c23843c9fb980691b301e1a4000a7f989f4993354e3b2f93378179c49926/sphinx_gp_opengraph-0.1.0a37.tar.gz", hash = "sha256:70036e4d160f514c0f13a3700d9da7bfe20f3976d245b84ca520c522e5af3731", size = 11954, upload-time = "2026-07-27T01:17:02.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/bd7463de5c72850ac8f4c5b5870af345b2e232dfda06a1d5eb5f30c9eea3/sphinx_gp_opengraph-0.1.0a38.tar.gz", hash = "sha256:a69da7d69281c1650505c8aa6901f506378869512fb01c886c7c2b1acda99564", size = 11953, upload-time = "2026-08-30T14:08:25.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/15/73fd166b9b9690c53f045203e538914bbf6e82cfeb629c0380a31f7008a3/sphinx_gp_opengraph-0.1.0a37-py3-none-any.whl", hash = "sha256:5db38ea07a7a3d422c03a15ccdc1825c21002f3b78efca252ae88d6c78d9da26", size = 12191, upload-time = "2026-07-27T01:16:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/91e0017631ed003f3de8c2f9f467a8d4b980c646e8d7cfcdd2dd6d0e8f95/sphinx_gp_opengraph-0.1.0a38-py3-none-any.whl", hash = "sha256:0adce2c3011eb0855a82a4330013362e1f013f72e600389b4b9070374d8f2db5", size = 12193, upload-time = "2026-08-30T14:08:07.774Z" }, ] [[package]] name = "sphinx-gp-sitemap" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/bc/ba3082d503177046f0481e5c3f537fab75f5c2c63b95b6c8a3cb02083530/sphinx_gp_sitemap-0.1.0a37.tar.gz", hash = "sha256:74e3f717ef936b0567026e316607f7ed28b80d404261685d1246da86b269850a", size = 9957, upload-time = "2026-07-27T01:17:03.435Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e5/aabd8d9bde187607c254313e2e5b3a5fa7273e4f9e4b7a16876f634ee9b8/sphinx_gp_sitemap-0.1.0a38.tar.gz", hash = "sha256:08ada6b2f797c473bcf194b9bca033f8effe490a45e92b9ca419978332c95236", size = 9957, upload-time = "2026-08-30T14:08:26.109Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/d8/edcd2d5c282940556e79e97c6eaab5ba876d80eca32aa280f591a9bda26d/sphinx_gp_sitemap-0.1.0a37-py3-none-any.whl", hash = "sha256:90c696793279708599e865d5b581852820d9a6db0a60d3cdfcd3c5cb3bb5f055", size = 8983, upload-time = "2026-07-27T01:16:44.833Z" }, + { url = "https://files.pythonhosted.org/packages/9a/32/40aaee9df67cc27fad4f7f2623571fbf2a856f7b924c837eb065c9ad1763/sphinx_gp_sitemap-0.1.0a38-py3-none-any.whl", hash = "sha256:1ac494cf06644b22983edee2ba4ba7c0a747b661fd69d6bfc4a48d9bc5853588", size = 8982, upload-time = "2026-08-30T14:08:08.85Z" }, ] [[package]] name = "sphinx-gp-theme" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gp-furo-theme" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/64/738adee88ecec4c3a8286da75d701062ac711c88b01b9713275329f7bc28/sphinx_gp_theme-0.1.0a37.tar.gz", hash = "sha256:6bd1248433d197aadaa2c6ceb01975b71ba9f8395db1f3e6bfd3ce98913bf4f7", size = 18489, upload-time = "2026-07-27T01:17:04.275Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/a1/1668f568d0f767968377da3873a519048e0eade7d0b80c0dac0fff13d0b0/sphinx_gp_theme-0.1.0a38.tar.gz", hash = "sha256:483b1cca1a1f245c32b98eb6972eabb9b4f6df3835983f4586599c8dab25265c", size = 18489, upload-time = "2026-08-30T14:08:26.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/47/5901ff6adc10cdf2812a8b0bb138e65f6d123ee89703b20a82ed54354fae/sphinx_gp_theme-0.1.0a37-py3-none-any.whl", hash = "sha256:afac9135c2d9eec3caa3dbff862509ea64802294c62191590c98ad9ebe9da2cc", size = 20107, upload-time = "2026-07-27T01:16:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/e7/35/189bd8ba777710fa238874a239c9849a76c23f86456fdfcbf336e40c0718/sphinx_gp_theme-0.1.0a38-py3-none-any.whl", hash = "sha256:047515d4682168717b377c3b3b8b00e793be48e119dbee1629d5861358cc2f01", size = 20104, upload-time = "2026-08-30T14:08:09.925Z" }, ] [[package]] @@ -3121,28 +3121,28 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/27/0b0af2d36310cc21a73fd8c58616577ead37aa420936e8111c173cbffcb4/sphinx_ux_autodoc_layout-0.1.0a37.tar.gz", hash = "sha256:3a0c40033797abfab785b01be1f0daea9c354a2d125ab959521f8c9cc96dc907", size = 30837, upload-time = "2026-07-27T01:17:05.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/77/efd705589f62ad6d1af2d6a58490b4076b9a6d8c8fca3e7cd39a7e667bbf/sphinx_ux_autodoc_layout-0.1.0a38.tar.gz", hash = "sha256:321bf86d2a2d722fbad512db06e3fce2ea9a6cccb07c5657b19daf9fc0327bb3", size = 30837, upload-time = "2026-08-30T14:08:27.723Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/7e/ce06f05cd096a564b718d8efe073934f6299feb72af6611773ab065ab23d/sphinx_ux_autodoc_layout-0.1.0a37-py3-none-any.whl", hash = "sha256:ef19d501718b1de586658006fe7d125daa73fc742afbec56a7dd635bc73205e7", size = 35257, upload-time = "2026-07-27T01:16:47.44Z" }, + { url = "https://files.pythonhosted.org/packages/9f/eb/fcdfe267966a724d3069e9d8ae49f653531ee79987f4b4ef0a2f42302494/sphinx_ux_autodoc_layout-0.1.0a38-py3-none-any.whl", hash = "sha256:cc265a05501ffc2166a48db55769ba37fa53003f4608b2e24cea362e331b2fd3", size = 35256, upload-time = "2026-08-30T14:08:10.975Z" }, ] [[package]] name = "sphinx-ux-badges" -version = "0.1.0a37" +version = "0.1.0a38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/dc/66f3b29bcb3be2132f5fed91f6e0ce9a23febcc79e499a4e07145a4aab74/sphinx_ux_badges-0.1.0a37.tar.gz", hash = "sha256:1e2f9e4908a090c9586aba83f1e5c262a3aa30875adb19afe5773fb44305d8cf", size = 16737, upload-time = "2026-07-27T01:17:05.929Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/d3/80a597e5052db0813d6372079548086453ab37fda44a2baffe9e606e9499/sphinx_ux_badges-0.1.0a38.tar.gz", hash = "sha256:7f35305d51529b728d6d389d1941b07cf6a2951841fe8da922b929a040e2e3ec", size = 16888, upload-time = "2026-08-30T14:08:28.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/c5/7947392920f26a579fb6c3f9a2f7f04773f26f2feaee8282e79a62f140c2/sphinx_ux_badges-0.1.0a37-py3-none-any.whl", hash = "sha256:5e4140c518a0c532018900f5beb965a1f470df71cd7bac1c1bd39dc0edd10cb2", size = 17503, upload-time = "2026-07-27T01:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/e963c1c8ef14760d44f25047cea41b6aa5e699e674067789ff9ed0ea7d61/sphinx_ux_badges-0.1.0a38-py3-none-any.whl", hash = "sha256:ee3220c4146044501f769ea9eaf7adbacdf034bf30d3d9b1ecc27514cda1075b", size = 17658, upload-time = "2026-08-30T14:08:12.196Z" }, ] [[package]] From d6e88e7933eb97fb91c21c360eb688d7acfee655 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 10:57:01 -0500 Subject: [PATCH 43/44] mcp(fix[mcp_swap]): Reject retired safety env why: use-local preserved LIBTMUX_SAFETY from a trunk registration, so the branch rejected its own generated server entry. Dropping it unconditionally could widen an old restricted surface. what: - Remove the retired key when LIBTMUX_TOOLSETS defines its replacement - Reject swaps without a replacement and explicit attempts to add it - Cover replacement and already-local paths --- scripts/mcp_swap.py | 24 +++++++++-- tests/test_mcp_swap.py | 70 ++++++++++++++++++++++++++------ tests/test_retired_vocabulary.py | 2 + 3 files changed, 81 insertions(+), 15 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index d1806eab..093f6a57 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -1400,6 +1400,22 @@ def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: return "other" +def _replacement_env( + current: McpServerSpec | None, extra_env: dict[str, str] +) -> dict[str, str]: + """Merge current env while requiring a replacement for retired safety.""" + env = dict(current.env) if current else {} + retired_safety = env.pop("LIBTMUX_SAFETY", None) + env.update(extra_env) + if retired_safety is not None and "LIBTMUX_TOOLSETS" not in env: + msg = ( + "LIBTMUX_SAFETY has been removed; rerun with " + "--env LIBTMUX_TOOLSETS=" + ) + raise RuntimeError(msg) + return env + + def cmd_use_local(args: argparse.Namespace) -> int: """Rewrite each target CLI's config to run the repo's checkout via ``uv``. @@ -1442,11 +1458,12 @@ def cmd_use_local(args: argparse.Namespace) -> int: original_bytes = info.config_path.read_bytes() config = load_config(info) current = get_server(cli, config, server, repo, scope=scope) + base_env = _replacement_env(current, extra_env) if ( current and current.is_local_uv_directory() and current.local_repo_path() == repo - and all(current.env.get(k) == v for k, v in extra_env.items()) + and current.env == base_env ): print(f"[{label}] already local (this repo) — no change") continue @@ -1455,8 +1472,6 @@ def cmd_use_local(args: argparse.Namespace) -> int: # client-side settings (LIBTMUX_TOOLSETS, LIBTMUX_SOCKET, custom dev # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on # the read side. - base_env = dict(current.env) if current else {} - base_env.update(extra_env) cli_spec = ( dataclasses.replace(spec, env=base_env) if (current or extra_env) @@ -1650,6 +1665,9 @@ def _env_pair(raw: str) -> tuple[str, str]: if not sep or not key: msg = f"--env expects KEY=VALUE, got {raw!r}" raise argparse.ArgumentTypeError(msg) + if key == "LIBTMUX_SAFETY": + msg = "LIBTMUX_SAFETY has been removed; use LIBTMUX_TOOLSETS" + raise argparse.ArgumentTypeError(msg) return key, value diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index e0715239..6aae0206 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -251,17 +251,7 @@ def test_load_config_tolerates_empty_json(tmp_path: pathlib.Path) -> None: def test_use_local_preserves_existing_env_when_replacing( fake_home: pathlib.Path, fake_repo: pathlib.Path ) -> None: - """Existing ``env`` on a replaced entry survives ``use-local``. - - Regression: ``cmd_use_local`` previously constructed the replacement - spec via ``build_local_spec`` (env={}) and wrote it directly, - silently dropping client-side settings like ``LIBTMUX_TOOLSETS`` or - ``LIBTMUX_SOCKET`` that the user had set on the prior pinned-PyPI - entry. The fix merges ``current.env`` into the new spec; this test - locks the behaviour by seeding env on a Cursor entry, running - ``use-local``, and asserting both the new local-uv command shape and - the original env survived. - """ + """Replacement keeps current env entries and drops retired settings.""" info = mcp_swap.CLIS["cursor"] _write_json( info.config_path, @@ -270,7 +260,11 @@ def test_use_local_preserves_existing_env_when_replacing( "libtmux": { "command": "uvx", "args": ["libtmux-mcp==0.1.0a2"], - "env": {"LIBTMUX_TOOLSETS": "inspect", "FOO": "bar"}, + "env": { + "LIBTMUX_SAFETY": "destructive", + "LIBTMUX_TOOLSETS": "inspect", + "FOO": "bar", + }, } } }, @@ -1584,6 +1578,58 @@ def test_env_pair_rejects_malformed() -> None: mcp_swap.build_parser().parse_args(["use-local", "--env", "NOEQUALS"]) +def test_env_pair_rejects_retired_safety(capsys: pytest.CaptureFixture[str]) -> None: + """The swap command names the current setting for a retired env key.""" + with pytest.raises(SystemExit): + mcp_swap.build_parser().parse_args( + ["use-local", "--env", "LIBTMUX_SAFETY=destructive"] + ) + assert "LIBTMUX_TOOLSETS" in capsys.readouterr().err + + +def test_use_local_removes_retired_env_from_already_local_entry( + fake_home: pathlib.Path, fake_repo: pathlib.Path +) -> None: + """A stale setting prevents the already-local no-op.""" + info = mcp_swap.CLIS["cursor"] + spec = _local_entry(fake_repo) + spec["env"] = { + "LIBTMUX_SAFETY": "destructive", + "LIBTMUX_TOOLSETS": "inspect", + "KEEP": "me", + } + _write_json(info.config_path, {"mcpServers": {"libtmux": spec}}) + + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "cursor"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + + entry = json.loads(info.config_path.read_text())["mcpServers"]["libtmux"] + assert entry["env"] == {"LIBTMUX_TOOLSETS": "inspect", "KEEP": "me"} + + +def test_use_local_refuses_retired_env_without_replacement( + fake_home: pathlib.Path, + fake_repo: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A stale setting cannot silently widen to the branch default.""" + info = mcp_swap.CLIS["cursor"] + spec = _local_entry(fake_repo) + spec["env"] = {"LIBTMUX_SAFETY": "readonly"} + _write_json(info.config_path, {"mcpServers": {"libtmux": spec}}) + before = info.config_path.read_bytes() + + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "cursor"] + ) + assert mcp_swap.cmd_use_local(args) == 1 + + assert info.config_path.read_bytes() == before + assert "LIBTMUX_TOOLSETS" in capsys.readouterr().err + + def test_use_local_env_written_on_already_local_entry( fake_home: pathlib.Path, fake_repo: pathlib.Path ) -> None: diff --git a/tests/test_retired_vocabulary.py b/tests/test_retired_vocabulary.py index 0f812703..97dbdb48 100644 --- a/tests/test_retired_vocabulary.py +++ b/tests/test_retired_vocabulary.py @@ -47,7 +47,9 @@ CONFIG_REJECTION_FILES = frozenset( { + pathlib.Path("scripts/mcp_swap.py"), pathlib.Path("src/libtmux_mcp/server.py"), + pathlib.Path("tests/test_mcp_swap.py"), pathlib.Path("tests/test_server.py"), } ) From 7150af9314a7cd96632218ce81e9cecbd32c02f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:02:32 -0500 Subject: [PATCH 44/44] docs(CHANGES): Capability toolsets and trust why: Users need one upgrade-time account of the new tool inventory, stricter input contracts, and tmux trust boundary. what: - Document safety-tier migration and batch changes - Record format, matching, annotation, retry, and cleanup behavior - Explain trust documentation and mcp_swap migration --- CHANGES | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/CHANGES b/CHANGES index 1db34af0..e910ca28 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,99 @@ _Notes on upcoming releases will be added here_ +### Breaking changes + +**Toolsets replace safety tiers** + +Tools now belong to the unordered `inspect`, `manage`, `execute`, and +`teardown` sets. `LIBTMUX_TOOLSETS` selects any combination; the default is +`inspect,manage,execute`, while `teardown` remains opt-in. +`LIBTMUX_TOOLS` enables individual tools and `LIBTMUX_EXCLUDE_TOOLS` takes +precedence over every inclusion. Unknown toolset or tool names fail startup. + +`LIBTMUX_SAFETY` now fails startup rather than silently widening the exposed +surface. + +**Before** + +```console +$ LIBTMUX_SAFETY=readonly libtmux-mcp +``` + +**After** + +```console +$ LIBTMUX_TOOLSETS=inspect libtmux-mcp +``` + +The former default maps to `inspect,manage,execute`; the former full surface +adds `teardown`. See {ref}`migration` and {ref}`trust`. (#128) + +**Write-capable batch wrappers are removed** + +`call_readonly_tools_batch` becomes +{tooliconl}`call-read-tools-batch` and accepts `inspect` tools only. +`call_mutating_tools_batch` and `call_destructive_tools_batch` are removed; +call write-capable tools directly so client policy sees each tool's own name. +(#128) + +**{tooliconl}`display-message` accepts literals and variables** + +`format_string` now accepts literal text and plain `#{variable}` references, +including hyphenated built-in and user-option names. Modifiers, conditionals, +padding, and jobs are rejected because they can re-expand values supplied by a +pane. Run `tmux display-message -p` through {tooliconl}`run-command` when raw +format syntax is required. (#128) + +**Spawn tools require an existing working directory** + +A supplied `start_directory` must resolve to an existing directory. Calls that +previously appeared to succeed while tmux silently placed the pane in `$HOME` +now fail and identify the invalid path. (#128) + +### Fixes + +**Names and paths remain literal** + +Caller-supplied session names, window names, pane titles, option names, and +working directories are escaped before tmux format expansion. Values containing +`#(...)`, `#{...}`, or `#[...]` now arrive intact instead of running a job or +being rewritten. (#128) + +**{tooliconl}`search-panes` stops pathological matching** + +Regex matching now has one two-second budget across the call, excluding tmux +pane-capture time. Pattern length is capped separately, and a pattern that +exhausts the matching budget returns a correctable error instead of blocking +the server. (#128) + +**Tool annotations account for programmable tmux targets** + +Every tool that requests a tmux operation now advertises conservative +whole-call annotations. Command aliases and `after-*` hooks can replace or +extend an operation on an existing tmux server, so the project-owned toolsets +carry the direct-operation distinction that standard MCP hints cannot promise. +(#128) + +**Failed tool calls run once** + +The server no longer retries failed inspection calls automatically. A tmux +alias or hook may already have acted before reporting an error, so the client or +operator decides whether repeating the request is appropriate. (#128) + +**Shutdown leaves tmux buffers alone** + +Server shutdown clears only the Python process cache. It no longer deletes +buffers by a shared name prefix that cannot prove which MCP server instance +created them. (#128) + +**Prompt adapters remain callable through `inspect`** + +With `LIBTMUX_MCP_PROMPTS_AS_TOOLS=1`, `list_prompts` and `get_prompt` remain +visible and callable when the `inspect` toolset is enabled. They render +server-local text without contacting tmux and retain their narrower +annotations. (#128) + ### Documentation #### opencode joins the install picker @@ -15,6 +108,14 @@ is non-interactive once a name and a `--` command are both given, so it is a CLI panel rather than a paste-this-JSON one — which also sidesteps opencode's unusual entry shape. +**The trust page separates tool filtering from confinement** + +The trust page replaces the safety topic and explains what the MCP call gate, +tmux aliases and hooks, shared sockets, status jobs, and OS isolation each +control. The old safety URL and renamed batch-tool URL redirect to their current +pages, and tool catalogs expose each toolset with a copyable label and colored +badge. (#128) + ### Development **`mcp_swap.py` covers opencode and pi** @@ -59,6 +160,13 @@ A non-mapping at a container key now raises a `RuntimeError` naming the path for every CLI, not just Claude. `scripts/README.md`'s extension guide described three branch sites when there were four; it now describes the fields instead. +**`mcp_swap.py` migrates retired safety settings** + +`use-local` removes a stored `LIBTMUX_SAFETY` only when +`LIBTMUX_TOOLSETS` supplies its replacement. Otherwise it stops with migration +instructions, and `--env LIBTMUX_SAFETY=...` is rejected so a restricted +configuration cannot widen silently. (#128) + ## libtmux-mcp 0.1.0a20 (2026-08-09) libtmux-mcp 0.1.0a20 changes no tool behavior. ruff's curated default rule @@ -325,11 +433,11 @@ MCP clients now see the effective server default for `suppress_history` on {tool {tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` now let callers opt into best-effort no-disk shell-history controls with `suppress_persistent_history=true`. Session controls reach the initial and future panes, while the other tools limit the setting to the process launched by that call. -History suppression reduces accidental persistence, but it is not secret transport: shells can override the controls, and terminal output and other observation surfaces remain visible. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for the remaining boundaries. (#91) +History suppression reduces accidental persistence, but it is not secret transport: shells can override the controls, and terminal output and other observation surfaces remain visible. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for the remaining boundaries. (#91) **Per-process environments for windows and panes** -{tooliconl}`create-window` and {tooliconl}`split-window` now accept per-process `environment` mappings or JSON object strings, so callers can configure new windows and panes without changing the tmux session environment. {tooliconl}`respawn-pane` now accepts the same JSON object form for its existing environment input. Values can still surface in host process inspection and child environments; use credential references, not literal credentials. See {ref}`safety` for details. (#91) +{tooliconl}`create-window` and {tooliconl}`split-window` now accept per-process `environment` mappings or JSON object strings, so callers can configure new windows and panes without changing the tmux session environment. {tooliconl}`respawn-pane` now accepts the same JSON object form for its existing environment input. Values can still surface in host process inspection and child environments; use credential references, not literal credentials. See {ref}`trust` for details. (#91) ### Documentation @@ -407,7 +515,7 @@ libtmux-mcp 0.1.0a12 hardens the MCP server's read-only and safety surface and a **Read-only tools no longer evaluate tmux `#()` format jobs** -{tooliconl}`search-panes` and {tooliconl}`display-message` are advertised as read-only, but tmux `#(...)` formats schedule shell jobs during expansion. Both now reject or route around `#()` so a read-only call can never spawn a shell. (#68, #69) +{tooliconl}`search-panes` and {tooliconl}`display-message` were advertised as read-only, but tmux `#(...)` formats schedule shell jobs during expansion. Both now reject or route around `#()` so those parameters can no longer spawn a shell. (#68, #69) **Invalid `LIBTMUX_SAFETY` fails closed** @@ -623,7 +731,7 @@ libtmux-mcp 0.1.0a4 adds pane recovery and closes the last gap in the core hiera ### Documentation -{ref}`safety` now documents {tooliconl}`respawn-pane` as a mutating recovery tool with real process side effects, including the `kill=true` default and the visibility tradeoffs of passing command or environment data through tmux. The same update refreshes socket-guard notes for macOS, tightens guidance around {tooliconl}`display-message` and {tooliconl}`pipe-pane`, and adds dedicated tool pages for the new recovery and metadata tools. (#27) +{ref}`trust` now documents {tooliconl}`respawn-pane` as a mutating recovery tool with real process side effects, including the `kill=true` default and the visibility tradeoffs of passing command or environment data through tmux. The same update refreshes socket-guard notes for macOS, tightens guidance around {tooliconl}`display-message` and {tooliconl}`pipe-pane`, and adds dedicated tool pages for the new recovery and metadata tools. (#27) ### Development @@ -667,7 +775,7 @@ for match in search_panes(...).matches: ... ``` -**Minimum `fastmcp>=3.2.4`** (was `>=3.1.0`). The newer FastMCP release is required for {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` and per-parameter input-schema descriptions. +**Minimum `fastmcp>=3.2.4`** (was `>=3.1.0`). The newer FastMCP release is required for the former `ReadonlyRetryMiddleware` and per-parameter input-schema descriptions. ### What's new @@ -685,7 +793,7 @@ Four MCP prompts ship for repeatable tmux work: `run_and_wait`, `diagnose_failin **Middleware for safer long-running automation** -The middleware stack now includes {class}`~libtmux_mcp.middleware.AuditMiddleware` for digest-redacted argument summaries, {class}`~libtmux_mcp.middleware.SafetyMiddleware` for safety-tier visibility, {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` for transient readonly libtmux failures, and {class}`~libtmux_mcp.middleware.TailPreservingResponseLimitingMiddleware` for oversized tool output. Timing and error-handling middleware round out the request path. (#15) +The middleware stack now includes {class}`~libtmux_mcp.middleware.AuditMiddleware` for digest-redacted argument summaries, the former `SafetyMiddleware` for safety-tier visibility, the former `ReadonlyRetryMiddleware` for transient readonly libtmux failures, and {class}`~libtmux_mcp.middleware.TailPreservingResponseLimitingMiddleware` for oversized tool output. Timing and error-handling middleware round out the request path. (#15) **Tail-preserving bounded output** @@ -700,11 +808,11 @@ Tool input schemas now include parameter descriptions extracted from docstrings, - {tooliconl}`search-panes` neutralizes tmux format-string injection in the regex fast path. - The macOS self-kill guard resolves the live tmux socket before falling back to `TMUX_TMPDIR` reconstruction. - The `build_dev_workspace` prompt uses real tool parameter names, avoids waiting for prompts after screen-grabbing commands, and replaces a Linux-specific log default with `log_command`. -- {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` logs retry warnings under `libtmux_mcp.retry`. +- The former `ReadonlyRetryMiddleware` logs retry warnings under `libtmux_mcp.retry`. ### Documentation -The tools documentation gains category pages for {doc}`/tools/buffer/index`, {doc}`/tools/hook/index`, and {doc}`/tools/index`. The pane tools docs explain the {class}`~libtmux_mcp.models.SearchPanesResult` migration, while {ref}`safety` documents the audit log, socket caveats, {tooliconl}`pipe-pane`, and {tooliconl}`set-environment`. (#15) +The tools documentation gains category pages for {doc}`/tools/buffer/index`, {doc}`/tools/hook/index`, and {doc}`/tools/index`. The pane tools docs explain the {class}`~libtmux_mcp.models.SearchPanesResult` migration, while {ref}`trust` documents the audit log, socket caveats, {tooliconl}`pipe-pane`, and {tooliconl}`set-environment`. (#15) ## libtmux-mcp 0.1.0a1 (2026-04-13)