From 86d2b908eb08fb44843f4bf169522dde719055fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:00:19 -0500 Subject: [PATCH 01/40] docs(chore[_ext]): add package marker for bundled extensions why: Establish a real package boundary under docs._ext before repointing imports. what: - add docs/_ext/__init__.py - make the docs extension namespace importable as a package root --- docs/_ext/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/_ext/__init__.py diff --git a/docs/_ext/__init__.py b/docs/_ext/__init__.py new file mode 100644 index 0000000..d8bf21e --- /dev/null +++ b/docs/_ext/__init__.py @@ -0,0 +1,3 @@ +"""Sphinx extensions bundled with the project documentation.""" + +from __future__ import annotations From 857abeb563e40beebad06e08afb68cc68a0e74a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:02:30 -0500 Subject: [PATCH 02/40] docs(refactor[widgets]): switch widget imports to docs._ext.widgets why: Use one canonical module path for the docs widget extension so tools and tests resolve it consistently. what: - load the Sphinx extension via docs._ext.widgets - put the repo root on sys.path for docs and widget tests - update widget test imports to the canonical package path --- docs/conf.py | 4 ++-- tests/docs/conftest.py | 15 +++++++-------- tests/docs/test_widgets.py | 11 ++++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 226418f..63dc887 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,8 +19,8 @@ project_root = cwd.parent project_src = project_root / "src" +sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_src)) -sys.path.insert(0, str(cwd / "_ext")) # package data about: dict[str, str] = {} @@ -40,7 +40,7 @@ "sphinx_autodoc_api_style", "sphinx.ext.todo", "sphinx_autodoc_fastmcp", - "widgets", + "docs._ext.widgets", ], intersphinx_mapping={ "python": ("https://docs.python.org/", None), diff --git a/tests/docs/conftest.py b/tests/docs/conftest.py index 0e040f0..7f5c4aa 100644 --- a/tests/docs/conftest.py +++ b/tests/docs/conftest.py @@ -1,9 +1,9 @@ """pytest config for widget tests: wire Sphinx's test fixtures + path. ``sphinx.testing.fixtures`` provides ``make_app``, ``app``, etc. that build a -throw-away Sphinx project in a tmp dir. We also add ``docs/_ext`` to -``sys.path`` so tests can import the ``widgets`` extension the same way -``conf.py`` does in production. +throw-away Sphinx project in a tmp dir. We also add the repo root to +``sys.path`` so tests can import the docs extension via +``docs._ext.widgets``, matching ``conf.py`` in production. """ from __future__ import annotations @@ -17,9 +17,8 @@ _REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] _DOCS_DIR = _REPO_ROOT / "docs" -_EXT_DIR = _DOCS_DIR / "_ext" -if str(_EXT_DIR) not in sys.path: - sys.path.insert(0, str(_EXT_DIR)) +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) @pytest.fixture @@ -44,8 +43,8 @@ def real_widget_srcdir(tmp_path: pathlib.Path, docs_dir: pathlib.Path) -> pathli (srcdir / "conf.py").write_text( f""" import sys -sys.path.insert(0, {str(_EXT_DIR)!r}) -extensions = ["myst_parser", "widgets"] +sys.path.insert(0, {str(_REPO_ROOT)!r}) +extensions = ["myst_parser", "docs._ext.widgets"] exclude_patterns = ["_build"] master_doc = "index" source_suffix = {{".md": "markdown"}} diff --git a/tests/docs/test_widgets.py b/tests/docs/test_widgets.py index 9a2a9ad..00f3940 100644 --- a/tests/docs/test_widgets.py +++ b/tests/docs/test_widgets.py @@ -9,10 +9,11 @@ import typing as t import pytest -from widgets import BaseWidget -from widgets._base import make_highlight_filter -from widgets._discovery import discover -from widgets.mcp_install import ( + +from docs._ext.widgets import BaseWidget +from docs._ext.widgets._base import make_highlight_filter +from docs._ext.widgets._discovery import discover +from docs._ext.widgets.mcp_install import ( CLIENTS, METHODS, MCPInstallWidget, @@ -73,7 +74,7 @@ def test_body_for_json_client_returns_config_snippet() -> None: def test_body_for_unknown_kind_raises() -> None: """An unrecognised ``client.kind`` surfaces as a ``ValueError``.""" - from widgets.mcp_install import Client + from docs._ext.widgets.mcp_install import Client fake = Client(id="x", label="X", kind="bogus", config_file="") with pytest.raises(ValueError, match="unknown client kind"): From b0c59aa98b8b0ecacd2f4b80e4211ee2e3fc5667 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:03:11 -0500 Subject: [PATCH 03/40] mypy(chore[config]): drop docs/_ext alias from mypy path why: The docs widgets no longer need a top-level alias, and keeping one reintroduces duplicate module identities. what: - remove docs/_ext from mypy_path - keep the existing checked roots and docs exclusion unchanged --- pyproject.toml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 03d69b1..cd9b590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,13 +139,6 @@ files = [ "src", "tests", ] -# ``docs/_ext`` is not in ``files`` to avoid double-naming the widgets package -# (mypy would see it as both ``widgets`` and ``docs._ext.widgets``); the path -# entry alone lets imports like ``from widgets import ...`` in tests resolve. -# ``exclude`` is also required so that ``uv run mypy .`` in CI -- which -# traverses every path under the current dir -- doesn't rediscover the same -# files under their ``docs._ext.`` prefix and error "Source file found twice". -mypy_path = ["docs/_ext"] exclude = ["^docs/"] [[tool.mypy.overrides]] From 74f2d2da5a7d06190bfee9a5a07f90080e294847 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:03:28 -0500 Subject: [PATCH 04/40] docs(fix[widgets]): update widget template code reference why: The template comment should point at the canonical docs._ext.widgets module path. what: - update the widget.html comment to reference docs._ext.widgets._base --- docs/_widgets/mcp-install/widget.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_widgets/mcp-install/widget.html b/docs/_widgets/mcp-install/widget.html index c0a5b37..f771371 100644 --- a/docs/_widgets/mcp-install/widget.html +++ b/docs/_widgets/mcp-install/widget.html @@ -4,7 +4,8 @@ MCPInstallWidget.context() / the directive's option merge. Each code block runs through the `highlight` filter (defined in - widgets._base.make_highlight_filter) which wraps Sphinx's PygmentsBridge — + docs._ext.widgets._base.make_highlight_filter) which wraps Sphinx's + PygmentsBridge — so the output is byte-identical to a native ``.. code-block::`` block, meaning sphinx-copybutton + its prompt-strip regex work automatically. #} From bbfffaeeb1a2af402bb7f23de29bcd230a2ff0c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:09:25 -0500 Subject: [PATCH 05/40] mcp(docs[instructions]): add hook, buffer, and is_caller disambiguators to _BASE_INSTRUCTIONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: agents waste turns asking for write-hook tools (not exposed by design), expecting a list_buffers affordance (privacy-contract declined), or reinventing a whoami tool (three layers already answer "where am I?"). Naming each boundary explicitly heads off the exploratory call. what: - Append HOOKS ARE READ-ONLY paragraph to _BASE_INSTRUCTIONS citing show_hooks / show_hook and pointing at the tmux config file for writes. - Append BUFFERS paragraph covering load_buffer/paste_buffer/ delete_buffer lifecycle, BufferRef tracking, and the OS-clipboard privacy reason for the list_buffers omission. - Extend the TMUX_PANE branch of _build_instructions with a one- sentence is_caller workflow pointer — only emitted when running inside tmux, since the sentence references the agent-context line. - Lock all three additions with new assertion tests. --- src/libtmux_mcp/server.py | 16 +++++++++-- tests/test_server.py | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index e21cfc7..ef49ba2 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -67,7 +67,16 @@ "wait_for_content_change (waits for any change). These block " "server-side until the condition is met or the timeout expires, " "which is dramatically cheaper in agent turns than capture_pane " - "in a retry loop." + "in a retry loop.\n\n" + "HOOKS ARE READ-ONLY: inspect via show_hooks / show_hook. Write-hook " + "tools are intentionally not exposed — tmux hooks survive process " + "death, so they belong in your tmux config file, not a transient " + "MCP session.\n\n" + "BUFFERS: load_buffer stages content, paste_buffer delivers it into " + "a pane, delete_buffer removes the staged buffer. Track owned " + "buffers via the BufferRef returned from load_buffer — there is no " + "list_buffers tool because tmux buffers may include OS clipboard " + "history (passwords, private snippets)." ) @@ -118,7 +127,10 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: context += f" (socket: {socket_name})" context += ( ". Tool results annotate the caller's own pane with " - "is_caller=true. Use this to distinguish your own pane from others." + "is_caller=true. Use this to distinguish your own pane from " + "others. To answer 'which pane/window/session am I in?' call " + "list_panes (or snapshot_pane) and filter for is_caller=true — " + "your pane is identified above. No dedicated whoami tool exists." ) parts.append(context) diff --git a/tests/test_server.py b/tests/test_server.py index b380d71..5f1504f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -161,6 +161,62 @@ def test_base_instructions_prefer_wait_over_poll() -> None: assert "wait_for_content_change" in _BASE_INSTRUCTIONS +def test_base_instructions_document_hook_boundary() -> None: + """_BASE_INSTRUCTIONS explains hooks are read-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 "show_hooks" in _BASE_INSTRUCTIONS + assert "tmux config file" in _BASE_INSTRUCTIONS + + +def test_base_instructions_document_buffer_lifecycle() -> None: + """_BASE_INSTRUCTIONS explains the buffer lifecycle + no list_buffers. + + The load/paste/delete triple is non-obvious, and agents otherwise + expect a ``list_buffers`` affordance. The instruction prevents both + confusions and surfaces the clipboard-privacy reason so the + omission reads as deliberate, not missing. + """ + assert "BUFFERS" in _BASE_INSTRUCTIONS + assert "load_buffer" in _BASE_INSTRUCTIONS + assert "paste_buffer" in _BASE_INSTRUCTIONS + assert "delete_buffer" in _BASE_INSTRUCTIONS + assert "BufferRef" in _BASE_INSTRUCTIONS + assert "list_buffers" in _BASE_INSTRUCTIONS + assert "clipboard history" in _BASE_INSTRUCTIONS + + +def test_build_instructions_documents_is_caller_workflow_inside_tmux( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The is_caller workflow sentence appears only when inside tmux. + + The sentence references "your pane is identified above", which is + only true when ``TMUX_PANE`` is set and the agent-context line has + been emitted. Outside tmux, the sentence would be a lie — so it + lives inside the ``if tmux_pane:`` branch of ``_build_instructions`` + and must NOT appear in ``_BASE_INSTRUCTIONS`` itself. + """ + # 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) + 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) + 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) From 25272a00c8da706d7817925b9b4cc30adb550aa8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:11:29 -0500 Subject: [PATCH 06/40] mcp(refactor[display_message]): sharpen description + title for LLM discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the name 'display_message' reads to an LLM like "show a notification to the user" — the opposite of what the tool does (evaluate a tmux format string and return the expanded value). _BASE_INSTRUCTIONS already had corrective prose to compensate, which is structural evidence the name is doing the wrong job. Rewording the docstring summary and the MCP title lets the description carry the meaning without touching the wire-name. A hard rename to evaluate_format is deliberately deferred behind telemetry. what: - Rewrite the first sentence of display_message's docstring so FastMCP-indexed description leads with 'Evaluate a tmux format string... and return the expanded value.' — FastMCP pulls description from the docstring when no description= kwarg is given (fastmcp/tools/function_tool.py:225-227). - Change the mcp.tool registration title from 'Display Message' to 'Evaluate tmux Format String' in pane_tools/__init__.py. - Refresh the corrective sentence in _BASE_INSTRUCTIONS to match the new wording and name-check the title shift. - Complete the truncated '## Act' section in display-message.md and lead with the new framing; retitle the page to reflect the MCP title. --- docs/tools/pane/display-message.md | 8 ++++---- src/libtmux_mcp/server.py | 8 +++++--- src/libtmux_mcp/tools/pane_tools/__init__.py | 8 +++++--- src/libtmux_mcp/tools/pane_tools/meta.py | 7 ++++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md index a45d725..2b8f426 100644 --- a/docs/tools/pane/display-message.md +++ b/docs/tools/pane/display-message.md @@ -1,11 +1,13 @@ -# Display message +# Evaluate tmux format string (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. +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. **Avoid when** a dedicated tool already provides the information — e.g. use {tooliconl}`snapshot-pane` for cursor position and mode, or @@ -33,5 +35,3 @@ zoomed=0 dead=0 ```{fastmcp-tool-input} pane_tools.display_message ``` - -## Act diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index ef49ba2..29aac02 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -59,9 +59,11 @@ "READ TOOLS TO PREFER: snapshot_pane returns pane content plus " "cursor position, mode, and scroll state in one call — use it " "instead of capture_pane + get_pane_info when you need context. " - "display_message evaluates any tmux format string (e.g. " - "'#{pane_current_command}', '#{session_name}') against a target, " - "which is often cheaper than parsing captured output.\n\n" + "display_message evaluates a tmux format string (e.g. " + "'#{pane_current_command}', '#{session_name}') against a target " + "and returns the expanded value — cheaper than parsing captured " + "output. (The tool is named after the tmux 'display-message -p' " + "verb it wraps; its MCP title is 'Evaluate tmux Format String'.)\n\n" "WAIT, DON'T POLL: for 'run command, wait for output' workflows " "use wait_for_text (matches text/regex on a pane) or " "wait_for_content_change (waits for any change). These block " diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index a8c1af2..bf2ec8d 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -120,9 +120,11 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="Pipe Pane", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})( pipe_pane ) - mcp.tool(title="Display Message", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( - display_message - ) + mcp.tool( + title="Evaluate tmux Format String", + annotations=ANNOTATIONS_RO, + tags={TAG_READONLY}, + )(display_message) mcp.tool( title="Enter Copy Mode", annotations=ANNOTATIONS_CREATE, diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 99e4bf5..ef54331 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -26,10 +26,11 @@ def display_message( window_id: str | None = None, socket_name: str | None = None, ) -> str: - """Query tmux using a format string. + """Evaluate a tmux format string against a target and return the expanded value. - Expands tmux format variables against a target pane. Use this as a - generic introspection tool to query any tmux variable, e.g. + 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. '#{window_zoomed_flag}', '#{pane_dead}', '#{client_activity}'. Parameters From 5e52adeb206f2e00d606b32a3780a8b20662f614 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:12:29 -0500 Subject: [PATCH 07/40] mcp(refactor[pipe_pane]): lead docstring with a concrete logging use case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: 'Start or stop piping pane output to a file.' is accurate but doesn't give the agent the hook it needs to reach for this tool. FastMCP indexes the docstring summary for search and LLM clients feed it into system prompts — a concrete example ('cat > /tmp/pane.log') anchors the discoverability without changing any behavior. what: - Replace the flat two-line summary with 'Log a pane's live output to a file (or stop an active log)' plus a one-sentence typical-use hint. The semantic reframe (log, not pipe) matches how agents think about the affordance. --- src/libtmux_mcp/tools/pane_tools/pipe.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/libtmux_mcp/tools/pane_tools/pipe.py b/src/libtmux_mcp/tools/pane_tools/pipe.py index b502d66..6f43a77 100644 --- a/src/libtmux_mcp/tools/pane_tools/pipe.py +++ b/src/libtmux_mcp/tools/pane_tools/pipe.py @@ -23,10 +23,14 @@ def pipe_pane( window_id: str | None = None, socket_name: str | None = None, ) -> str: - """Start or stop piping pane output to a file. + """Log a pane's live output to a file (or stop an active log). - When output_path is given, starts logging all pane output to the file. - When output_path is None, stops any active pipe for the pane. + Streams everything written to the pane (stdout plus terminal + control sequences) into a file on disk — the common use is + ``output_path="/tmp/pane.log"`` to capture scrollback continuously + while the agent watches for errors. When ``output_path`` is given, + starts logging; when ``output_path`` is None, stops any active pipe + for the pane. .. warning:: This tool writes to arbitrary filesystem paths chosen by the MCP From ae89b9c0287d574473d508eedbcce00348c2ac19 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:23:30 -0500 Subject: [PATCH 08/40] mcp(feat[window_tools]): add get_window_info for single-window metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: get_pane_info and get_server_info exist, but the window and session peers do not. Agents given a window_id and asked "what are this window's dimensions?" have to call list_panes or list_windows and filter — wasteful. Adding get_window_info closes one half of the core-tmux-hierarchy symmetry (get_session_info follows next). The symmetry argument is deliberately bounded to the four-level core hierarchy (Server > Session > Window > Pane). This is NOT a license to add get_buffer_info / get_hook_info / get_option_info — those scopes are outside the hierarchy and the existing show_*/load_* tools already cover their reads. An inline comment on the function memorializes that boundary so future contributors don't re-relitigate it. what: - Add get_window_info(window_id, window_index, session_name, session_id, socket_name) returning WindowInfo. Reuses _resolve_window (accepts window_id OR window_index+session) and _serialize_window — no new helpers. - Register with ANNOTATIONS_RO + TAG_READONLY, placed next to list_panes in the Window tool group. - Add test_get_window_info (resolves by window_id) and test_get_window_info_by_index (resolves by index+session) mirroring the minimal-assertion style used by test_list_panes. - Add docs/tools/window/get-window-info.md modeled after get-pane-info.md; insert the new page into the Window tools index grid and toctree. - Append get_window_info to the README tool catalog Window row. --- README.md | 2 +- docs/tools/window/get-window-info.md | 58 +++++++++++++++++++++++++++ docs/tools/window/index.md | 5 +++ src/libtmux_mcp/tools/window_tools.py | 53 ++++++++++++++++++++++++ tests/test_window_tools.py | 26 ++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 docs/tools/window/get-window-info.md diff --git a/README.md b/README.md index 70930f3..ff66b4f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Give your AI agent hands inside the terminal — create sessions, run commands, |--------|-------| | **Server** | `list_sessions`, `create_session`, `kill_server`, `get_server_info` | | **Session** | `list_windows`, `create_window`, `rename_session`, `select_window`, `kill_session` | -| **Window** | `list_panes`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` | +| **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` | | **Pane** | `send_keys`, `paste_text`, `capture_pane`, `snapshot_pane`, `search_panes`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `kill_pane` | | **Options** | `show_option`, `set_option` | | **Environment** | `show_environment`, `set_environment` | diff --git a/docs/tools/window/get-window-info.md b/docs/tools/window/get-window-info.md new file mode 100644 index 0000000..2e20ba3 --- /dev/null +++ b/docs/tools/window/get-window-info.md @@ -0,0 +1,58 @@ +# Get window info + +```{fastmcp-tool} window_tools.get_window_info +``` + +**Use when** you need metadata for a single window (name, index, layout, +dimensions, pane count) and you already know the `window_id` or +`window_index`. Avoids the `list_windows` + filter dance. + +**Avoid when** you need every window in a session — call `list_panes` with +`session_id` or iterate through the session's windows via the +`tmux://sessions/{name}/windows` resource. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_window_info", + "arguments": { + "window_id": "@1" + } +} +``` + +Response: + +```json +{ + "window_id": "@1", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +Resolve by `window_index` when only the index is known — requires +`session_name` or `session_id` to disambiguate: + +```json +{ + "tool": "get_window_info", + "arguments": { + "window_index": "1", + "session_name": "dev" + } +} +``` + +```{fastmcp-tool-input} window_tools.get_window_info +``` diff --git a/docs/tools/window/index.md b/docs/tools/window/index.md index 3ab89e5..a14131e 100644 --- a/docs/tools/window/index.md +++ b/docs/tools/window/index.md @@ -9,6 +9,10 @@ Window-scoped tools — enumerate panes, split / rename / relayout / resize / mo Enumerate panes inside a window. ::: +:::{grid-item-card} {tooliconl}`get-window-info` +Read metadata for one window. +::: + :::{grid-item-card} {tooliconl}`split-window` Split a window into a new pane. ::: @@ -40,6 +44,7 @@ Terminate a window. Destructive. :maxdepth: 1 list-panes +get-window-info split-window rename-window select-layout diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index c4e97df..cf51850 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -98,6 +98,56 @@ def list_panes( return _apply_filters(panes, filters, _serialize_pane) +# get_window_info completes the core-tmux-hierarchy symmetry of get_*_info +# tools: the four hierarchy levels (server, session, window, pane) now each +# have a targeted single-object read. This is deliberately NOT a license to +# add get_buffer_info / get_hook_info / get_option_info — those scopes are +# not part of the hierarchy and the existing show_*/load_* tools already +# cover their reads. See the brainstorm-and-refine audit §7.1. +@handle_tool_errors +def get_window_info( + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> WindowInfo: + """Return metadata for a single tmux window (ID, name, layout, dimensions). + + Use this instead of list_windows + filter when you only need one + window's info. Resolves the window by window_id first; falls back + to window_index within a session if window_id is not given. + + Parameters + ---------- + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. Requires session_name or + session_id to disambiguate. + session_name : str, optional + Session name for window_index lookup. + session_id : str, optional + Session ID for window_index lookup. + socket_name : str, optional + tmux socket name. + + Returns + ------- + WindowInfo + Serialized window metadata. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + return _serialize_window(window) + + @handle_tool_errors def split_window( pane_id: str | None = None, @@ -425,6 +475,9 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="List Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( list_panes ) + mcp.tool(title="Get Window Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + get_window_info + ) mcp.tool(title="Split Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( split_window ) diff --git a/tests/test_window_tools.py b/tests/test_window_tools.py index 64c7b9b..c029608 100644 --- a/tests/test_window_tools.py +++ b/tests/test_window_tools.py @@ -8,6 +8,7 @@ from fastmcp.exceptions import ToolError from libtmux_mcp.tools.window_tools import ( + get_window_info, kill_window, list_panes, move_window, @@ -34,6 +35,31 @@ def test_list_panes(mcp_server: Server, mcp_session: Session) -> None: assert result[0].pane_id is not None +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 + result = get_window_info( + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == window.window_id + assert result.window_name is not None + assert result.pane_count >= 1 + assert result.session_id == mcp_session.session_id + + +def test_get_window_info_by_index(mcp_server: Server, mcp_session: Session) -> None: + """get_window_info resolves by window_index when session is named.""" + window = mcp_session.active_window + assert window.window_index is not None + result = get_window_info( + window_index=window.window_index, + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == window.window_id + + def test_split_window(mcp_server: Server, mcp_session: Session) -> None: """split_window creates a new pane.""" window = mcp_session.active_window From 8c251b60d3c02df21d751a3d6dd438f7a7c9a8fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:33:10 -0500 Subject: [PATCH 09/40] mcp(feat[session_tools]): add get_session_info for single-session metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: completes the symmetry started by get_window_info. Agents given a session_id and asked "how many windows does this session have?" no longer need to call list_sessions and filter. The bounded-to-core-hierarchy rule from get_window_info applies here too — inline comment memorializes it. what: - Add get_session_info(session_id, session_name, socket_name) returning SessionInfo. Reuses _resolve_session and _serialize_session; no new helpers. - Register with ANNOTATIONS_RO + TAG_READONLY, placed next to list_windows in the Session tool group. - Add test_get_session_info (by id) and test_get_session_info_by_name mirroring the test_list_windows style. - Add docs/tools/session/get-session-info.md modeled after the peer get-window-info page; insert the new page into the Session index grid and toctree. - Append get_session_info to the README tool catalog Session row. --- README.md | 2 +- docs/tools/session/get-session-info.md | 51 ++++++++++++++++++++++++++ docs/tools/session/index.md | 5 +++ src/libtmux_mcp/tools/session_tools.py | 37 +++++++++++++++++++ tests/test_session_tools.py | 22 +++++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 docs/tools/session/get-session-info.md diff --git a/README.md b/README.md index ff66b4f..842f455 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Give your AI agent hands inside the terminal — create sessions, run commands, | Module | Tools | |--------|-------| | **Server** | `list_sessions`, `create_session`, `kill_server`, `get_server_info` | -| **Session** | `list_windows`, `create_window`, `rename_session`, `select_window`, `kill_session` | +| **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** | `send_keys`, `paste_text`, `capture_pane`, `snapshot_pane`, `search_panes`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `kill_pane` | | **Options** | `show_option`, `set_option` | diff --git a/docs/tools/session/get-session-info.md b/docs/tools/session/get-session-info.md new file mode 100644 index 0000000..c37e701 --- /dev/null +++ b/docs/tools/session/get-session-info.md @@ -0,0 +1,51 @@ +# Get session info + +```{fastmcp-tool} session_tools.get_session_info +``` + +**Use when** you need metadata for a single session (ID, name, window +count, attachment status, activity timestamp) and you already know its +`session_id` or `session_name`. Avoids the `list_sessions` + filter dance. + +**Avoid when** you need every session — call `list_sessions` or iterate +via the `tmux://sessions` resource. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_session_info", + "arguments": { + "session_id": "$0" + } +} +``` + +Response: + +```json +{ + "session_id": "$0", + "session_name": "dev", + "window_count": 3, + "session_attached": "1", + "session_created": "1713600000", + "active_pane_id": "%0" +} +``` + +Resolve by name when only the session_name is known: + +```json +{ + "tool": "get_session_info", + "arguments": { + "session_name": "dev" + } +} +``` + +```{fastmcp-tool-input} session_tools.get_session_info +``` diff --git a/docs/tools/session/index.md b/docs/tools/session/index.md index d8c8ea8..b15697e 100644 --- a/docs/tools/session/index.md +++ b/docs/tools/session/index.md @@ -9,6 +9,10 @@ Session-scoped tools — enumerate windows, rename or kill a session, switch win Enumerate windows inside a session. ::: +:::{grid-item-card} {tooliconl}`get-session-info` +Read metadata for one session. +::: + :::{grid-item-card} {tooliconl}`select-window` Switch to a window by id, index, or direction. ::: @@ -32,6 +36,7 @@ Terminate a session. Destructive. :maxdepth: 1 list-windows +get-session-info select-window create-window rename-session diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 1df5fb0..c80ad8f 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -71,6 +71,40 @@ def list_windows( return _apply_filters(windows, filters, _serialize_window) +# get_session_info completes the core-tmux-hierarchy symmetry alongside +# get_window_info / get_pane_info / get_server_info. Bounded to the four +# hierarchy levels — see the same note in window_tools.get_window_info. +@handle_tool_errors +def get_session_info( + session_id: str | None = None, + session_name: str | None = None, + socket_name: str | None = None, +) -> SessionInfo: + """Return metadata for a single tmux session (ID, name, window count, activity). + + Use this instead of list_sessions + filter when you only need one + session's info. Resolves by session_id first; falls back to + session_name. + + Parameters + ---------- + session_id : str, optional + Session ID (e.g. '$0'). + session_name : str, optional + Session name. + socket_name : str, optional + tmux socket name. + + Returns + ------- + SessionInfo + Serialized session metadata. + """ + server = _get_server(socket_name=socket_name) + session = _resolve_session(server, session_name=session_name, session_id=session_id) + return _serialize_session(session) + + @handle_tool_errors def create_window( session_name: str | None = None, @@ -298,6 +332,9 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="List Windows", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( list_windows ) + mcp.tool(title="Get Session Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + get_session_info + ) mcp.tool( title="Create Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING} )(create_window) diff --git a/tests/test_session_tools.py b/tests/test_session_tools.py index 8eb662a..0fcc8af 100644 --- a/tests/test_session_tools.py +++ b/tests/test_session_tools.py @@ -9,6 +9,7 @@ from libtmux_mcp.tools.session_tools import ( create_window, + get_session_info, kill_session, list_windows, rename_session, @@ -40,6 +41,27 @@ def test_list_windows_by_id(mcp_server: Server, mcp_session: Session) -> None: assert len(result) >= 1 +def test_get_session_info(mcp_server: Server, mcp_session: Session) -> None: + """get_session_info returns a SessionInfo for a single session.""" + result = get_session_info( + session_id=mcp_session.session_id, + socket_name=mcp_server.socket_name, + ) + assert result.session_id == mcp_session.session_id + assert result.session_name == mcp_session.session_name + assert result.window_count >= 1 + + +def test_get_session_info_by_name(mcp_server: Server, mcp_session: Session) -> None: + """get_session_info resolves by session_name when no ID is given.""" + assert mcp_session.session_name is not None + result = get_session_info( + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + assert result.session_id == mcp_session.session_id + + def test_create_window(mcp_server: Server, mcp_session: Session) -> None: """create_window creates a new window in a session.""" result = create_window( From d5362a9c5d6b93ebfd6fed5b822c427e3e58f2e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 17:49:27 -0500 Subject: [PATCH 10/40] mcp(feat[pane_tools]): add respawn_pane for in-place shell recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: when an agent wedges a shell (hung REPL, runaway process, bad terminal mode) the only current recourse is kill_pane + split_window, which destroys pane_id references the agent may still be holding and reshuffles the layout. tmux's respawn-pane -k restarts the process in place, preserving both pane_id and layout — the right primitive for agent recovery flows. what: - Add respawn_pane(pane_id, session_name, session_id, window_id, kill=True, shell_command, start_directory, socket_name) returning PaneInfo. Default kill=True threads -k to tmux (matches the recovery-flow intent). Optional shell_command and start_directory map to tmux's respawn-pane positional arg and -c flag respectively; -e env is deliberately omitted (use set_environment if needed). - Call pane.refresh() after the cmd so _serialize_pane reads the fresh pane_pid and pane_current_command. - Register with ANNOTATIONS_MUTATING + TAG_MUTATING, placed next to kill_pane in the pane lifecycle module. Export from pane_tools __init__. - Add two tests: test_respawn_pane_preserves_pane_id_and_refreshes_pid (pane_id survives, pane_pid changes) and test_respawn_pane_replaces_shell_command (shell_command override takes effect). - Add docs/tools/pane/respawn-pane.md modeled on kill-pane.md plus relaunch-with-different-command example; add the page to the Pane tools index grid and toctree. - Append respawn_pane to the README tool catalog Pane row. Defer respawn_window until respawn_pane shows usage (audit §6.1). --- README.md | 2 +- docs/tools/pane/index.md | 5 ++ docs/tools/pane/respawn-pane.md | 58 ++++++++++++++ src/libtmux_mcp/tools/pane_tools/__init__.py | 7 ++ src/libtmux_mcp/tools/pane_tools/lifecycle.py | 78 +++++++++++++++++++ tests/test_pane_tools.py | 56 +++++++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 docs/tools/pane/respawn-pane.md diff --git a/README.md b/README.md index 842f455..a108407 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Give your AI agent hands inside the terminal — create sessions, run commands, | **Server** | `list_sessions`, `create_session`, `kill_server`, `get_server_info` | | **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** | `send_keys`, `paste_text`, `capture_pane`, `snapshot_pane`, `search_panes`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `kill_pane` | +| **Pane** | `send_keys`, `paste_text`, `capture_pane`, `snapshot_pane`, `search_panes`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `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` | | **Options** | `show_option`, `set_option` | | **Environment** | `show_environment`, `set_environment` | diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md index c69bf67..f77c1ab 100644 --- a/docs/tools/pane/index.md +++ b/docs/tools/pane/index.md @@ -81,6 +81,10 @@ Block until a tmux wait-for channel is signalled. Signal a waiting channel. ::: +:::{grid-item-card} {tooliconl}`respawn-pane` +Restart a pane's process in place, preserving pane_id. +::: + :::{grid-item-card} {tooliconl}`kill-pane` Terminate a pane. Destructive. ::: @@ -110,5 +114,6 @@ wait-for-text wait-for-content-change wait-for-channel signal-channel +respawn-pane kill-pane ``` diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md new file mode 100644 index 0000000..990ef43 --- /dev/null +++ b/docs/tools/pane/respawn-pane.md @@ -0,0 +1,58 @@ +# Respawn pane + +```{fastmcp-tool} pane_tools.respawn_pane +``` + +**Use when** a pane's shell or command has wedged (hung REPL, runaway +process, bad terminal mode) and you need a clean restart *without* +destroying the `pane_id` references other tools or callers may still +be holding. With `kill=True` (the default) tmux kills the current +process first; optional `shell_command` relaunches with a different +command; optional `start_directory` sets its cwd. + +**Avoid when** the pane genuinely needs to go away — use +{tooliconl}`kill-pane` instead. Also avoid when you want to change +the layout: `respawn-pane` preserves the pane in place. + +**Side effects:** Kills the current process (with `kill=True`) and +starts a new one. **The `pane_id` is preserved** — that's the whole +point of the tool. `pane_pid` updates to the new process. + +**Example — recover a wedged pane, relaunching the default shell:** + +```json +{ + "tool": "respawn_pane", + "arguments": { + "pane_id": "%5" + } +} +``` + +**Example — relaunch with a different command and working directory:** + +```json +{ + "tool": "respawn_pane", + "arguments": { + "pane_id": "%5", + "shell_command": "pytest -x", + "start_directory": "/home/user/project" + } +} +``` + +Response (PaneInfo): + +```json +{ + "pane_id": "%5", + "pane_pid": "98765", + "pane_current_command": "pytest", + "pane_current_path": "/home/user/project", + ... +} +``` + +```{fastmcp-tool-input} pane_tools.respawn_pane +``` diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index bf2ec8d..a294f56 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -36,6 +36,7 @@ from libtmux_mcp.tools.pane_tools.lifecycle import ( get_pane_info, kill_pane, + respawn_pane, set_pane_title, ) from libtmux_mcp.tools.pane_tools.meta import display_message, snapshot_pane @@ -61,6 +62,7 @@ "pipe_pane", "register", "resize_pane", + "respawn_pane", "search_panes", "select_pane", "send_keys", @@ -88,6 +90,11 @@ def register(mcp: FastMCP) -> None: annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_DESTRUCTIVE}, )(kill_pane) + mcp.tool( + title="Respawn Pane", + annotations=ANNOTATIONS_MUTATING, + tags={TAG_MUTATING}, + )(respawn_pane) mcp.tool( title="Set Pane Title", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} )(set_pane_title) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index cb4baa9..0115dbd 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -58,6 +58,84 @@ def kill_pane( return f"Pane killed: {pid}" +@handle_tool_errors +def respawn_pane( + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + kill: bool = True, + shell_command: str | None = None, + start_directory: str | None = None, + socket_name: str | None = None, +) -> PaneInfo: + """Restart a pane's process in place, preserving pane_id and layout. + + Use when a shell wedges (hung REPL, runaway process, bad terminal + mode). The alternative — kill_pane + split_window — destroys + pane_id references the agent may still be holding, and rearranges + the layout. respawn-pane preserves both. + + With ``kill=True`` (the default), tmux kills the existing process + before respawning. Optional ``shell_command`` replaces the + command tmux relaunches; ``start_directory`` sets the working + directory for the new process. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. + window_id : str, optional + Window ID for pane resolution. + kill : bool + When True (default), pass ``-k`` to tmux so the current + process is killed before respawning. When False, respawn + fails if the pane already has a running process. + shell_command : str, optional + Replacement command for tmux to launch. When omitted, tmux + restarts the original shell/command. + start_directory : str, optional + Working directory for the relaunched command (maps to + ``respawn-pane -c``). + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + Serialized pane metadata after respawn. The pane_id is + preserved; pane_pid reflects the new process. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + argv: list[str] = ["-t", pane.pane_id or ""] + if kill: + argv.append("-k") + if start_directory is not None: + argv.extend(["-c", start_directory]) + if shell_command is not None: + argv.append(shell_command) + result = pane.cmd("respawn-pane", *argv) + if result.stderr: + stderr = " ".join(result.stderr).strip() + msg = f"tmux respawn-pane failed: {stderr}" + raise ToolError(msg) + # Pick up fresh pane_pid and any command/path updates; tmux does + # not invalidate the underlying object on respawn. + pane.refresh() + return _serialize_pane(pane) + + @handle_tool_errors def set_pane_title( title: str, diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index c42b105..7189b34 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -27,6 +27,7 @@ paste_text, pipe_pane, resize_pane, + respawn_pane, search_panes, select_pane, send_keys, @@ -231,6 +232,61 @@ def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None: assert "killed" in result.lower() +# --------------------------------------------------------------------------- +# respawn_pane tests +# --------------------------------------------------------------------------- + + +def test_respawn_pane_preserves_pane_id_and_refreshes_pid( + mcp_server: Server, mcp_session: Session +) -> None: + """respawn_pane keeps the same pane_id but picks up a new pane_pid. + + Uses a fresh split so the caller-pane self-guard doesn't fire and + so the test is independent of what the main mcp_pane is running. + """ + window = mcp_session.active_window + new_pane = window.split(shell="sleep 3600") + assert new_pane.pane_id is not None + # Force a read of the original pid before we respawn. + new_pane.refresh() + original_pid = new_pane.pane_pid + + result = respawn_pane( + pane_id=new_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == new_pane.pane_id, "pane_id must be preserved" + assert result.pane_pid is not None + assert result.pane_pid != original_pid, ( + "pane_pid should reflect the new process after respawn" + ) + + # Cleanup + new_pane.kill() + + +def test_respawn_pane_replaces_shell_command( + mcp_server: Server, mcp_session: Session +) -> None: + """respawn_pane with shell_command relaunches with the new command.""" + window = mcp_session.active_window + new_pane = window.split(shell="sleep 3600") + assert new_pane.pane_id is not None + + result = respawn_pane( + pane_id=new_pane.pane_id, + shell_command="sleep 7200", + 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 + + new_pane.kill() + + # --------------------------------------------------------------------------- # search_panes tests # --------------------------------------------------------------------------- From 88577024e19da2ee70069e7a03f55004258f1c4a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 20 Apr 2026 18:59:15 -0500 Subject: [PATCH 11/40] scripts(feat[mcp_swap]): add cross-CLI MCP config detect/swap/revert tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: when developing an MCP server locally, each agent CLI (Claude, Codex, Cursor, Gemini) needs its config pointed at the checkout so uv run picks up source edits editable-style. Doing that by hand across four config formats (three JSON shapes + TOML) is fragile — Codex's TOML in particular needs comment/order preservation that sed/jq can't offer. A scripted swap with timestamped backups + a state file makes the flow reversible and reproducible across any MCP repo, not just this one. what: - Add scripts/mcp_swap.py as a PEP 723 single-file script modeled on ~/scripts/py/agentex_mcp.py: inline uv deps (tomlkit>=0.13), CLIName = Literal["claude","codex","cursor","gemini"], per-CLI get/set/delete on the MCP server entry. Subcommands: detect, status, use-local, revert. Server name + entry command auto-derived from the repo's pyproject.toml (libtmux-mcp -> libtmux, first [project.scripts] key for the entry). - Claude's per-project keying (projects."".mcpServers) is handled explicitly — only the current repo's key is rewritten, leaving other projects untouched. Claude's full entry shape is preserved (type/env fields). - Codex TOML edits go through tomlkit so [notice] blocks, top-level comments, and sibling tables survive the round-trip. When no libtmux entry exists yet (common for Codex), use-local records action="added" so revert correctly removes the block. - Safety: atomic tempfile+os.replace writes, timestamped .bak.mcp-swap- backups (never overwrites existing .bak.*), post- write re-parse validation with rollback on failure, --dry-run that prints unified diffs and writes nothing, state file at ~/.local/state/libtmux-mcp/mcp_swap.json tracking per-CLI backup paths. - Add scripts/README.md with usage + extension notes (add an entry to CLIS and extend the three per-CLI branches). - Add 12 tests in tests/test_mcp_swap.py using importlib.util to load the out-of-tree script: JSON round-trip (cursor/gemini), Claude per-project keying, Codex comment preservation + add-then-revert, unrelated-server preservation, --dry-run writes nothing, second-swap-is-noop, state-file cleared on full revert, spec helpers. - Add four [group: 'mcp'] recipes to justfile: mcp-detect, mcp-status, mcp-use-local, mcp-revert. - Add tomlkit>=0.13 to the dev dependency group so pytest can import the PEP 723 script without uv run bootstrapping. --- justfile | 20 ++ pyproject.toml | 2 + scripts/README.md | 80 ++++++ scripts/mcp_swap.py | 620 +++++++++++++++++++++++++++++++++++++++++ tests/test_mcp_swap.py | 358 ++++++++++++++++++++++++ uv.lock | 11 + 6 files changed, 1091 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/mcp_swap.py create mode 100644 tests/test_mcp_swap.py diff --git a/justfile b/justfile index 8322b62..396845b 100644 --- a/justfile +++ b/justfile @@ -119,6 +119,26 @@ watch-mypy: format-markdown: prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES +# Detect which CLI agents (claude/codex/cursor/gemini) exist on this machine +[group: 'mcp'] +mcp-detect: + uv run scripts/mcp_swap.py detect + +# Show how each detected CLI resolves this MCP server today +[group: 'mcp'] +mcp-status *args: + uv run scripts/mcp_swap.py status {{ args }} + +# Rewrite each detected CLI's config to run this checkout (editable) +[group: 'mcp'] +mcp-use-local *args: + uv run scripts/mcp_swap.py use-local {{ args }} + +# Restore each CLI's config from the backup written by mcp-use-local +[group: 'mcp'] +mcp-revert *args: + uv run scripts/mcp_swap.py revert {{ args }} + [private] _entr-warn: @echo "----------------------------------------------------------" diff --git a/pyproject.toml b/pyproject.toml index cd9b590..d057fac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,8 @@ dev = [ "pytest-watcher", "pytest-xdist", "syrupy>=5.1.0", + # scripts/mcp_swap.py (PEP 723 script; dep listed here so tests can import it) + "tomlkit>=0.13", # Coverage "codecov", "coverage", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..dbebd69 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,80 @@ +# scripts/ + +Developer utilities shipped with the repo but not part of the installed +package. + +## `mcp_swap.py` + +Swap the libtmux MCP server entry across every detected agent CLI +(Claude Code, Codex, Cursor, Gemini) so all four run the **local checkout** +instead of a pinned PyPI release. Useful when testing a branch or working +on the server itself. + +### Usage + +From the repo root: + +```console +$ uv run scripts/mcp_swap.py detect # which CLIs are installed? +$ uv run scripts/mcp_swap.py status # what does each point at today? +$ uv run scripts/mcp_swap.py use-local --dry-run +$ uv run scripts/mcp_swap.py use-local # rewrite configs (with backups) +$ uv run scripts/mcp_swap.py revert # restore from backups +``` + +Or via `just`: + +```console +$ just mcp-detect +$ just mcp-status +$ just mcp-use-local --dry-run +$ just mcp-use-local +$ just mcp-revert +``` + +### What `use-local` does + +For each detected CLI, the libtmux entry (or equivalent — derived from +`pyproject.toml` project name, trailing `-mcp` stripped) is rewritten to: + +``` +command = "uv" +args = ["--directory", "", "run", "libtmux-mcp"] +``` + +This matches Claude's conventional dev form and takes advantage of `uv +run`'s automatic editable install — source edits flow through on the next +invocation with no reinstall step. + +### Safety + +- Every rewrite writes a timestamped backup (`.bak.mcp-swap-`) + before touching the file. +- State is tracked in `~/.local/state/libtmux-mcp/mcp_swap.json` so + `revert` knows which backup to restore per CLI, including the "added" + case where Codex had no libtmux block before. +- Writes are atomic (tempfile + `os.replace`) and re-validated by + re-parsing; a bad write is rolled back immediately. +- `--dry-run` prints a unified diff and writes nothing. + +### Scope + +Covers four CLIs and their canonical config paths: + +| CLI | Config | Format | +|--------|-------------------------------|--------| +| Claude | `~/.claude.json` | JSON (per-project keying) | +| Codex | `~/.codex/config.toml` | TOML (format-preserving via `tomlkit`) | +| Cursor | `~/.cursor/mcp.json` | JSON | +| Gemini | `~/.gemini/settings.json` | JSON | + +Claude's config is keyed per-project under the repo's absolute path — the +script writes only under the current repo's key, leaving other projects' +entries untouched. + +### Extending to a new CLI + +Add an entry to the `CLIS` table in `mcp_swap.py` and extend the three +per-CLI branches in `get_server` / `set_server` / `delete_server`. Tests +in `tests/test_mcp_swap.py` use a `fake_home` fixture that monkeypatches +`CLIS`, so the extension pattern is already established. diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py new file mode 100644 index 0000000..28e8ab1 --- /dev/null +++ b/scripts/mcp_swap.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["tomlkit>=0.13"] +# /// +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini CLIs. + +Use when you want every installed agent CLI to run a local checkout of an +MCP server (editable) instead of a pinned release. ``use-local`` rewrites +each CLI's config to invoke the checkout via ``uv --directory run +``; ``revert`` restores from the timestamped backup the swap wrote. + +Defaults are derived from the current repo's ``pyproject.toml``: + +- server name = ``project.name`` with a trailing ``-mcp`` stripped + (``libtmux-mcp`` -> ``libtmux``) +- entry command = first key of ``[project.scripts]`` + +Examples +-------- +```console +$ uv run scripts/mcp_swap.py detect +$ uv run scripts/mcp_swap.py status +$ uv run scripts/mcp_swap.py use-local --dry-run +$ uv run scripts/mcp_swap.py use-local +$ uv run scripts/mcp_swap.py revert +``` +""" + +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import json +import os +import pathlib +import shutil +import sys +import tempfile +import time +import typing as t + +import tomlkit +import tomlkit.items + +CLIName = t.Literal["claude", "codex", "cursor", "gemini"] +ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini") + +STATE_DIR = pathlib.Path.home() / ".local" / "state" / "libtmux-mcp" +STATE_FILE = STATE_DIR / "mcp_swap.json" +STATE_VERSION = 1 + +BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class CLIInfo: + """Static descriptor for a CLI's config file and discovery heuristics.""" + + name: CLIName + binary: str + config_path: pathlib.Path + fmt: t.Literal["json", "toml"] + + +CLIS: dict[CLIName, CLIInfo] = { + "claude": CLIInfo( + name="claude", + binary="claude", + config_path=pathlib.Path.home() / ".claude.json", + fmt="json", + ), + "codex": CLIInfo( + name="codex", + binary="codex", + config_path=pathlib.Path.home() / ".codex" / "config.toml", + fmt="toml", + ), + "cursor": CLIInfo( + name="cursor", + binary="cursor-agent", + config_path=pathlib.Path.home() / ".cursor" / "mcp.json", + fmt="json", + ), + "gemini": CLIInfo( + name="gemini", + binary="gemini", + config_path=pathlib.Path.home() / ".gemini" / "settings.json", + fmt="json", + ), +} + + +@dataclasses.dataclass +class McpServerSpec: + """The portable shape shared across CLI configs.""" + + command: str + args: list[str] = dataclasses.field(default_factory=list) + env: dict[str, str] = dataclasses.field(default_factory=dict) + + def to_json_dict(self, *, include_stdio_type: bool = False) -> dict[str, t.Any]: + """Serialize to the JSON shape (Claude-extended when ``include_stdio_type``).""" + # Claude's format always includes ``type`` and ``env`` (even when empty); + # Cursor/Gemini omit both. include_stdio_type selects Claude shape. + if include_stdio_type: + return { + "type": "stdio", + "command": self.command, + "args": list(self.args), + "env": dict(self.env), + } + out: dict[str, t.Any] = {"command": self.command, "args": list(self.args)} + if self.env: + out["env"] = dict(self.env) + return out + + def is_local_uv_directory(self) -> bool: + """Return True for a ``uv --directory run `` shape.""" + return ( + self.command == "uv" and "--directory" in self.args and "run" in self.args + ) + + def local_repo_path(self) -> pathlib.Path | None: + """Extract the ``--directory`` argument, if any.""" + try: + i = self.args.index("--directory") + except ValueError: + return None + if i + 1 >= len(self.args): + return None + return pathlib.Path(self.args[i + 1]) + + +@dataclasses.dataclass +class SwapEntry: + """One CLI's bookkeeping for a swap, written to the state file.""" + + config_path: str + backup_path: str + server: str + action: t.Literal["replaced", "added"] + + +# --------------------------------------------------------------------------- +# Config IO — per format +# --------------------------------------------------------------------------- + + +def load_config(info: CLIInfo) -> t.Any: + """Parse a CLI's config file (JSON or TOML) into an editable structure.""" + raw = info.config_path.read_bytes() + if info.fmt == "json": + return json.loads(raw) + return tomlkit.parse(raw.decode()) + + +def dump_config_bytes(info: CLIInfo, config: t.Any) -> bytes: + """Serialize an edited config back to bytes in its original format.""" + if info.fmt == "json": + return (json.dumps(config, indent=2) + "\n").encode() + return tomlkit.dumps(config).encode() + + +def atomic_write(path: pathlib.Path, data: bytes) -> None: + """Write bytes to ``path`` via tempfile + ``os.replace`` to avoid partial writes.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + tmp = pathlib.Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + tmp.replace(path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +# --------------------------------------------------------------------------- +# Per-CLI get / set / delete (the only CLI-specific logic) +# --------------------------------------------------------------------------- + + +def _claude_project_node( + config: dict[str, t.Any], repo: pathlib.Path, *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the ``projects.`` node Claude keys per-project.""" + key = str(repo.resolve()) + projects = ( + config.setdefault("projects", {}) if create else config.get("projects", {}) + ) + node: dict[str, t.Any] | None = projects.get(key) + if node is None and create: + node = {"allowedTools": [], "mcpContextUris": [], "mcpServers": {}, "env": {}} + projects[key] = node + return node + + +def get_server( + cli: CLIName, config: t.Any, name: str, repo: pathlib.Path +) -> McpServerSpec | None: + """Fetch the MCP server entry for ``name`` from a CLI's config, if present.""" + if cli == "claude": + node = _claude_project_node(config, repo, create=False) + if not node: + return None + entry = node.get("mcpServers", {}).get(name) + elif cli in ("cursor", "gemini"): + entry = config.get("mcpServers", {}).get(name) + else: # cli == "codex" + entry = config.get("mcp_servers", {}).get(name) + if entry is None: + return None + return _spec_from_entry(entry, fmt=CLIS[cli].fmt) + + +def set_server( + cli: CLIName, + config: t.Any, + name: str, + spec: McpServerSpec, + repo: pathlib.Path, +) -> t.Literal["replaced", "added"]: + """Write ``spec`` under ``name`` in a CLI's config, returning replaced/added.""" + if cli == "claude": + node = _claude_project_node(config, repo, create=True) + assert node is not None + servers = node.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + if cli in ("cursor", "gemini"): + servers = config.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict() + return "replaced" if had else "added" + if cli == "codex": + # tomlkit: top-level tables are accessed via dict protocol too. + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + mcp_servers = tomlkit.table() + config["mcp_servers"] = mcp_servers + had = name in mcp_servers + table = tomlkit.table() + table["command"] = spec.command + table["args"] = list(spec.args) + if spec.env: + env_tbl = tomlkit.table() + for k, v in spec.env.items(): + env_tbl[k] = v + table["env"] = env_tbl + mcp_servers[name] = table + return "replaced" if had else "added" + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def delete_server(cli: CLIName, config: t.Any, name: str, repo: pathlib.Path) -> bool: + """Remove the entry for ``name`` from a CLI's config; return whether it existed.""" + if cli == "claude": + node = _claude_project_node(config, repo, create=False) + if not node: + return False + servers = node.get("mcpServers", {}) + return servers.pop(name, None) is not None + if cli in ("cursor", "gemini"): + return config.get("mcpServers", {}).pop(name, None) is not None + if cli == "codex": + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + return False + if name in mcp_servers: + del mcp_servers[name] + return True + return False + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def _spec_from_entry(entry: t.Any, *, fmt: t.Literal["json", "toml"]) -> McpServerSpec: + """Convert a raw config entry (dict or tomlkit Table) into an McpServerSpec.""" + # tomlkit items quack like dicts/lists; coerce to plain Python for our spec. + if fmt == "toml": + entry = ( + tomlkit.items.Table.unwrap(entry) + if isinstance(entry, tomlkit.items.Table) + else dict(entry) + ) + command = str(entry.get("command", "")) + raw_args = entry.get("args", []) + args = [str(a) for a in raw_args] if raw_args else [] + raw_env = entry.get("env") or {} + env = {str(k): str(v) for k, v in dict(raw_env).items()} + return McpServerSpec(command=command, args=args, env=env) + + +# --------------------------------------------------------------------------- +# Repo metadata +# --------------------------------------------------------------------------- + + +def resolve_repo_meta(repo: pathlib.Path) -> tuple[str, str]: + """Derive (server_name, entry_command) from the repo's pyproject.toml.""" + pyproject = repo / "pyproject.toml" + doc = tomlkit.parse(pyproject.read_text()) + project = doc.get("project") + if project is None: + msg = f"{pyproject} has no [project] table" + raise RuntimeError(msg) + name = str(project["name"]) + server = name[: -len("-mcp")] if name.endswith("-mcp") else name + scripts = project.get("scripts") or {} + if not scripts: + msg = f"{pyproject} has no [project.scripts] — cannot derive entry" + raise RuntimeError(msg) + entry = next(iter(scripts)) + return server, entry + + +def build_local_spec(repo: pathlib.Path, entry: str) -> McpServerSpec: + """Build the ``uv --directory run `` spec used by ``use-local``.""" + return McpServerSpec( + command="uv", + args=["--directory", str(repo.resolve()), "run", entry], + ) + + +# --------------------------------------------------------------------------- +# State file +# --------------------------------------------------------------------------- + + +def load_state() -> dict[CLIName, SwapEntry]: + """Read the swap-state file, returning an empty mapping when absent.""" + if not STATE_FILE.exists(): + return {} + raw = json.loads(STATE_FILE.read_text()) + entries = raw.get("entries", {}) + out: dict[CLIName, SwapEntry] = {} + for k, v in entries.items(): + if k in ALL_CLIS: + out[t.cast(CLIName, k)] = SwapEntry(**v) + return out + + +def save_state(entries: dict[CLIName, SwapEntry]) -> None: + """Write the swap-state file atomically (versioned payload).""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "version": STATE_VERSION, + "entries": {k: dataclasses.asdict(v) for k, v in entries.items()}, + } + STATE_FILE.write_text(json.dumps(payload, indent=2) + "\n") + + +def clear_state(clis: t.Iterable[CLIName]) -> None: + """Remove the given CLIs from the state file; delete the file if empty.""" + current = load_state() + for cli in clis: + current.pop(cli, None) + if current: + save_state(current) + elif STATE_FILE.exists(): + STATE_FILE.unlink() + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Presence: + """Detection outcome for a CLI: binary on PATH and config file present.""" + + cli: CLIName + binary_found: bool + config_found: bool + + @property + def present(self) -> bool: + """Return True only when both the binary and the config file were found.""" + return self.binary_found and self.config_found + + +def detect_clis() -> list[Presence]: + """Probe all supported CLIs and return their detection results.""" + return [ + Presence( + cli=info.name, + binary_found=shutil.which(info.binary) is not None, + config_found=info.config_path.exists(), + ) + for info in CLIS.values() + ] + + +def present_clis() -> list[CLIName]: + """Return the list of CLIs that have both a binary and a config present.""" + return [p.cli for p in detect_clis() if p.present] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +def cmd_detect(args: argparse.Namespace) -> int: + """Print detection results for every supported CLI.""" + for p in detect_clis(): + flag = "yes" if p.present else " no" + extra = [] + if not p.binary_found: + extra.append("binary missing") + if not p.config_found: + extra.append(f"config missing: {CLIS[p.cli].config_path}") + suffix = f" ({', '.join(extra)})" if extra else "" + print(f" [{flag}] {p.cli:<7}{suffix}") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + """Print the current MCP server entry per detected CLI.""" + repo = pathlib.Path(args.repo).resolve() + server = args.server or resolve_repo_meta(repo)[0] + for cli in args.cli or present_clis(): + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{cli}] (no config at {info.config_path})") + continue + config = load_config(info) + spec = get_server(cli, config, server, repo) + if spec is None: + print(f"[{cli}] no entry for {server!r}") + continue + tag = _describe_spec(spec, repo) + print(f"[{cli}] {server} = {spec.command} {' '.join(spec.args)} ({tag})") + return 0 + + +def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: + """Return a short label classifying a spec (local/pypi-pin/other).""" + if spec.is_local_uv_directory(): + local = spec.local_repo_path() + if local and local.resolve() == repo.resolve(): + return "local: this repo" + return f"local: {local}" + if spec.command == "uvx": + pinned = next((a for a in spec.args if "==" in a or "@" in a), None) + return f"pypi pin: {pinned}" if pinned else "pypi (unpinned)" + return "other" + + +def cmd_use_local(args: argparse.Namespace) -> int: + """Rewrite each target CLI's config to run the repo's checkout via ``uv``.""" + repo = pathlib.Path(args.repo).resolve() + server, default_entry = resolve_repo_meta(repo) + server = args.server or server + entry = args.entry or default_entry + spec = build_local_spec(repo, entry) + + targets = args.cli or present_clis() + if not targets: + print("no CLIs detected — nothing to do", file=sys.stderr) + return 1 + + ts = time.strftime("%Y%m%d%H%M%S") + state = load_state() + had_error = 0 + for cli in targets: + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{cli}] skip — config not found at {info.config_path}") + continue + original_bytes = info.config_path.read_bytes() + config = load_config(info) + current = get_server(cli, config, server, repo) + if ( + current + and current.is_local_uv_directory() + and current.local_repo_path() == repo + ): + print(f"[{cli}] already local (this repo) — no change") + continue + action = set_server(cli, config, server, spec, repo) + new_bytes = dump_config_bytes(info, config) + + if args.dry_run: + print(f"--- {info.config_path} (current)") + print(f"+++ {info.config_path} (proposed)") + diff = difflib.unified_diff( + original_bytes.decode(errors="replace").splitlines(keepends=True), + new_bytes.decode(errors="replace").splitlines(keepends=True), + lineterm="", + ) + sys.stdout.writelines(diff) + continue + + backup_path = info.config_path.with_suffix( + info.config_path.suffix + f"{BACKUP_SUFFIX_PREFIX}{ts}" + ) + backup_path.write_bytes(original_bytes) + try: + atomic_write(info.config_path, new_bytes) + _revalidate(info) + except Exception as exc: + atomic_write(info.config_path, original_bytes) + print( + f"[{cli}] write failed ({exc}); backup at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + state[cli] = SwapEntry( + config_path=str(info.config_path), + backup_path=str(backup_path), + server=server, + action=action, + ) + print(f"[{cli}] {action}; backup: {backup_path}") + + if not args.dry_run: + save_state(state) + return had_error + + +def _revalidate(info: CLIInfo) -> None: + """Re-parse the file after writing; raise on failure.""" + load_config(info) + + +def cmd_revert(args: argparse.Namespace) -> int: + """Restore each target CLI's config from the backup recorded in the state file.""" + state = load_state() + targets = args.cli or list(state.keys()) + if not targets: + print("no recorded swaps — nothing to revert", file=sys.stderr) + return 1 + + reverted: list[CLIName] = [] + for cli in targets: + entry = state.get(cli) + if entry is None: + print(f"[{cli}] no state entry — skip") + continue + backup = pathlib.Path(entry.backup_path) + dest = pathlib.Path(entry.config_path) + if not backup.exists(): + print(f"[{cli}] backup missing: {backup}", file=sys.stderr) + continue + if args.dry_run: + print(f"[{cli}] would restore {dest} from {backup}") + continue + atomic_write(dest, backup.read_bytes()) + print(f"[{cli}] restored from {backup}") + reverted.append(cli) + + if not args.dry_run and reverted: + clear_state(reverted) + return 0 + + +# --------------------------------------------------------------------------- +# argparse glue +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + """Construct the ``argparse`` parser for ``mcp_swap``.""" + p = argparse.ArgumentParser(prog="mcp_swap", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser( + "detect", help="list installed CLIs and their config presence" + ).set_defaults(func=cmd_detect) + + ps = sub.add_parser("status", help="show the current MCP server entry per CLI") + ps.add_argument("--repo", default=".", help="repo root (default: .)") + ps.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + ps.add_argument( + "--cli", action="append", choices=ALL_CLIS, help="limit to one or more CLIs" + ) + ps.set_defaults(func=cmd_status) + + pu = sub.add_parser("use-local", help="rewrite configs to run this checkout") + pu.add_argument("--repo", default=".", help="repo root (default: .)") + pu.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + pu.add_argument( + "--entry", help="uv run entry command (default: [project.scripts] first key)" + ) + pu.add_argument("--cli", action="append", choices=ALL_CLIS) + pu.add_argument("--dry-run", action="store_true") + pu.set_defaults(func=cmd_use_local) + + pr = sub.add_parser("revert", help="restore each CLI's config from its swap backup") + pr.add_argument("--cli", action="append", choices=ALL_CLIS) + pr.add_argument("--dry-run", action="store_true") + pr.set_defaults(func=cmd_revert) + + return p + + +def main(argv: list[str] | None = None) -> int: + """Entry point — dispatches to the selected subcommand.""" + args = build_parser().parse_args(argv) + return t.cast("int", args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py new file mode 100644 index 0000000..aa14c73 --- /dev/null +++ b/tests/test_mcp_swap.py @@ -0,0 +1,358 @@ +"""Tests for scripts/mcp_swap.py. + +The swap script lives outside the ``src/`` package, so we load it via the +module's file path and exercise the round-trip behavior against temporary +config fixtures that mirror each CLI's real layout. +""" + +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import typing as t + +import pytest +import tomlkit + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +_SCRIPT = _REPO_ROOT / "scripts" / "mcp_swap.py" + +_spec = importlib.util.spec_from_file_location("mcp_swap", _SCRIPT) +assert _spec and _spec.loader +mcp_swap = importlib.util.module_from_spec(_spec) +sys.modules["mcp_swap"] = mcp_swap +_spec.loader.exec_module(mcp_swap) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_home(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: + """Redirect every config path the script touches into ``tmp_path``.""" + monkeypatch.setattr( + mcp_swap, + "CLIS", + { + "claude": mcp_swap.CLIInfo( + name="claude", + binary="claude", + config_path=tmp_path / ".claude.json", + fmt="json", + ), + "codex": mcp_swap.CLIInfo( + name="codex", + binary="codex", + config_path=tmp_path / ".codex" / "config.toml", + fmt="toml", + ), + "cursor": mcp_swap.CLIInfo( + name="cursor", + binary="cursor-agent", + config_path=tmp_path / ".cursor" / "mcp.json", + fmt="json", + ), + "gemini": mcp_swap.CLIInfo( + name="gemini", + binary="gemini", + config_path=tmp_path / ".gemini" / "settings.json", + fmt="json", + ), + }, + ) + state_dir = tmp_path / "state" + monkeypatch.setattr(mcp_swap, "STATE_DIR", state_dir) + monkeypatch.setattr(mcp_swap, "STATE_FILE", state_dir / "mcp_swap.json") + return tmp_path + + +@pytest.fixture +def fake_repo(tmp_path: pathlib.Path) -> pathlib.Path: + """Create a minimal pyproject.toml repo for meta resolution.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").write_text( + "[project]\n" + 'name = "libtmux-mcp"\n' + "[project.scripts]\n" + 'libtmux-mcp = "libtmux_mcp:main"\n' + ) + return repo + + +def _write_json(path: pathlib.Path, data: dict[str, t.Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + + +def _pinned_json_entry() -> dict[str, t.Any]: + return {"command": "uvx", "args": ["libtmux-mcp==0.1.0a2"]} + + +def _pinned_claude_entry() -> dict[str, t.Any]: + return { + "type": "stdio", + "command": "uvx", + "args": ["libtmux-mcp==0.1.0a2"], + "env": {}, + } + + +# --------------------------------------------------------------------------- +# resolve_repo_meta +# --------------------------------------------------------------------------- + + +def test_resolve_repo_meta_strips_mcp_suffix(fake_repo: pathlib.Path) -> None: + """``libtmux-mcp`` resolves to server name ``libtmux`` and entry ``libtmux-mcp``.""" + server, entry = mcp_swap.resolve_repo_meta(fake_repo) + assert server == "libtmux" + assert entry == "libtmux-mcp" + + +def test_resolve_repo_meta_uses_name_when_no_suffix(tmp_path: pathlib.Path) -> None: + """Names without ``-mcp`` suffix pass through unchanged as the server name.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").write_text( + '[project]\nname = "weather"\n[project.scripts]\nweather = "weather:main"\n' + ) + assert mcp_swap.resolve_repo_meta(repo) == ("weather", "weather") + + +# --------------------------------------------------------------------------- +# JSON round-trip: cursor / gemini +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cli", ["cursor", "gemini"]) +def test_json_swap_and_revert_round_trip( + fake_home: pathlib.Path, fake_repo: pathlib.Path, cli: str +) -> None: + """Swap then revert a JSON-backed CLI must yield byte-identical bytes.""" + info = mcp_swap.CLIS[cli] + _write_json(info.config_path, {"mcpServers": {"libtmux": _pinned_json_entry()}}) + original = info.config_path.read_bytes() + + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", cli] + ) + assert mcp_swap.cmd_use_local(args) == 0 + + after = json.loads(info.config_path.read_text()) + entry = after["mcpServers"]["libtmux"] + assert entry["command"] == "uv" + assert entry["args"] == [ + "--directory", + str(fake_repo.resolve()), + "run", + "libtmux-mcp", + ] + + revert_args = mcp_swap.build_parser().parse_args(["revert", "--cli", cli]) + assert mcp_swap.cmd_revert(revert_args) == 0 + assert info.config_path.read_bytes() == original + + +def test_json_swap_preserves_unrelated_servers( + fake_home: pathlib.Path, fake_repo: pathlib.Path +) -> None: + """Other servers in ``mcpServers`` are not touched during a libtmux swap.""" + info = mcp_swap.CLIS["cursor"] + _write_json( + info.config_path, + { + "mcpServers": { + "libtmux": _pinned_json_entry(), + "agentex": { + "command": "uv", + "args": ["--directory", "/tmp", "run", "x"], + }, + } + }, + ) + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "cursor"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + after = json.loads(info.config_path.read_text()) + assert set(after["mcpServers"].keys()) == {"libtmux", "agentex"} + + +# --------------------------------------------------------------------------- +# Claude — per-project keying +# --------------------------------------------------------------------------- + + +def test_claude_swap_writes_under_repo_abspath_only( + fake_home: pathlib.Path, fake_repo: pathlib.Path +) -> None: + """Claude's per-project keying: only this repo's key gets rewritten.""" + info = mcp_swap.CLIS["claude"] + other_repo_key = "/home/someone/other-project" + _write_json( + info.config_path, + { + "projects": { + other_repo_key: { + "mcpServers": {"libtmux": _pinned_claude_entry()}, + }, + } + }, + ) + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "claude"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + after = json.loads(info.config_path.read_text()) + + assert ( + after["projects"][other_repo_key]["mcpServers"]["libtmux"] + == _pinned_claude_entry() + ) + + repo_key = str(fake_repo.resolve()) + new_entry = after["projects"][repo_key]["mcpServers"]["libtmux"] + assert new_entry["type"] == "stdio" + assert new_entry["command"] == "uv" + assert new_entry["args"][0:2] == ["--directory", str(fake_repo.resolve())] + + +# --------------------------------------------------------------------------- +# Codex TOML — format preservation + add-when-missing +# --------------------------------------------------------------------------- + + +def test_codex_swap_preserves_toml_comments( + fake_home: pathlib.Path, fake_repo: pathlib.Path +) -> None: + """TOML round-trip preserves top-level comments and sibling tables.""" + info = mcp_swap.CLIS["codex"] + info.config_path.parent.mkdir(parents=True) + info.config_path.write_text( + "# Top-level comment preserved across swap\n" + "[mcp_servers.libtmux]\n" + 'command = "uvx"\n' + 'args = ["libtmux-mcp==0.1.0a2"]\n' + "\n" + "[other]\n" + "keep = true\n" + ) + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "codex"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + text = info.config_path.read_text() + assert "# Top-level comment preserved across swap" in text + doc = tomlkit.loads(text).unwrap() + assert doc["mcp_servers"]["libtmux"]["command"] == "uv" + assert doc["other"]["keep"] is True + + +def test_codex_adds_block_when_absent_and_revert_removes_it( + fake_home: pathlib.Path, fake_repo: pathlib.Path +) -> None: + """When no entry exists, ``use-local`` adds one and ``revert`` removes it again.""" + info = mcp_swap.CLIS["codex"] + info.config_path.parent.mkdir(parents=True) + info.config_path.write_text("[notice]\nhello = true\n") + original = info.config_path.read_bytes() + + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "codex"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + state = mcp_swap.load_state() + assert state["codex"].action == "added" + + revert_args = mcp_swap.build_parser().parse_args(["revert", "--cli", "codex"]) + assert mcp_swap.cmd_revert(revert_args) == 0 + assert info.config_path.read_bytes() == original + + +# --------------------------------------------------------------------------- +# Idempotence + dry-run +# --------------------------------------------------------------------------- + + +def test_dry_run_does_not_write( + fake_home: pathlib.Path, + fake_repo: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """``--dry-run`` prints a diff but leaves the config and state file untouched.""" + info = mcp_swap.CLIS["cursor"] + _write_json(info.config_path, {"mcpServers": {"libtmux": _pinned_json_entry()}}) + original = info.config_path.read_bytes() + + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "cursor", "--dry-run"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + + assert info.config_path.read_bytes() == original + assert not mcp_swap.STATE_FILE.exists() + assert "uv" in capsys.readouterr().out + + +def test_second_swap_is_noop( + fake_home: pathlib.Path, + fake_repo: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Re-running ``use-local`` against an already-local config writes nothing new.""" + info = mcp_swap.CLIS["cursor"] + _write_json(info.config_path, {"mcpServers": {"libtmux": _pinned_json_entry()}}) + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "cursor"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + first_bytes = info.config_path.read_bytes() + + capsys.readouterr() + assert mcp_swap.cmd_use_local(args) == 0 + assert info.config_path.read_bytes() == first_bytes + assert "already local" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# State file +# --------------------------------------------------------------------------- + + +def test_state_file_cleared_after_full_revert( + fake_home: pathlib.Path, fake_repo: pathlib.Path +) -> None: + """Reverting every recorded swap deletes the empty state file on disk.""" + info = mcp_swap.CLIS["cursor"] + _write_json(info.config_path, {"mcpServers": {"libtmux": _pinned_json_entry()}}) + mcp_swap.cmd_use_local( + mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "cursor"] + ) + ) + assert mcp_swap.STATE_FILE.exists() + mcp_swap.cmd_revert(mcp_swap.build_parser().parse_args(["revert"])) + assert not mcp_swap.STATE_FILE.exists() + + +# --------------------------------------------------------------------------- +# McpServerSpec helpers +# --------------------------------------------------------------------------- + + +def test_is_local_uv_directory_detection() -> None: + """``McpServerSpec`` shape classification: uv-directory vs uvx-pin.""" + spec = mcp_swap.McpServerSpec( + command="uv", args=["--directory", "/tmp", "run", "x"] + ) + assert spec.is_local_uv_directory() is True + assert spec.local_repo_path() == pathlib.Path("/tmp") + + pypi = mcp_swap.McpServerSpec(command="uvx", args=["libtmux-mcp==0.1.0a2"]) + assert pypi.is_local_uv_directory() is False + assert pypi.local_repo_path() is None diff --git a/uv.lock b/uv.lock index f6effb3..c600102 100644 --- a/uv.lock +++ b/uv.lock @@ -1120,6 +1120,7 @@ dev = [ { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-fastmcp" }, { name = "syrupy" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ @@ -1173,6 +1174,7 @@ dev = [ { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a10" }, { name = "sphinx-autodoc-fastmcp", specifier = "==0.0.1a10" }, { name = "syrupy", specifier = ">=5.1.0" }, + { name = "tomlkit", specifier = ">=0.13" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ @@ -2684,6 +2686,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 63d344b2bc936cfc8a75d26c82047a52077700a0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 05:23:23 -0500 Subject: [PATCH 12/40] mcp(fix[pane_tools]): refuse to respawn the caller's own pane respawn_pane with kill=True kills the running process in the target pane, so an agent calling it on its own pane kills the MCP server's shell. Mirror kill_pane's self-kill guard: check the resolved pane against the caller identity, raise ToolError before invoking tmux. The guard sits after _resolve_pane so window/session-targeted paths are also caught (kill_pane can guard pre-resolve because its pane_id is required and used literally). --- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 11 ++++++++ tests/test_pane_tools.py | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 0115dbd..e1b4919 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -118,6 +118,17 @@ def respawn_pane( session_id=session_id, window_id=window_id, ) + caller = _get_caller_identity() + if ( + caller is not None + and caller.pane_id == pane.pane_id + and _caller_is_on_server(server, caller) + ): + msg = ( + "Refusing to respawn the pane running this MCP server. " + "Use a manual tmux command if intended." + ) + raise ToolError(msg) argv: list[str] = ["-t", pane.pane_id or ""] if kill: argv.append("-k") diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 7189b34..d038a4a 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -287,6 +287,33 @@ def test_respawn_pane_replaces_shell_command( new_pane.kill() +def test_respawn_pane_self_kill_guard( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """respawn_pane refuses when the caller's pane is the target.""" + from libtmux_mcp._utils import _effective_socket_path + + window = mcp_session.active_window + new_pane = window.split(shell="sleep 3600") + assert new_pane.pane_id is not None + + socket_path = _effective_socket_path(mcp_server) + monkeypatch.setenv( + "TMUX", + f"{socket_path},12345,{mcp_session.session_id}", + ) + monkeypatch.setenv("TMUX_PANE", new_pane.pane_id) + with pytest.raises(ToolError, match="Refusing to respawn"): + respawn_pane( + pane_id=new_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + + new_pane.kill() + + # --------------------------------------------------------------------------- # search_panes tests # --------------------------------------------------------------------------- From c24ce532331e4d159f86715a9ca2415c3b54d653 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 05:26:28 -0500 Subject: [PATCH 13/40] scripts(fix[mcp_swap]): route save_state through atomic_write save_state's docstring promised an atomic write but used Path.write_text directly. A crash between the config atomic_write succeeding and save_state completing could leave a corrupt JSON state file, breaking subsequent revert. Use the existing atomic_write helper (tempfile.mkstemp + os.replace). --- scripts/mcp_swap.py | 2 +- tests/test_mcp_swap.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 28e8ab1..d311824 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -356,7 +356,7 @@ def save_state(entries: dict[CLIName, SwapEntry]) -> None: "version": STATE_VERSION, "entries": {k: dataclasses.asdict(v) for k, v in entries.items()}, } - STATE_FILE.write_text(json.dumps(payload, indent=2) + "\n") + atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) def clear_state(clis: t.Iterable[CLIName]) -> None: diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index aa14c73..71b6069 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -340,6 +340,30 @@ def test_state_file_cleared_after_full_revert( assert not mcp_swap.STATE_FILE.exists() +def test_save_state_writes_atomically(fake_home: pathlib.Path) -> None: + """save_state routes through atomic_write — no leftover temp files.""" + entry = mcp_swap.SwapEntry( + config_path="/tmp/cfg.json", + backup_path="/tmp/cfg.json.bak", + server="libtmux", + action="replaced", + ) + mcp_swap.save_state({"claude": entry}) + + assert mcp_swap.STATE_FILE.exists() + payload = json.loads(mcp_swap.STATE_FILE.read_text()) + assert payload["entries"]["claude"]["server"] == "libtmux" + + # tempfile.mkstemp writes siblings prefixed "." — none should + # remain after a successful atomic_write. + leftovers = [ + p + for p in mcp_swap.STATE_DIR.iterdir() + if p.name.startswith("mcp_swap.json.") and p != mcp_swap.STATE_FILE + ] + assert leftovers == [], f"unexpected tempfile leftovers: {leftovers}" + + # --------------------------------------------------------------------------- # McpServerSpec helpers # --------------------------------------------------------------------------- From 8170c2591b6a2539b1ea4ab4b203276b6440a4fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 05:27:48 -0500 Subject: [PATCH 14/40] mcp(docs[pane_tools]): list respawn in lifecycle module docstring --- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index e1b4919..13a96af 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -1,4 +1,4 @@ -"""Pane lifecycle tools: kill, title, info.""" +"""Pane lifecycle tools: kill, respawn, title, info.""" from __future__ import annotations From 48ae1fc6ebaf2c024d6effbe1387b5b7126a068b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:08:28 -0500 Subject: [PATCH 15/40] mcp(fix[pane_tools]): mark respawn destructive non-idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit respawn_pane was registered with ANNOTATIONS_MUTATING which advertises destructiveHint=False and idempotentHint=True, but its default kill=True sends SPAWN_KILL to the running process (cmd-respawn-pane.c:78-79) and repeated calls kill repeated processes. The spec defines idempotent as "calling repeatedly will have no additional effect" (mcp/types.py: 1276-1282) — the inherited preset was a lie that could drive harmful agent retry loops. Add a new ANNOTATIONS_MUTATING_DESTRUCTIVE preset that mirrors ANNOTATIONS_DESTRUCTIVE's hint values (destructiveHint=True, idempotentHint=False) but is paired with TAG_MUTATING so the recovery use case stays visible to default-profile agents. Apply to respawn_pane. Add a registration-introspection test that locks the contract. --- CHANGES | 13 ++++++++ src/libtmux_mcp/_utils.py | 22 +++++++++++++ src/libtmux_mcp/tools/pane_tools/__init__.py | 3 +- tests/test_pane_tools.py | 33 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 174637c..97b8060 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,19 @@ _Notes on upcoming releases will be added here_ +### Fixes + +- {tooliconl}`respawn-pane` now advertises honest MCP annotations. + Previously the tool inherited ``ANNOTATIONS_MUTATING`` defaults + (`destructiveHint=False`, `idempotentHint=True`) even though its + default `kill=True` sends `SPAWN_KILL` to the running process and + repeated calls kill repeated processes. The new + `ANNOTATIONS_MUTATING_DESTRUCTIVE` preset keeps the tool in + `TAG_MUTATING` (so it stays visible to default-profile agents for + shell recovery) while exporting `destructiveHint=True` and + `idempotentHint=False`. Agents reading the annotations can no longer + conclude that respawn-retries are safe. + ## libtmux-mcp 0.1.0a3 (2026-04-19) _Post-0.1.0a2 smoke-test fixes and `libtmux` floor bump_ diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 13f8104..6a597a0 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -328,6 +328,28 @@ def _caller_is_strictly_on_server( "idempotentHint": False, "openWorldHint": False, } +#: 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. +#: +#: ``respawn_pane`` is the canonical user: tier=mutating because shell +#: recovery is part of the normal agent workflow; ``destructiveHint=True`` +#: because ``kill=True`` (the default) sends ``SPAWN_KILL`` to the existing +#: process (`cmd-respawn-pane.c:78-79`); ``idempotentHint=False`` because +#: repeated calls kill repeated processes — the MCP spec defines idempotent +#: as "calling repeatedly with the same arguments will have no additional +#: effect" (`mcp/types.py:1276-1282`). +#: +#: 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 _tmux_argv(server: Server, *tmux_args: str) -> list[str]: diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index a294f56..a3787ef 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -15,6 +15,7 @@ ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_MUTATING, + ANNOTATIONS_MUTATING_DESTRUCTIVE, ANNOTATIONS_RO, ANNOTATIONS_SHELL, TAG_DESTRUCTIVE, @@ -92,7 +93,7 @@ def register(mcp: FastMCP) -> None: )(kill_pane) mcp.tool( title="Respawn Pane", - annotations=ANNOTATIONS_MUTATING, + annotations=ANNOTATIONS_MUTATING_DESTRUCTIVE, tags={TAG_MUTATING}, )(respawn_pane) mcp.tool( diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index d038a4a..ac4897e 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -2095,6 +2095,39 @@ def test_pane_tool_open_world_hint_registration( assert tool.annotations.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 tool.annotations.destructiveHint is True + assert tool.annotations.idempotentHint is False + assert tool.annotations.readOnlyHint is False + + # --------------------------------------------------------------------------- # Typed-output regression guard # --------------------------------------------------------------------------- From d9583be7da80f60ac994da3bd66a9b90fa40c6eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:11:40 -0500 Subject: [PATCH 16/40] mcp(fix[pane_tools]): require explicit pane for respawn respawn_pane accepted optional pane_id with hierarchical fallback through _resolve_pane (session_name / session_id / window_id), and when no target was given it returned the first pane of the first window of the first session (_utils.py:617-621, 637-640). Combined with default kill=True, calling respawn_pane(session_name="dev") to recover a wedged shell could silently terminate a critical running process (e.g. an `npm run dev` server in pane 1) instead of the intended wedged shell elsewhere in the session. The self-pane guard at lifecycle.py:121-131 only protects the caller's own pane. Insert a runtime guard at the top of respawn_pane that raises ToolError when pane_id is None, mirroring kill_pane's exact-pane_id contract. session_name/session_id/window_id stay in the signature (dropping them is technically an MCP schema change for clients that have cached the parameters); the docstring marks them as accepted-but-unreachable for backwards-compat. Add four tests: - rejects no-target call - rejects session-only target (the dangerous case) - kill=False on a dead pane succeeds (no prior kill=False coverage) - kill=False on a live pane raises ToolError --- CHANGES | 10 ++ src/libtmux_mcp/tools/pane_tools/lifecycle.py | 25 ++++- tests/test_pane_tools.py | 93 +++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index 97b8060..2fbc790 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,16 @@ _Notes on upcoming releases will be added here_ ### Fixes +- {tooliconl}`respawn-pane` now requires an explicit ``pane_id``. Its + signature still accepts ``session_name`` / ``session_id`` / + ``window_id`` for backwards-compatibility with the shared pane-target + resolution surface, but a runtime guard raises before + ``_resolve_pane`` is invoked. Without the guard, calling + ``respawn_pane(session_name="dev")`` resolved through ``_resolve_pane`` + to the first pane of the first window — combined with default + ``kill=True`` that could silently kill a critical running process + (e.g. an `npm run dev` server) instead of the intended wedged shell. + Resolve the target via {tooliconl}`list-panes` first. - {tooliconl}`respawn-pane` now advertises honest MCP annotations. Previously the tool inherited ``ANNOTATIONS_MUTATING`` defaults (`destructiveHint=False`, `idempotentHint=True`) even though its diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 13a96af..2857f3f 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -81,16 +81,24 @@ def respawn_pane( command tmux relaunches; ``start_directory`` sets the working directory for the new process. + ``pane_id`` is required — no fallback to ``_resolve_pane``'s + "first pane in session/window" behaviour. Default ``kill=True`` + will terminate the resolved pane's process, so accidental targeting + can silently kill an unrelated server. Resolve via ``list_panes`` + first. + Parameters ---------- - pane_id : str, optional - Pane ID (e.g. '%1'). + pane_id : str + Pane ID (e.g. '%1'). Required — no fallback resolution. session_name : str, optional - Session name for pane resolution. + Accepted for backwards-compatibility with the ``_resolve_pane`` + signature shared across pane tools, but the explicit ``pane_id`` + guard above raises before this is consulted. session_id : str, optional - Session ID (e.g. '$1') for pane resolution. + See ``session_name``. window_id : str, optional - Window ID for pane resolution. + See ``session_name``. kill : bool When True (default), pass ``-k`` to tmux so the current process is killed before respawning. When False, respawn @@ -110,6 +118,13 @@ def respawn_pane( Serialized pane metadata after respawn. The pane_id is preserved; pane_pid reflects the new process. """ + if pane_id is None: + msg = ( + "respawn_pane requires an explicit pane_id (e.g. '%1') because " + "default kill=True will terminate the resolved pane's process. " + "Resolve the target via list_panes first." + ) + raise ToolError(msg) server = _get_server(socket_name=socket_name) pane = _resolve_pane( server, diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index ac4897e..eada2d6 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -314,6 +314,99 @@ def test_respawn_pane_self_kill_guard( new_pane.kill() +def test_respawn_pane_rejects_implicit_target(mcp_server: Server) -> None: + """respawn_pane refuses when no targeting parameter is supplied. + + Without ``pane_id`` (or any other discriminator) ``_resolve_pane`` + falls back to the first pane of the first window of the first + session — combined with default ``kill=True`` that could silently + kill an unrelated server. The runtime guard requires explicit + ``pane_id``. + """ + with pytest.raises(ToolError, match="explicit pane_id"): + respawn_pane(socket_name=mcp_server.socket_name) + + +def test_respawn_pane_rejects_session_only_target( + mcp_server: Server, mcp_session: Session +) -> None: + """respawn_pane refuses ``session_name`` without ``pane_id``. + + ``session_name`` alone resolves to the first pane of the first + window, which is not what the caller intends when recovering a + wedged shell elsewhere in the session. The guard requires + ``pane_id`` regardless of which other targeting parameters are + present. + """ + assert mcp_session.session_name is not None + with pytest.raises(ToolError, match="explicit pane_id"): + respawn_pane( + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + + +def test_respawn_pane_kill_false_on_dead_pane_succeeds( + mcp_server: Server, mcp_session: Session +) -> None: + """``kill=False`` respawn on a dead pane returns fresh PaneInfo. + + tmux's ``respawn-pane`` without ``-k`` is the safer default: it + only succeeds when the pane has no running process. Existing tests + only cover ``kill=True`` paths (see :func:`test_respawn_pane_*` + above); this test locks the safer-default behaviour for any future + flip of the default. + """ + window = mcp_session.active_window + # remain-on-exit=on keeps the pane around after its process exits so + # we can drive a kill=False respawn on a confirmed-dead process. + # Without it, tmux removes the pane the moment its child exits and + # the respawn call fails with PaneNotFound instead of exercising + # the kill=False branch. Set the option on the window *before* + # splitting so the new pane inherits it. + window.cmd("set-option", "-w", "remain-on-exit", "on") + new_pane = window.split(shell="true") + assert new_pane.pane_id is not None + + def _pane_dead() -> bool: + out = new_pane.cmd("display-message", "-p", "#{pane_dead}").stdout + return bool(out) and out[0].strip() == "1" + + retry_until(_pane_dead, seconds=5, raises=True) + + result = respawn_pane( + pane_id=new_pane.pane_id, + kill=False, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == new_pane.pane_id + new_pane.kill() + window.cmd("set-option", "-wu", "remain-on-exit") + + +def test_respawn_pane_kill_false_on_live_pane_raises( + mcp_server: Server, mcp_session: Session +) -> None: + """``kill=False`` respawn on a live pane raises ToolError from tmux. + + tmux refuses to respawn a pane that still has a running process + unless ``-k`` is passed. The MCP wrapper surfaces the stderr as a + ``ToolError`` rather than swallowing it. + """ + window = mcp_session.active_window + new_pane = window.split(shell="sleep 3600") + assert new_pane.pane_id is not None + + with pytest.raises(ToolError): + respawn_pane( + pane_id=new_pane.pane_id, + kill=False, + socket_name=mcp_server.socket_name, + ) + + new_pane.kill() + + # --------------------------------------------------------------------------- # search_panes tests # --------------------------------------------------------------------------- From 3ad5ff7f306403aea907b27648e7974c244ceb05 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:13:10 -0500 Subject: [PATCH 17/40] mcp(docs[safety]): document respawn pane risks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `respawn_pane` subsection under "Footguns inside the `mutating` tier" parallel to `pipe_pane` and `set_environment`. Documents: - default `kill=True` SIGHUPs the running process; pane_id/layout preserved, but unsaved REPL/ssh/in-flight job state is lost - repeated calls are not idempotent — each call kills a new process - explicit pane_id is required (no first-pane fallback) — agents that pass only session_name get a ToolError instead of an unintended kill - shell argument is briefly visible via OS process table / pane_current_command metadata before the spawned shell takes over; audit log redacts shell payloads but credentials still leak via OS channels - same self-pane guard as the destructive kill commands Append a respawn-pane row to the per-tool annotation table showing the unusual mutating-tier + destructiveHint=true + idempotentHint=false combination. --- CHANGES | 11 +++++++++++ docs/topics/safety.md | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGES b/CHANGES index 2fbc790..5cfffe9 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,17 @@ _Notes on upcoming releases will be added here_ +### Docs + +- {ref}`safety` adds a {tooliconl}`respawn-pane` subsection under + "Footguns inside the `mutating` tier" alongside {tooliconl}`pipe-pane` + and {tooliconl}`set-environment`. Documents the `kill=True` default, + the non-idempotent retry semantics, the explicit-`pane_id` + requirement, and the `pane_current_command` / OS-process-table + visibility window for any `shell` argument. The per-tool annotation + table picks up a `respawn-pane` row showing the unusual mutating-tier + + `destructiveHint=true` + `idempotentHint=false` combination. + ### Fixes - {tooliconl}`respawn-pane` now requires an explicit ``pane_id``. Its diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 3d1760b..22f7f5b 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -90,6 +90,18 @@ Mitigations: - The audit log redacts the `value` argument to a `{len, sha256_prefix}` digest so log files don't leak the secrets agents set, but operators should still treat the tool as high-privilege. - If only a single command needs an env override, prefer having the agent invoke `env VAR=value command` via `send_keys` instead — the blast radius is one command, not every future child. +### `respawn_pane` + +{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. + +Mitigations: + +- `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get a `ToolError` 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 same self-pane guard that protects the destructive kill commands also refuses to respawn the pane running the MCP server. + ### `send_keys` / `paste_text` These can execute anything the pane's shell accepts. There is no payload validation. The audit log stores a digest of the content, not the content itself, so a secret typed via `send_keys` does not land in logs. @@ -135,6 +147,7 @@ Each tool carries MCP tool annotations that hint at its behavior: | {ref}`select-layout` | {badge}`mutating` | false | false | true | | {ref}`set-option` | {badge}`mutating` | false | false | true | | {ref}`set-environment` | {badge}`mutating` | false | false | true | +| {ref}`respawn-pane` | {badge}`mutating` | false | true | false | | {ref}`kill-server` | {badge}`destructive` | false | true | false | | {ref}`kill-session` | {badge}`destructive` | false | true | false | | {ref}`kill-window` | {badge}`destructive` | false | true | false | From bec9ab1dfda6978c1276b498d7ae525d28f064da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:15:04 -0500 Subject: [PATCH 18/40] mcp(api[pane_tools]): rename respawn shell parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename respawn_pane's `shell_command` parameter to `shell` so it matches: - split_window's existing `shell` parameter (window_tools.py:161) - the eventual upstream Pane.respawn(shell=, start_directory=, environment=, kill=) signature on libtmux's tmux-parity branch - tmux's own `respawn-pane shell-command` argument named `shell` in the tmux source Land the rename now, before agents cache the parameter schema; the post-cache rename is an MCP protocol break. No compatibility alias since this is a pre-release library (0.1.0a3) — keeping the alias would compound the schema-locking problem. Touched: - src/libtmux_mcp/tools/pane_tools/lifecycle.py: parameter, body, docstring - tests/test_pane_tools.py: rename test + update kwarg - docs/tools/pane/respawn-pane.md: rename in JSON example + prose --- CHANGES | 9 +++++++++ docs/tools/pane/respawn-pane.md | 6 +++--- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 19 +++++++++++-------- tests/test_pane_tools.py | 8 +++----- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/CHANGES b/CHANGES index 5cfffe9..5ea9eaf 100644 --- a/CHANGES +++ b/CHANGES @@ -17,6 +17,15 @@ _Notes on upcoming releases will be added here_ table picks up a `respawn-pane` row showing the unusual mutating-tier + `destructiveHint=true` + `idempotentHint=false` combination. +### Breaking changes + +- {tooliconl}`respawn-pane` parameter ``shell_command`` is renamed to + ``shell`` to align with {tooliconl}`split-window` and the eventual + upstream ``Pane.respawn(shell=)`` API on libtmux's ``tmux-parity`` + branch. Renamed before agents cache the schema; renaming after + caching is an MCP protocol break. No compatibility alias — this is + a pre-release library. + ### Fixes - {tooliconl}`respawn-pane` now requires an explicit ``pane_id``. Its diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 990ef43..d32c921 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -7,8 +7,8 @@ process, bad terminal mode) and you need a clean restart *without* destroying the `pane_id` references other tools or callers may still be holding. With `kill=True` (the default) tmux kills the current -process first; optional `shell_command` relaunches with a different -command; optional `start_directory` sets its cwd. +process first; optional `shell` relaunches with a different command; +optional `start_directory` sets its cwd. **Avoid when** the pane genuinely needs to go away — use {tooliconl}`kill-pane` instead. Also avoid when you want to change @@ -36,7 +36,7 @@ point of the tool. `pane_pid` updates to the new process. "tool": "respawn_pane", "arguments": { "pane_id": "%5", - "shell_command": "pytest -x", + "shell": "pytest -x", "start_directory": "/home/user/project" } } diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 2857f3f..57a0df1 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -65,7 +65,7 @@ def respawn_pane( session_id: str | None = None, window_id: str | None = None, kill: bool = True, - shell_command: str | None = None, + shell: str | None = None, start_directory: str | None = None, socket_name: str | None = None, ) -> PaneInfo: @@ -77,9 +77,9 @@ def respawn_pane( the layout. respawn-pane preserves both. With ``kill=True`` (the default), tmux kills the existing process - before respawning. Optional ``shell_command`` replaces the - command tmux relaunches; ``start_directory`` sets the working - directory for the new process. + before respawning. Optional ``shell`` replaces the command tmux + relaunches; ``start_directory`` sets the working directory for + the new process. ``pane_id`` is required — no fallback to ``_resolve_pane``'s "first pane in session/window" behaviour. Default ``kill=True`` @@ -103,9 +103,12 @@ def respawn_pane( When True (default), pass ``-k`` to tmux so the current process is killed before respawning. When False, respawn fails if the pane already has a running process. - shell_command : str, optional + shell : str, optional Replacement command for tmux to launch. When omitted, tmux - restarts the original shell/command. + replays the original argv (good default for shells; may differ + for processes spawned via custom shell at split time). Matches + 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``). @@ -149,8 +152,8 @@ def respawn_pane( argv.append("-k") if start_directory is not None: argv.extend(["-c", start_directory]) - if shell_command is not None: - argv.append(shell_command) + if shell is not None: + argv.append(shell) result = pane.cmd("respawn-pane", *argv) if result.stderr: stderr = " ".join(result.stderr).strip() diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index eada2d6..adadb4e 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -266,17 +266,15 @@ def test_respawn_pane_preserves_pane_id_and_refreshes_pid( new_pane.kill() -def test_respawn_pane_replaces_shell_command( - mcp_server: Server, mcp_session: Session -) -> None: - """respawn_pane with shell_command relaunches with the new command.""" +def test_respawn_pane_replaces_shell(mcp_server: Server, mcp_session: Session) -> None: + """respawn_pane with ``shell`` relaunches with the new command.""" window = mcp_session.active_window new_pane = window.split(shell="sleep 3600") assert new_pane.pane_id is not None result = respawn_pane( pane_id=new_pane.pane_id, - shell_command="sleep 7200", + shell="sleep 7200", socket_name=mcp_server.socket_name, ) assert result.pane_id == new_pane.pane_id From 9e1997b01ef8f7b5fcc718687b742b4e38862eb6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:16:29 -0500 Subject: [PATCH 19/40] mcp(fix[middleware]): redact respawn shell payloads Add `shell` to `_SENSITIVE_ARG_NAMES` so respawn_pane's shell argument is replaced by `{len, sha256_prefix}` in the audit log, matching the existing treatment of `keys`, `text`, `value`, and `content`. An agent that passes `shell="psql -U user -W secret"` would otherwise leak the credential to long-lived audit archives. Extend test_summarize_args_redacts_sensitive_keys to cover both `shell` and `content` (the latter was already in the redaction set in code but the test didn't exercise it). Update the safety doc's redaction list to match the code (was: `keys`, `text`, `value`; now: `keys`, `text`, `value`, `content`, `shell`). Note for the threat model: redaction protects the MCP audit log, not the OS process table or tmux's `pane_current_command` metadata. The new safety subsection (1.C) documents the brief leakage window between respawn-pane invocation and the spawned shell taking over. --- CHANGES | 9 +++++++++ docs/topics/safety.md | 2 +- src/libtmux_mcp/middleware.py | 15 +++++++++++---- tests/test_middleware.py | 4 +++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGES b/CHANGES index 5ea9eaf..b645b83 100644 --- a/CHANGES +++ b/CHANGES @@ -28,6 +28,15 @@ _Notes on upcoming releases will be added here_ ### Fixes +- Audit log now redacts the ``shell`` argument on + {tooliconl}`respawn-pane` (and ``content`` on {tooliconl}`load-buffer`, + which the code already redacted but the docs did not list). The + ``shell`` payload may carry credentials passed to a relaunched + process; redacting the MCP audit log keeps them out of long-lived + log archives. Note: ``shell`` may still appear briefly in the OS + process table and tmux's ``pane_current_command`` metadata until the + spawned shell takes over — do not pass credentials directly even + with redaction. - {tooliconl}`respawn-pane` now requires an explicit ``pane_id``. Its signature still accepts ``session_name`` / ``session_id`` / ``window_id`` for backwards-compatibility with the shared pane-target diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 22f7f5b..062e010 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -114,7 +114,7 @@ Every tool call emits one `INFO` record on the `libtmux_mcp.audit` logger carryi - `outcome` — `ok` or `error`, with `error_type` on failure - `duration_ms` - `client_id` / `request_id` — from the fastmcp context when available -- `args` — a summary of arguments. Sensitive keys (`keys`, `text`, `value`) are replaced by `{len, sha256_prefix}`; non-sensitive strings over 200 characters are truncated. +- `args` — a summary of arguments. Sensitive keys (`keys`, `text`, `value`, `content`, `shell`) are replaced by `{len, sha256_prefix}`; non-sensitive strings over 200 characters are truncated. Route this logger to a dedicated sink if you want a durable audit trail; it is deliberately namespaced separately from the main `libtmux_mcp` logger. diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 44a37d3..e2e97f2 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -100,10 +100,17 @@ async def on_call_tool( #: Argument names that carry user-supplied payloads we never want in logs. #: ``keys`` (send_keys), ``text`` (paste_text), ``value`` (set_environment), -#: and ``content`` (load_buffer) can contain commands, secrets, or -#: arbitrary large strings. Matched by exact name, case-sensitive, to -#: mirror the tool signatures. -_SENSITIVE_ARG_NAMES: frozenset[str] = frozenset({"keys", "text", "value", "content"}) +#: ``content`` (load_buffer), and ``shell`` (respawn_pane) can contain +#: commands, secrets, or arbitrary large strings. Matched by exact name, +#: case-sensitive, to mirror the tool signatures. +#: +#: Note on ``shell`` redaction: this redacts the MCP audit log only. +#: ``respawn_pane(shell="env SECRET=... bash")`` may briefly expose the +#: argument via the OS process table and tmux's ``pane_current_command`` +#: metadata until the spawned shell takes over — see ``docs/topics/safety.md``. +_SENSITIVE_ARG_NAMES: frozenset[str] = frozenset( + {"keys", "text", "value", "content", "shell"} +) #: String arguments longer than this get truncated in the log summary to #: keep records bounded. Non-sensitive strings only — sensitive ones are diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 69eb10d..2920c37 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -149,11 +149,13 @@ def test_summarize_args_redacts_sensitive_keys() -> None: "keys": "rm -rf /", "text": "hello world", "value": "supersecret", + "content": "buffer payload", + "shell": "psql -U user -W secret123 mydb", "pane_id": "%1", "bracket": True, } summary = _summarize_args(args) - for sensitive in ("keys", "text", "value"): + for sensitive in ("keys", "text", "value", "content", "shell"): assert isinstance(summary[sensitive], dict) assert "len" in summary[sensitive] assert "sha256_prefix" in summary[sensitive] From 3be1786db10e41610b9f194a49512136785be8df Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:17:42 -0500 Subject: [PATCH 20/40] mcp(docs[pane_tools]): explain respawn command capture Add a tip to the respawn_pane docstring (lifecycle.py) and to the docs/tools/pane/respawn-pane.md topic page advising agents to call get_pane_info before respawn if they need to preserve pane_current_command across the restart. Tmux's default is to replay the original argv when shell is omitted (good for shells), but a pane spawned with a custom shell at split time may not respawn identically. --- CHANGES | 5 +++++ docs/tools/pane/respawn-pane.md | 6 ++++++ src/libtmux_mcp/tools/pane_tools/lifecycle.py | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/CHANGES b/CHANGES index b645b83..d5d53dc 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,11 @@ _Notes on upcoming releases will be added here_ ### Docs +- {tooliconl}`respawn-pane` docstring and topic page now include a tip + to call {tooliconl}`get-pane-info` first if the agent needs to + preserve ``pane_current_command`` across the respawn — tmux's default + behaviour is to replay the original argv, but a custom split-time + shell may differ. - {ref}`safety` adds a {tooliconl}`respawn-pane` subsection under "Footguns inside the `mutating` tier" alongside {tooliconl}`pipe-pane` and {tooliconl}`set-environment`. Documents the `kill=True` default, diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index d32c921..ea0e28a 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -18,6 +18,12 @@ the layout: `respawn-pane` preserves the pane in place. starts a new one. **The `pane_id` is preserved** — that's the whole point of the tool. `pane_pid` updates to the new process. +**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 +shells; may differ for processes spawned via custom shell at split +time). + **Example — recover a wedged pane, relaunching the default shell:** ```json diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 57a0df1..38443cf 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -87,6 +87,12 @@ def respawn_pane( can silently kill an unrelated server. Resolve via ``list_panes`` first. + Tip: call ``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 shells; may differ for processes spawned via custom + shell at split time). + Parameters ---------- pane_id : str From 31ff2208ec9e41e94fceccef465c67f79a29cec2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:20:01 -0500 Subject: [PATCH 21/40] mcp(refactor[pane_tools]): drop redundant pane targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libtmux's `Pane.cmd` already injects `-t self.pane_id` via `Server.cmd` (libtmux/pane.py:177-211 + server.py:325). Five sites in pane_tools were also passing `["-t", pane.pane_id]` explicitly, producing `tmux -t %X -t %X ...` on the wire. tmux's args parser keeps the last `-t` so behaviour was identical, but the redundancy was confusing slop. The convention exemplar at copy_mode.py:60-66 (`pane.cmd("send-keys", "-X", ...)`) shows the intended call shape. Subtractive sweep at five sites: - pane_tools/lifecycle.py:132 (respawn_pane) - pane_tools/copy_mode.py:58 (enter_copy_mode) - pane_tools/copy_mode.py:110 (exit_copy_mode) - pane_tools/meta.py:64 (display_message) - pane_tools/meta.py:150 (snapshot_pane) The respawn case also drops the dead `or ""` defensive fallback — `_resolve_pane` already raises `PaneNotFound` when no pane is resolvable, so `pane.pane_id` cannot be None at that point. No introspection test — over-engineering for a one-shot mechanical sweep. Existing tests for each tool exercise the call paths. --- CHANGES | 12 ++++++++++++ src/libtmux_mcp/tools/pane_tools/copy_mode.py | 4 ++-- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 2 +- src/libtmux_mcp/tools/pane_tools/meta.py | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index d5d53dc..1a10f85 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,18 @@ _Notes on upcoming releases will be added here_ caching is an MCP protocol break. No compatibility alias — this is a pre-release library. +### Refactor + +- Drop redundant ``["-t", pane.pane_id]`` argument prefixes at five + ``pane.cmd(...)`` sites (`pane_tools/lifecycle.py:132`, + `copy_mode.py:58, 110`, `meta.py:64, 150`). libtmux's ``Pane.cmd`` + already injects ``-t self.pane_id`` via ``Server.cmd``, so the + resulting wire form was ``tmux -t %X -t %X ...``; tmux's args + parser kept the last ``-t`` so behaviour was identical, but the + redundancy was confusing slop. The convention exemplar at + `copy_mode.py:60-66` (``pane.cmd("send-keys", "-X", ...)``) shows the + intended call shape. + ### Fixes - Audit log now redacts the ``shell`` argument on diff --git a/src/libtmux_mcp/tools/pane_tools/copy_mode.py b/src/libtmux_mcp/tools/pane_tools/copy_mode.py index 4d607f4..efbd38d 100644 --- a/src/libtmux_mcp/tools/pane_tools/copy_mode.py +++ b/src/libtmux_mcp/tools/pane_tools/copy_mode.py @@ -55,7 +55,7 @@ def enter_copy_mode( session_id=session_id, window_id=window_id, ) - pane.cmd("copy-mode", "-t", pane.pane_id) + pane.cmd("copy-mode") if scroll_up is not None and scroll_up > 0: pane.cmd( "send-keys", @@ -107,6 +107,6 @@ def exit_copy_mode( session_id=session_id, window_id=window_id, ) - pane.cmd("send-keys", "-t", pane.pane_id, "-X", "cancel") + pane.cmd("send-keys", "-X", "cancel") pane.refresh() return _serialize_pane(pane) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 38443cf..2c47b05 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -153,7 +153,7 @@ def respawn_pane( "Use a manual tmux command if intended." ) raise ToolError(msg) - argv: list[str] = ["-t", pane.pane_id or ""] + argv: list[str] = [] if kill: argv.append("-k") if start_directory is not None: diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index ef54331..373f2de 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -61,7 +61,7 @@ def display_message( session_id=session_id, window_id=window_id, ) - result = pane.cmd("display-message", "-p", "-t", pane.pane_id, format_string) + result = pane.cmd("display-message", "-p", format_string) return "\n".join(result.stdout) if result.stdout else "" @@ -147,7 +147,7 @@ def snapshot_pane( "#{pane_current_path}", ] ) - result = pane.cmd("display-message", "-p", "-t", pane.pane_id, fmt) + result = pane.cmd("display-message", "-p", fmt) raw = result.stdout[0] if result.stdout else "" # Pad defensively to guarantee 11 fields even if tmux drops an # unknown format variable on older versions. From d61bc88a84db56ff26f9ac08b5c71b2021775808 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:21:10 -0500 Subject: [PATCH 22/40] docs(fix[get-window-info]): point users to list_windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Avoid when" paragraph told agents to call `list_panes` with `session_id` for whole-session window enumeration. That returns `PaneInfo` objects, not windows — agents following the doc verbatim would either re-derive the window set from `pane.window_id` (extra tokens, wrong abstraction) or burn another turn on the right call. Switch to `list_windows`, which accepts `session_id` and is the actual window enumerator (session_tools.py:34-71). --- CHANGES | 5 +++++ docs/tools/window/get-window-info.md | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 1a10f85..f3eb63e 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,11 @@ _Notes on upcoming releases will be added here_ ### Docs +- {tooliconl}`get-window-info` "Avoid when" guidance now points to + {tooliconl}`list-windows` (not {tooliconl}`list-panes`) for whole- + session window enumeration. The previous wording trained agents into + the wrong tool: ``list_panes`` returns ``PaneInfo`` objects, while + ``list_windows`` is the window enumerator and accepts ``session_id``. - {tooliconl}`respawn-pane` docstring and topic page now include a tip to call {tooliconl}`get-pane-info` first if the agent needs to preserve ``pane_current_command`` across the respawn — tmux's default diff --git a/docs/tools/window/get-window-info.md b/docs/tools/window/get-window-info.md index 2e20ba3..1357a39 100644 --- a/docs/tools/window/get-window-info.md +++ b/docs/tools/window/get-window-info.md @@ -7,9 +7,8 @@ dimensions, pane count) and you already know the `window_id` or `window_index`. Avoids the `list_windows` + filter dance. -**Avoid when** you need every window in a session — call `list_panes` with -`session_id` or iterate through the session's windows via the -`tmux://sessions/{name}/windows` resource. +**Avoid when** you need every window in a session — call `list_windows` with +`session_id` or iterate via the `tmux://sessions/{name}/windows` resource. **Side effects:** None. Readonly. From 8afc779d6c18e6aba53b66b6a38db932257d14eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:23:08 -0500 Subject: [PATCH 23/40] scripts(fix[mcp_swap]): align python floor with project PEP 723 header demanded `>=3.11` while pyproject.toml supports `>=3.10`. Static scan confirms no 3.11-only features in mcp_swap.py (no tomllib, ExceptionGroup, except*, or structural match). Lower to `>=3.10` so `uv run scripts/mcp_swap.py` shares the project venv on 3.10 dev environments instead of provisioning a fresh toolchain. --- CHANGES | 10 ++++++++++ scripts/mcp_swap.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index f3eb63e..7c28f95 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,16 @@ _Notes on upcoming releases will be added here_ +### Scripts + +- ``scripts/mcp_swap.py`` PEP 723 ``requires-python`` lowered from + ``>=3.11`` to ``>=3.10`` to match the project floor in + ``pyproject.toml``. The script does not use any 3.11-only features + (verified: no ``tomllib``, ``ExceptionGroup``, ``except*``, or + structural ``match``); the previous bound made ``uv run scripts/ + mcp_swap.py`` provision a fresh 3.11 toolchain on contributor + machines that otherwise share the project's 3.10 venv. + ### Docs - {tooliconl}`get-window-info` "Avoid when" guidance now points to diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index d311824..6da8043 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.10" # dependencies = ["tomlkit>=0.13"] # /// """Swap MCP server configs across Claude / Codex / Cursor / Gemini CLIs. From 42307e04eb04a41a73ba2d8c6c917e80e66cdca6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:25:23 -0500 Subject: [PATCH 24/40] mcp(docs[tools]): drop internal audit references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip dangling internal-audit citations from production source so future readers see durable design rationale, not process artefacts: - window_tools.py:106 — drop "See the brainstorm-and-refine audit §7.1." sentence. The architectural fence preserved (the four-hierarchy-level rule is self-contained without the citation). - hook_tools.py:5 — rephrase the module docstring's opening from "The brainstorm-and-refine plan deliberately excludes write-hooks..." to "Write-hooks (`set-hook` / `unset-hook`) are deliberately excluded." The persistence + lifespan-teardown-gap rationale stays intact. session_tools.py:74-76 already cross-references "the same note in window_tools.get_window_info" without the §7.1 citation; that pointer still resolves. --- CHANGES | 8 ++++++++ src/libtmux_mcp/tools/hook_tools.py | 14 +++++++------- src/libtmux_mcp/tools/window_tools.py | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGES b/CHANGES index 7c28f95..938f70d 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,14 @@ _Notes on upcoming releases will be added here_ ### Docs +- Strip dangling internal-audit citations from production source + comments. ``window_tools.py:106`` no longer references "the + brainstorm-and-refine audit §7.1" — the architectural fence + (the four-hierarchy-level rule) stays, the orphaned citation goes. + ``session_tools.py:74-76`` cross-reference still resolves. The + ``hook_tools.py`` module docstring drops "The brainstorm-and-refine + plan deliberately excludes write-hooks" and keeps the rationale + (hook persistence + lifespan teardown gap). - {tooliconl}`get-window-info` "Avoid when" guidance now points to {tooliconl}`list-windows` (not {tooliconl}`list-panes`) for whole- session window enumeration. The previous wording trained agents into diff --git a/src/libtmux_mcp/tools/hook_tools.py b/src/libtmux_mcp/tools/hook_tools.py index 1ea7ca7..f9e8b48 100644 --- a/src/libtmux_mcp/tools/hook_tools.py +++ b/src/libtmux_mcp/tools/hook_tools.py @@ -2,13 +2,13 @@ Why read-only only ------------------ -The brainstorm-and-refine plan deliberately excludes write-hooks -(``set-hook`` / ``unset-hook``) from this commit. 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. +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 diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index cf51850..d9e86a1 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -103,7 +103,7 @@ def list_panes( # have a targeted single-object read. This is deliberately NOT a license to # add get_buffer_info / get_hook_info / get_option_info — those scopes are # not part of the hierarchy and the existing show_*/load_* tools already -# cover their reads. See the brainstorm-and-refine audit §7.1. +# cover their reads. @handle_tool_errors def get_window_info( window_id: str | None = None, From b9db6d532b143ac292cc8b805053849d2ec6dc3d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:26:38 -0500 Subject: [PATCH 25/40] docs(safety): refresh tmux socket guard notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS TMUX_TMPDIR caveat described the self-kill guard's reconstruction-only socket-path resolution and tracked the display-message-based fix as future work. _effective_socket_path already implements the proper three-step resolution (libtmux socket_path → tmux display-message → reconstruction fallback) and has since v0.1.x. Document the actual shipped behaviour so doc readers don't chase a problem that's already solved. --- CHANGES | 8 ++++++++ docs/topics/safety.md | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 938f70d..c706eb6 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,14 @@ _Notes on upcoming releases will be added here_ ### Docs +- {ref}`safety` macOS ``TMUX_TMPDIR`` caveat now reflects shipped + behaviour. ``_effective_socket_path`` already queries tmux's + ``display-message -p '#{socket_path}'`` first and only falls back to + ``$TMUX_TMPDIR`` reconstruction when the server is unreachable, but + the doc still framed the structural fix as future work and told + operators to set ``TMUX_TMPDIR`` explicitly. Replace with the actual + three-step resolution order so doc readers don't write code or + service files chasing a problem that's already solved. - Strip dangling internal-audit citations from production source comments. ``window_tools.py:106`` no longer references "the brainstorm-and-refine audit §7.1" — the architectural fence diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 062e010..0a3433e 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -63,9 +63,13 @@ These protections read both the `TMUX` and `TMUX_PANE` environment variables tha ### macOS `TMUX_TMPDIR` caveat -The self-kill guard reconstructs the target server's socket path by combining {envvar}`TMUX_TMPDIR` (or `/tmp` if unset) with the configured socket name. On macOS, `TMUX_TMPDIR` commonly differs between interactive shells and background service environments — if the MCP process and the tmux server were launched under different values, the reconstructed target path won't match the caller's `TMUX` socket path and the guard may decline to fire. The target-side comparison still protects the common case (same shell, same launchd context), but a mismatched {envvar}`TMUX_TMPDIR` can degrade the protection into a no-op. +The self-kill guard resolves the target server's socket path in three steps (`_effective_socket_path` in `src/libtmux_mcp/_utils.py`): -Mitigation today: set {envvar}`TMUX_TMPDIR` explicitly in both the MCP server's environment and the shell that starts tmux, so both reconstructions resolve to the same path. The proper structural fix — querying tmux for its own socket via `display-message '#{socket_path}'` rather than reconstructing — is tracked outside this documentation. +1. Use `Server.socket_path` if libtmux already has it. +2. Otherwise query the running server via `display-message -p '#{socket_path}'` — authoritative because tmux itself reports the path it is actually using, regardless of the MCP process environment. This closes the launchd-vs-interactive-shell gap on macOS where {envvar}`TMUX_TMPDIR` commonly differs between contexts. +3. Fall back to reconstruction from {envvar}`TMUX_TMPDIR` (or `/tmp`) + euid + socket name. Only reached when the target server is unreachable (not running), in which case no self-kill is possible anyway and `_caller_is_on_server`'s None-socket branch blocks conservatively. + +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 From 909552d577ebe633e9e7f180758bbbde9d605337 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:27:53 -0500 Subject: [PATCH 26/40] scripts(typing[mcp_swap]): overload claude project lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _claude_project_node returns dict[str, t.Any] | None because create=False callers want None-as-not-found. set_server's create=True path then carried `assert node is not None` to narrow for the .setdefault() call — runtime dead code that disappears under `python -O` and that mypy strict mode handles better via overloads. Add @t.overload variants: - create: t.Literal[True] -> dict[str, t.Any] - create: t.Literal[False] -> dict[str, t.Any] | None Match the existing keyword-only signature exactly (no `= False` default — `create` is mandatory in the implementation). Use `t.overload`/`t.Literal` to match the file's existing `import typing as t` namespace. set_server drops the assert; mypy now proves create=True returns a non-None dict via the overload. --- CHANGES | 6 ++++++ scripts/mcp_swap.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index c706eb6..8042d3c 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,12 @@ _Notes on upcoming releases will be added here_ ### Scripts +- ``scripts/mcp_swap.py``: ``_claude_project_node`` now uses + ``@t.overload`` so ``create=True`` is statically narrowed to + ``dict[str, t.Any]``. ``set_server`` drops the runtime + ``assert node is not None`` (which would have been stripped under + ``python -O``) — mypy proves the invariant via the overload instead. + Strict-typing-priority alignment. - ``scripts/mcp_swap.py`` PEP 723 ``requires-python`` lowered from ``>=3.11`` to ``>=3.10`` to match the project floor in ``pyproject.toml``. The script does not use any 3.11-only features diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 6da8043..4377b33 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -187,10 +187,35 @@ def atomic_write(path: pathlib.Path, data: bytes) -> None: # --------------------------------------------------------------------------- +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[True], +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[False], +) -> dict[str, t.Any] | None: ... + + def _claude_project_node( config: dict[str, t.Any], repo: pathlib.Path, *, create: bool ) -> dict[str, t.Any] | None: - """Return (or create) the ``projects.`` node Claude keys per-project.""" + """Return (or create) the ``projects.`` node Claude keys per-project. + + With ``create=True``, the node is unconditionally created if missing + and the return type is statically narrowed to ``dict[str, t.Any]``; + callers can drop runtime ``assert node is not None`` defensiveness. + With ``create=False``, the absence of the node is a real return value + and the type stays ``dict[str, t.Any] | None``. + """ key = str(repo.resolve()) projects = ( config.setdefault("projects", {}) if create else config.get("projects", {}) @@ -230,7 +255,6 @@ def set_server( """Write ``spec`` under ``name`` in a CLI's config, returning replaced/added.""" if cli == "claude": node = _claude_project_node(config, repo, create=True) - assert node is not None servers = node.setdefault("mcpServers", {}) had = name in servers servers[name] = spec.to_json_dict(include_stdio_type=True) From 21ad23ee3a4b6df0b604f10707d489c84045499b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:29:43 -0500 Subject: [PATCH 27/40] scripts(fix[mcp_swap]): guard claude config shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _claude_project_node writes into projects..mcpServers — Claude Code's undocumented internal layout. Existing defenses (timestamped backups, atomic write + reparse rollback, whole-file revert) protect against a corrupted file but not against a *partial* mutation that would happen if Claude reshapes 'projects' to e.g. a list and the script silently writes through it before reparse catches anything. Validate the shape before mutating: 'projects' and the per-project node must both be mappings. Raise RuntimeError with "Claude config layout appears to have changed..." before the atomic write begins, so the backup defense doesn't have to recover from a half-mutated structure. Three tests in tests/test_mcp_swap.py: - non-mapping projects raises - non-mapping per-project node raises - well-shaped empty config passes through to creation --- CHANGES | 8 ++++++++ scripts/mcp_swap.py | 23 ++++++++++++++++++++++- tests/test_mcp_swap.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 8042d3c..417233b 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,14 @@ _Notes on upcoming releases will be added here_ ### Scripts +- ``scripts/mcp_swap.py``: ``_claude_project_node`` validates that + Claude's undocumented ``projects..mcpServers`` layout is still + mapping-shaped before mutating it. If a future Claude release + reshapes the structure, the script raises ``RuntimeError("Claude + config layout appears to have changed...")`` *before* the atomic + write — so the timestamped backup defense isn't asked to recover + from a partial mutation. Three new tests in ``tests/test_mcp_swap.py`` + cover the rejection paths and the well-shaped happy path. - ``scripts/mcp_swap.py``: ``_claude_project_node`` now uses ``@t.overload`` so ``create=True`` is statically narrowed to ``dict[str, t.Any]``. ``set_server`` drops the runtime diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 4377b33..e0e185b 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -215,12 +215,33 @@ def _claude_project_node( callers can drop runtime ``assert node is not None`` defensiveness. With ``create=False``, the absence of the node is a real return value and the type stays ``dict[str, t.Any] | None``. + + Raises ``RuntimeError`` if Claude's config layout is not the + expected ``projects..mcpServers`` mapping shape — the layout + is undocumented Claude Code internal state, so a clear error before + the atomic write beats a silent partial mutation that the backup + defense would be asked to recover from. """ key = str(repo.resolve()) + projects_node = config.get("projects") + if projects_node is not None and not isinstance(projects_node, dict): + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects' to be a mapping but got " + f"{type(projects_node).__name__}" + ) + raise RuntimeError(msg) projects = ( config.setdefault("projects", {}) if create else config.get("projects", {}) ) - node: dict[str, t.Any] | None = projects.get(key) + node = projects.get(key) + if node is not None and not isinstance(node, dict): + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects[{key!r}]' to be a mapping but got " + f"{type(node).__name__}" + ) + raise RuntimeError(msg) if node is None and create: node = {"allowedTools": [], "mcpContextUris": [], "mcpServers": {}, "env": {}} projects[key] = node diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index 71b6069..6152496 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -380,3 +380,44 @@ def test_is_local_uv_directory_detection() -> None: pypi = mcp_swap.McpServerSpec(command="uvx", args=["libtmux-mcp==0.1.0a2"]) assert pypi.is_local_uv_directory() is False assert pypi.local_repo_path() is None + + +# --------------------------------------------------------------------------- +# _claude_project_node schema-shape guard +# --------------------------------------------------------------------------- + + +def test_claude_project_node_rejects_non_mapping_projects( + fake_repo: pathlib.Path, +) -> None: + """A non-mapping ``projects`` value is rejected with a clear error. + + Claude's ``~/.claude.json`` layout is undocumented internal state. + If a future Claude release reshapes ``projects`` (e.g. to a list), + the script should fail before the atomic write begins so the + backup defense is not asked to recover from a partially-mutated + structure. + """ + config: dict[str, t.Any] = {"projects": "not a dict"} + with pytest.raises(RuntimeError, match="layout appears to have changed"): + mcp_swap._claude_project_node(config, fake_repo, create=True) + + +def test_claude_project_node_rejects_non_mapping_project_node( + fake_repo: pathlib.Path, +) -> None: + """A non-mapping per-project node is rejected with a clear error.""" + key = str(fake_repo.resolve()) + config: dict[str, t.Any] = {"projects": {key: "scalar instead of dict"}} + with pytest.raises(RuntimeError, match="layout appears to have changed"): + mcp_swap._claude_project_node(config, fake_repo, create=True) + + +def test_claude_project_node_accepts_well_shaped_config( + fake_repo: pathlib.Path, +) -> None: + """Well-shaped config passes through to creation without error.""" + config: dict[str, t.Any] = {} + node = mcp_swap._claude_project_node(config, fake_repo, create=True) + assert isinstance(node, dict) + assert "mcpServers" in node From cafb64f8eb4d62798fb903d278f09e0ab836692f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 10:33:25 -0500 Subject: [PATCH 28/40] mcp(test[server]): verify socket_name instruction contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _BASE_INSTRUCTIONS promised "All tools accept an optional socket_name parameter" — but list_servers signature is list_servers(extra_socket_paths: list[str] | None = None) — no socket_name. The instruction lied to agents, and any contract test written against the original wording would fail. Tighten the wording to acknowledge list_servers as the explicit exception: Targeted tmux tools accept an optional socket_name parameter (defaults to LIBTMUX_SOCKET env var); list_servers discovers sockets via TMUX_TMPDIR plus optional extra_socket_paths instead. Add two tests in tests/test_server.py: - test_base_instructions_document_socket_name_contract: locks the three new keywords in the wording. - test_registered_tools_accept_socket_name: enumerates every registered tool via FastMCP.list_tools(), introspects each FunctionTool's signature, and asserts `socket_name in sig.parameters` with `list_servers` as the documented exception. Catches future tool drift before it silently makes the agent-facing instructions a lie again. --- CHANGES | 16 +++++++++++ src/libtmux_mcp/server.py | 5 ++-- tests/test_server.py | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 417233b..a66fc06 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,22 @@ _Notes on upcoming releases will be added here_ +### Tests + +- New ``test_registered_tools_accept_socket_name`` introspection test + in ``tests/test_server.py`` enumerates every registered tool via + ``FastMCP.list_tools()`` and asserts each accepts a ``socket_name`` + parameter, with ``list_servers`` as the documented exception (it + takes ``extra_socket_paths`` instead). Catches future tool drift + before it silently makes ``_BASE_INSTRUCTIONS`` lie to agents. The + ``_BASE_INSTRUCTIONS`` text itself is tightened from "All tools + accept an optional socket_name parameter" to "Targeted tmux tools + accept an optional socket_name parameter (defaults to LIBTMUX_SOCKET + env var); list_servers discovers sockets via TMUX_TMPDIR plus + optional extra_socket_paths instead." A companion content test + (``test_base_instructions_document_socket_name_contract``) locks the + wording. + ### Scripts - ``scripts/mcp_swap.py``: ``_claude_project_node`` validates that diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 29aac02..b5e4d3b 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -48,8 +48,9 @@ "Use pane_id (e.g. '%1') as the preferred targeting method - " "it is globally unique within a tmux server. " "Use send_keys to execute commands and capture_pane to read output. " - "All tools accept an optional socket_name parameter for multi-server " - "support (defaults to LIBTMUX_SOCKET env var).\n\n" + "Targeted tmux tools accept an optional socket_name parameter " + "(defaults to LIBTMUX_SOCKET env var); list_servers discovers " + "sockets via TMUX_TMPDIR plus optional extra_socket_paths instead.\n\n" "IMPORTANT — metadata vs content: list_windows, list_panes, and " "list_sessions only search metadata (names, IDs, current command). " "To find text that is actually visible in terminals — when users ask " diff --git a/tests/test_server.py b/tests/test_server.py index 5f1504f..202333a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -173,6 +173,63 @@ def test_base_instructions_document_hook_boundary() -> None: assert "tmux config file" in _BASE_INSTRUCTIONS +def test_base_instructions_document_socket_name_contract() -> None: + """_BASE_INSTRUCTIONS frames the socket_name promise precisely. + + list_servers does NOT accept socket_name (it's the discovery tool — + see server_tools.py:263-264 where the signature is + ``list_servers(extra_socket_paths=...)``), so the previous "All + tools accept socket_name" wording was a lie. The instruction now + qualifies "Targeted tmux tools" and explicitly names list_servers + as the documented exception, matching what + test_registered_tools_accept_socket_name asserts at the schema + level. + """ + assert "Targeted tmux tools accept" in _BASE_INSTRUCTIONS + assert "list_servers" in _BASE_INSTRUCTIONS + assert "extra_socket_paths" in _BASE_INSTRUCTIONS + + +def test_registered_tools_accept_socket_name() -> None: + """All registered tools (except list_servers) accept ``socket_name``. + + ``_BASE_INSTRUCTIONS`` promises this with ``list_servers`` as the + documented exception (it discovers sockets via + ``extra_socket_paths`` instead, see ``server_tools.py:263-264``). + If a future tool registration drops ``socket_name``, this test + catches the regression instead of silently making the agent-facing + instructions a lie. + """ + import asyncio + import inspect + + from fastmcp import FastMCP + from fastmcp.tools.function_tool import FunctionTool + + from libtmux_mcp.tools import register_tools + + socket_name_exempt = {"list_servers"} + + mcp = FastMCP(name="socket-name-contract") + register_tools(mcp) + + tools = asyncio.run(mcp.list_tools()) + assert tools, "register_tools should have registered at least one tool" + for tool in tools: + if tool.name in socket_name_exempt: + continue + assert isinstance(tool, FunctionTool), ( + f"Tool {tool.name!r} is not a FunctionTool; the registry " + f"introspection assumes FastMCP wraps each registered " + f"function with FunctionTool" + ) + sig = inspect.signature(tool.fn) + assert "socket_name" in sig.parameters, ( + f"Tool {tool.name!r} omits socket_name; either add it, " + f"add to socket_name_exempt, or update _BASE_INSTRUCTIONS" + ) + + def test_base_instructions_document_buffer_lifecycle() -> None: """_BASE_INSTRUCTIONS explains the buffer lifecycle + no list_buffers. From efb7bcb5cdc683c8779c506ee66ac46cd5837ad8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 11:01:54 -0500 Subject: [PATCH 29/40] mcp(docs[copy_mode]): note pane.cmd injects -t pane_id Inline comments at both ``pane.cmd`` sites in ``copy_mode.py`` so a future contributor doesn't "fix" the missing ``-t pane.pane_id`` back in. ``libtmux.Pane.cmd`` injects ``-t self.pane_id`` already; the manual prefix produced ``tmux -t %X -t %X ...`` and tmux's argparser silently kept the last ``-t``. The CHANGES file documents the original cleanup; this lifts the explanation onto the line that needs it. --- src/libtmux_mcp/tools/pane_tools/copy_mode.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libtmux_mcp/tools/pane_tools/copy_mode.py b/src/libtmux_mcp/tools/pane_tools/copy_mode.py index efbd38d..ed776f6 100644 --- a/src/libtmux_mcp/tools/pane_tools/copy_mode.py +++ b/src/libtmux_mcp/tools/pane_tools/copy_mode.py @@ -55,6 +55,8 @@ def enter_copy_mode( session_id=session_id, window_id=window_id, ) + # libtmux's Pane.cmd injects ``-t pane.pane_id``; passing it again + # produced the duplicated ``-t %X -t %X`` shape tmux silently accepted. pane.cmd("copy-mode") if scroll_up is not None and scroll_up > 0: pane.cmd( @@ -107,6 +109,7 @@ def exit_copy_mode( session_id=session_id, window_id=window_id, ) + # See enter_copy_mode: Pane.cmd injects ``-t pane.pane_id`` already. pane.cmd("send-keys", "-X", "cancel") pane.refresh() return _serialize_pane(pane) From 96fed9f224511ef162e3709c629a66ec82d739ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 11:02:21 -0500 Subject: [PATCH 30/40] mcp(docs[lifecycle]): mark respawn argv shape as stopgap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled ``pane.cmd("respawn-pane", *argv)`` exists because ``libtmux>=0.55.1`` ships no ``Pane.respawn()`` yet — the wrapper lives on libtmux's ``tmux-parity`` branch and uses the same flag ordering this code mirrors (``-k``, ``-c``, optional trailing shell). The inline note tells future-you that the swap, when ``Pane.respawn`` lands on the release line, is a few lines: drop the argv builder, call ``pane.respawn(kill=, start_directory=, shell=)``, drop the stderr branch (``Pane.respawn`` raises ``LibTmuxException``). Without the comment a reader would assume oversight. --- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 2c47b05..ae35770 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -153,6 +153,13 @@ def respawn_pane( "Use a manual tmux command if intended." ) raise ToolError(msg) + # Stopgap: ``libtmux>=0.55.1`` has no ``Pane.respawn()`` yet — the + # wrapper exists on the upstream ``tmux-parity`` branch (see + # ``libtmux/pane.py:respawn``) and mirrors this argv shape (``-k``, + # ``-c ``, optional trailing shell). When the release line picks + # it up, swap ``pane.cmd("respawn-pane", *argv)`` for ``pane.respawn( + # kill=kill, start_directory=start_directory, shell=shell)`` and drop + # the stderr branch — ``Pane.respawn`` raises ``LibTmuxException``. argv: list[str] = [] if kill: argv.append("-k") From c2dd6ef77eef7defd27b0e09fc6076715f061f73 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 11:03:08 -0500 Subject: [PATCH 31/40] mcp(docs[changes]): reframe respawn shell rename as pre-release decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ``shell_command -> shell`` bullet out of "Breaking changes" into "API decisions (pre-release)". The rename happened entirely on this branch and never reached ``origin/main``, so there is no shipped contract to break — calling it a *breaking change* in the changelog miscommunicates state to readers tracking releases. The remaining prose still records why the name landed at ``shell`` (alignment with ``split_window`` + upstream ``Pane.respawn`` on the ``tmux-parity`` branch); the speculative "renaming after caching is a protocol break" sentence is dropped — that's a future-state risk note, not a description of *this* release. --- CHANGES | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGES b/CHANGES index a66fc06..87496d7 100644 --- a/CHANGES +++ b/CHANGES @@ -83,14 +83,14 @@ _Notes on upcoming releases will be added here_ table picks up a `respawn-pane` row showing the unusual mutating-tier + `destructiveHint=true` + `idempotentHint=false` combination. -### Breaking changes +### API decisions (pre-release) - {tooliconl}`respawn-pane` parameter ``shell_command`` is renamed to - ``shell`` to align with {tooliconl}`split-window` and the eventual - upstream ``Pane.respawn(shell=)`` API on libtmux's ``tmux-parity`` - branch. Renamed before agents cache the schema; renaming after - caching is an MCP protocol break. No compatibility alias — this is - a pre-release library. + ``shell`` to align with {tooliconl}`split-window` and the upstream + ``Pane.respawn(shell=)`` signature on libtmux's ``tmux-parity`` + branch. Settled before the tool reached ``origin/main`` so no + compatibility alias is shipped — alignment is the load-bearing + reason, not a fix to a previously released name. ### Refactor From e5926bdae1dd708247c18111af144cceb83d75db Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 11:03:48 -0500 Subject: [PATCH 32/40] mcp(refactor[server_tools]): centralize socket_name exemption The contract "every registered tool accepts socket_name except list_servers" was encoded in two places: the prose of ``_BASE_INSTRUCTIONS`` (server.py) and a hardcoded set in ``test_registered_tools_accept_socket_name`` (tests/test_server.py). A second exempt tool would have required two synchronized edits. Lift the exemption to a module-level constant ``SOCKET_NAME_EXEMPT: frozenset[str]`` next to ``list_servers`` in ``server_tools.py``. The test imports it; the docstring near the constant tells future contributors to update the ``_BASE_INSTRUCTIONS`` prose alongside any addition. No behaviour change. --- src/libtmux_mcp/tools/server_tools.py | 10 ++++++++++ tests/test_server.py | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index e387d63..1d7fb1d 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -259,6 +259,16 @@ def _probe_server_by_path(socket_path: pathlib.Path) -> ServerInfo | None: ) +#: Tools that intentionally do NOT accept ``socket_name`` because they +#: discover or enumerate sockets themselves rather than connecting to a +#: known one. Read by ``test_registered_tools_accept_socket_name`` to +#: enforce the agent-facing contract advertised in +#: :data:`libtmux_mcp.server._BASE_INSTRUCTIONS`. When you add a new +#: 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({"list_servers"}) + + @handle_tool_errors def list_servers( extra_socket_paths: list[str] | None = None, diff --git a/tests/test_server.py b/tests/test_server.py index 202333a..a56bf70 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -207,8 +207,7 @@ def test_registered_tools_accept_socket_name() -> None: from fastmcp.tools.function_tool import FunctionTool from libtmux_mcp.tools import register_tools - - socket_name_exempt = {"list_servers"} + from libtmux_mcp.tools.server_tools import SOCKET_NAME_EXEMPT mcp = FastMCP(name="socket-name-contract") register_tools(mcp) @@ -216,7 +215,7 @@ def test_registered_tools_accept_socket_name() -> None: tools = asyncio.run(mcp.list_tools()) assert tools, "register_tools should have registered at least one tool" for tool in tools: - if tool.name in socket_name_exempt: + if tool.name in SOCKET_NAME_EXEMPT: continue assert isinstance(tool, FunctionTool), ( f"Tool {tool.name!r} is not a FunctionTool; the registry " @@ -226,7 +225,8 @@ def test_registered_tools_accept_socket_name() -> None: sig = inspect.signature(tool.fn) assert "socket_name" in sig.parameters, ( f"Tool {tool.name!r} omits socket_name; either add it, " - f"add to socket_name_exempt, or update _BASE_INSTRUCTIONS" + f"add to server_tools.SOCKET_NAME_EXEMPT, or update " + f"_BASE_INSTRUCTIONS" ) From 6432646d02dbd780e286693286d12beb15b8649c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 11:04:54 -0500 Subject: [PATCH 33/40] mcp(refactor[server]): structure base instructions as composable segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``_BASE_INSTRUCTIONS`` had grown into a six-paragraph string literal mixing positive guidance (HIERARCHY, READ-TOOLS, WAIT-DON'T-POLL) with *gap-explainers* (HOOKS-ARE-READ-ONLY, BUFFERS) — sentences that exist to tell agents why an expected tool is absent. Appending more gap-explainers to one literal makes "should this be a server segment or a tool description?" easy to skip. Split into named module constants joined by ``"\n\n"``: ``_INSTR_HIERARCHY``, ``_INSTR_METADATA_VS_CONTENT``, ``_INSTR_READ_TOOLS``, ``_INSTR_WAIT_NOT_POLL``, ``_INSTR_HOOKS_GAP``, ``_INSTR_BUFFERS_GAP``. The module-level comment names the gap-explainer pattern explicitly so the next contributor sees the "prefer the tool description" decision before adding another segment. Output text is byte-identical (verified via ``repr`` diff); existing substring assertions in ``tests/test_server.py`` continue to pass unchanged. --- src/libtmux_mcp/server.py | 61 +++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index b5e4d3b..ecf87a5 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -42,7 +42,25 @@ #: :func:`libtmux_mcp._utils._get_server`. _ServerCacheKey: t.TypeAlias = tuple[str | None, str | None, str | None] -_BASE_INSTRUCTIONS = ( +# --------------------------------------------------------------------------- +# _BASE_INSTRUCTIONS — composed from named segments. +# +# The string handed to FastMCP grew organically from "what does this server +# do?" toward a hybrid of positive guidance (HIERARCHY, READ_TOOLS, +# WAIT_NOT_POLL) and *gap-explainers* (HOOKS_GAP, BUFFERS_GAP) that document +# why a tool the agent might expect is absent. Splitting into named +# constants keeps additions deliberate: when a new ``_GAP`` segment feels +# tempting, prefer first to push the explanation into the relevant tool's +# docstring/description (where the agent encounters it at call time) and +# only fall back to a server-level segment when the gap is *server-shaped* +# (e.g. an entire tool family is intentionally missing). +# +# Output text is byte-identical to the previous monolith; tests assert on +# substrings of ``_BASE_INSTRUCTIONS``, so keeping the join shape stable +# matters. +# --------------------------------------------------------------------------- + +_INSTR_HIERARCHY = ( "libtmux MCP server for programmatic tmux control. " "tmux hierarchy: Server > Session > Window > Pane. " "Use pane_id (e.g. '%1') as the preferred targeting method - " @@ -50,13 +68,19 @@ "Use send_keys to execute commands and capture_pane to read output. " "Targeted tmux tools accept an optional socket_name parameter " "(defaults to LIBTMUX_SOCKET env var); list_servers discovers " - "sockets via TMUX_TMPDIR plus optional extra_socket_paths instead.\n\n" + "sockets via TMUX_TMPDIR plus optional extra_socket_paths instead." +) + +_INSTR_METADATA_VS_CONTENT = ( "IMPORTANT — metadata vs content: list_windows, list_panes, and " "list_sessions only search metadata (names, IDs, current command). " "To find text that is actually visible in terminals — when users ask " "what panes 'contain', 'mention', 'show', or 'have' — use " "search_panes to search across all pane contents, or list_panes + " - "capture_pane on each pane for manual inspection.\n\n" + "capture_pane on each pane for manual inspection." +) + +_INSTR_READ_TOOLS = ( "READ TOOLS TO PREFER: snapshot_pane returns pane content plus " "cursor position, mode, and scroll state in one call — use it " "instead of capture_pane + get_pane_info when you need context. " @@ -64,17 +88,31 @@ "'#{pane_current_command}', '#{session_name}') against a target " "and returns the expanded value — cheaper than parsing captured " "output. (The tool is named after the tmux 'display-message -p' " - "verb it wraps; its MCP title is 'Evaluate tmux Format String'.)\n\n" + "verb it wraps; its MCP title is 'Evaluate tmux Format String'.)" +) + +_INSTR_WAIT_NOT_POLL = ( "WAIT, DON'T POLL: for 'run command, wait for output' workflows " "use wait_for_text (matches text/regex on a pane) or " "wait_for_content_change (waits for any change). These block " "server-side until the condition is met or the timeout expires, " "which is dramatically cheaper in agent turns than capture_pane " - "in a retry loop.\n\n" + "in a retry loop." +) + +#: Gap-explainer: write-hook tools are intentionally absent. See module +#: 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. Write-hook " "tools are intentionally not exposed — tmux hooks survive process " "death, so they belong in your tmux config file, not a transient " - "MCP session.\n\n" + "MCP session." +) + +#: Gap-explainer: ``list_buffers`` is intentionally absent because tmux +#: buffers can include OS clipboard history. See module comment above. +_INSTR_BUFFERS_GAP = ( "BUFFERS: load_buffer stages content, paste_buffer delivers it into " "a pane, delete_buffer removes the staged buffer. Track owned " "buffers via the BufferRef returned from load_buffer — there is no " @@ -82,6 +120,17 @@ "history (passwords, private snippets)." ) +_BASE_INSTRUCTIONS = "\n\n".join( + ( + _INSTR_HIERARCHY, + _INSTR_METADATA_VS_CONTENT, + _INSTR_READ_TOOLS, + _INSTR_WAIT_NOT_POLL, + _INSTR_HOOKS_GAP, + _INSTR_BUFFERS_GAP, + ) +) + def _build_instructions(safety_level: str = TAG_MUTATING) -> str: """Build server instructions with agent context and safety level. From 58a0ce23f18e34e0bd55b32284825b767e4b274d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 11:10:11 -0500 Subject: [PATCH 34/40] mcp(feat[pane_tools]): expose respawn-pane environment override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the parity gap with libtmux's ``Pane.respawn(environment=)`` on the ``tmux-parity`` branch. The new ``environment: dict[str, str]`` parameter on ``respawn_pane`` maps to one ``-e KEY=VALUE`` flag per entry (single-arg ``-e=`` form, mirroring upstream's emitter — tmux's ``cmd-respawn-pane.c`` accepts both joined and split forms but upstream uses joined). The stopgap comment is updated to include ``-e`` so the eventual swap to ``pane.respawn(environment=)`` is a single internal change. Audit-log redaction is extended to recognise dict-shaped sensitive args. Each ``environment`` *value* is replaced by a ``{len, sha256_prefix}`` digest while keys remain visible (env var names like ``DATABASE_URL`` are operator-debug-useful; values are the secret). The same OS-process-table caveat as ``shell`` applies and is documented in ``docs/topics/safety.md`` under the ``respawn_pane`` subsection — the audit log redacts, but ``ps`` may still observe the flag string briefly before the spawned process inherits the env. Tests cover the new redaction shape (`tests/test_middleware.py`) and the runtime propagation path (`tests/test_pane_tools.py` — ``printenv`` under ``remain-on-exit`` so the assertion runs against captured pane content, with ``capture-pane -S -50`` to read enough scrollback even on a small pane). --- CHANGES | 16 +++++++ docs/tools/pane/respawn-pane.md | 21 ++++++++- docs/topics/safety.md | 3 +- src/libtmux_mcp/middleware.py | 39 ++++++++++++---- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 28 +++++++++--- tests/test_middleware.py | 32 +++++++++++++ tests/test_pane_tools.py | 45 +++++++++++++++++++ 7 files changed, 168 insertions(+), 16 deletions(-) diff --git a/CHANGES b/CHANGES index 87496d7..36bc2db 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,22 @@ _Notes on upcoming releases will be added here_ +### Features + +- {tooliconl}`respawn-pane` gains an ``environment`` parameter + (``dict[str, str]``) that maps to tmux's ``respawn-pane -e + KEY=VALUE`` flag (one ``-e`` per entry, single-arg ``-e=`` + form to mirror the upstream emitter). Closes the parity gap with + ``Pane.respawn(environment=)`` on libtmux's ``tmux-parity`` branch. + The audit-log redaction policy is extended to recognise dict-shaped + sensitive args: each value is replaced by a ``{len, sha256_prefix}`` + digest while keys (env var names like ``DATABASE_URL``) remain + visible — keys are operator-debug-useful, values are the secret. + Note: like ``shell``, env var values may briefly appear in the OS + process table before the spawned shell inherits them; do not pass + long-lived secrets when other tenants on the host could observe + ``ps``. + ### Tests - New ``test_registered_tools_accept_socket_name`` introspection test diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index ea0e28a..f59cdc0 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -8,7 +8,8 @@ process, bad terminal mode) and you need a clean restart *without* destroying the `pane_id` references other tools or callers may still be holding. With `kill=True` (the default) tmux kills the current process first; optional `shell` relaunches with a different command; -optional `start_directory` sets its cwd. +optional `start_directory` sets its cwd; optional `environment` adds +per-process env vars (one `-e KEY=VALUE` flag per entry). **Avoid when** the pane genuinely needs to go away — use {tooliconl}`kill-pane` instead. Also avoid when you want to change @@ -48,6 +49,24 @@ time). } ``` +**Example — relaunch with extra environment variables:** + +```json +{ + "tool": "respawn_pane", + "arguments": { + "pane_id": "%5", + "shell": "pytest -x", + "environment": { + "PYTHONPATH": "/home/user/project/src", + "DATABASE_URL": "postgres://localhost/test" + } + } +} +``` + +The audit log redacts each `environment` *value* via `{len, sha256_prefix}` digests but keeps the keys visible (env var names like `DATABASE_URL` are operator-debug-useful, while their values are the secret). Note that values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details. + Response (PaneInfo): ```json diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 0a3433e..7138463 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -104,6 +104,7 @@ Mitigations: - `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get a `ToolError` 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 (`dict[str, str]`) maps to one tmux `-e KEY=VALUE` flag per item. The audit log redacts each *value* via a `{len, sha256_prefix}` digest while keeping the *keys* visible — env var names like `DATABASE_URL` are usually operator-debug-useful, but their values are the secret. 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. ### `send_keys` / `paste_text` @@ -118,7 +119,7 @@ Every tool call emits one `INFO` record on the `libtmux_mcp.audit` logger carryi - `outcome` — `ok` or `error`, with `error_type` on failure - `duration_ms` - `client_id` / `request_id` — from the fastmcp context when available -- `args` — a summary of arguments. Sensitive keys (`keys`, `text`, `value`, `content`, `shell`) are replaced by `{len, sha256_prefix}`; non-sensitive strings over 200 characters are truncated. +- `args` — a summary of arguments. Sensitive scalar keys (`keys`, `text`, `value`, `content`, `shell`) are replaced by `{len, sha256_prefix}`; the dict-shaped sensitive key `environment` keeps its keys but digests each value individually. Non-sensitive strings over 200 characters are truncated. Route this logger to a dedicated sink if you want a durable audit trail; it is deliberately namespaced separately from the main `libtmux_mcp` logger. diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index e2e97f2..81d1090 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -100,16 +100,23 @@ async def on_call_tool( #: Argument names that carry user-supplied payloads we never want in logs. #: ``keys`` (send_keys), ``text`` (paste_text), ``value`` (set_environment), -#: ``content`` (load_buffer), and ``shell`` (respawn_pane) can contain -#: commands, secrets, or arbitrary large strings. Matched by exact name, -#: case-sensitive, to mirror the tool signatures. +#: ``content`` (load_buffer), ``shell`` (respawn_pane), and ``environment`` +#: (respawn_pane) can contain commands, secrets, or arbitrary large strings. +#: Matched by exact name, case-sensitive, to mirror the tool signatures. #: -#: Note on ``shell`` redaction: this redacts the MCP audit log only. -#: ``respawn_pane(shell="env SECRET=... bash")`` may briefly expose the -#: argument via the OS process table and tmux's ``pane_current_command`` -#: metadata until the spawned shell takes over — see ``docs/topics/safety.md``. +#: ``environment`` is dict-shaped (``dict[str, str]``); the redaction logic +#: in :func:`_summarize_args` recognises this and digests each *value* while +#: leaving the *keys* (env var names like ``DATABASE_URL``) visible — env +#: var names are operator-debug-useful, but their values are the secret. +#: All other entries are scalar strings; mixing the two is intentional. +#: +#: Note on ``shell`` and ``environment`` redaction: this redacts the MCP +#: 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``. _SENSITIVE_ARG_NAMES: frozenset[str] = frozenset( - {"keys", "text", "value", "content", "shell"} + {"keys", "text", "value", "content", "shell", "environment"} ) #: String arguments longer than this get truncated in the log summary to @@ -143,6 +150,10 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: Sensitive keys get replaced by a digest; over-long strings get truncated with a marker; everything else passes through as-is. + Sensitive values that are dict-shaped (e.g. ``environment`` on + ``respawn_pane``) have each *value* digested while keys remain + visible — env-var-name-like keys are operator-debug-useful and + rarely sensitive, while their values usually are. Examples -------- @@ -155,11 +166,21 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: >>> _summarize_args({"keys": "rm -rf /"})["keys"]["len"] 8 + + Sensitive dict-shaped payloads keep their keys but digest values: + + >>> redacted = _summarize_args({"environment": {"FOO": "bar"}}) + >>> redacted["environment"]["FOO"]["len"] + 3 + >>> "bar" in str(redacted) + False """ summary: dict[str, t.Any] = {} for key, value in args.items(): - if isinstance(value, str) and key in _SENSITIVE_ARG_NAMES: + if key in _SENSITIVE_ARG_NAMES and isinstance(value, str): summary[key] = _redact_digest(value) + elif key in _SENSITIVE_ARG_NAMES and isinstance(value, dict): + summary[key] = {k: _redact_digest(str(v)) for k, v in value.items()} elif isinstance(value, str) and len(value) > _MAX_LOGGED_STR_LEN: summary[key] = value[:_MAX_LOGGED_STR_LEN] + "..." else: diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index ae35770..05fcd70 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -67,6 +67,7 @@ def respawn_pane( kill: bool = True, shell: str | None = None, start_directory: str | None = None, + environment: dict[str, str] | None = None, socket_name: str | None = None, ) -> PaneInfo: """Restart a pane's process in place, preserving pane_id and layout. @@ -79,7 +80,9 @@ def respawn_pane( With ``kill=True`` (the default), tmux kills the existing process before respawning. Optional ``shell`` replaces the command tmux relaunches; ``start_directory`` sets the working directory for - the new process. + the new process; ``environment`` sets per-process environment + variables for the relaunched command (one ``-e KEY=VALUE`` flag + per entry). ``pane_id`` is required — no fallback to ``_resolve_pane``'s "first pane in session/window" behaviour. Default ``kill=True`` @@ -118,6 +121,16 @@ def respawn_pane( start_directory : str, optional Working directory for the relaunched command (maps to ``respawn-pane -c``). + environment : dict[str, str], optional + Environment variables to set for the relaunched process. Each + item becomes one ``-e KEY=VALUE`` flag (tmux's + ``cmd-respawn-pane.c`` supports the flag repeatedly). Values + are redacted in the audit log on a per-key basis — keys like + ``DATABASE_URL`` remain visible but their values are replaced + by ``{len, sha256_prefix}`` digests. Note that the values may + still appear briefly in the OS process table while tmux spawns + the new process; do not pass long-lived secrets here when a + host-resident agent or other tenant could observe ``ps``. socket_name : str, optional tmux socket name. @@ -155,16 +168,21 @@ def respawn_pane( raise ToolError(msg) # Stopgap: ``libtmux>=0.55.1`` has no ``Pane.respawn()`` yet — the # wrapper exists on the upstream ``tmux-parity`` branch (see - # ``libtmux/pane.py:respawn``) and mirrors this argv shape (``-k``, - # ``-c ``, optional trailing shell). When the release line picks - # it up, swap ``pane.cmd("respawn-pane", *argv)`` for ``pane.respawn( - # kill=kill, start_directory=start_directory, shell=shell)`` and drop + # ``libtmux/pane.py:respawn``) and mirrors this argv shape: ``-k``, + # ``-c ``, repeated ``-e=`` (single-arg form, NOT + # split ``-e KEY=VAL`` — tmux's args parser accepts both but + # upstream emits the joined form), then optional trailing shell. + # When the release line picks it up, swap ``pane.cmd("respawn-pane", + # *argv)`` for ``pane.respawn(kill=kill, start_directory= + # start_directory, environment=environment, shell=shell)`` and drop # the stderr branch — ``Pane.respawn`` raises ``LibTmuxException``. argv: list[str] = [] if kill: argv.append("-k") if start_directory is not None: argv.extend(["-c", start_directory]) + if environment: + argv.extend(f"-e{k}={v}" for k, v in environment.items()) if shell is not None: argv.append(shell) result = pane.cmd("respawn-pane", *argv) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 2920c37..d5ebe36 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -167,6 +167,38 @@ def test_summarize_args_redacts_sensitive_keys() -> None: assert summary["bracket"] is True +def test_summarize_args_redacts_sensitive_dict_values() -> None: + """Dict-shaped sensitive args keep keys but digest values per-entry. + + ``environment`` on ``respawn_pane`` is a ``dict[str, str]``. The + values typically carry secrets (DB passwords, API keys), but the + keys (``DATABASE_URL``, ``AWS_SECRET_KEY``) are operator-useful for + debugging which env var was set. The redaction policy preserves + keys and digests values. + """ + args: dict[str, t.Any] = { + "environment": { + "DATABASE_URL": "postgres://user:hunter2@db/app", + "AWS_SECRET_KEY": "AKIAIOSFODNN7EXAMPLE", + }, + "pane_id": "%1", + } + summary = _summarize_args(args) + assert isinstance(summary["environment"], dict) + assert set(summary["environment"].keys()) == {"DATABASE_URL", "AWS_SECRET_KEY"} + for key in ("DATABASE_URL", "AWS_SECRET_KEY"): + digest = summary["environment"][key] + assert isinstance(digest, dict) + assert "len" in digest + assert "sha256_prefix" in digest + # No value bytes leak into the rendered summary. + rendered = str(summary) + assert "hunter2" not in rendered + assert "AKIAIOSFODNN7EXAMPLE" not in rendered + # Non-sensitive args still pass through. + assert summary["pane_id"] == "%1" + + def test_summarize_args_truncates_long_non_sensitive_strings() -> None: """Non-sensitive strings over the cap get truncated with a marker.""" args = {"output_path": "x" * 500} diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index adadb4e..8bf0c20 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -405,6 +405,51 @@ def test_respawn_pane_kill_false_on_live_pane_raises( new_pane.kill() +def test_respawn_pane_with_environment( + mcp_server: Server, mcp_session: Session +) -> None: + """``environment`` propagates through to the relaunched process. + + tmux's ``respawn-pane -e KEY=VALUE`` sets per-process env vars on + the spawned command (``cmd-respawn-pane.c`` accepts the flag + repeatedly). Verify by relaunching with ``sh -c 'env'`` under + ``remain-on-exit`` so we can capture the env output after the + process exits without tmux deleting the pane out from under us. + """ + window = mcp_session.active_window + window.cmd("set-option", "-w", "remain-on-exit", "on") + new_pane = window.split(shell="sleep 3600") + assert new_pane.pane_id is not None + + # Use ``printenv`` over ``env`` so the output fits the visible pane + # (default capture-pane reads only the visible screen, not history). + # Wrap the values in markers so we don't false-match on similarly + # named host env vars that might already be set. + result = respawn_pane( + pane_id=new_pane.pane_id, + shell="sh -c 'printenv LIBTMUX_TEST_FOO LIBTMUX_TEST_BAZ'", + environment={"LIBTMUX_TEST_FOO": "bar", "LIBTMUX_TEST_BAZ": "qux"}, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == new_pane.pane_id + + def _pane_dead() -> bool: + out = new_pane.cmd("display-message", "-p", "#{pane_dead}").stdout + return bool(out) and out[0].strip() == "1" + + retry_until(_pane_dead, seconds=5, raises=True) + + # ``-S -50`` reads the last 50 lines of scrollback so we don't lose + # the first ``printenv`` line off the top of the visible screen. + captured = new_pane.cmd("capture-pane", "-p", "-S", "-50").stdout + rendered = "\n".join(captured) + assert "bar" in rendered + assert "qux" in rendered + + new_pane.kill() + window.cmd("set-option", "-wu", "remain-on-exit") + + # --------------------------------------------------------------------------- # search_panes tests # --------------------------------------------------------------------------- From 6488a9a26a4b837c4d1594f645b141714bacd672 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 11:11:01 -0500 Subject: [PATCH 35/40] scripts(docs[mcp_swap]): clarify global-config + simple-detection scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script's previous docstring implied broad cross-CLI parity with the actual CLIs' install/discovery surface. In practice it writes only to the four canonical *global* config paths and probes binaries via ``shutil.which`` + file-exists. Workspace configs (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``), npm prefixes, Homebrew paths, and post-migration locations like ``~/.claude/local/claude`` are intentionally not handled — those match each CLI's native installer logic, which this script does not duplicate. Add an explicit "Scope" section to the module docstring and an "Out of scope" subsection to ``scripts/README.md`` so contributors know where to use ``cursor mcp add`` / ``gemini mcp add`` directly. No behaviour change. --- scripts/README.md | 15 ++++++++++++++- scripts/mcp_swap.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/README.md b/scripts/README.md index dbebd69..e6699cc 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -59,7 +59,7 @@ invocation with no reinstall step. ### Scope -Covers four CLIs and their canonical config paths: +Covers four CLIs and their canonical **global** config paths: | CLI | Config | Format | |--------|-------------------------------|--------| @@ -72,6 +72,19 @@ Claude's config is keyed per-project under the repo's absolute path — the script writes only under the current repo's key, leaving other projects' entries untouched. +#### Out of scope (use the CLI's native command) + +- **Workspace / project-local configs** for Cursor and Gemini + (`$PWD/.cursor/mcp.json`, `$PWD/.gemini/settings.json`). When + workspace precedence matters, use `cursor mcp add` / `gemini mcp add` + directly — workspace files take precedence over the global ones this + script writes. +- **Custom binary install locations.** Detection is `shutil.which` plus + the file existing at the configured global path. Homebrew, npm + prefixes (`~/.npm-global/bin`), and post-migration paths + (`~/.claude/local/claude`, `~/.gemini/local/gemini`) are picked up + only when the binary is already on `PATH`. + ### Extending to a new CLI Add an entry to the `CLIS` table in `mcp_swap.py` and extend the three diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index e0e185b..fbaead1 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -25,6 +25,29 @@ $ uv run scripts/mcp_swap.py use-local $ uv run scripts/mcp_swap.py revert ``` + +Scope +----- +This script is best-effort and intentionally narrow: + +- **Global configs only.** Writes to ``~/.cursor/mcp.json``, + ``~/.claude.json``, ``~/.codex/config.toml``, and + ``~/.gemini/settings.json``. Workspace / project-local configs + (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, + per-project ``projects..mcpServers`` entries inside + ``~/.claude.json`` *are* recognised for Claude only) are NOT + walked — workspace files for Cursor/Gemini are silently ignored. + When workspace precedence matters, run the CLI's own + ``cursor mcp add ...`` / ``gemini mcp add ...`` directly. +- **Simple binary detection.** Probing is ``shutil.which()`` + plus ``.exists()``. Custom install locations + (Homebrew, npm prefixes, ``~/.npm-global/bin``, + ``~/.claude/local/claude``, ``~/.gemini/local/gemini``) are picked + up only if the binary is on ``PATH``. FastMCP's installer probes + these locations directly; this script does not. +- **Single config shape per CLI.** No fallback paths, no merge of + multiple sources. If your setup deviates from the defaults above, + use the CLI's native ``mcp`` subcommand instead. """ from __future__ import annotations From 199404aac3bcbd5d876da837d660c0d1b690fe3f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 12:27:24 -0500 Subject: [PATCH 36/40] mcp(api[pane_tools]): drop respawn-pane resolver fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim respawn_pane's signature to the parameters it actually honors. The session_name / session_id / window_id fields existed only to mirror sibling pane tools' shared resolver shape, but the in-tool guard raised ToolError on any combination missing pane_id — so they were dead surface area broadcasting targeting flexibility to LLMs that did not exist. Validation now lives at the FastMCP schema boundary (pane_id: str, no default) instead of an in-tool ToolError raise. The two tests that exercised the runtime guard are deleted; one became a Python TypeError on the trimmed signature, the other passed a parameter that no longer exists. The remaining respawn_pane tests are unchanged — they all called with explicit pane_id from the start. Settled before respawn-pane reaches origin/main so no compat alias ships. --- CHANGES | 10 +++++ src/libtmux_mcp/tools/pane_tools/lifecycle.py | 42 +++++-------------- tests/test_pane_tools.py | 32 -------------- 3 files changed, 20 insertions(+), 64 deletions(-) diff --git a/CHANGES b/CHANGES index 36bc2db..d2517a2 100644 --- a/CHANGES +++ b/CHANGES @@ -107,6 +107,16 @@ _Notes on upcoming releases will be added here_ branch. Settled before the tool reached ``origin/main`` so no compatibility alias is shipped — alignment is the load-bearing reason, not a fix to a previously released name. +- {tooliconl}`respawn-pane` signature drops the optional + ``session_name`` / ``session_id`` / ``window_id`` resolver fallbacks + that sibling pane tools accept. The runtime guard would have raised + on any combination missing ``pane_id`` anyway (the tool requires + explicit ``pane_id`` to prevent silent-kill-via-resolver), so the + parameters were dead surface area broadcasting targeting flexibility + to LLMs that did not exist. Validation now lives at the FastMCP + schema boundary via ``pane_id: str`` rather than an in-tool + ``ToolError`` raise. Settled before the tool reached ``origin/main`` + so no compat alias ships. ### Refactor diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 05fcd70..1edd0dc 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -60,10 +60,7 @@ def kill_pane( @handle_tool_errors def respawn_pane( - pane_id: str | None = None, - session_name: str | None = None, - session_id: str | None = None, - window_id: str | None = None, + pane_id: str, kill: bool = True, shell: str | None = None, start_directory: str | None = None, @@ -84,11 +81,13 @@ def respawn_pane( variables for the relaunched command (one ``-e KEY=VALUE`` flag per entry). - ``pane_id`` is required — no fallback to ``_resolve_pane``'s - "first pane in session/window" behaviour. Default ``kill=True`` - will terminate the resolved pane's process, so accidental targeting - can silently kill an unrelated server. Resolve via ``list_panes`` - first. + ``pane_id`` is required — sibling pane tools accept a hierarchical + fallback (``session_name`` / ``window_id`` / ``pane_index``) that + resolves to "first pane in session/window", but combined with + default ``kill=True`` that fallback could silently kill an + unrelated process. The signature deliberately omits the resolver + fields so the FastMCP schema rejects them at the framework + boundary. Resolve via ``list_panes`` first. Tip: call ``get_pane_info`` first if you need to capture ``pane_current_command`` before respawn — the new process loses its @@ -99,15 +98,7 @@ def respawn_pane( Parameters ---------- pane_id : str - Pane ID (e.g. '%1'). Required — no fallback resolution. - session_name : str, optional - Accepted for backwards-compatibility with the ``_resolve_pane`` - signature shared across pane tools, but the explicit ``pane_id`` - guard above raises before this is consulted. - session_id : str, optional - See ``session_name``. - window_id : str, optional - See ``session_name``. + Pane ID (e.g. '%1'). Required. kill : bool When True (default), pass ``-k`` to tmux so the current process is killed before respawning. When False, respawn @@ -140,21 +131,8 @@ def respawn_pane( Serialized pane metadata after respawn. The pane_id is preserved; pane_pid reflects the new process. """ - if pane_id is None: - msg = ( - "respawn_pane requires an explicit pane_id (e.g. '%1') because " - "default kill=True will terminate the resolved pane's process. " - "Resolve the target via list_panes first." - ) - raise ToolError(msg) server = _get_server(socket_name=socket_name) - pane = _resolve_pane( - server, - pane_id=pane_id, - session_name=session_name, - session_id=session_id, - window_id=window_id, - ) + pane = _resolve_pane(server, pane_id=pane_id) caller = _get_caller_identity() if ( caller is not None diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 8bf0c20..87deb55 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -312,38 +312,6 @@ def test_respawn_pane_self_kill_guard( new_pane.kill() -def test_respawn_pane_rejects_implicit_target(mcp_server: Server) -> None: - """respawn_pane refuses when no targeting parameter is supplied. - - Without ``pane_id`` (or any other discriminator) ``_resolve_pane`` - falls back to the first pane of the first window of the first - session — combined with default ``kill=True`` that could silently - kill an unrelated server. The runtime guard requires explicit - ``pane_id``. - """ - with pytest.raises(ToolError, match="explicit pane_id"): - respawn_pane(socket_name=mcp_server.socket_name) - - -def test_respawn_pane_rejects_session_only_target( - mcp_server: Server, mcp_session: Session -) -> None: - """respawn_pane refuses ``session_name`` without ``pane_id``. - - ``session_name`` alone resolves to the first pane of the first - window, which is not what the caller intends when recovering a - wedged shell elsewhere in the session. The guard requires - ``pane_id`` regardless of which other targeting parameters are - present. - """ - assert mcp_session.session_name is not None - with pytest.raises(ToolError, match="explicit pane_id"): - respawn_pane( - session_name=mcp_session.session_name, - socket_name=mcp_server.socket_name, - ) - - def test_respawn_pane_kill_false_on_dead_pane_succeeds( mcp_server: Server, mcp_session: Session ) -> None: From 62a200ad6cd41c78d6afc829afd54adb0492b879 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 12:29:22 -0500 Subject: [PATCH 37/40] scripts(fix[mcp_swap]): preserve existing env on use-local replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_use_local previously constructed the replacement spec via build_local_spec (which leaves env={}) and wrote it directly. Any env entries the user had set on the prior pinned-PyPI block — LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev knobs — were silently dropped on swap. The timestamped backup at .bak.mcp-swap- still captured the original config so revert restored everything, but a day-to-day swap shouldn't require manual env restoration. Merge current.env into the new spec via dataclasses.replace before set_server. The merge is symmetric with _spec_from_entry, which already round-trips env on the read side. Only fires when current is not None, so the Codex "added" path keeps writing empty env. Two regression tests: one seeds env on a Cursor entry and asserts preservation through swap; the paired test pins the no-prior-entry Codex path to lock the merge logic against accidental env synthesis. --- CHANGES | 13 ++++++++ scripts/mcp_swap.py | 8 ++++- tests/test_mcp_swap.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index d2517a2..dcf1fdd 100644 --- a/CHANGES +++ b/CHANGES @@ -40,6 +40,19 @@ _Notes on upcoming releases will be added here_ ### Scripts +- ``scripts/mcp_swap.py``: ``use-local`` now preserves an existing + entry's ``env`` dict on replacement. Previously a swap silently + dropped client-side environment settings (``LIBTMUX_SAFETY``, + ``LIBTMUX_SOCKET``, custom dev knobs) because ``build_local_spec`` + returned a spec with empty env and ``set_server`` wrote it unchanged. + The timestamped backup at ``.bak.mcp-swap-`` still captures the + full original config for full recovery via ``revert``, but day-to-day + swaps no longer require manual env restoration. The merge mirrors + ``_spec_from_entry`` (which round-trips env on the read side). + Regression test: + ``tests/test_mcp_swap.py:test_use_local_preserves_existing_env_when_replacing`` + plus a paired ``test_use_local_with_no_prior_entry_writes_empty_env`` + to lock the Codex "added" path against accidental env synthesis. - ``scripts/mcp_swap.py``: ``_claude_project_node`` validates that Claude's undocumented ``projects..mcpServers`` layout is still mapping-shaped before mutating it. If a future Claude release diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index fbaead1..266f711 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -556,7 +556,13 @@ def cmd_use_local(args: argparse.Namespace) -> int: ): print(f"[{cli}] already local (this repo) — no change") continue - action = set_server(cli, config, server, spec, repo) + # 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 + # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on + # the read side. + cli_spec = dataclasses.replace(spec, env={**current.env}) if current else spec + action = set_server(cli, config, server, cli_spec, repo) new_bytes = dump_config_bytes(info, config) if args.dry_run: diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index 6152496..6a570e1 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -158,6 +158,76 @@ def test_json_swap_and_revert_round_trip( assert info.config_path.read_bytes() == original +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_SAFETY`` 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. + """ + info = mcp_swap.CLIS["cursor"] + _write_json( + info.config_path, + { + "mcpServers": { + "libtmux": { + "command": "uvx", + "args": ["libtmux-mcp==0.1.0a2"], + "env": {"LIBTMUX_SAFETY": "readonly", "FOO": "bar"}, + } + } + }, + ) + + 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["command"] == "uv" + assert entry["args"] == [ + "--directory", + str(fake_repo.resolve()), + "run", + "libtmux-mcp", + ] + assert entry["env"] == {"LIBTMUX_SAFETY": "readonly", "FOO": "bar"} + + +def test_use_local_with_no_prior_entry_writes_empty_env( + fake_home: pathlib.Path, fake_repo: pathlib.Path +) -> None: + """When no prior entry exists, the new spec lands with empty env. + + The env-merge branch only fires for replacements; the "added" path + (e.g. Codex with no prior libtmux block) should match + ``build_local_spec``'s default empty env. This pins the Codex add + case so the merge logic doesn't accidentally synthesise env from + nothing. + """ + info = mcp_swap.CLIS["codex"] + info.config_path.parent.mkdir(parents=True, exist_ok=True) + info.config_path.write_text("# empty config\n") + + args = mcp_swap.build_parser().parse_args( + ["use-local", "--repo", str(fake_repo), "--cli", "codex"] + ) + assert mcp_swap.cmd_use_local(args) == 0 + + config = tomlkit.parse(info.config_path.read_text()) + table = config["mcp_servers"]["libtmux"] # type: ignore[index] + assert isinstance(table, tomlkit.items.Table) + assert "env" not in table + + def test_json_swap_preserves_unrelated_servers( fake_home: pathlib.Path, fake_repo: pathlib.Path ) -> None: From 7216f257f950bf6937431363fce463efcf8b6400 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 14:05:38 -0500 Subject: [PATCH 38/40] docs(CHANGES) Trim pre-release block to high-level deliveries Replace the layered Features/Tests/Scripts/Docs/API decisions/Refactor/ Fixes block with three sub-section entries under What's new (one per shipped surface), three Documentation bullets, and one API-decisions bullet. Folds intra-branch corrections into the parent feature entries since they describe how the new surface ships, not separate deliveries. Drops the Refactor section (behavior-preserving). The introspection test that backstops the socket_name contract is internal infrastructure; the tightened wording is what users see. --- CHANGES | 183 ++++++-------------------------------------------------- 1 file changed, 19 insertions(+), 164 deletions(-) diff --git a/CHANGES b/CHANGES index dcf1fdd..463606f 100644 --- a/CHANGES +++ b/CHANGES @@ -6,174 +6,29 @@ _Notes on upcoming releases will be added here_ -### Features - -- {tooliconl}`respawn-pane` gains an ``environment`` parameter - (``dict[str, str]``) that maps to tmux's ``respawn-pane -e - KEY=VALUE`` flag (one ``-e`` per entry, single-arg ``-e=`` - form to mirror the upstream emitter). Closes the parity gap with - ``Pane.respawn(environment=)`` on libtmux's ``tmux-parity`` branch. - The audit-log redaction policy is extended to recognise dict-shaped - sensitive args: each value is replaced by a ``{len, sha256_prefix}`` - digest while keys (env var names like ``DATABASE_URL``) remain - visible — keys are operator-debug-useful, values are the secret. - Note: like ``shell``, env var values may briefly appear in the OS - process table before the spawned shell inherits them; do not pass - long-lived secrets when other tenants on the host could observe - ``ps``. - -### Tests - -- New ``test_registered_tools_accept_socket_name`` introspection test - in ``tests/test_server.py`` enumerates every registered tool via - ``FastMCP.list_tools()`` and asserts each accepts a ``socket_name`` - parameter, with ``list_servers`` as the documented exception (it - takes ``extra_socket_paths`` instead). Catches future tool drift - before it silently makes ``_BASE_INSTRUCTIONS`` lie to agents. The - ``_BASE_INSTRUCTIONS`` text itself is tightened from "All tools - accept an optional socket_name parameter" to "Targeted tmux tools - accept an optional socket_name parameter (defaults to LIBTMUX_SOCKET - env var); list_servers discovers sockets via TMUX_TMPDIR plus - optional extra_socket_paths instead." A companion content test - (``test_base_instructions_document_socket_name_contract``) locks the - wording. - -### Scripts - -- ``scripts/mcp_swap.py``: ``use-local`` now preserves an existing - entry's ``env`` dict on replacement. Previously a swap silently - dropped client-side environment settings (``LIBTMUX_SAFETY``, - ``LIBTMUX_SOCKET``, custom dev knobs) because ``build_local_spec`` - returned a spec with empty env and ``set_server`` wrote it unchanged. - The timestamped backup at ``.bak.mcp-swap-`` still captures the - full original config for full recovery via ``revert``, but day-to-day - swaps no longer require manual env restoration. The merge mirrors - ``_spec_from_entry`` (which round-trips env on the read side). - Regression test: - ``tests/test_mcp_swap.py:test_use_local_preserves_existing_env_when_replacing`` - plus a paired ``test_use_local_with_no_prior_entry_writes_empty_env`` - to lock the Codex "added" path against accidental env synthesis. -- ``scripts/mcp_swap.py``: ``_claude_project_node`` validates that - Claude's undocumented ``projects..mcpServers`` layout is still - mapping-shaped before mutating it. If a future Claude release - reshapes the structure, the script raises ``RuntimeError("Claude - config layout appears to have changed...")`` *before* the atomic - write — so the timestamped backup defense isn't asked to recover - from a partial mutation. Three new tests in ``tests/test_mcp_swap.py`` - cover the rejection paths and the well-shaped happy path. -- ``scripts/mcp_swap.py``: ``_claude_project_node`` now uses - ``@t.overload`` so ``create=True`` is statically narrowed to - ``dict[str, t.Any]``. ``set_server`` drops the runtime - ``assert node is not None`` (which would have been stripped under - ``python -O``) — mypy proves the invariant via the overload instead. - Strict-typing-priority alignment. -- ``scripts/mcp_swap.py`` PEP 723 ``requires-python`` lowered from - ``>=3.11`` to ``>=3.10`` to match the project floor in - ``pyproject.toml``. The script does not use any 3.11-only features - (verified: no ``tomllib``, ``ExceptionGroup``, ``except*``, or - structural ``match``); the previous bound made ``uv run scripts/ - mcp_swap.py`` provision a fresh 3.11 toolchain on contributor - machines that otherwise share the project's 3.10 venv. - -### Docs - -- {ref}`safety` macOS ``TMUX_TMPDIR`` caveat now reflects shipped - behaviour. ``_effective_socket_path`` already queries tmux's - ``display-message -p '#{socket_path}'`` first and only falls back to - ``$TMUX_TMPDIR`` reconstruction when the server is unreachable, but - the doc still framed the structural fix as future work and told - operators to set ``TMUX_TMPDIR`` explicitly. Replace with the actual - three-step resolution order so doc readers don't write code or - service files chasing a problem that's already solved. -- Strip dangling internal-audit citations from production source - comments. ``window_tools.py:106`` no longer references "the - brainstorm-and-refine audit §7.1" — the architectural fence - (the four-hierarchy-level rule) stays, the orphaned citation goes. - ``session_tools.py:74-76`` cross-reference still resolves. The - ``hook_tools.py`` module docstring drops "The brainstorm-and-refine - plan deliberately excludes write-hooks" and keeps the rationale - (hook persistence + lifespan teardown gap). -- {tooliconl}`get-window-info` "Avoid when" guidance now points to - {tooliconl}`list-windows` (not {tooliconl}`list-panes`) for whole- - session window enumeration. The previous wording trained agents into - the wrong tool: ``list_panes`` returns ``PaneInfo`` objects, while - ``list_windows`` is the window enumerator and accepts ``session_id``. -- {tooliconl}`respawn-pane` docstring and topic page now include a tip - to call {tooliconl}`get-pane-info` first if the agent needs to - preserve ``pane_current_command`` across the respawn — tmux's default - behaviour is to replay the original argv, but a custom split-time - shell may differ. -- {ref}`safety` adds a {tooliconl}`respawn-pane` subsection under - "Footguns inside the `mutating` tier" alongside {tooliconl}`pipe-pane` - and {tooliconl}`set-environment`. Documents the `kill=True` default, - the non-idempotent retry semantics, the explicit-`pane_id` - requirement, and the `pane_current_command` / OS-process-table - visibility window for any `shell` argument. The per-tool annotation - table picks up a `respawn-pane` row showing the unusual mutating-tier - + `destructiveHint=true` + `idempotentHint=false` combination. +### What's new -### API decisions (pre-release) +#### New tool: `respawn_pane` -- {tooliconl}`respawn-pane` parameter ``shell_command`` is renamed to - ``shell`` to align with {tooliconl}`split-window` and the upstream - ``Pane.respawn(shell=)`` signature on libtmux's ``tmux-parity`` - branch. Settled before the tool reached ``origin/main`` so no - compatibility alias is shipped — alignment is the load-bearing - reason, not a fix to a previously released name. -- {tooliconl}`respawn-pane` signature drops the optional - ``session_name`` / ``session_id`` / ``window_id`` resolver fallbacks - that sibling pane tools accept. The runtime guard would have raised - on any combination missing ``pane_id`` anyway (the tool requires - explicit ``pane_id`` to prevent silent-kill-via-resolver), so the - parameters were dead surface area broadcasting targeting flexibility - to LLMs that did not exist. Validation now lives at the FastMCP - schema boundary via ``pane_id: str`` rather than an in-tool - ``ToolError`` raise. Settled before the tool reached ``origin/main`` - so no compat alias ships. - -### Refactor - -- Drop redundant ``["-t", pane.pane_id]`` argument prefixes at five - ``pane.cmd(...)`` sites (`pane_tools/lifecycle.py:132`, - `copy_mode.py:58, 110`, `meta.py:64, 150`). libtmux's ``Pane.cmd`` - already injects ``-t self.pane_id`` via ``Server.cmd``, so the - resulting wire form was ``tmux -t %X -t %X ...``; tmux's args - parser kept the last ``-t`` so behaviour was identical, but the - redundancy was confusing slop. The convention exemplar at - `copy_mode.py:60-66` (``pane.cmd("send-keys", "-X", ...)``) shows the - intended call shape. +Restart a wedged pane in place — preserves `pane_id` and the window layout (the alternative `kill_pane` + `split_window` invalidates pane references and rearranges the window). Required `pane_id`; optional `kill` (default true), `shell`, `start_directory`, `environment`. Refuses to respawn the MCP server's own pane, mirroring the existing `kill_*` self-guards. Annotations honestly mark it `destructiveHint=true` / `idempotentHint=false` while staying in the `mutating` tier so default-profile agents can use it for shell recovery. Audit log redacts the `shell` argument and digests `environment` values (keys stay visible). (#27) -### Fixes +#### New tools: `get_session_info`, `get_window_info` + +Targeted single-object metadata reads — no more `list_*` + filter when you have an ID. Completes the four-level `get_*_info` hierarchy (server / session / window / pane); buffers, hooks, and options have existing read paths and are deliberately excluded. (#27) + +#### New dev script: `scripts/mcp_swap.py` + +Point Claude / Codex / Cursor / Gemini at a local checkout in one command. `just mcp-use-local` rewrites each CLI's global config to run the local repo via `uv`; `just mcp-revert` restores from a timestamped backup. `just mcp-detect` and `just mcp-status` report install state. Atomic writes, `--dry-run` mode, per-CLI state file, env preservation on replacement, layout-shape guard before mutating Claude's per-project config. Scope is global configs only — see `scripts/README.md`. (#27) + +### Documentation + +- {ref}`safety` adds a {tooliconl}`respawn-pane` "Footgun" subsection alongside {tooliconl}`pipe-pane` and {tooliconl}`set-environment`: `kill=true` default, non-idempotent retries, explicit-`pane_id` requirement, OS-process-table visibility for `shell` / `environment`. The macOS `TMUX_TMPDIR` caveat is rewritten to reflect shipped behaviour — tmux's three-step socket-path resolution is documented, so operators no longer need to set `TMUX_TMPDIR` explicitly to chase a problem that's already solved. (#27) +- LLM-facing discoverability tightened: {tooliconl}`display-message` retitled "Evaluate tmux Format String" with a docstring that leads with read-only format expansion (the tool wraps `display-message -p` but `-p` expands rather than displays), {tooliconl}`pipe-pane` leads with the `/tmp/pane.log` logging use case, and the server instructions explain why hooks are read-only and why there is no `list_buffers` tool. The `socket_name` contract is tightened to acknowledge {tooliconl}`list-servers` as the documented exception. (#27) +- Topic pages added for {tooliconl}`respawn-pane`, {tooliconl}`get-session-info`, and {tooliconl}`get-window-info`. (#27) + +### API decisions (pre-release) -- Audit log now redacts the ``shell`` argument on - {tooliconl}`respawn-pane` (and ``content`` on {tooliconl}`load-buffer`, - which the code already redacted but the docs did not list). The - ``shell`` payload may carry credentials passed to a relaunched - process; redacting the MCP audit log keeps them out of long-lived - log archives. Note: ``shell`` may still appear briefly in the OS - process table and tmux's ``pane_current_command`` metadata until the - spawned shell takes over — do not pass credentials directly even - with redaction. -- {tooliconl}`respawn-pane` now requires an explicit ``pane_id``. Its - signature still accepts ``session_name`` / ``session_id`` / - ``window_id`` for backwards-compatibility with the shared pane-target - resolution surface, but a runtime guard raises before - ``_resolve_pane`` is invoked. Without the guard, calling - ``respawn_pane(session_name="dev")`` resolved through ``_resolve_pane`` - to the first pane of the first window — combined with default - ``kill=True`` that could silently kill a critical running process - (e.g. an `npm run dev` server) instead of the intended wedged shell. - Resolve the target via {tooliconl}`list-panes` first. -- {tooliconl}`respawn-pane` now advertises honest MCP annotations. - Previously the tool inherited ``ANNOTATIONS_MUTATING`` defaults - (`destructiveHint=False`, `idempotentHint=True`) even though its - default `kill=True` sends `SPAWN_KILL` to the running process and - repeated calls kill repeated processes. The new - `ANNOTATIONS_MUTATING_DESTRUCTIVE` preset keeps the tool in - `TAG_MUTATING` (so it stays visible to default-profile agents for - shell recovery) while exporting `destructiveHint=True` and - `idempotentHint=False`. Agents reading the annotations can no longer - conclude that respawn-retries are safe. +- {tooliconl}`respawn-pane` settles on `shell` (renamed from `shell_command`) to align with {tooliconl}`split-window` and upstream `Pane.respawn(shell=)`, and drops the `session_name` / `session_id` / `window_id` resolver fallbacks — the runtime guard rejected any call missing `pane_id` anyway. Validation now lives at the FastMCP schema boundary. (#27) ## libtmux-mcp 0.1.0a3 (2026-04-19) From 082080559a6045c8271c91e5e0a1f43dadea6915 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 15:26:49 -0500 Subject: [PATCH 39/40] scripts(refactor[mcp_swap]): rename state dir to libtmux-mcp-dev under XDG_STATE_HOME Old path ~/.local/state/libtmux-mcp/mcp_swap.json shared its directory name with the runtime package even though this is dev-only tooling. New path resolves $XDG_STATE_HOME (defaulting to ~/.local/state) and namespaces under libtmux-mcp-dev/swap/state.json. State (vs. cache/config/data) is the correct XDG bucket: the file is machine-written, must persist across runs so revert can find the right backup, but is neither safely deletable like cache nor user-edited like config. No migration shim: pre-alpha, dev-only, single-user surface. Anyone holding state from the old path should run revert before pulling, or manually move ~/.local/state/libtmux-mcp/mcp_swap.json to ~/.local/state/libtmux-mcp-dev/swap/state.json. --- scripts/README.md | 7 ++++--- scripts/mcp_swap.py | 22 ++++++++++++++++++++-- tests/test_mcp_swap.py | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index e6699cc..a2704f5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -50,9 +50,10 @@ invocation with no reinstall step. - Every rewrite writes a timestamped backup (`.bak.mcp-swap-`) before touching the file. -- State is tracked in `~/.local/state/libtmux-mcp/mcp_swap.json` so - `revert` knows which backup to restore per CLI, including the "added" - case where Codex had no libtmux block before. +- State is tracked in `~/.local/state/libtmux-mcp-dev/swap/state.json` + (honours `XDG_STATE_HOME`) so `revert` knows which backup to restore + per CLI, including the "added" case where Codex had no libtmux block + before. - Writes are atomic (tempfile + `os.replace`) and re-validated by re-parsing; a bad write is rolled back immediately. - `--dry-run` prints a unified diff and writes nothing. diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 266f711..5c79f95 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -70,8 +70,26 @@ CLIName = t.Literal["claude", "codex", "cursor", "gemini"] ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini") -STATE_DIR = pathlib.Path.home() / ".local" / "state" / "libtmux-mcp" -STATE_FILE = STATE_DIR / "mcp_swap.json" + +def _xdg_state_home() -> pathlib.Path: + """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. + + Defaults to ``~/.local/state`` when the env var is unset or empty. + State is the right XDG bucket here (vs. cache / config / data): the + file is machine-written, must persist across runs so ``revert`` can + locate the right backup, but is not safely deletable like cache nor + user-edited like config. + """ + env = os.environ.get("XDG_STATE_HOME") + if env: + return pathlib.Path(env) + return pathlib.Path.home() / ".local" / "state" + + +# ``-dev`` suffix in the namespace makes it loud that this is dev-only +# tooling state, distinct from the runtime ``libtmux-mcp`` package. +STATE_DIR = _xdg_state_home() / "libtmux-mcp-dev" / "swap" +STATE_FILE = STATE_DIR / "state.json" STATE_VERSION = 1 BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index 6a570e1..fe300b8 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -66,7 +66,7 @@ def fake_home(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathli ) state_dir = tmp_path / "state" monkeypatch.setattr(mcp_swap, "STATE_DIR", state_dir) - monkeypatch.setattr(mcp_swap, "STATE_FILE", state_dir / "mcp_swap.json") + monkeypatch.setattr(mcp_swap, "STATE_FILE", state_dir / "state.json") return tmp_path From d8f00984c5345420f16f9e801037ebaf1974addb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 16:49:46 -0500 Subject: [PATCH 40/40] scripts(typing[mcp_swap]): narrow claude project node via isinstance CI's ``uv run mypy .`` flagged ``Returning Any from function declared to return "dict[str, Any] | None"`` at the return of ``_claude_project_node``: ``projects.get(key)`` produces ``Any`` and the existing inner shape guard didn't propagate that narrowing to the return site. Replace the post-hoc check with an isinstance-narrowed assignment: ``raw_node`` stays ``Any`` from ``projects.get`` but the ``isinstance(raw_node, dict)`` branch narrows it onto a typed ``node: dict[str, t.Any] | None``. Same runtime behaviour (the non-None / non-dict path still raises ``RuntimeError``) without ``t.cast``. This supersedes a transient ``t.cast`` fix on this branch. --- scripts/mcp_swap.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 5c79f95..3391ce6 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -275,12 +275,15 @@ def _claude_project_node( projects = ( config.setdefault("projects", {}) if create else config.get("projects", {}) ) - node = projects.get(key) - if node is not None and not isinstance(node, dict): + raw_node = projects.get(key) + node: dict[str, t.Any] | None = None + if isinstance(raw_node, dict): + node = raw_node + elif raw_node is not None: msg = ( "Claude config layout appears to have changed; expected " f"'projects[{key!r}]' to be a mapping but got " - f"{type(node).__name__}" + f"{type(raw_node).__name__}" ) raise RuntimeError(msg) if node is None and create: