diff --git a/CHANGES b/CHANGES index 174637c..463606f 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,30 @@ _Notes on upcoming releases will be added here_ +### What's new + +#### New tool: `respawn_pane` + +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) + +#### 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) + +- {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) _Post-0.1.0a2 smoke-test fixes and `libtmux` floor bump_ diff --git a/README.md b/README.md index 70930f3..a108407 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ 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` | -| **Window** | `list_panes`, `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` | +| **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`, `respawn_pane`, `kill_pane` | | **Options** | `show_option`, `set_option` | | **Environment** | `show_environment`, `set_environment` | 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 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. #} 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/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/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..f59cdc0 --- /dev/null +++ b/docs/tools/pane/respawn-pane.md @@ -0,0 +1,83 @@ +# 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` relaunches with a different command; +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 +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. + +**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 +{ + "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": "pytest -x", + "start_directory": "/home/user/project" + } +} +``` + +**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 +{ + "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/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/docs/tools/window/get-window-info.md b/docs/tools/window/get-window-info.md new file mode 100644 index 0000000..1357a39 --- /dev/null +++ b/docs/tools/window/get-window-info.md @@ -0,0 +1,57 @@ +# 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_windows` with +`session_id` or iterate 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/docs/topics/safety.md b/docs/topics/safety.md index 3d1760b..7138463 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 @@ -90,6 +94,19 @@ 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 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` 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. @@ -102,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`) 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. @@ -135,6 +152,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 | 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 03d69b1..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", @@ -139,13 +141,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]] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..a2704f5 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,94 @@ +# 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-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. + +### Scope + +Covers four CLIs and their canonical **global** 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. + +#### 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 +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..3391ce6 --- /dev/null +++ b/scripts/mcp_swap.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# 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 +``` + +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 + +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") + + +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-" + + +# --------------------------------------------------------------------------- +# 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) +# --------------------------------------------------------------------------- + + +@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. + + 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``. + + 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", {}) + ) + 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(raw_node).__name__}" + ) + raise RuntimeError(msg) + 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) + 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()}, + } + atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) + + +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 + # 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: + 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/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/middleware.py b/src/libtmux_mcp/middleware.py index 44a37d3..81d1090 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -100,10 +100,24 @@ 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), ``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. +#: +#: ``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", "environment"} +) #: String arguments longer than this get truncated in the log summary to #: keep records bounded. Non-sensitive strings only — sensitive ones are @@ -136,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 -------- @@ -148,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/server.py b/src/libtmux_mcp/server.py index e21cfc7..ecf87a5 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -42,26 +42,56 @@ #: :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 - " "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." +) + +_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. " - "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'.)" +) + +_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 " @@ -70,6 +100,37 @@ "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." +) + +#: 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 " + "list_buffers tool because tmux buffers may include OS clipboard " + "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. @@ -118,7 +179,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/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/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index a8c1af2..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, @@ -36,6 +37,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 +63,7 @@ "pipe_pane", "register", "resize_pane", + "respawn_pane", "search_panes", "select_pane", "send_keys", @@ -88,6 +91,11 @@ def register(mcp: FastMCP) -> None: annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_DESTRUCTIVE}, )(kill_pane) + mcp.tool( + title="Respawn Pane", + annotations=ANNOTATIONS_MUTATING_DESTRUCTIVE, + tags={TAG_MUTATING}, + )(respawn_pane) mcp.tool( title="Set Pane Title", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} )(set_pane_title) @@ -120,9 +128,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/copy_mode.py b/src/libtmux_mcp/tools/pane_tools/copy_mode.py index 4d607f4..ed776f6 100644 --- a/src/libtmux_mcp/tools/pane_tools/copy_mode.py +++ b/src/libtmux_mcp/tools/pane_tools/copy_mode.py @@ -55,7 +55,9 @@ def enter_copy_mode( session_id=session_id, window_id=window_id, ) - pane.cmd("copy-mode", "-t", pane.pane_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( "send-keys", @@ -107,6 +109,7 @@ def exit_copy_mode( session_id=session_id, window_id=window_id, ) - pane.cmd("send-keys", "-t", pane.pane_id, "-X", "cancel") + # See enter_copy_mode: Pane.cmd injects ``-t pane.pane_id`` already. + 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 cb4baa9..1edd0dc 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 @@ -58,6 +58,122 @@ def kill_pane( return f"Pane killed: {pid}" +@handle_tool_errors +def respawn_pane( + pane_id: str, + 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. + + 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`` replaces the command tmux + relaunches; ``start_directory`` sets the working directory for + the new process; ``environment`` sets per-process environment + variables for the relaunched command (one ``-e KEY=VALUE`` flag + per entry). + + ``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 + 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 + 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 + fails if the pane already has a running process. + shell : str, optional + Replacement command for tmux to launch. When omitted, tmux + 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``). + 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. + + 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) + 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) + # 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 ``, 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) + 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/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 99e4bf5..373f2de 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 @@ -60,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 "" @@ -146,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. 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 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/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/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index c4e97df..d9e86a1 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. +@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/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"): diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py new file mode 100644 index 0000000..fe300b8 --- /dev/null +++ b/tests/test_mcp_swap.py @@ -0,0 +1,493 @@ +"""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 / "state.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_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: + """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() + + +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 +# --------------------------------------------------------------------------- + + +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 + + +# --------------------------------------------------------------------------- +# _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 diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 69eb10d..d5ebe36 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] @@ -165,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 c42b105..87deb55 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,192 @@ 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(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="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() + + +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() + + +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() + + +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 # --------------------------------------------------------------------------- @@ -2012,6 +2199,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 # --------------------------------------------------------------------------- diff --git a/tests/test_server.py b/tests/test_server.py index b380d71..a56bf70 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -161,6 +161,119 @@ 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_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 + from libtmux_mcp.tools.server_tools import SOCKET_NAME_EXEMPT + + 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 server_tools.SOCKET_NAME_EXEMPT, or update " + f"_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) 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( 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 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"