diff --git a/.agents/skills/testing-mcp-with-cli-agents/SKILL.md b/.agents/skills/testing-mcp-with-cli-agents/SKILL.md
index ab0ca476..205ee32f 100644
--- a/.agents/skills/testing-mcp-with-cli-agents/SKILL.md
+++ b/.agents/skills/testing-mcp-with-cli-agents/SKILL.md
@@ -59,8 +59,8 @@ tool list, a couple of representative calls, an error path. Use this to answer
"is the tool surface and result shape correct?" before spending a CLI on it.
Shape-normalization gotchas seen in practice — normalize before asserting:
-- Match the current `LIBTMUX_SAFETY` tier: destructive-batch wrappers are hidden
- at the default tier, so don't assert they're visible.
+- Match the current `LIBTMUX_TOOLSETS` selection: excluded toolsets are hidden
+ when omitted from the selection, so don't assert they're visible.
- List-returning tools surface `structuredContent` as `{"result": [...]}`;
`capture_pane` output can come back under `result` as a string. Don't assume a
top-level `count`.
@@ -75,7 +75,7 @@ handshake, codex's `mcp get` only parses config, and agy has no proof short of a
model call. `references/cli-matrix.md` has the verified per-CLI invocation,
isolation lever, and approval-bypass flag for all six. Two things that surprise
people: some `mcp list`/`list-tools` subcommands read the *ambient* config and
-ignore your isolated one, and a mutating tool call needs a per-CLI
+ignore your isolated one, and a write-capable tool call needs a per-CLI
approval-bypass flag or it hangs on a no-TTY prompt. Flags drift — re-verify with
`--help`.
@@ -230,7 +230,7 @@ not add new keys, so inject `LIBTMUX_SOCKET=mcp-target` via each CLI's native
real CLI configs, so dry-run first, record the pre-existing swap state, and
revert only what you swapped.
-**Prefer zero-mutation isolation for a test.** mcp_swap is for a swap you *want*
+**Prefer no-config-write isolation for a test.** mcp_swap is for a swap you *want*
to persist. To just exercise a checkout, use each CLI's throwaway config-home /
project-config lever instead — `references/cli-matrix.md` gives the verified one
per CLI (codex `CODEX_HOME` or `-c` overrides, grok `GROK_HOME`, agy
@@ -275,7 +275,7 @@ Claude, which has two layers) and preview with `--dry-run`. State is keyed by
`(cli, scope)`, so two swaps of the same layer collapse into one entry — there
is no chain to unwind one step at a time. Re-run `status`/`doctor` and compare
against the state you recorded before starting. If you stayed on the
-zero-mutation path there is nothing to revert.
+no-config-write path there is nothing to revert.
## When NOT to reach for the full harness
diff --git a/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md b/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md
index b4763dfd..9c4ae742 100644
--- a/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md
+++ b/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md
@@ -41,8 +41,8 @@ below.
the CLI inherits them and launches the `uv` server fine. The alternate-socket-pane
PATH gap (a `-L` pane's non-login shell lacks the mise shims) only bites when you
launch a CLI **TUI inside a harness pane** (Layer 2).
-7. **Non-interactive mutating tool calls need an approval-bypass flag** — different per
- CLI (table). Without it, a mutating call blocks on an approval prompt with no TTY
+7. **Non-interactive write-capable tool calls need an approval-bypass flag** — different per
+ CLI (table). Without it, a write-capable call blocks on an approval prompt with no TTY
and the harness hangs.
8. **Interactive send-keys submit:** send the prompt text and `Enter` as **separate
`send-keys` events** — then a single Enter submits. The "needs a double Enter"
diff --git a/.github/WRITING.md b/.github/WRITING.md
index 349cbbe9..411a37fb 100644
--- a/.github/WRITING.md
+++ b/.github/WRITING.md
@@ -22,7 +22,7 @@ The most useful editing operation is deleting the introductory sentence.
Lead with verbs and name concrete things. Put identifiers in backticks.
Prefer short declarative sentences, one operational fact each. Do not
explain Python to Python developers; do explain this project's semantics —
-what a safety tier does, what a tool will and will not touch, what a stale
+what a toolset does, what a tool will and will not touch, what a stale
pane object means.
Type annotations describe shape. Documentation describes meaning. A
@@ -77,7 +77,7 @@ Rules that follow:
common call, then the one argument a few will tune, then the lower-level
primitive. Each step is for a smaller audience than the last.
- **Name the trade-off.** If a call costs something — an extra tmux
- round-trip, a stale object needing a refresh, a wider safety tier — say
+ round-trip, a stale object needing a refresh, a wider tool surface — say
so, and say what it buys. State it; do not sell it.
## README
@@ -164,7 +164,7 @@ its title is chrome, not disambiguation. The title is not part of the
search corpus above; it is a human-readable label only.
**`anthropic/alwaysLoad` is a scarce hint, not a default.** A handful of
-high-traffic, read-only tools (list/inspect operations a session usually
+high-traffic `inspect` tools (list/inspect operations a session usually
starts with) carry this per-tool `meta` flag so a client can keep a small
tmux vocabulary visible without preloading every tool's schema. Reserve it
for tools nearly every session needs early; adding it broadly defeats the
@@ -187,7 +187,7 @@ recovery suggestion rather than folding it into the sentence — "call
model has to parse for the actionable part. Reserve the loud, unrecoverable
category (a missing `tmux` binary, a genuine bug in this server) for
failures an operator — not the calling agent — has to fix; an
-agent-correctable failure like a bad id or a tier denial should not read as
+agent-correctable failure like a bad id or a toolset denial should not read as
loud as a crash.
## Documentation site voice
@@ -226,12 +226,12 @@ answer the operator's questions:
- **Use when** describes the practical workflow.
- **Avoid when** names the common wrong turn and points to the better
tool.
-- **Side effects** states the safety consequence plainly.
+- **Side effects** states the operational consequence plainly.
- **Examples** stay copyable, minimal, and realistic.
### What stays precise
-Warm the framing, never the facts. Safety tiers, exact tool names,
+Warm the framing, never the facts. Toolsets, exact tool names,
parameter names, environment variables, error strings, tmux targets,
format strings, JSON/TOML examples, and class or function
cross-references carry meaning in their exact form. Leave them exact and
@@ -244,12 +244,12 @@ of any symbol that has a useful destination on that page:
- `{class}`, `{meth}`, `{func}`, `{mod}`, `{exc}`, `{attr}` — Python
objects.
-- `{tool}` — code chip + full safety badge (text + icon). Use in headers,
+- `{tool}` — code chip + full toolset badge (text + icon). Use in headers,
bulleted lists, and tables where the badge gives scannable context.
- `{tooliconl}` — code chip + small colored icon (left). Use in inline
paragraph text where the full badge is too heavy.
- `{toolref}` — code chip only, no badge. Use for dense inline sequences
- or where the safety tier is already established.
+ or where the toolset is already established.
- `{tooliconil}` / `{tooliconir}` — bare emoji inside a code chip. Use for
compact lists and scan-heavy surfaces.
- `{ref}` / `{doc}` — documentation pages and section anchors.
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 2f067317..ae8f065c 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -94,7 +94,7 @@ jobs:
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ secrets.LIBTMUX_MCP_DOCS_DISTRIBUTION }}" \
- --paths "/index.html" "/objects.inv" "/searchindex.js"
+ --paths "/*"
- name: Purge cache on Cloudflare
if: env.PUBLISH == 'true'
diff --git a/AGENTS.md b/AGENTS.md
index 534862c9..c1758b69 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,7 +12,7 @@ what was asked for.
| Path | What it is |
| ---- | ---------- |
| `src/libtmux_mcp/server.py` | FastMCP instance: construction, instructions, lifespan |
-| `src/libtmux_mcp/middleware.py` | Safety-tier gating, response limiting, error mapping |
+| `src/libtmux_mcp/middleware.py` | Toolset gating, response limiting, error mapping |
| `src/libtmux_mcp/_utils.py` | Server cache, object resolvers/serializers, `handle_tool_errors` |
| `src/libtmux_mcp/models.py` | Pydantic models for tool outputs |
| `src/libtmux_mcp/tools/` | MCP tool implementations: one module per tmux object, plus batch/buffer/hook/wait_for |
@@ -49,9 +49,11 @@ be stated twice, the file listed above is the one that governs.
- A passing gate is evidence only once it has been shown capable of
failing. Pair a new test with a deliberate break that proves it bites.
-Tools are tagged `readonly`, `mutating`, or `destructive`; `LIBTMUX_SAFETY`
-caps which tier is exposed (default `mutating`), and the middleware denies
-any tool whose tag it does not recognize. All tmux access goes through
+Tools are grouped into the unordered toolsets `inspect`, `manage`,
+`execute`, and `teardown`; `LIBTMUX_TOOLSETS` selects which are exposed
+(default `inspect,manage,execute`), and the middleware refuses any tool
+carrying none of them. Filtering shapes what is advertised, not what a
+pane can run. All tmux access goes through
libtmux's `cmd()` on `Server`/`Session`/`Window`/`Pane`, returning a
`CommandResult` with `stdout`/`stderr`; a libtmux object can go stale when
tmux state changes externally, so call `.refresh()` before trusting a
diff --git a/CHANGES b/CHANGES
index 1db34af0..e910ca28 100644
--- a/CHANGES
+++ b/CHANGES
@@ -6,6 +6,99 @@
_Notes on upcoming releases will be added here_
+### Breaking changes
+
+**Toolsets replace safety tiers**
+
+Tools now belong to the unordered `inspect`, `manage`, `execute`, and
+`teardown` sets. `LIBTMUX_TOOLSETS` selects any combination; the default is
+`inspect,manage,execute`, while `teardown` remains opt-in.
+`LIBTMUX_TOOLS` enables individual tools and `LIBTMUX_EXCLUDE_TOOLS` takes
+precedence over every inclusion. Unknown toolset or tool names fail startup.
+
+`LIBTMUX_SAFETY` now fails startup rather than silently widening the exposed
+surface.
+
+**Before**
+
+```console
+$ LIBTMUX_SAFETY=readonly libtmux-mcp
+```
+
+**After**
+
+```console
+$ LIBTMUX_TOOLSETS=inspect libtmux-mcp
+```
+
+The former default maps to `inspect,manage,execute`; the former full surface
+adds `teardown`. See {ref}`migration` and {ref}`trust`. (#128)
+
+**Write-capable batch wrappers are removed**
+
+`call_readonly_tools_batch` becomes
+{tooliconl}`call-read-tools-batch` and accepts `inspect` tools only.
+`call_mutating_tools_batch` and `call_destructive_tools_batch` are removed;
+call write-capable tools directly so client policy sees each tool's own name.
+(#128)
+
+**{tooliconl}`display-message` accepts literals and variables**
+
+`format_string` now accepts literal text and plain `#{variable}` references,
+including hyphenated built-in and user-option names. Modifiers, conditionals,
+padding, and jobs are rejected because they can re-expand values supplied by a
+pane. Run `tmux display-message -p` through {tooliconl}`run-command` when raw
+format syntax is required. (#128)
+
+**Spawn tools require an existing working directory**
+
+A supplied `start_directory` must resolve to an existing directory. Calls that
+previously appeared to succeed while tmux silently placed the pane in `$HOME`
+now fail and identify the invalid path. (#128)
+
+### Fixes
+
+**Names and paths remain literal**
+
+Caller-supplied session names, window names, pane titles, option names, and
+working directories are escaped before tmux format expansion. Values containing
+`#(...)`, `#{...}`, or `#[...]` now arrive intact instead of running a job or
+being rewritten. (#128)
+
+**{tooliconl}`search-panes` stops pathological matching**
+
+Regex matching now has one two-second budget across the call, excluding tmux
+pane-capture time. Pattern length is capped separately, and a pattern that
+exhausts the matching budget returns a correctable error instead of blocking
+the server. (#128)
+
+**Tool annotations account for programmable tmux targets**
+
+Every tool that requests a tmux operation now advertises conservative
+whole-call annotations. Command aliases and `after-*` hooks can replace or
+extend an operation on an existing tmux server, so the project-owned toolsets
+carry the direct-operation distinction that standard MCP hints cannot promise.
+(#128)
+
+**Failed tool calls run once**
+
+The server no longer retries failed inspection calls automatically. A tmux
+alias or hook may already have acted before reporting an error, so the client or
+operator decides whether repeating the request is appropriate. (#128)
+
+**Shutdown leaves tmux buffers alone**
+
+Server shutdown clears only the Python process cache. It no longer deletes
+buffers by a shared name prefix that cannot prove which MCP server instance
+created them. (#128)
+
+**Prompt adapters remain callable through `inspect`**
+
+With `LIBTMUX_MCP_PROMPTS_AS_TOOLS=1`, `list_prompts` and `get_prompt` remain
+visible and callable when the `inspect` toolset is enabled. They render
+server-local text without contacting tmux and retain their narrower
+annotations. (#128)
+
### Documentation
#### opencode joins the install picker
@@ -15,6 +108,14 @@ is non-interactive once a name and a `--` command are both given, so it is a CLI
panel rather than a paste-this-JSON one — which also sidesteps opencode's
unusual entry shape.
+**The trust page separates tool filtering from confinement**
+
+The trust page replaces the safety topic and explains what the MCP call gate,
+tmux aliases and hooks, shared sockets, status jobs, and OS isolation each
+control. The old safety URL and renamed batch-tool URL redirect to their current
+pages, and tool catalogs expose each toolset with a copyable label and colored
+badge. (#128)
+
### Development
**`mcp_swap.py` covers opencode and pi**
@@ -59,6 +160,13 @@ A non-mapping at a container key now raises a `RuntimeError` naming the path for
every CLI, not just Claude. `scripts/README.md`'s extension guide described
three branch sites when there were four; it now describes the fields instead.
+**`mcp_swap.py` migrates retired safety settings**
+
+`use-local` removes a stored `LIBTMUX_SAFETY` only when
+`LIBTMUX_TOOLSETS` supplies its replacement. Otherwise it stops with migration
+instructions, and `--env LIBTMUX_SAFETY=...` is rejected so a restricted
+configuration cannot widen silently. (#128)
+
## libtmux-mcp 0.1.0a20 (2026-08-09)
libtmux-mcp 0.1.0a20 changes no tool behavior. ruff's curated default rule
@@ -325,11 +433,11 @@ MCP clients now see the effective server default for `suppress_history` on {tool
{tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` now let callers opt into best-effort no-disk shell-history controls with `suppress_persistent_history=true`. Session controls reach the initial and future panes, while the other tools limit the setting to the process launched by that call.
-History suppression reduces accidental persistence, but it is not secret transport: shells can override the controls, and terminal output and other observation surfaces remain visible. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for the remaining boundaries. (#91)
+History suppression reduces accidental persistence, but it is not secret transport: shells can override the controls, and terminal output and other observation surfaces remain visible. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for the remaining boundaries. (#91)
**Per-process environments for windows and panes**
-{tooliconl}`create-window` and {tooliconl}`split-window` now accept per-process `environment` mappings or JSON object strings, so callers can configure new windows and panes without changing the tmux session environment. {tooliconl}`respawn-pane` now accepts the same JSON object form for its existing environment input. Values can still surface in host process inspection and child environments; use credential references, not literal credentials. See {ref}`safety` for details. (#91)
+{tooliconl}`create-window` and {tooliconl}`split-window` now accept per-process `environment` mappings or JSON object strings, so callers can configure new windows and panes without changing the tmux session environment. {tooliconl}`respawn-pane` now accepts the same JSON object form for its existing environment input. Values can still surface in host process inspection and child environments; use credential references, not literal credentials. See {ref}`trust` for details. (#91)
### Documentation
@@ -407,7 +515,7 @@ libtmux-mcp 0.1.0a12 hardens the MCP server's read-only and safety surface and a
**Read-only tools no longer evaluate tmux `#()` format jobs**
-{tooliconl}`search-panes` and {tooliconl}`display-message` are advertised as read-only, but tmux `#(...)` formats schedule shell jobs during expansion. Both now reject or route around `#()` so a read-only call can never spawn a shell. (#68, #69)
+{tooliconl}`search-panes` and {tooliconl}`display-message` were advertised as read-only, but tmux `#(...)` formats schedule shell jobs during expansion. Both now reject or route around `#()` so those parameters can no longer spawn a shell. (#68, #69)
**Invalid `LIBTMUX_SAFETY` fails closed**
@@ -623,7 +731,7 @@ libtmux-mcp 0.1.0a4 adds pane recovery and closes the last gap in the core hiera
### Documentation
-{ref}`safety` now documents {tooliconl}`respawn-pane` as a mutating recovery tool with real process side effects, including the `kill=true` default and the visibility tradeoffs of passing command or environment data through tmux. The same update refreshes socket-guard notes for macOS, tightens guidance around {tooliconl}`display-message` and {tooliconl}`pipe-pane`, and adds dedicated tool pages for the new recovery and metadata tools. (#27)
+{ref}`trust` now documents {tooliconl}`respawn-pane` as a mutating recovery tool with real process side effects, including the `kill=true` default and the visibility tradeoffs of passing command or environment data through tmux. The same update refreshes socket-guard notes for macOS, tightens guidance around {tooliconl}`display-message` and {tooliconl}`pipe-pane`, and adds dedicated tool pages for the new recovery and metadata tools. (#27)
### Development
@@ -667,7 +775,7 @@ for match in search_panes(...).matches:
...
```
-**Minimum `fastmcp>=3.2.4`** (was `>=3.1.0`). The newer FastMCP release is required for {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` and per-parameter input-schema descriptions.
+**Minimum `fastmcp>=3.2.4`** (was `>=3.1.0`). The newer FastMCP release is required for the former `ReadonlyRetryMiddleware` and per-parameter input-schema descriptions.
### What's new
@@ -685,7 +793,7 @@ Four MCP prompts ship for repeatable tmux work: `run_and_wait`, `diagnose_failin
**Middleware for safer long-running automation**
-The middleware stack now includes {class}`~libtmux_mcp.middleware.AuditMiddleware` for digest-redacted argument summaries, {class}`~libtmux_mcp.middleware.SafetyMiddleware` for safety-tier visibility, {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` for transient readonly libtmux failures, and {class}`~libtmux_mcp.middleware.TailPreservingResponseLimitingMiddleware` for oversized tool output. Timing and error-handling middleware round out the request path. (#15)
+The middleware stack now includes {class}`~libtmux_mcp.middleware.AuditMiddleware` for digest-redacted argument summaries, the former `SafetyMiddleware` for safety-tier visibility, the former `ReadonlyRetryMiddleware` for transient readonly libtmux failures, and {class}`~libtmux_mcp.middleware.TailPreservingResponseLimitingMiddleware` for oversized tool output. Timing and error-handling middleware round out the request path. (#15)
**Tail-preserving bounded output**
@@ -700,11 +808,11 @@ Tool input schemas now include parameter descriptions extracted from docstrings,
- {tooliconl}`search-panes` neutralizes tmux format-string injection in the regex fast path.
- The macOS self-kill guard resolves the live tmux socket before falling back to `TMUX_TMPDIR` reconstruction.
- The `build_dev_workspace` prompt uses real tool parameter names, avoids waiting for prompts after screen-grabbing commands, and replaces a Linux-specific log default with `log_command`.
-- {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` logs retry warnings under `libtmux_mcp.retry`.
+- The former `ReadonlyRetryMiddleware` logs retry warnings under `libtmux_mcp.retry`.
### Documentation
-The tools documentation gains category pages for {doc}`/tools/buffer/index`, {doc}`/tools/hook/index`, and {doc}`/tools/index`. The pane tools docs explain the {class}`~libtmux_mcp.models.SearchPanesResult` migration, while {ref}`safety` documents the audit log, socket caveats, {tooliconl}`pipe-pane`, and {tooliconl}`set-environment`. (#15)
+The tools documentation gains category pages for {doc}`/tools/buffer/index`, {doc}`/tools/hook/index`, and {doc}`/tools/index`. The pane tools docs explain the {class}`~libtmux_mcp.models.SearchPanesResult` migration, while {ref}`trust` documents the audit log, socket caveats, {tooliconl}`pipe-pane`, and {tooliconl}`set-environment`. (#15)
## libtmux-mcp 0.1.0a1 (2026-04-13)
diff --git a/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 00000000..39f70aec
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,88 @@
+# Migration
+
+Breaking changes and how to move through them, newest first.
+
+## Safety tiers become toolsets
+
+`readonly`, `mutating` and `destructive` are gone. Tools belong to four
+unordered toolsets named for what they do.
+
+### Environment variables
+
+| Before | After |
+| --- | --- |
+| `LIBTMUX_SAFETY=readonly` | `LIBTMUX_TOOLSETS=inspect` |
+| `LIBTMUX_SAFETY=mutating` (the default) | `LIBTMUX_TOOLSETS=inspect,manage,execute` (the default) |
+| `LIBTMUX_SAFETY=destructive` | `LIBTMUX_TOOLSETS=inspect,manage,execute,teardown` |
+
+A server started with `LIBTMUX_SAFETY` set now fails at startup naming the
+replacement. It is not ignored: a variable that silently stopped working
+would leave you believing a surface was narrower than it is.
+
+Two variables are new. `LIBTMUX_TOOLS` enables individual tools regardless
+of toolset, and `LIBTMUX_EXCLUDE_TOOLS` refuses them regardless of every
+enable above. An unknown name in any of the three fails startup.
+
+### Surfaces the tiers could not express
+
+The tiers accumulated upward, so every surface was a prefix of the ladder.
+The toolsets are a set, so this is now legal:
+
+```console
+$ LIBTMUX_TOOLSETS=inspect,teardown libtmux-mcp
+```
+
+An agent that can look and clean up, but not type.
+
+### Tool names
+
+| Before | After |
+| --- | --- |
+| `call_readonly_tools_batch` | `call_read_tools_batch` |
+| `call_mutating_tools_batch` | removed — call the tool directly |
+| `call_destructive_tools_batch` | removed — call the tool directly |
+
+A batch gives every nested call the wrapper's name, so a client rule keyed
+on `kill_session` never fires for a `kill_session` run inside one. That is
+tolerable for reads and not for writes.
+
+### Which toolset a tool is in
+
+`inspect`
+: Every `list_*`, `get_*`, `show_*`, `capture_*`, `snapshot_pane`,
+ `search_panes`, `find_pane_by_position`, `display_message`,
+ `wait_for_text`, `call_read_tools_batch`.
+
+`manage`
+: `rename_*`, `select_*`, `resize_*`, `move_window`, `swap_pane`,
+ `set_pane_title`, `enter_copy_mode`, `exit_copy_mode`,
+ `wait_for_channel`, `signal_channel`, `load_buffer`.
+
+`execute`
+: `create_session`, `create_window`, `split_window`, `respawn_pane`,
+ `run_command`, `send_keys`, `send_keys_batch`, `paste_text`,
+ `paste_buffer`, `pipe_pane`, `set_option`, `set_environment`.
+
+`set_option` and `set_environment` are here rather than in `manage`
+because tmux runs some stored values later: a `#(...)` job in a status
+format runs when tmux draws it and repeats on the status interval, and
+`default-command` decides what every future pane runs.
+
+`teardown`
+: `kill_pane`, `kill_window`, `kill_session`, `kill_server`, `clear_pane`,
+ `delete_buffer`.
+
+### Documentation
+
+The safety topic is now the trust page. The old URL redirects.
+
+### MCP annotations
+
+Every tool that requests a tmux operation now explicitly advertises
+`readOnlyHint: false`,
+`destructiveHint: true`, `idempotentHint: false`, and `openWorldHint: true`.
+An existing tmux server can use aliases and hooks to replace or extend the
+operation libtmux-mcp requests, so no stronger static promise holds for every
+target. The optional prompt adapter tools render text without contacting tmux
+and retain their narrower hints. Use the project-owned toolsets to distinguish
+the direct operation libtmux-mcp requests.
diff --git a/README.md b/README.md
index c2636bb9..220d8d00 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ commands, read output, orchestrate panes.
| Module | Tools |
|--------|-------|
| **Server** | `list_servers`, `list_sessions`, `create_session`, `kill_server`, `get_server_info` |
-| **Batch** | `call_readonly_tools_batch`, `call_mutating_tools_batch`, `call_destructive_tools_batch` |
+| **Batch** | `call_read_tools_batch` |
| **Session** | `list_windows`, `get_session_info`, `create_window`, `rename_session`, `select_window`, `kill_session` |
| **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` |
| **Pane** | `run_command`, `send_keys`, `send_keys_batch`, `paste_text`, `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `find_pane_by_position`, `get_pane_info`, `wait_for_text`, `wait_for_channel`, `signal_channel`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `respawn_pane`, `kill_pane` |
@@ -132,11 +132,14 @@ newly written or rewritten rows on follow-up calls. The alternative is
re-sending the same scrollback to the model on every check.
**Guarding.** The server detects the agent's own pane across sockets
-and declines self-destructive operations — [`kill_session`](https://libtmux-mcp.git-pull.com/tools/session/kill-session/)
+and declines to end its own — [`kill_session`](https://libtmux-mcp.git-pull.com/tools/session/kill-session/)
on itself fails loudly instead of silently terminating the host
-environment the agent is running in. [`LIBTMUX_SAFETY`](https://libtmux-mcp.git-pull.com/configuration/#envvar-LIBTMUX_SAFETY)
-(`readonly`, `mutating`, `destructive`) hides whole tiers from the
-client's tool list before any prompt is built.
+environment the agent is running in. [`LIBTMUX_TOOLSETS`](https://libtmux-mcp.git-pull.com/configuration/#envvar-LIBTMUX_TOOLSETS)
+(`inspect`, `manage`, `execute`, `teardown`) drops whole toolsets from the
+client's tool list before any prompt is built. The sets are unordered, so
+`inspect,teardown` is a legal surface. Dropping one is inventory and MCP
+tool-call configuration, not containment: an enabled `execute` tool can type
+the equivalent of anything it hides.
## Documentation
diff --git a/docs/_static/css/project-badges.css b/docs/_static/css/project-badges.css
new file mode 100644
index 00000000..8da50215
--- /dev/null
+++ b/docs/_static/css/project-badges.css
@@ -0,0 +1,5 @@
+#toolsets-gate-mcp-tool-calls-not-tmux > p
+ > .gp-sphinx-badge .gp-sphinx-badge__label {
+ user-select: text;
+ -webkit-user-select: text;
+}
diff --git a/docs/clients.md b/docs/clients.md
index 28a6b8ca..6110862e 100644
--- a/docs/clients.md
+++ b/docs/clients.md
@@ -119,4 +119,5 @@ $ grok mcp add \
- **Absolute paths**: Some clients require absolute paths in config. Use `$HOME/...` or the full path instead of `~/...`.
- **Virtual environments**: If using pip install, ensure the venv is activated or the `libtmux-mcp` binary is on your PATH.
-- **Socket isolation**: Set `LIBTMUX_SOCKET` in the `env` block to isolate the MCP server from your default tmux. See {ref}`configuration`.
+- **Socket selection**: Set `LIBTMUX_SOCKET` in the `env` block to address a
+ separate tmux object namespace. See {ref}`configuration`.
diff --git a/docs/conf.py b/docs/conf.py
index 288b6a78..288f705b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -173,10 +173,45 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any:
"BufferRef",
"BufferContent",
)
+# Our tags sit on one axis: the capability name implies the risk, so
+# declaration order runs most destructive first and the summary tables
+# follow it.
+conf["fastmcp_axes"] = (
+ {
+ "name": "capability",
+ "terms": (
+ {
+ "term": "teardown",
+ "tone": "red",
+ "tooltip": "Teardown \u2014 deletes tmux objects or scrollback",
+ "icon": "\U0001f4a3",
+ },
+ {
+ "term": "execute",
+ "tone": "amber",
+ "tooltip": "Execute \u2014 starts or drives a pane process",
+ "icon": "\u270f\ufe0f",
+ },
+ {
+ "term": "manage",
+ "tone": "blue",
+ "tooltip": "Manage \u2014 changes tmux structure or presentation",
+ "icon": "\U0001f527",
+ },
+ {
+ "term": "inspect",
+ "tone": "green",
+ "tooltip": "Inspect \u2014 reads tmux state and terminal output",
+ "icon": "\U0001f50d",
+ },
+ ),
+ },
+)
conf["fastmcp_section_badge_map"] = {
- "Inspect": "readonly",
- "Act": "mutating",
- "Destroy": "destructive",
+ "Inspect": "inspect",
+ "Manage": "manage",
+ "Execute": "execute",
+ "Teardown": "teardown",
}
conf["fastmcp_section_badge_pages"] = ("tools/index", "index")
@@ -208,6 +243,7 @@ def setup(app: Sphinx) -> None:
app.connect("autodoc-process-docstring", _convert_md_xrefs)
app.add_js_file("js/prompt-copy.js", loading_method="defer")
app.add_css_file("css/project-admonitions.css")
+ app.add_css_file("css/project-badges.css")
app.add_css_file("css/project-cards.css")
diff --git a/docs/configuration.md b/docs/configuration.md
index 6eec03e8..7ddc6122 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -9,7 +9,7 @@ Runtime configuration for the libtmux-mcp server. For MCP client setup, see {ref
```{envvar} LIBTMUX_SOCKET
```
-tmux socket name (`-L`). Isolates the MCP server to a specific tmux socket.
+tmux socket name (`-L`). Selects the tmux server the MCP process addresses.
- **Type:** string
- **Default:** (none — uses the default tmux socket)
@@ -30,14 +30,34 @@ Path to tmux binary. Useful for testing with different tmux versions.
- **Type:** string
- **Default:** `tmux`
-```{envvar} LIBTMUX_SAFETY
+```{envvar} LIBTMUX_TOOLSETS
```
-Safety tier controlling which tools are available. See {ref}`safety`.
+Comma list of toolsets to advertise. See {ref}`trust`.
- **Type:** string
-- **Default:** `mutating`
-- **Values:** `readonly`, `mutating`, `destructive`
+- **Default:** `inspect,manage,execute`
+- **Values:** any of `inspect`, `manage`, `execute`, `teardown`; may be empty
+
+An unknown name fails startup rather than being ignored. The setting filters
+tools only; an empty value still leaves `tmux://` resources and native prompts
+available. Filtering does not constrain what tmux or a pane's shell can do.
+
+```{envvar} LIBTMUX_TOOLS
+```
+
+Comma list of tool names to advertise regardless of toolset.
+
+- **Type:** string
+- **Default:** empty
+
+```{envvar} LIBTMUX_EXCLUDE_TOOLS
+```
+
+Comma list of tool names to refuse, beating every enable above.
+
+- **Type:** string
+- **Default:** empty
```{envvar} LIBTMUX_MCP_WAIT_MAX_SECONDS
```
@@ -79,7 +99,7 @@ Process creation uses a separate control. {toolref}`create-session`, {toolref}`c
Leaving it `false` adds no history controls. That choice cannot remove inherited, session, or startup-file controls; the process can still receive them from tmux, your supplied `environment`, or a shell startup file. The startup default never changes the raw-input behavior of {toolref}`send-keys`, {toolref}`send-keys-batch`, {toolref}`paste-text`, or {toolref}`paste-buffer`.
-The server resolves {envvar}`LIBTMUX_SUPPRESS_HISTORY` once during startup. Restart the MCP server only after changing this startup setting, usually by reconnecting or restarting the MCP client. Per-call arguments take effect without a restart. See {ref}`history-hygiene` for shell-specific limits and {ref}`safety` for surfaces that history suppression does not hide.
+The server resolves {envvar}`LIBTMUX_SUPPRESS_HISTORY` once during startup. Restart the MCP server only after changing this startup setting, usually by reconnecting or restarting the MCP client. Per-call arguments take effect without a restart. See {ref}`history-hygiene` for shell-specific limits and {ref}`trust` for surfaces that history suppression does not hide.
## Setting environment variables
@@ -93,7 +113,7 @@ Set environment variables in your MCP client config:
"args": ["libtmux-mcp"],
"env": {
"LIBTMUX_SOCKET": "ai_workspace",
- "LIBTMUX_SAFETY": "readonly",
+ "LIBTMUX_TOOLSETS": "inspect",
"LIBTMUX_SUPPRESS_HISTORY": "1"
}
}
@@ -101,16 +121,22 @@ Set environment variables in your MCP client config:
}
```
-## Socket isolation
+## Socket selection
-By default, the MCP server connects to the default tmux socket. Set {envvar}`LIBTMUX_SOCKET` to isolate AI agent activity from your personal tmux sessions:
+By default, the MCP server connects to the default tmux socket. Set
+{envvar}`LIBTMUX_SOCKET` to address a separate tmux object namespace:
```json
"env": { "LIBTMUX_SOCKET": "ai_workspace" }
```
-The agent will only see sessions on the `ai_workspace` socket, not your personal sessions.
+The agent sees sessions on the `ai_workspace` socket through calls that use
+that default. A socket does not confine processes, prevent same-user clients
+from connecting, or prove which configuration started an existing server. See
+{ref}`trust`.
-## All tools accept `socket_name`
+## Targeted tools accept `socket_name`
-Every tool accepts an optional `socket_name` parameter that overrides {envvar}`LIBTMUX_SOCKET` for that call. This allows agents to work across multiple tmux servers in a single session.
+Tools that address one tmux server accept an optional `socket_name` parameter
+that overrides {envvar}`LIBTMUX_SOCKET` for that call. This allows agents to
+work across multiple tmux servers in a single session.
diff --git a/docs/demo.md b/docs/demo.md
index 6d4eeb63..825a08aa 100644
--- a/docs/demo.md
+++ b/docs/demo.md
@@ -6,13 +6,14 @@ orphan: true
A showcase of the custom Sphinx roles and visual elements available in libtmux-mcp documentation.
-## Safety badges
+## Toolset badges
Standalone badges via `{badge}`:
-- {badge}`readonly` — green, read-only operations
-- {badge}`mutating` — amber, state-changing operations
-- {badge}`destructive` — red, irreversible operations
+- {badge}`inspect` — read tmux state and terminal output
+- {badge}`manage` — change tmux structure or presentation
+- {badge}`execute` — start or drive pane processes
+- {badge}`teardown` — delete tmux objects or retained scrollback
## Tool references
@@ -50,19 +51,19 @@ Standalone badges via `{badge}`:
These are the actual tool headings as they render on tool pages:
-> `capture_pane` {badge}`readonly`
+> `capture_pane` {badge}`inspect`
-> `split_window` {badge}`mutating`
+> `split_window` {badge}`execute`
-> `kill_session` {badge}`destructive`
+> `kill_session` {badge}`teardown`
### In a table
-| Tool | Tier | Description |
-|------|------|-------------|
-| {toolref}`list-sessions` | {badge}`readonly` | List all sessions |
-| {toolref}`send-keys` | {badge}`mutating` | Send commands to a pane |
-| {toolref}`kill-pane` | {badge}`destructive` | Destroy a pane |
+| Tool | Toolset | Description |
+|------|---------|-------------|
+| {toolref}`list-sessions` | {badge}`inspect` | List all sessions |
+| {toolref}`send-keys` | {badge}`execute` | Send input to a pane |
+| {toolref}`kill-pane` | {badge}`teardown` | Delete a pane |
### In prose
@@ -74,11 +75,11 @@ The fundamental command pattern: {toolref}`run-command` → inspect `exit_status
## Environment variable references
-{envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_SAFETY` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN`
+{envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_TOOLSETS` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN`
## Glossary terms
-{term}`SIGINT` · {term}`SIGQUIT` · {term}`MCP` · {term}`Safety tier` · {term}`Pane` · {term}`Session`
+{term}`SIGINT` · {term}`SIGQUIT` · {term}`MCP` · {term}`Toolset` · {term}`Pane` · {term}`Session`
## Admonitions
@@ -91,7 +92,7 @@ Do not call {toolref}`capture-pane` immediately after {toolref}`send-keys` — t
```
```{note}
-All tools accept an optional `socket_name` parameter for multi-server support.
+Targeted tools accept an optional `socket_name` parameter for multi-server support.
```
## Badge anatomy
@@ -101,14 +102,14 @@ Each badge renders as:
```html
- 🔍 readonly
+ aria-label="Toolset: inspect">
+ 🔍 inspect
```
Features:
-- **Emoji icon** — 🔍 readonly, ✏️ mutating, 💣 destructive (native system emoji, no filters)
-- **Matte colors** — forest green, smoky amber, matte crimson with 1px border
+- **Emoji icon** — 🔍 inspect, 🔧 manage, ✏️ execute, 💣 teardown (native system emoji, no filters)
+- **Matte colors** — forest green, blue, smoky amber, matte crimson with 1px border
- **Accessible** — `role="note"` + `aria-label` for screen readers
- **Non-selectable** — `user-select: none` so copying tool names skips badge text
- **Context-aware sizing** — slightly larger in headings, smaller inline
diff --git a/docs/glossary.md b/docs/glossary.md
index 34eee36e..af9fd737 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -28,8 +28,8 @@ Window
Pane
A tmux pane within a window. A pseudoterminal that runs a single process. Has an ID (e.g. `%1`) that is globally unique within a server.
-Safety tier
- A level controlling which MCP tools are available: `readonly`, `mutating`, or `destructive`. Set via the {envvar}`LIBTMUX_SAFETY` env var.
+Toolset
+ One of four groups a tool belongs to by what it does: `inspect`, `manage`, `execute`, or `teardown`. Unordered — `inspect,teardown` is a legal surface. Selected with the {envvar}`LIBTMUX_TOOLSETS` env var. Filtering them shapes what this server advertises, not what a pane can run.
Socket
The Unix socket used to communicate with a tmux server. Can be specified by name (`-L`) or path (`-S`).
diff --git a/docs/index.md b/docs/index.md
index 162ca864..05d3617b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,7 +4,9 @@
Terminal control for AI agents, built on [libtmux](https://libtmux.git-pull.com) and [FastMCP](https://gofastmcp.com).
-This server maps tmux's object hierarchy — sessions, windows, panes — into MCP tools. Some tools read state. Some mutate it. Some destroy. The distinction is explicit and enforced.
+This server maps tmux's object hierarchy — sessions, windows, panes — into
+MCP tools grouped by the operation they request. The server enforces the
+selected groups at the MCP tool boundary.
```{warning}
**Pre-alpha.** APIs may change. [Feedback welcome](https://github.com/tmux-python/libtmux-mcp/issues).
@@ -30,7 +32,7 @@ Install, connect, get a first result. Under 2 minutes.
:link: tools/index
:link-type: doc
-Every tool, grouped by intent and safety tier.
+Every tool, grouped by intent and toolset.
:::
:::{grid-item-card} Prompts
@@ -47,11 +49,11 @@ Four workflow recipes the client renders for the model.
Snapshot views of the tmux hierarchy via `tmux://` URIs.
:::
-:::{grid-item-card} Safety tiers
-:link: topics/safety
+:::{grid-item-card} Trust model
+:link: topics/trust
:link-type: doc
-Readonly, mutating, destructive. Know what changes state.
+What the toolsets group, and what they do not bound.
:::
:::{grid-item-card} Client setup
@@ -67,23 +69,44 @@ Config blocks for Claude Desktop, Claude Code, Cursor, and others.
## What you can do
-### Inspect (readonly)
+### Inspect
-Read tmux state without changing anything.
+Request tmux state and terminal output.
-{toolref}`list-sessions` · {toolref}`capture-pane` · {toolref}`capture-since` · {toolref}`snapshot-pane` · {toolref}`get-pane-info` · {toolref}`find-pane-by-position` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`display-message` · {toolref}`call-readonly-tools-batch`
+{toolref}`list-sessions` · {toolref}`capture-pane` ·
+{toolref}`capture-since` · {toolref}`snapshot-pane` ·
+{toolref}`get-pane-info` · {toolref}`find-pane-by-position` ·
+{toolref}`search-panes` · {toolref}`wait-for-text` ·
+{toolref}`display-message` · {toolref}`call-read-tools-batch`
-### Act (mutating)
+### Manage
-Create or modify tmux objects.
+Change tmux structure or presentation.
-{toolref}`create-session` · {toolref}`send-keys` · {toolref}`send-keys-batch` · {toolref}`run-command` · {toolref}`paste-text` · {toolref}`create-window` · {toolref}`split-window` · {toolref}`select-pane` · {toolref}`select-window` · {toolref}`move-window` · {toolref}`resize-pane` · {toolref}`pipe-pane` · {toolref}`set-option` · {toolref}`call-mutating-tools-batch`
+{toolref}`rename-session` · {toolref}`resize-pane` ·
+{toolref}`select-layout` · {toolref}`select-pane` ·
+{toolref}`move-window` · {toolref}`enter-copy-mode` ·
+{toolref}`load-buffer` · {toolref}`wait-for-channel`
-### Destroy (destructive)
+### Execute
-Tear down tmux objects. Not reversible.
+Start or drive pane processes, or store state that can control later
+execution.
-{toolref}`kill-session` · {toolref}`kill-window` · {toolref}`kill-pane` · {toolref}`kill-server` · {toolref}`call-destructive-tools-batch`
+{toolref}`create-session` · {toolref}`create-window` ·
+{toolref}`split-window` · {toolref}`respawn-pane` ·
+{toolref}`send-keys` · {toolref}`send-keys-batch` ·
+{toolref}`run-command` · {toolref}`paste-text` ·
+{toolref}`paste-buffer` · {toolref}`pipe-pane` ·
+{toolref}`set-option` · {toolref}`set-environment`
+
+### Teardown
+
+Delete tmux objects or retained scrollback. Not reversible.
+
+{toolref}`clear-pane` · {toolref}`kill-session` ·
+{toolref}`kill-window` · {toolref}`kill-pane` ·
+{toolref}`kill-server` · {toolref}`delete-buffer`
### Example: keep test runs out of persistent history
@@ -108,7 +131,7 @@ The agent calls {tooliconl}`create-session` with
These controls reduce history noise; they do not make commands secret. See
{ref}`history-suppression` for shell-specific behavior,
-{ref}`configuration` for the server default, and {ref}`safety` for other
+{ref}`configuration` for the server default, and {ref}`trust` for other
observation surfaces.
[Browse all tools →](tools/index)
@@ -118,7 +141,7 @@ observation surfaces.
## Mental model
- **Object hierarchy** — sessions contain windows, windows contain panes ({doc}`topics/concepts`)
-- **Read vs. mutate** — some tools observe, some act, some destroy ({doc}`topics/safety`)
+- **Toolsets** — inspect, manage, execute, teardown; what each groups ({doc}`topics/trust`)
- **tmux is the source of truth** — the server reads from it and writes to it, never caches or abstracts
---
diff --git a/docs/prompts.md b/docs/prompts.md
index fbb81320..c2c4abbf 100644
--- a/docs/prompts.md
+++ b/docs/prompts.md
@@ -45,7 +45,8 @@ Most MCP clients render prompts via a slash-command UI
(``/:``). For tools-only clients that don't expose
prompts, set ``LIBTMUX_MCP_PROMPTS_AS_TOOLS=1`` in the server
environment to surface them as ``list_prompts`` / ``get_prompt``
-tools instead.
+tools instead. These text-rendering adapters belong to `inspect`; naming them
+in {envvar}`LIBTMUX_TOOLS` also enables them individually.
```
---
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 5487dcb3..71eb95ba 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -73,6 +73,6 @@ return only new pane output.
## Next steps
- {ref}`concepts` — Understand the tmux hierarchy and how tools target panes
-- {ref}`configuration` — Environment variables and socket isolation
-- {ref}`safety` — Control which tools are available
+- {ref}`configuration` — Environment variables and socket selection
+- {ref}`trust` — Control which tools are available
- {ref}`Tools ` — Browse all available tools
diff --git a/docs/redirects.txt b/docs/redirects.txt
index 2c28f11b..03983588 100644
--- a/docs/redirects.txt
+++ b/docs/redirects.txt
@@ -13,7 +13,8 @@
"api/utils" "reference/api/utils"
"architecture" "topics/architecture"
"concepts" "topics/concepts"
-"safety" "topics/safety"
+"safety" "topics/trust"
+"topics/safety" "topics/trust"
"guides/troubleshooting" "topics/troubleshooting"
"tools/panes" "tools/pane/index"
"tools/sessions" "tools/server/index"
@@ -22,3 +23,4 @@
"tools/hooks" "tools/hook/index"
"tools/options" "tools/server/index"
"tools/waits" "tools/pane/index"
+"tools/batch/call-readonly-tools-batch" "tools/batch/call-read-tools-batch"
diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md
index bd899006..255ab3ce 100644
--- a/docs/reference/api/index.md
+++ b/docs/reference/api/index.md
@@ -26,7 +26,7 @@ Pydantic models for requests and responses.
:::{grid-item-card} Middleware
:link: middleware
:link-type: doc
-Safety-tier enforcement and request hooks.
+Toolset enforcement and request hooks.
:::
:::{grid-item-card} Utils
diff --git a/docs/resources.md b/docs/resources.md
index aeadc264..fffbc6d6 100644
--- a/docs/resources.md
+++ b/docs/resources.md
@@ -5,15 +5,19 @@
MCP resources are addressable documents the server exposes at
``tmux://`` URIs. Clients read them via ``resources/read``. All
libtmux-mcp resources are
-[resource templates](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates)
+[resource templates](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#resource-templates)
— each URI includes a ``{?socket_name}`` query parameter for socket
-isolation, plus structural path parameters (``{session_name}``,
+selection, plus structural path parameters (``{session_name}``,
``{pane_id}``, …) so a single template covers every session, window,
or pane.
-Every resource delivers a snapshot of the tmux hierarchy at call
-time. Agents use them for read-only inspection; any write workflow
-goes through the corresponding {doc}`tools `.
+Every resource requests a snapshot of the tmux hierarchy. Its handler accepts
+identifiers and socket names rather than command text, but it still sends tmux
+queries. A target `command-alias` or hook may add effects; see {ref}`trust`.
+
+`LIBTMUX_TOOLSETS` filters tools only. Resources remain available when no
+toolsets are enabled. They do not carry ToolAnnotations or produce a
+`tools/call` audit event.
## Available resources
diff --git a/docs/tools/batch/call-destructive-tools-batch.md b/docs/tools/batch/call-destructive-tools-batch.md
deleted file mode 100644
index aad382c8..00000000
--- a/docs/tools/batch/call-destructive-tools-batch.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Call destructive tools batch
-
-```{fastmcp-tool} batch_tools.call_destructive_tools_batch
-```
-
-**Use when** a reviewed workflow intentionally includes destructive
-tools and should still return one per-operation result envelope.
-
-**Avoid when** the workflow can fit inside
-{tooliconl}`call-mutating-tools-batch`. This wrapper can invoke
-destructive nested tools when the server safety tier permits them.
-
-**Side effects:** Runs readonly, mutating, and destructive nested tools
-in order. Recursive batch calls are rejected.
-
-**Example:**
-
-```json
-{
- "tool": "call_destructive_tools_batch",
- "arguments": {
- "operations": [
- {"tool": "kill_pane", "arguments": {"pane_id": "%7"}},
- {"tool": "list_panes", "arguments": {"window_id": "@3"}}
- ],
- "on_error": "stop"
- }
-}
-```
-
-```{fastmcp-tool-input} batch_tools.call_destructive_tools_batch
-```
diff --git a/docs/tools/batch/call-mutating-tools-batch.md b/docs/tools/batch/call-mutating-tools-batch.md
deleted file mode 100644
index ceb34bc5..00000000
--- a/docs/tools/batch/call-mutating-tools-batch.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Call mutating tools batch
-
-```{fastmcp-tool} batch_tools.call_mutating_tools_batch
-```
-
-**Use when** you need an ordered workflow made from existing typed MCP
-tools, such as renaming and splitting a known window, while preserving
-each tool's own schema and safety checks.
-
-**Avoid when** you need tmux's native semicolon command parsing. This
-tool batches MCP tools; it does not create one tmux command sequence.
-For shell commands with completion and output, prefer
-{tooliconl}`run-command`.
-
-**Side effects:** Runs readonly and mutating nested tools in order.
-Destructive nested tools are rejected even when the server process is
-running with `LIBTMUX_SAFETY=destructive`.
-
-**Example:**
-
-```json
-{
- "tool": "call_mutating_tools_batch",
- "arguments": {
- "operations": [
- {
- "tool": "rename_window",
- "arguments": {"window_id": "@2", "new_name": "logs"}
- },
- {
- "tool": "split_window",
- "arguments": {"window_id": "@2", "direction": "right"}
- }
- ],
- "on_error": "stop"
- }
-}
-```
-
-```{fastmcp-tool-input} batch_tools.call_mutating_tools_batch
-```
diff --git a/docs/tools/batch/call-read-tools-batch.md b/docs/tools/batch/call-read-tools-batch.md
new file mode 100644
index 00000000..8be427c4
--- /dev/null
+++ b/docs/tools/batch/call-read-tools-batch.md
@@ -0,0 +1,35 @@
+# Call Read Tools Batch
+
+```{fastmcp-tool} batch_tools.call_read_tools_batch
+```
+
+**Use when** you need several `inspect` operations in one ordered MCP turn,
+such as listing sessions and then reading server metadata.
+
+**Avoid when** any nested operation changes tmux state — call anything that
+writes directly. Call the tools individually when each result should be
+reviewed before choosing the next action.
+
+**Side effects:** Calls only `inspect` tools. Each nested built-in request reads
+data only; a configured alias or hook can add effects. Anything outside that
+toolset is rejected, whatever this server has enabled. A batch gives every
+nested call the wrapper's name, so a client rule keyed on the inner tool would
+not fire. See {ref}`trust`.
+
+**Example:**
+
+```json
+{
+ "tool": "call_read_tools_batch",
+ "arguments": {
+ "operations": [
+ {"tool": "list_sessions", "arguments": {}},
+ {"tool": "get_server_info", "arguments": {}}
+ ],
+ "on_error": "stop"
+ }
+}
+```
+
+```{fastmcp-tool-input} batch_tools.call_read_tools_batch
+```
diff --git a/docs/tools/batch/call-readonly-tools-batch.md b/docs/tools/batch/call-readonly-tools-batch.md
deleted file mode 100644
index 8d32190c..00000000
--- a/docs/tools/batch/call-readonly-tools-batch.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Call readonly tools batch
-
-```{fastmcp-tool} batch_tools.call_readonly_tools_batch
-```
-
-**Use when** you need several read-only observations in one ordered
-MCP turn, such as listing sessions and then reading server metadata.
-
-**Avoid when** any nested operation changes tmux state — use
-{tooliconl}`call-mutating-tools-batch` for readonly + mutating
-workflows, or call the individual tools when each result should be
-reviewed before choosing the next action.
-
-**Side effects:** None beyond the nested readonly tools. Mutating and
-destructive nested tools are rejected even when the server process is
-running with a higher safety tier.
-
-**Example:**
-
-```json
-{
- "tool": "call_readonly_tools_batch",
- "arguments": {
- "operations": [
- {"tool": "list_sessions", "arguments": {}},
- {"tool": "get_server_info", "arguments": {}}
- ],
- "on_error": "stop"
- }
-}
-```
-
-```{fastmcp-tool-input} batch_tools.call_readonly_tools_batch
-```
diff --git a/docs/tools/batch/index.md b/docs/tools/batch/index.md
index be5684cf..ab5929e4 100644
--- a/docs/tools/batch/index.md
+++ b/docs/tools/batch/index.md
@@ -7,16 +7,8 @@ including `socket_name` when needed.
::::{grid} 1 1 2 3
:gutter: 2 2 3 3
-:::{grid-item-card} {tooliconl}`call-readonly-tools-batch`
-Call readonly tools in order.
-:::
-
-:::{grid-item-card} {tooliconl}`call-mutating-tools-batch`
-Call readonly or mutating tools in order.
-:::
-
-:::{grid-item-card} {tooliconl}`call-destructive-tools-batch`
-Call readonly, mutating, or destructive tools in order.
+:::{grid-item-card} {tooliconl}`call-read-tools-batch`
+Call several `inspect` tools in order.
:::
::::
@@ -25,7 +17,5 @@ Call readonly, mutating, or destructive tools in order.
:hidden:
:maxdepth: 1
-call-readonly-tools-batch
-call-mutating-tools-batch
-call-destructive-tools-batch
+call-read-tools-batch
```
diff --git a/docs/tools/buffer/show-buffer.md b/docs/tools/buffer/show-buffer.md
index 07a3893d..da405436 100644
--- a/docs/tools/buffer/show-buffer.md
+++ b/docs/tools/buffer/show-buffer.md
@@ -7,7 +7,8 @@
to read back a buffer between modifications. Restricted to
MCP-namespaced buffers — non-agent buffers are rejected.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
```{fastmcp-tool-input} buffer_tools.show_buffer
```
diff --git a/docs/tools/hook/index.md b/docs/tools/hook/index.md
index 63af7d6b..9de35115 100644
--- a/docs/tools/hook/index.md
+++ b/docs/tools/hook/index.md
@@ -1,20 +1,16 @@
# Hook tools
-tmux hooks let you attach commands to lifecycle events — `pane-exited`, `session-renamed`, `command-error`, and so on. libtmux-mcp exposes **read-only** hook introspection so agents can audit what hooks the human user has configured before running automation that might trigger them.
+tmux hooks attach commands to lifecycle events — `pane-exited`,
+`session-renamed`, `command-error`, and so on. libtmux-mcp exposes hook
+inspection, but no dedicated tool that installs or removes one.
## Why no `set_hook`?
-Write-hooks are deliberately not exposed. tmux servers outlive the MCP
-process, and [FastMCP](https://gofastmcp.com)'s `lifespan` teardown
-runs only on graceful SIGTERM/SIGINT — it's bypassed on `kill -9`,
-OOM-kill, and C-extension-fault crashes. Any cleanup registry in
-Python could be silently bypassed, leaking agent-installed shell hooks
-into the user's persistent tmux server where they would fire forever.
-Three plausible future paths exist (a tmux-side `client-detached`
-meta-hook for self-cleanup, requiring `LIBTMUX_SAFETY=destructive`, or
-exposing one-shot `run_hook` only); none is in scope.
-
-Until one of those paths is implemented, the surface here is visibility only.
+The hook API is deliberately inspection-only. tmux servers outlive the MCP
+process and can run tmux command lists. Cleanup is not guaranteed after
+SIGKILL, OOM, or a native crash, so this server exposes no dedicated
+hook-write tool.
+Keep intentional persistent hooks in tmux configuration.
## Inspect
diff --git a/docs/tools/hook/show-hook.md b/docs/tools/hook/show-hook.md
index 33fa3af7..07a18d6a 100644
--- a/docs/tools/hook/show-hook.md
+++ b/docs/tools/hook/show-hook.md
@@ -9,7 +9,8 @@ empty when the hook is unset; raises an
unknown hook names (typos, wrong scope) so input mistakes don't
masquerade as "nothing configured".
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
```{fastmcp-tool-input} hook_tools.show_hook
```
diff --git a/docs/tools/hook/show-hooks.md b/docs/tools/hook/show-hooks.md
index 62159bb9..b55c109a 100644
--- a/docs/tools/hook/show-hooks.md
+++ b/docs/tools/hook/show-hooks.md
@@ -7,7 +7,8 @@
target — the human user's tmux config, an inherited team setup, or
a session that another tool may have touched.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
```{fastmcp-tool-input} hook_tools.show_hooks
```
diff --git a/docs/tools/index.md b/docs/tools/index.md
index 6bde2301..287d85a7 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -55,9 +55,9 @@ leave socket selection inside each nested tool's arguments. See
- Signal a waiter → {tool}`signal-channel`
**Batching typed tool calls?**
-- Read-only observations → {tool}`call-readonly-tools-batch`
-- Ordered readonly + mutating workflows → {tool}`call-mutating-tools-batch`
-- Reviewed workflows that include destructive steps → {tool}`call-destructive-tools-batch`
+- Several `inspect` calls → {tool}`call-read-tools-batch`
+- Anything that writes → call the tool directly; a batch would hide its
+ name from a client rule keyed on it
**Staging multi-line input?**
- Stage content → {tool}`load-buffer`
@@ -82,7 +82,9 @@ shell-agnostic guidance.
## Inspect
-Read tmux state without changing anything.
+Request tmux state or terminal output, or render server-local prompt text. The
+built-in operation does not pass caller input as a tmux or shell command.
+Configured tmux aliases and hooks may still execute; see {ref}`trust`.
::::{grid} 1 2 3 3
:gutter: 2 2 3 3
@@ -159,10 +161,10 @@ Wait for text to appear in a pane.
Get tmux server info.
:::
-:::{grid-item-card} call_readonly_tools_batch
-:link: call-readonly-tools-batch
+:::{grid-item-card} call_read_tools_batch
+:link: call-read-tools-batch
:link-type: ref
-Call typed readonly tools in order.
+Call typed `inspect` tools in order.
:::
:::{grid-item-card} list_servers
@@ -192,7 +194,7 @@ Rich capture: content + cursor + mode + scroll.
:::{grid-item-card} display_message
:link: display-message
:link-type: ref
-Query arbitrary tmux format strings.
+Read literal text and validated tmux variables.
:::
:::{grid-item-card} show_buffer
@@ -215,55 +217,16 @@ Inspect a single tmux hook by name.
::::
-## Act
+## Manage
-Create or modify tmux objects.
+Change tmux-managed structure, presentation, staging, or coordination state.
+The built-in operation does not supply a shell command, pane input, or a value
+tmux treats as executable configuration. Configured aliases and hooks may still
+execute; see {ref}`trust`.
::::{grid} 1 2 3 3
:gutter: 2 2 3 3
-:::{grid-item-card} create_session
-:link: create-session
-:link-type: ref
-Start a new tmux session.
-:::
-
-:::{grid-item-card} create_window
-:link: create-window
-:link-type: ref
-Add a window to a session.
-:::
-
-:::{grid-item-card} split_window
-:link: split-window
-:link-type: ref
-Split a window into panes.
-:::
-
-:::{grid-item-card} send_keys
-:link: send-keys
-:link-type: ref
-Send raw keystrokes to a pane.
-:::
-
-:::{grid-item-card} send_keys_batch
-:link: send-keys-batch
-:link-type: ref
-Send several ordered raw-input operations.
-:::
-
-:::{grid-item-card} run_command
-:link: run-command
-:link-type: ref
-Run a shell command and report exit status.
-:::
-
-:::{grid-item-card} call_mutating_tools_batch
-:link: call-mutating-tools-batch
-:link-type: ref
-Call typed readonly or mutating tools in order.
-:::
-
:::{grid-item-card} rename_session
:link: rename-session
:link-type: ref
@@ -300,30 +263,6 @@ Set window layout.
Set pane title.
:::
-:::{grid-item-card} clear_pane
-:link: clear-pane
-:link-type: ref
-Clear pane content.
-:::
-
-:::{grid-item-card} respawn_pane
-:link: respawn-pane
-:link-type: ref
-Restart a pane's process, keeping its `pane_id`.
-:::
-
-:::{grid-item-card} set_option
-:link: set-option
-:link-type: ref
-Set a tmux option.
-:::
-
-:::{grid-item-card} set_environment
-:link: set-environment
-:link-type: ref
-Set a tmux environment variable.
-:::
-
:::{grid-item-card} select_pane
:link: select-pane
:link-type: ref
@@ -348,12 +287,6 @@ Exchange positions of two panes.
Move window to another index or session.
:::
-:::{grid-item-card} pipe_pane
-:link: pipe-pane
-:link-type: ref
-Stream pane output to a file.
-:::
-
:::{grid-item-card} enter_copy_mode
:link: enter-copy-mode
:link-type: ref
@@ -366,24 +299,12 @@ Enter copy mode for scrollback.
Exit copy mode.
:::
-:::{grid-item-card} paste_text
-:link: paste-text
-:link-type: ref
-Paste multi-line text via tmux buffer.
-:::
-
:::{grid-item-card} load_buffer
:link: load-buffer
:link-type: ref
Stage multi-line text into an MCP-namespaced tmux buffer.
:::
-:::{grid-item-card} paste_buffer
-:link: paste-buffer
-:link-type: ref
-Paste an MCP buffer into a target pane.
-:::
-
:::{grid-item-card} wait_for_channel
:link: wait-for-channel
:link-type: ref
@@ -398,13 +319,101 @@ Wake clients blocked on a ``wait-for`` channel.
::::
-## Destroy
+## Execute
-Tear down tmux objects. Not reversible.
+Start a pane process, deliver input to one, or store state that can control
+later execution.
::::{grid} 1 2 3 3
:gutter: 2 2 3 3
+:::{grid-item-card} create_session
+:link: create-session
+:link-type: ref
+Start a new tmux session.
+:::
+
+:::{grid-item-card} create_window
+:link: create-window
+:link-type: ref
+Add a window to a session.
+:::
+
+:::{grid-item-card} split_window
+:link: split-window
+:link-type: ref
+Split a window into panes.
+:::
+
+:::{grid-item-card} send_keys
+:link: send-keys
+:link-type: ref
+Send raw keystrokes to a pane.
+:::
+
+:::{grid-item-card} send_keys_batch
+:link: send-keys-batch
+:link-type: ref
+Send several ordered raw-input operations.
+:::
+
+:::{grid-item-card} run_command
+:link: run-command
+:link-type: ref
+Run a shell command and report exit status.
+:::
+
+:::{grid-item-card} respawn_pane
+:link: respawn-pane
+:link-type: ref
+Restart a pane's process, keeping its `pane_id`.
+:::
+
+:::{grid-item-card} set_option
+:link: set-option
+:link-type: ref
+Set a tmux option.
+:::
+
+:::{grid-item-card} set_environment
+:link: set-environment
+:link-type: ref
+Set a tmux environment variable.
+:::
+
+:::{grid-item-card} pipe_pane
+:link: pipe-pane
+:link-type: ref
+Stream pane output to a file.
+:::
+
+:::{grid-item-card} paste_text
+:link: paste-text
+:link-type: ref
+Paste multi-line text via tmux buffer.
+:::
+
+:::{grid-item-card} paste_buffer
+:link: paste-buffer
+:link-type: ref
+Paste an MCP buffer into a target pane.
+:::
+
+::::
+
+## Teardown
+
+Delete tmux objects or retained scrollback. Not reversible.
+
+::::{grid} 1 2 3 3
+:gutter: 2 2 3 3
+
+:::{grid-item-card} clear_pane
+:link: clear-pane
+:link-type: ref
+Clear pane content and scrollback.
+:::
+
:::{grid-item-card} kill_session
:link: kill-session
:link-type: ref
@@ -429,12 +438,6 @@ Destroy a pane.
Kill the entire tmux server.
:::
-:::{grid-item-card} call_destructive_tools_batch
-:link: call-destructive-tools-batch
-:link-type: ref
-Call typed tools including destructive steps.
-:::
-
:::{grid-item-card} delete_buffer
:link: delete-buffer
:link-type: ref
diff --git a/docs/tools/pane/capture-pane.md b/docs/tools/pane/capture-pane.md
index 757bdcf4..2fe6dea1 100644
--- a/docs/tools/pane/capture-pane.md
+++ b/docs/tools/pane/capture-pane.md
@@ -11,7 +11,8 @@ after running a command, checking output, or verifying state.
{tooliconl}`capture-since` with its cursor so unchanged scrollback is not sent
again. If you only need pane metadata (not content), use {tooliconl}`get-pane-info`.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/pane/capture-since.md b/docs/tools/pane/capture-since.md
index 5e1b95e7..84ca98b9 100644
--- a/docs/tools/pane/capture-since.md
+++ b/docs/tools/pane/capture-since.md
@@ -15,7 +15,8 @@ output in one typed result. If you need a one-shot content + metadata
view, use {tooliconl}`snapshot-pane`; if you do not know which pane
contains text, use {tooliconl}`search-panes`.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md
index ffb4878a..828a2ce0 100644
--- a/docs/tools/pane/display-message.md
+++ b/docs/tools/pane/display-message.md
@@ -1,13 +1,12 @@
-# Evaluate tmux format string (display_message)
+# Read tmux variables (display_message)
```{fastmcp-tool} pane_tools.display_message
```
-**Use when** you need to query arbitrary tmux variables — zoom state, pane
-dead flag, client activity, or any `#{format}` string that isn't covered by
-other tools. Despite the historical name (`display_message` is the tmux verb
-it wraps), this tool does **not** display anything to the user; it expands
-the format string with `display-message -p` and returns the value.
+**Use when** you need to read a tmux variable no dedicated tool covers —
+zoom state, pane dead flag, client activity. Despite the historical name
+(`display_message` is the tmux verb it wraps), this tool does **not** display
+anything to the user; it substitutes the variables and returns the value.
**Avoid when** a dedicated tool already provides the information — e.g. use
{tooliconl}`snapshot-pane` for cursor position and mode,
@@ -17,7 +16,17 @@ the format string with `display-message -p` and returns the value.
{class}`~libtmux_mcp.models.PaneInfo` without parsing `#{pane_at_bottom}` / `#{pane_at_right}`
yourself.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
+
+Accepts literal text and `#{variable}` references only. Modifiers such as
+`#{E:...}` re-expand a variable's *value*, which can arrive from a pane
+rather than from you, so they are refused rather than filtered.
+
+Modifiers, conditionals, and padding are therefore no longer available here.
+Run `tmux display-message -p` through {tooliconl}`run-command` for those: raw
+format syntax belongs on the surface labelled execution, where the caller is
+the one supplying it.
**Example:**
diff --git a/docs/tools/pane/find-pane-by-position.md b/docs/tools/pane/find-pane-by-position.md
index 974bb823..6157de75 100644
--- a/docs/tools/pane/find-pane-by-position.md
+++ b/docs/tools/pane/find-pane-by-position.md
@@ -10,7 +10,8 @@ computing geometry yourself.
**Avoid when** you already know the `pane_id`. Use {tooliconl}`get-pane-info`
or {tooliconl}`select-pane` directly.
-**Side effects:** None. Read-only.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/pane/get-pane-info.md b/docs/tools/pane/get-pane-info.md
index 74e2bc50..89a7294e 100644
--- a/docs/tools/pane/get-pane-info.md
+++ b/docs/tools/pane/get-pane-info.md
@@ -8,7 +8,8 @@ other metadata without reading the terminal content.
**Avoid when** you need the actual text — use {tooliconl}`capture-pane`.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md
index 85e8a75a..d1842e4a 100644
--- a/docs/tools/pane/index.md
+++ b/docs/tools/pane/index.md
@@ -98,7 +98,7 @@ Restart a pane's process in place, preserving pane_id.
:::
:::{grid-item-card} {tooliconl}`kill-pane`
-Terminate a pane. Destructive.
+Terminate a pane. Not reversible.
:::
::::
diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md
index 7142910f..d6899011 100644
--- a/docs/tools/pane/respawn-pane.md
+++ b/docs/tools/pane/respawn-pane.md
@@ -23,7 +23,10 @@ point of the tool. `pane_pid` updates to the new process.
Set it to `true` and {tooliconl}`respawn-pane` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment or affect later panes. The shell can retain in-memory history, and a startup file can override these controls after the process starts.
-When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries.
+When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries.
+
+`start_directory` must name a directory that exists. tmux would otherwise
+start the pane in `$HOME` without reporting anything.
**Tip:** Call {tooliconl}`get-pane-info` first if you need to capture
`pane_current_command` before respawn — the new process loses its argv.
@@ -71,7 +74,7 @@ time).
}
```
-Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, sha256_prefix}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details.
+Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, sha256_prefix}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`trust` for details.
Response ({class}`~libtmux_mcp.models.PaneInfo`):
diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md
index c2e669d9..41ff1882 100644
--- a/docs/tools/pane/run-command.md
+++ b/docs/tools/pane/run-command.md
@@ -15,7 +15,7 @@ names, and partial commands.
For MCP calls, lightweight suppression is enabled by default. The {ref}`configuration ` setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` controls only omitted MCP `suppress_history` arguments, and an explicit `suppress_history` value wins. Direct Python calls default to `False`. `suppress_history=false` permits intentional multiline input.
-Suppression is best effort: {tooliconl}`run-command` prefixes one space to the grouped event that carries your single-line command, but the existing shell must be configured to ignore space-prefixed commands. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input because one prefix cannot protect multiple shell events. This control does not change the shell's environment or startup configuration, clear its memory or pane scrollback, or hide the command from other observers. See {ref}`history-hygiene` for shell behavior and {ref}`safety` before handling credentials.
+Suppression is best effort: {tooliconl}`run-command` prefixes one space to the grouped event that carries your single-line command, but the existing shell must be configured to ignore space-prefixed commands. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input because one prefix cannot protect multiple shell events. This control does not change the shell's environment or startup configuration, clear its memory or pane scrollback, or hide the command from other observers. See {ref}`history-hygiene` for shell behavior and {ref}`trust` before handling credentials.
**Example:**
diff --git a/docs/tools/pane/search-panes.md b/docs/tools/pane/search-panes.md
index 2f8f277b..b1452e5b 100644
--- a/docs/tools/pane/search-panes.md
+++ b/docs/tools/pane/search-panes.md
@@ -10,7 +10,13 @@ without knowing which pane to look in.
**Avoid when** you already know the target pane — use {tooliconl}`capture-pane`
for a one-shot read, or {tooliconl}`capture-since` for repeated observation.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
+
+Matching shares a two-second ceiling across every line of every pane. A
+`regex=true` pattern with nested quantifiers such as `(a+)+` backtracks
+exponentially and will exhaust it on one ordinary line; anchor the pattern or
+search for a literal.
**Example:**
diff --git a/docs/tools/pane/signal-channel.md b/docs/tools/pane/signal-channel.md
index 02d456c8..bfe7de17 100644
--- a/docs/tools/pane/signal-channel.md
+++ b/docs/tools/pane/signal-channel.md
@@ -5,11 +5,13 @@
**Use when** you need to wake a blocked {tooliconl}`wait-for-channel`
caller from a different MCP context (e.g. when a long-running task in
-one pane completes and another pane should proceed). Signalling an
-unwaited channel is a successful no-op — safe to call defensively.
+one pane completes and another pane should proceed). With no current
+waiter, tmux records one pending signal for the next waiter. A second
+signal before that wait clears the channel.
-**Side effects:** Wakes any clients blocked on the named channel.
-Doesn't allocate or persist state.
+**Side effects:** Wakes clients blocked on the named channel or records one
+pending signal for the next waiter to consume. A waiter resumes whatever work
+follows its wait.
```{fastmcp-tool-input} wait_for_tools.signal_channel
```
diff --git a/docs/tools/pane/snapshot-pane.md b/docs/tools/pane/snapshot-pane.md
index 89c0d873..c444fc66 100644
--- a/docs/tools/pane/snapshot-pane.md
+++ b/docs/tools/pane/snapshot-pane.md
@@ -11,7 +11,8 @@ terminal mode.
**Avoid when** you only need raw text — {tooliconl}`capture-pane` is lighter.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/pane/wait-for-channel.md b/docs/tools/pane/wait-for-channel.md
index 82cc8a22..9cba38db 100644
--- a/docs/tools/pane/wait-for-channel.md
+++ b/docs/tools/pane/wait-for-channel.md
@@ -17,7 +17,7 @@ send_keys(
wait_for_channel("tests_done", timeout=60)
```
-The `; tmux wait-for -S NAME` suffix is the load-bearing safety contract — `wait-for` is edge-triggered, so a crash before the signal would deadlock until the wait's `timeout`. The shell separator `;` runs the next statement unconditionally, so the signal fires on both success and failure paths.
+The `; tmux wait-for -S NAME` suffix is the load-bearing completion contract. The shell separator `;` runs the signal after either command success or failure; if the shell exits before reaching it, the wait remains bounded by `timeout`.
The payload deliberately does not append `exit $?` — in an interactive
shell that exits the shell itself, taking single-pane sessions down
diff --git a/docs/tools/pane/wait-for-text.md b/docs/tools/pane/wait-for-text.md
index d77b2c5b..61e386aa 100644
--- a/docs/tools/pane/wait-for-text.md
+++ b/docs/tools/pane/wait-for-text.md
@@ -11,7 +11,9 @@ server to start, a build to complete, or a prompt to return.
{tooliconl}`capture-since`; for command completion you control, use
{tooliconl}`wait-for-channel`.
-**Side effects:** None. Readonly. Blocks until text appears or timeout.
+**Side effects:** Each built-in poll reads data only. The call blocks until text
+appears or the timeout expires. A configured alias or hook can add effects; see
+{ref}`trust`.
**Example:**
diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md
index bb65860b..d9c42e5a 100644
--- a/docs/tools/server/create-session.md
+++ b/docs/tools/server/create-session.md
@@ -14,13 +14,16 @@ container — create one before creating windows or panes.
**Do not pass credentials directly in `environment`.** Values persist in the
new session, can be inspected with {tooliconl}`show-environment`, and reach
its initial pane and future panes. Pass credential references instead; see
-{ref}`safety` for details.
+{ref}`trust` for details.
`suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls.
Set it to `true` and {tooliconl}`create-session` copies and merges best-effort no-disk history controls into the tmux session environment. They reach the initial pane and future panes in that session. The shell can retain in-memory history, and a startup file can override these controls after the process starts.
-When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries.
+When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries.
+
+`start_directory` must name a directory that exists. tmux would otherwise
+start the pane in `$HOME` without reporting anything.
**Example:**
diff --git a/docs/tools/server/get-server-info.md b/docs/tools/server/get-server-info.md
index 5b59a48e..8abf9919 100644
--- a/docs/tools/server/get-server-info.md
+++ b/docs/tools/server/get-server-info.md
@@ -8,7 +8,8 @@ or inspect server-level state before creating sessions.
**Avoid when** you only need session names — use {tooliconl}`list-sessions`.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/server/index.md b/docs/tools/server/index.md
index c5350f84..299d5bbb 100644
--- a/docs/tools/server/index.md
+++ b/docs/tools/server/index.md
@@ -22,7 +22,7 @@ Create a new tmux session.
:::
:::{grid-item-card} {tooliconl}`kill-server`
-Terminate the tmux daemon. Destructive.
+Terminate the tmux daemon. Not reversible.
:::
:::{grid-item-card} {tooliconl}`show-option`
diff --git a/docs/tools/server/list-servers.md b/docs/tools/server/list-servers.md
index cfe681ea..b0a5b19b 100644
--- a/docs/tools/server/list-servers.md
+++ b/docs/tools/server/list-servers.md
@@ -11,7 +11,8 @@ side project.
**Avoid when** you already know the socket name or path you want to
target — pass it directly to the tool that needs it via `socket_name`.
-**Side effects:** None. Readonly. Stale socket files are filtered
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`. Stale socket files are filtered
via a kernel-fast UNIX `connect()` probe so the call stays under one
second even on machines with thousands of orphaned `tmux-/`
inodes.
diff --git a/docs/tools/server/list-sessions.md b/docs/tools/server/list-sessions.md
index 2d791776..bf03b3fe 100644
--- a/docs/tools/server/list-sessions.md
+++ b/docs/tools/server/list-sessions.md
@@ -9,7 +9,8 @@ which session to target.
**Avoid when** you need window or pane details — use {tooliconl}`list-windows` or
{tooliconl}`list-panes` instead.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/server/show-environment.md b/docs/tools/server/show-environment.md
index a9d617f2..a417caf3 100644
--- a/docs/tools/server/show-environment.md
+++ b/docs/tools/server/show-environment.md
@@ -5,7 +5,8 @@
**Use when** you need to inspect tmux environment variables.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/server/show-option.md b/docs/tools/server/show-option.md
index f6718bec..7473b1cc 100644
--- a/docs/tools/server/show-option.md
+++ b/docs/tools/server/show-option.md
@@ -6,7 +6,8 @@
**Use when** you need to check a tmux configuration value — buffer limits,
history size, status bar settings, etc.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md
index 2521d5b0..370ae5aa 100644
--- a/docs/tools/session/create-window.md
+++ b/docs/tools/session/create-window.md
@@ -11,13 +11,16 @@
process started in the new window; it does not change the tmux session
environment. Values can remain visible in the tmux client argv and child
environment even though the MCP audit record is redacted. Pass credential
-references, not literal credentials; see {ref}`safety`.
+references, not literal credentials; see {ref}`trust`.
`suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls.
Set it to `true` and {tooliconl}`create-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so future windows and panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts.
-When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries.
+When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries.
+
+`start_directory` must name a directory that exists. tmux would otherwise
+start the pane in `$HOME` without reporting anything.
**Example:**
diff --git a/docs/tools/session/get-session-info.md b/docs/tools/session/get-session-info.md
index 15460f09..45fefcfd 100644
--- a/docs/tools/session/get-session-info.md
+++ b/docs/tools/session/get-session-info.md
@@ -10,7 +10,8 @@ count, attachment status, activity timestamp) and you already know its
**Avoid when** you need every session — call {tooliconl}`list-sessions` or iterate
via the `tmux://sessions` resource.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/session/index.md b/docs/tools/session/index.md
index b15697e3..0dd7fe81 100644
--- a/docs/tools/session/index.md
+++ b/docs/tools/session/index.md
@@ -26,7 +26,7 @@ Rename an existing session.
:::
:::{grid-item-card} {tooliconl}`kill-session`
-Terminate a session. Destructive.
+Terminate a session. Not reversible.
:::
::::
diff --git a/docs/tools/session/list-windows.md b/docs/tools/session/list-windows.md
index c4b99e31..9bf4072b 100644
--- a/docs/tools/session/list-windows.md
+++ b/docs/tools/session/list-windows.md
@@ -8,7 +8,8 @@ session before selecting a window to work with.
**Avoid when** you need pane-level detail — use {tooliconl}`list-panes`.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/window/get-window-info.md b/docs/tools/window/get-window-info.md
index bfaa32d3..2f29bdec 100644
--- a/docs/tools/window/get-window-info.md
+++ b/docs/tools/window/get-window-info.md
@@ -10,7 +10,8 @@ dimensions, pane count) and you already know the `window_id` or
**Avoid when** you need every window in a session — call {tooliconl}`list-windows` with
`session_id` or iterate via the `tmux://sessions/{name}/windows` resource.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/window/index.md b/docs/tools/window/index.md
index a14131ee..8a1f3cae 100644
--- a/docs/tools/window/index.md
+++ b/docs/tools/window/index.md
@@ -34,7 +34,7 @@ Reorder a window or move it across sessions.
:::
:::{grid-item-card} {tooliconl}`kill-window`
-Terminate a window. Destructive.
+Terminate a window. Not reversible.
:::
::::
diff --git a/docs/tools/window/list-panes.md b/docs/tools/window/list-panes.md
index e08754e6..a1ad8a74 100644
--- a/docs/tools/window/list-panes.md
+++ b/docs/tools/window/list-panes.md
@@ -6,7 +6,8 @@
**Use when** you need to discover which panes exist in a window before
sending keys or capturing output.
-**Side effects:** None. Readonly.
+**Side effects:** The built-in request reads data only. A configured alias or
+hook can add effects; see {ref}`trust`.
**Example:**
diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md
index 18e6514c..c3fe07c2 100644
--- a/docs/tools/window/split-window.md
+++ b/docs/tools/window/split-window.md
@@ -12,13 +12,16 @@ window.
process started in the new pane; it does not change the tmux session
environment. Values can remain visible in the tmux client argv and child
environment even though the MCP audit record is redacted. Pass credential
-references, not literal credentials; see {ref}`safety`.
+references, not literal credentials; see {ref}`trust`.
`suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls.
Set it to `true` and {tooliconl}`split-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so later panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts.
-When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries.
+When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`trust` for output, scrollback, process, transcript, hook, and logging boundaries.
+
+`start_directory` must name a directory that exists. tmux would otherwise
+start the pane in `$HOME` without reporting anything.
**Example:**
diff --git a/docs/topics/architecture.md b/docs/topics/architecture.md
index c24c0178..44264fb7 100644
--- a/docs/topics/architecture.md
+++ b/docs/topics/architecture.md
@@ -13,9 +13,9 @@ src/libtmux_mcp/
server.py # FastMCP instance and configuration
_utils.py # Server caching, resolvers, serializers, error handling
models.py # Pydantic output models
- middleware.py # Safety, audit, retry, and error-result middleware
+ middleware.py # Toolset, audit, and error-result middleware
tools/
- batch_tools.py # call_readonly_tools_batch, call_mutating_tools_batch, call_destructive_tools_batch
+ batch_tools.py # call_read_tools_batch
server_tools.py # list_servers, list_sessions, create_session, kill_server, get_server_info
session_tools.py # list_windows, create_window, rename_session, kill_session
window_tools.py # list_panes, split_window, rename_window, kill_window, select_layout, resize_window
@@ -41,11 +41,10 @@ MCP Client (Claude, Cursor, etc.)
→ TailPreservingResponseLimitingMiddleware (response size backstop)
→ ToolErrorResultMiddleware (exceptions → is_error results)
→ AuditMiddleware (one log record per call)
- → ReadonlyRetryMiddleware (retries readonly tools only)
- → SafetyMiddleware (tier gate, fail-closed)
- → Tool function (tools/*.py)
- → libtmux Python objects
- → tmux binary (via subprocess)
+ → ToolsetMiddleware (membership, fail-closed)
+ → Tool function (tools/*.py)
+ → libtmux Python objects
+ → tmux binary (via subprocess)
```
The libtmux layer is the tmux object hierarchy:
@@ -58,8 +57,8 @@ The libtmux layer is the tmux object hierarchy:
Each tool module defines a `register(mcp)` function that registers tools with metadata:
- `title` — human-readable name
-- `annotations` — MCP tool annotations (readOnlyHint, destructiveHint, idempotentHint)
-- `tags` — safety tier tags for middleware filtering
+- `annotations` — all four MCP hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), never partial
+- `tags` — the tool's toolset, which drives filtering
### Server caching
@@ -76,23 +75,23 @@ targeting parameters and resolve to the correct
{external+libtmux:doc}`libtmux ` object. Resolution follows a
priority chain: direct ID → name lookup → error.
-### Safety middleware
+### Toolset middleware
-{class}`~libtmux_mcp.middleware.SafetyMiddleware` implements
+{class}`~libtmux_mcp.middleware.ToolsetMiddleware` implements
[FastMCP](https://gofastmcp.com)'s middleware interface. It operates
-as a secondary gate behind FastMCP's native tag visibility system,
-providing clear error messages when a tool above the configured tier
-is invoked.
+as a secondary classification gate behind FastMCP's native tag and name
+visibility. Tools hidden by FastMCP return its unknown-tool error before
+dispatch reaches this middleware.
### Error handling
Three boundaries split the work:
-1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e`, which is what lets {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` match transient {exc}`~libtmux.exc.LibTmuxException` causes.
+1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e` for logs and debuggers.
2. **Schema classification** — FastMCP validates tool arguments before tool code runs, so [Pydantic](https://docs.pydantic.dev/) validation failures never reach the decorator. {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` classifies those schema-validation errors as expected, agent-correctable WARNINGs before converting them.
-3. **Conversion** — {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` catches the exception once it has cleared the audit/retry/safety trio and returns an error `ToolResult` carrying the message exactly as raised, plus a `_meta` payload (`error_type`, `expected`, and an optional agent-facing `suggestion` for recovery hints such as discovery tools or rejected-argument fixes).
+3. **Conversion** — {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` catches the exception once it has cleared the audit/toolset pair and returns an error `ToolResult` carrying the message exactly as raised, plus a `_meta` payload (`error_type`, `expected`, and an optional agent-facing `suggestion` for recovery hints such as discovery tools or rejected-argument fixes).
-Errors must stay exceptions through the audit/retry/safety trio — audit detects failures by catching, retry matches via `__cause__` — so conversion happens only in the outermost error layer. The response limiter sits outside conversion and may truncate large success or error results on the return path; its truncation path preserves `is_error` and `_meta` so oversized expected failures stay tool errors. Level policy lives in {doc}`/topics/logging`.
+Errors must stay exceptions through the audit/toolset pair so audit can detect failures before conversion in the outermost error layer. The response limiter sits outside conversion and may truncate large success or error results on the return path; its truncation path preserves `is_error` and `_meta` so oversized expected failures stay tool errors. Level policy lives in {doc}`/topics/logging`.
## References
diff --git a/docs/topics/concepts.md b/docs/topics/concepts.md
index 4fa68d0a..80152fed 100644
--- a/docs/topics/concepts.md
+++ b/docs/topics/concepts.md
@@ -41,28 +41,22 @@ Most tools accept multiple targeting parameters. The resolution order is:
For pane tools, you can combine parameters to narrow the search: `session_name` + `window_id` → find the pane in that specific window.
-## Discovery vs. mutation
-
-Tools fall into three categories:
-
-- **Discovery** — Read-only operations: {toolref}`list-sessions`,
- {toolref}`list-windows`, {toolref}`list-panes`,
- {toolref}`capture-pane`, {toolref}`capture-since`,
- {toolref}`get-pane-info`, {toolref}`find-pane-by-position`,
- {toolref}`search-panes`, {toolref}`wait-for-text`,
- {toolref}`show-option`, {toolref}`show-environment`
-- **Mutation** — Create, modify, or send input:
- {toolref}`create-session`, {toolref}`create-window`,
- {toolref}`split-window`, {toolref}`send-keys`,
- {toolref}`send-keys-batch`, {toolref}`rename-session`,
- {toolref}`rename-window`, {toolref}`resize-pane`,
- {toolref}`resize-window`, {toolref}`set-pane-title`,
- {toolref}`clear-pane`, {toolref}`select-layout`,
- {toolref}`set-option`, {toolref}`set-environment`
-- **Destruction** — Remove tmux objects: {toolref}`kill-server`,
- {toolref}`kill-session`, {toolref}`kill-window`, {toolref}`kill-pane`
-
-These map to {ref}`safety tiers `.
+## Toolsets
+
+Tools belong to four unordered sets:
+
+- **Inspect** requests tmux state or terminal output, or renders server-local
+ prompt text. Its built-in operation does not pass caller input as a tmux or
+ shell command.
+- **Manage** changes tmux-managed structure, presentation, staging, or
+ coordination state.
+- **Execute** starts or drives pane processes, or stores state that can
+ control later execution.
+- **Teardown** deletes tmux objects or retained scrollback.
+
+The selected sets determine which MCP tools this server advertises and
+accepts. Configured tmux aliases and hooks can add effects to any tmux
+request; see {ref}`trust`.
## Agent self-awareness
@@ -70,9 +64,10 @@ When the MCP server runs inside a tmux pane (detected via the `TMUX_PANE` enviro
- Includes the caller's pane context in server instructions
- Annotates the caller's own pane with `is_caller=true` in tool results
-- Prevents destructive tools from killing the caller's own pane, window, session, or server
+- Prevents the `teardown` tools from killing the caller's own pane, window, session, or server
-This means agents can safely explore and manage tmux without accidentally terminating themselves.
+These checks prevent the teardown tools from terminating their own MCP pane.
+They do not constrain tmux aliases, hooks, other clients, or pane processes.
## Server caching
diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md
index 9bc678e4..509115cf 100644
--- a/docs/topics/gotchas.md
+++ b/docs/topics/gotchas.md
@@ -62,6 +62,23 @@ returns a cursor, and follow-up calls return only output written or rewritten
after that cursor. If tmux has already trimmed or cleared the needed history,
the result marks `lines_missed=true` and gives you a fresh cursor.
+## Names you pass are stored literally
+
+tmux expands several argument values as formats, where `#H` becomes the
+hostname and `#(cmd)` runs a shell job. Session names, window names, pane
+titles, option names, and `start_directory` all land in such an argument.
+
+This server escapes each of them, so a window named `build #2` or a directory
+named `has#hash` is stored as written. Nothing has to be pre-escaped, and
+doubling a `#` yourself produces a literal doubled `#`.
+
+Values are the exception, and deliberately so. {tooliconl}`set-option` stores
+an option's value unexpanded, because a status format is *supposed* to keep
+its `#{...}`. tmux then runs it when it draws the status line, and again on
+every status interval. `default-command` and `default-shell` decide what
+future panes run. Setting an option is therefore a way to schedule
+execution, not only to configure.
+
## Window names are not unique across sessions
Two sessions can each have a window named "editor". Targeting by `window_name` alone is ambiguous — always include `session_name` or use the globally unique `window_id` (e.g., `@0`, `@1`).
@@ -97,4 +114,4 @@ This has been observed with stock Gemini CLI behavior (no extensions involved).
Tool schemas are strict (`additionalProperties: false`), so the call is rejected with a validation error — classified as expected (WARNING log, `expected: true` in the result's `_meta`) and carrying a suggestion that names the rejected argument and identifies `wait_for_previous` as a client scheduling flag to retry without. Gemini's model reads it, drops the flag, and retries successfully on its own.
-The visible symptom is harmless noise: `Error executing tool mcp_tmux_: ... reported an error` lines in Gemini's output for calls that then succeed on retry, and matching WARNING records in the server log. Similar reports in other MCP servers have handled this injected key by stripping it or whitelisting arguments against the schema ([MemPalace/mempalace#816](https://github.com/MemPalace/mempalace/issues/816)). This server deliberately keeps the rejection: silently dropping unknown arguments would also swallow genuine argument typos from every client — on a server with mutating and destructive tools, a mis-named flag (`enter` on {toolref}`send-keys`, say) must fail loudly, not run with defaults.
+The visible symptom is harmless noise: `Error executing tool mcp_tmux_: ... reported an error` lines in Gemini's output for calls that then succeed on retry, and matching WARNING records in the server log. Similar reports in other MCP servers have handled this injected key by stripping it or whitelisting arguments against the schema ([MemPalace/mempalace#816](https://github.com/MemPalace/mempalace/issues/816)). This server deliberately keeps the rejection: silently dropping unknown arguments would also swallow genuine argument typos from every client — on a server that can type into panes and delete them, a mis-named flag (`enter` on {toolref}`send-keys`, say) must fail loudly, not run with defaults.
diff --git a/docs/topics/history-suppression.md b/docs/topics/history-suppression.md
index e6a014bf..88902007 100644
--- a/docs/topics/history-suppression.md
+++ b/docs/topics/history-suppression.md
@@ -11,7 +11,7 @@ best-effort no-disk controls, opt in with `suppress_persistent_history=true`.
Neither control makes a command secret. Shell configuration can override the
request, in-memory history can remain available, and terminal output or other
-observers can still record the command. See {ref}`safety` before handling
+observers can still record the command. See {ref}`trust` before handling
credentials.
## Why raw input stays explicit
@@ -139,5 +139,5 @@ History suppression does not clear pane echo, scrollback, in-memory history,
process arguments, tmux environment state, MCP client transcripts, hooks, or
logs. Prefer credential references that the child process resolves over
literal credentials in `command`, `keys`, `text`, `shell`, or `environment`.
-See {ref}`safety` for the full observation boundary and {ref}`logging` for
+See {ref}`trust` for the full observation boundary and {ref}`logging` for
audit-record behavior.
diff --git a/docs/topics/index.md b/docs/topics/index.md
index 2b927654..c7f5a7ef 100644
--- a/docs/topics/index.md
+++ b/docs/topics/index.md
@@ -17,10 +17,10 @@ Source layout, request flow, and extension points.
tmux hierarchy, MCP protocol, and the mental model.
:::
-:::{grid-item-card} Safety Tiers
-:link: safety
+:::{grid-item-card} Trust model
+:link: trust
:link-type: doc
-Three-tier safety system for controlling tool access.
+What the four toolsets group, and what filtering them does not bound.
:::
:::{grid-item-card} History Suppression
@@ -92,7 +92,7 @@ observation cursors.
architecture
concepts
-safety
+trust
history-suppression
waiting
gotchas
diff --git a/docs/topics/logging.md b/docs/topics/logging.md
index a4bcd01f..d6489c53 100644
--- a/docs/topics/logging.md
+++ b/docs/topics/logging.md
@@ -14,11 +14,7 @@ No manual wiring needed.
All loggers are children of ``libtmux_mcp``. The primary streams
are:
-- ``libtmux_mcp.audit`` — one structured line per tool call, emitted by {class}`~libtmux_mcp.middleware.AuditMiddleware`. Includes tool name, digest-redacted arguments, latency, and outcome. See {doc}`/topics/safety` for the argument-redaction rules. It does not record tool return values.
-- ``libtmux_mcp.retry`` — warnings from
- {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` when a
- readonly tool retried after a transient
- {exc}`~libtmux.exc.LibTmuxException`.
+- ``libtmux_mcp.audit`` — one structured line per tool call, emitted by {class}`~libtmux_mcp.middleware.AuditMiddleware`. Includes tool name, digest-redacted arguments, latency, and outcome. See {doc}`/topics/trust` for the argument-redaction rules. It does not record tool return values.
- ``libtmux_mcp.server`` / ``libtmux_mcp.tools.*`` / etc. — ad-hoc
warnings and debug messages from the codebase.
- ``fastmcp.errors`` — one record per failed tool call, emitted by
@@ -29,7 +25,7 @@ are:
Tool failures are logged at a level matching who needs to act:
- **WARNING** — expected, agent-correctable failures: unknown
- pane/window/session ids, invalid arguments, safety-tier denials,
+ pane/window/session ids, invalid arguments, toolset refusals,
transient tmux errors. The calling agent receives the message and
can correct course; operators don't need to.
- **ERROR** — operator faults and potential bugs: a missing ``tmux``
@@ -61,7 +57,7 @@ the Python logger name, which the protocol doesn't model.
## History controls stay observable
-`suppress_history` and `suppress_persistent_history` affect shell-history behavior only. They do not disable audit logging and do not clear pane echo or scrollback. The audit logger still summarizes the call's arguments and outcome, other library or application loggers keep their own behavior, and an MCP client can still retain the original request and response. See {ref}`safety` for every observation boundary that remains outside history suppression.
+`suppress_history` and `suppress_persistent_history` affect shell-history behavior only. They do not disable audit logging and do not clear pane echo or scrollback. The audit logger still summarizes the call's arguments and outcome, other library or application loggers keep their own behavior, and an MCP client can still retain the original request and response. See {ref}`trust` for every observation boundary that remains outside history suppression.
```{tip}
If a tool call has no user-visible error or side effect, the ``libtmux_mcp.audit`` log shows the invocation and whether it returned or raised, not the tool's return value. Use the MCP response and current tmux state to determine what the tool returned or changed.
@@ -70,6 +66,6 @@ If a tool call has no user-visible error or side effect, the ``libtmux_mcp.audit
## Further reading
- [MCP logging spec](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging)
-- {doc}`/topics/safety` — audit log redaction rules
+- {doc}`/topics/trust` — audit log redaction rules
- {class}`~libtmux_mcp.middleware.AuditMiddleware` — the primary
audit emitter
diff --git a/docs/topics/prompting.md b/docs/topics/prompting.md
index e0493c3f..ebb011c1 100644
--- a/docs/topics/prompting.md
+++ b/docs/topics/prompting.md
@@ -29,7 +29,7 @@ known pane, or capture_pane for a one-shot manual inspection.
```
The server also dynamically adds:
-- **Safety tier context**: Which tier is active and what tools are available
+- **Toolset context**: Which toolsets are enabled and what tools they carry
- **Caller pane awareness**: If the server runs inside tmux, it tells the agent which pane is its own (via `TMUX_PANE`). See {ref}`concepts` "Agent self-awareness" for details.
## Activation and discovery
@@ -79,7 +79,7 @@ These natural-language prompts reliably trigger the right tool sequences:
|--------|---------|---------------|
| [Run this command]{.prompt} | Ambiguous — agent may use its own shell instead of tmux | [Run `make test` in a tmux pane]{.prompt} |
| [Check my terminal]{.prompt} | Which pane? Agent must discover first | [Check the pane running `npm dev`]{.prompt} or [Search all panes for errors]{.prompt} |
-| [Clean up everything]{.prompt} | Too broad for destructive operations | [Kill the `ci-test` session]{.prompt} |
+| [Clean up everything]{.prompt} | Too broad for a call that deletes | [Kill the `ci-test` session]{.prompt} |
| [Show me the output]{.prompt} | Capture immediately? Or wait? | [Wait for the command to finish, then show me the output]{.prompt} |
## System prompt fragments
@@ -113,7 +113,7 @@ command may still be running.
Before creating tmux sessions, check list_sessions to avoid duplicates.
Always use pane_id for targeting — it is globally unique. Never run
-destructive operations (kill_session, kill_server) without confirming
+deletions (kill_session, kill_server) without confirming
the target with the user first.
```
@@ -148,4 +148,4 @@ When an agent is unsure which tool to use, these rules help:
2. **Prefer IDs**: Once you have a `pane_id`, use it for all subsequent calls — it never changes during the pane's lifetime
3. **Run, wait, or observe deliberately**: For commands the agent authors, prefer {toolref}`run-command`. Use {toolref}`wait-for-channel` only for custom shell composition outside that shape. Use {toolref}`capture-since` for repeated observation, and fall back to {toolref}`wait-for-text` for output the agent doesn't author. Never call {toolref}`capture-pane` in a retry loop.
4. **Content vs. metadata**: If looking for text *in* a terminal, use {toolref}`search-panes`. If looking for pane *properties* (name, PID, path), use {toolref}`list-panes` or {toolref}`get-pane-info`
-5. **Destructive tools are opt-in**: Never kill sessions, windows, or panes unless the user explicitly asks
+5. **Deletion is opt-in**: Never kill sessions, windows, or panes unless the user explicitly asks
diff --git a/docs/topics/safety.md b/docs/topics/safety.md
deleted file mode 100644
index 32084c28..00000000
--- a/docs/topics/safety.md
+++ /dev/null
@@ -1,180 +0,0 @@
-```{eval-rst}
-.. _safety:
-```
-
-# Safety tiers
-
-libtmux-mcp uses a three-tier safety system to control which tools are available to AI agents.
-
-## Overview
-
-| Tier | Label | Access | Use case |
-|------|-------|--------|----------|
-| `readonly` | {badge}`readonly` | List, capture, search, info, readonly batches | Monitoring, browsing |
-| `mutating` (default) | {badge}`mutating` | + create, {toolref}`send-keys`, {toolref}`send-keys-batch`, mutating batches, rename, resize | Normal agent workflow |
-| `destructive` | {badge}`destructive` | + destructive batches, {toolref}`kill-server`, {toolref}`kill-session`, {toolref}`kill-window`, {toolref}`kill-pane` | Full control |
-
-## Configuration
-
-Set the safety tier via the {envvar}`LIBTMUX_SAFETY` environment variable:
-
-```json
-{
- "mcpServers": {
- "libtmux": {
- "command": "uvx",
- "args": ["libtmux-mcp"],
- "env": {
- "LIBTMUX_SAFETY": "readonly"
- }
- }
- }
-}
-```
-
-## How it works
-
-### Dual-layer gating
-
-1. **[FastMCP](https://gofastmcp.com) tag visibility**: Tools are tagged with their tier. Only tags at or below the configured tier are enabled via `mcp.enable(tags=..., only=True)`.
-
-2. **Safety middleware**: A secondary middleware layer hides tools from listings and blocks execution with clear error messages if a tool above the tier is somehow invoked.
-
-### Tool tags
-
-Every tool is tagged with exactly one safety tier:
-
-- {badge}`readonly` `readonly` — Read-only operations that don't modify tmux state
-- {badge}`mutating` `mutating` — Operations that create, modify, or send input to tmux objects
-- {badge}`destructive` `destructive` — Operations that destroy tmux objects (kill commands)
-
-### Fail-closed design
-
-Tools without a recognized tier tag are **denied by default**. This prevents accidentally exposing new tools without explicit safety classification.
-
-## Self-kill protection
-
-Destructive tools include safeguards against self-harm:
-
-- {tool}`kill-server` refuses to run if the MCP server is inside the target server
-- {tool}`kill-session` refuses to kill the session containing the MCP pane
-- {tool}`kill-window` refuses to kill the window containing the MCP pane
-- {tool}`kill-pane` refuses to kill the pane running the MCP server
-
-These protections read both the `TMUX` and `TMUX_PANE` environment variables that tmux injects into pane child processes. The `TMUX` value is formatted `socket_path,server_pid,session_id` — libtmux-mcp parses the socket path and compares it to the target server's so the guard only fires when the caller is actually on the same tmux server. A kill across unrelated sockets is allowed; a kill of the caller's own pane/window/session/server is refused. If the caller's socket can't be determined (rare — `TMUX_PANE` set without `TMUX`), the guard errs on the side of blocking.
-
-### macOS `TMUX_TMPDIR` caveat
-
-The self-kill guard resolves the target server's socket path in three
-steps ({func}`~libtmux_mcp._utils._effective_socket_path` in
-`src/libtmux_mcp/_utils.py`):
-
-1. Use {attr}`libtmux.Server.socket_path` if {external+libtmux:doc}`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 {func}`~libtmux_mcp._utils._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
-
-Most `mutating` tools are bounded: {toolref}`resize-pane` only
-resizes, {toolref}`rename-window` only renames. A few have broader
-reach because tmux itself exposes broader reach. Treat these as
-elevated risk even though they share the default tier:
-
-### Piping pane output
-
-{tool}`pipe-pane` pipes a pane's output to a shell command that the server runs. In practice this means the caller chooses an arbitrary path or pipeline on the server host. There is no allow-list. Assume it can create files anywhere the server process can write.
-
-Mitigations:
-
-- Run the server as an unprivileged user with a scoped home directory.
-- Consider `LIBTMUX_SAFETY=readonly` for untrusted MCP clients.
-- Audit log records (see below) capture the `output_path` argument so reviewers can spot unexpected destinations.
-
-### Setting tmux environment
-
-{tool}`set-environment` writes into tmux's global, session, or window environment. Those values propagate into every shell tmux spawns afterwards. An agent that writes `PATH`, `LD_PRELOAD`, or `AWS_*` variables can influence every future command on that scope — including commands the user runs directly, not just commands the agent issues.
-
-Mitigations:
-
-- The server audit record replaces the `value` argument with a `{len, sha256_prefix}` digest, so the value does not appear verbatim in `libtmux_mcp.audit`. That redaction does not cover separate library, process, application, or client logs, so operators should still treat the tool as high-privilege.
-- If only a single command needs a non-sensitive env override, prefer having the agent invoke `env VAR=value command` via {tooliconl}`send-keys` instead — the blast radius is one command, not every future child. For credentials, pass a reference that the child resolves instead of a literal value through tmux.
-
-### Respawning panes
-
-{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 an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of an unintended kill — resolve via {tool}`list-panes` first.
-- Any `shell` argument is briefly visible in the OS process table and tmux's `pane_current_command` metadata before the spawned shell takes over; the audit log redacts `shell` payloads (see below), but do not pass credentials directly even with redaction.
-- The optional `environment` argument accepts either a mapping of string keys and values or a JSON object string, then maps each item to one tmux `-e KEY=VALUE` flag. For a mapping, the audit log keeps each *key* visible and replaces each *value* with a `{len, sha256_prefix}` digest. A JSON string is redacted as one scalar digest, so its keys are not retained in the audit record. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env.
-- The same self-pane guard that protects the destructive kill commands also refuses to respawn the pane running the MCP server.
-
-### Raw pane input
-
-These can execute anything the pane's shell accepts. There is no payload validation. The server audit log stores a digest of the content, not the content itself, so a secret typed via {tooliconl}`send-keys` or {tooliconl}`send-keys-batch` does not land in that audit record.
-
-### History suppression is not secret transport
-
-`suppress_history` on {tooliconl}`run-command` asks the current shell not to persist one space-prefixed command event. `suppress_persistent_history=true` on the four spawn tools adds best-effort no-disk controls to a new environment. Shell behavior and startup files can defeat either request. History suppression does not isolate the process, does not clear in-memory history or scrollback, and does not hide the command from other observation surfaces:
-
-- **pane echo and scrollback:** the terminal can display input, tmux can retain it in pane history, and an attached terminal can keep its own scrollback.
-- **capture tools and piping:** {toolref}`capture-pane`, {toolref}`capture-since`, {toolref}`snapshot-pane`, {toolref}`search-panes`, and {toolref}`pipe-pane` can return or route displayed and retained text.
-- **hooks:** configured tmux hooks, including state visible through {tooliconl}`show-hooks`, and shell instrumentation can observe process or pane activity independently of shell history.
-- **process visibility:** command arguments and launch strings can appear in the tmux client argv. Environment values passed to {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` can also remain in a child process environment; {toolref}`create-session` retains them in tmux session state for future panes, where {toolref}`show-environment` can reveal them. MCP audit redaction does not hide any of these surfaces from host process or tmux environment inspection.
-- **MCP client transcripts:** clients can retain the original request and response outside the server's control.
-- **logs:** `libtmux_mcp.audit` records redacted arguments and whether the call succeeded or raised; it does not contain tool return values. Redaction applies only to these audit records and does not rewrite separate records emitted by libtmux, FastMCP, shells, or MCP clients. libtmux DEBUG or error records may contain shell-joined tmux arguments, while MCP client request logs and application logs remain outside the server's guarantee.
-
-Prefer credential references that a process resolves from a secret manager, scoped file descriptor, or preconfigured host lookup. Avoid literal credentials in `command`, raw `keys` or `text`, `shell`, and `environment` arguments; history suppression cannot retract a value after another surface records it.
-
-## Audit log
-
-Every tool call emits one `INFO` record on the `libtmux_mcp.audit` logger carrying:
-
-- `tool` — the tool name
-- `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 scalar keys (`keys`, `text`, `command`, `value`, `content`, `shell`, and string-form `environment`) are replaced by `{len, sha256_prefix}`. Mapping-form `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.
-
-## Tool annotations
-
-Each tool carries MCP tool annotations that hint at its behavior:
-
-| Tool | Tier | readOnly | destructive | idempotent |
-|------|------|----------|-------------|------------|
-| {toolref}`list-sessions` | {badge}`readonly` | true | false | true |
-| {toolref}`get-server-info` | {badge}`readonly` | true | false | true |
-| {toolref}`list-windows` | {badge}`readonly` | true | false | true |
-| {toolref}`list-panes` | {badge}`readonly` | true | false | true |
-| {toolref}`capture-pane` | {badge}`readonly` | true | false | true |
-| {toolref}`capture-since` | {badge}`readonly` | true | false | true |
-| {toolref}`get-pane-info` | {badge}`readonly` | true | false | true |
-| {toolref}`search-panes` | {badge}`readonly` | true | false | true |
-| {toolref}`wait-for-text` | {badge}`readonly` | true | false | true |
-| {toolref}`show-option` | {badge}`readonly` | true | false | true |
-| {toolref}`show-environment` | {badge}`readonly` | true | false | true |
-| {toolref}`create-session` | {badge}`mutating` | false | false | false |
-| {toolref}`create-window` | {badge}`mutating` | false | false | false |
-| {toolref}`split-window` | {badge}`mutating` | false | false | false |
-| {toolref}`send-keys` | {badge}`mutating` | false | false | false |
-| {toolref}`rename-session` | {badge}`mutating` | false | false | true |
-| {toolref}`rename-window` | {badge}`mutating` | false | false | true |
-| {toolref}`resize-pane` | {badge}`mutating` | false | false | true |
-| {toolref}`resize-window` | {badge}`mutating` | false | false | true |
-| {toolref}`set-pane-title` | {badge}`mutating` | false | false | true |
-| {toolref}`clear-pane` | {badge}`mutating` | false | true | false |
-| {toolref}`select-layout` | {badge}`mutating` | false | false | true |
-| {toolref}`set-option` | {badge}`mutating` | false | false | true |
-| {toolref}`set-environment` | {badge}`mutating` | false | false | true |
-| {toolref}`respawn-pane` | {badge}`mutating` | false | true | false |
-| {toolref}`kill-server` | {badge}`destructive` | false | true | false |
-| {toolref}`kill-session` | {badge}`destructive` | false | true | false |
-| {toolref}`kill-window` | {badge}`destructive` | false | true | false |
-| {toolref}`kill-pane` | {badge}`destructive` | false | true | false |
diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md
index 21f47af7..4d127037 100644
--- a/docs/topics/troubleshooting.md
+++ b/docs/topics/troubleshooting.md
@@ -54,7 +54,8 @@ can't find targets.
**Symptoms**: Server sees different sessions than expected, or sees nothing.
-**Cause**: `LIBTMUX_SOCKET` in the MCP config isolates the server to a specific socket. Your personal sessions are on the default socket.
+**Cause**: `LIBTMUX_SOCKET` in the MCP config selects a different tmux socket.
+Your personal sessions are on the default socket.
**Fix**: Either remove `LIBTMUX_SOCKET` from the config to use the default socket, or ensure sessions exist on the configured socket.
@@ -107,13 +108,13 @@ what you expect.
$ python --version
```
-## Safety tier blocking tools
+## A toolset is hiding tools
-**Symptoms**: Some tools are missing from the tool list, or return "blocked by safety tier" errors.
+**Symptoms**: Some tools are missing from the tool list, or a call returns "not in this server's enabled toolsets".
-**Cause**: `LIBTMUX_SAFETY` is set to a restrictive tier.
+**Cause**: `LIBTMUX_TOOLSETS` does not include the tool's toolset.
-**Fix**: Check the configured tier. Default is `mutating`, which includes most tools. Only `destructive` enables kill commands. See {ref}`safety`.
+**Fix**: The default is `inspect,manage,execute`. Add `teardown` for the kill commands, or name the tool in `LIBTMUX_TOOLS`. See {ref}`trust`.
## How to see logs
diff --git a/docs/topics/trust.md b/docs/topics/trust.md
new file mode 100644
index 00000000..3c8737fe
--- /dev/null
+++ b/docs/topics/trust.md
@@ -0,0 +1,274 @@
+```{eval-rst}
+.. _trust:
+```
+
+# Trust model
+
+This server gives an agent a terminal. What follows is what that does and
+does not bound.
+
+## Toolsets gate MCP tool calls, not tmux
+
+Tools are grouped into four sets by what they do:
+
+{badge}`inspect`: Request tmux state or terminal output, or render
+server-local prompt text. The built-in operation does not pass caller input as
+a tmux or shell command.
+
+{badge}`manage`: Change tmux-managed structure, presentation, staging, or
+coordination state. The built-in operation does not supply a shell command,
+pane input, or a value tmux treats as executable configuration.
+
+{badge}`execute`: Start a pane process, deliver input to one, or store state
+that can control later execution. {tooliconl}`set-option` is here, not in
+`manage`: a `#(...)` job in a status format runs when tmux draws it and repeats
+on the status interval, and `default-command` decides what every future pane
+runs.
+
+{badge}`teardown`: Delete tmux objects or retained scrollback. Irreversible
+at the tmux level.
+
+The sets are unordered. FastMCP visibility and libtmux-mcp middleware both
+enforce which MCP tool calls the server advertises and accepts.
+`LIBTMUX_TOOLSETS=inspect,teardown` is therefore a legal surface — an agent
+that can look and clean up through this server's tools, but not type through
+them.
+
+{envvar}`LIBTMUX_TOOLSETS`, {envvar}`LIBTMUX_TOOLS`, and
+{envvar}`LIBTMUX_EXCLUDE_TOOLS` filter tools only. The `tmux://` hierarchy
+resources and native prompts remain available when every toolset is disabled.
+
+**Dropping a toolset is not containment.** It changes what this server
+advertises. An enabled `execute` tool can type the equivalent of anything
+you hid, because a pane's shell runs with your user's authority. Existing pane
+processes and other clients of the same tmux server also remain outside the MCP
+call gate.
+
+## The tmux server is programmable
+
+tmux is a separate, long-lived process. A configured `command-alias` can
+replace a command this server sends, and an `after-*` hook can run a command
+list after many built-in commands. A nominal `inspect` call can therefore
+change state or run a shell without receiving executable input from the MCP
+caller.
+
+Hierarchy resource reads are a separate MCP surface, but they send the same
+class of tmux queries as `inspect` tools. A `resources/read` request can
+therefore activate aliases and hooks too. Resources have no ToolAnnotations
+and do not produce this server's tool-call audit record. Native prompts only
+return text and do not contact tmux.
+
+Execution can occur without any MCP call. A `#(...)` job in a status format
+runs when tmux redraws the status line and can repeat on the status interval.
+No MCP tool filter can intercept work that never passes through this server.
+
+libtmux-mcp startup and shutdown send no tmux commands. Failed tool calls run
+once: the server does not retry them automatically because tmux may already
+have applied an alias or hook effect before reporting an error.
+
+A toolset describes the built-in operation its tools request. It does not
+describe everything the target tmux server may do around that request.
+
+### Responsibility by layer
+
+| Layer | Owns |
+| --- | --- |
+| libtmux-mcp | Input validation and refusal, tmux argv construction, the advertised and callable tool surface, direct-operation classification, wait ceilings, selected high-volume output caps, resource disclosure, and tool-call audit redaction. |
+| Model or agent | Chooses requested calls and command text, but is not an enforcement boundary against its own errors or prompt injection. |
+| MCP client and user | Whether to request and confirm a call, whether to retry it, and which credentials the agent receives. |
+| tmux operator | The target socket, configuration, aliases, hooks, key bindings, status formats, pane programs, and other clients. |
+| OS and deployment | Process identity and limits on filesystem, network, credentials, privileges, and resources. |
+
+For local stdio use, the launching client and OS account are the trust context;
+FastMCP has no OAuth token to authorize. A remote HTTP deployment must
+authenticate users and enforce authorization on the server as well as asking
+for client-side confirmation. See [FastMCP authorization](https://gofastmcp.com/servers/authorization).
+
+### Guarantee by topology
+
+| Topology | Strongest guarantee |
+| --- | --- |
+| Existing or shared tmux server | The MCP tool-call gate and libtmux-mcp's input handling; tmux configuration and peer activity remain unknown and mutable. |
+| Fresh, separately supervised tmux server with a minimal config | A separate tmux object namespace and known startup configuration for that daemon generation; same-user clients and pane processes can still reconfigure it. |
+| OS identity, container, or VM boundary | Effects are limited by the configured process, filesystem, network, credential, privilege, and resource policy. tmux still executes processes inside that boundary. |
+
+A socket alone is an endpoint, not process confinement. Starting a normal tmux
+client with `-f` also does not prove that configuration was used: if the server
+already exists, tmux keeps the configuration from that daemon's startup.
+
+## `inspect` does not mean safe
+
+An `inspect` tool's built-in command sequence does not pass caller input as a
+tmux or shell command. That is a property of these implementations, and it is
+the only thing the name claims. A target tmux server may still replace or
+extend the requested command through its aliases and hooks.
+
+It is not a claim that the result is harmless. A capture returns whatever the
+pane holds: credentials someone typed, a command line with a token in it,
+output from a remote host, text written by another agent. Auto-approving the
+whole set is a decision to make with that in mind, not one the name endorses.
+Treat pane and hierarchy-resource output as untrusted data, never as
+instructions.
+
+## Configuration
+
+```json
+{
+ "mcpServers": {
+ "libtmux": {
+ "command": "uvx",
+ "args": ["libtmux-mcp"],
+ "env": {
+ "LIBTMUX_TOOLSETS": "inspect"
+ }
+ }
+ }
+}
+```
+
+{envvar}`LIBTMUX_TOOLSETS` is a comma list, defaulting to
+`inspect,manage,execute`. `teardown` is not in the default: this server
+reaches whichever tmux server the environment points at, so deletion is
+something you ask for by name. {envvar}`LIBTMUX_TOOLS` enables individual
+tools regardless of toolset, and {envvar}`LIBTMUX_EXCLUDE_TOOLS` refuses
+them regardless of every enable above.
+
+An unknown toolset or tool name fails startup rather than being ignored.
+A typo that silently widened a surface you believed was narrow is worse
+than a server that will not start.
+
+### How it works
+
+Two layers use the same tags and names. [FastMCP](https://gofastmcp.com)
+visibility is the primary wire filter: omitted or excluded tools disappear
+from listings, and direct calls return an unknown-tool error. Middleware
+rechecks the classification for tools that reach dispatch.
+
+Both fail closed: a tool carrying no recognized toolset is refused, so
+adding one without classifying it cannot expose it by accident.
+
+## Self-kill protection
+
+The `teardown` tools include safeguards against self-harm:
+
+- {tool}`kill-server` refuses to run if the MCP server is inside the target server
+- {tool}`kill-session` refuses to kill the session containing the MCP pane
+- {tool}`kill-window` refuses to kill the window containing the MCP pane
+- {tool}`kill-pane` refuses to kill the pane running the MCP server
+
+These protections read both the `TMUX` and `TMUX_PANE` environment variables that tmux injects into pane child processes. The `TMUX` value is formatted `socket_path,server_pid,session_id` — libtmux-mcp parses the socket path and compares it to the target server's so the guard only fires when the caller is actually on the same tmux server. A kill across unrelated sockets is allowed; a kill of the caller's own pane/window/session/server is refused. If the caller's socket can't be determined (rare — `TMUX_PANE` set without `TMUX`), the guard errs on the side of blocking.
+
+### macOS `TMUX_TMPDIR` caveat
+
+The self-kill guard resolves the target server's socket path in three
+steps ({func}`~libtmux_mcp._utils._effective_socket_path` in
+`src/libtmux_mcp/_utils.py`):
+
+1. Use {attr}`libtmux.Server.socket_path` if {external+libtmux:doc}`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 {func}`~libtmux_mcp._utils._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 `execute`
+
+Most `manage` tools are bounded: {toolref}`resize-pane` only
+resizes, {toolref}`rename-window` only renames. A few have broader
+reach because tmux itself exposes broader reach. Treat these as
+elevated risk even though the default enables their toolset:
+
+### Piping pane output
+
+{tool}`pipe-pane` pipes a pane's output through a fixed shell redirection. The
+caller chooses the destination path. There is no path allow-list; assume it can
+create files anywhere the server process can write.
+
+Mitigations:
+
+- Run the server as an unprivileged user with a scoped home directory.
+- Exclude `execute` when pane control is unnecessary. This narrows the direct
+ tool surface; it does not establish trust or confinement.
+- Audit log records (see below) capture the `output_path` argument so reviewers can spot unexpected destinations.
+
+### Setting tmux environment
+
+{tool}`set-environment` writes into tmux's global, session, or window environment. Those values propagate into every shell tmux spawns afterwards. An agent that writes `PATH`, `LD_PRELOAD`, or `AWS_*` variables can influence every future command on that scope — including commands the user runs directly, not just commands the agent issues.
+
+Mitigations:
+
+- The server audit record replaces the `value` argument with a `{len, sha256_prefix}` digest, so the value does not appear verbatim in `libtmux_mcp.audit`. That redaction does not cover separate library, process, application, or client logs, so operators should still treat the tool as high-privilege.
+- If only a single command needs a non-sensitive env override, prefer having the agent invoke `env VAR=value command` via {tooliconl}`send-keys` instead — the blast radius is one command, not every future child. For credentials, pass a reference that the child resolves instead of a literal value through tmux.
+
+### Respawning panes
+
+{tool}`respawn-pane` restarts a pane's process while preserving the pane id and layout — exactly what an agent wants when a shell wedges. Default `kill=True` terminates the running process before relaunch. The `pane_id` and layout are preserved (the point of the tool), but any unsaved REPL state, ssh session, or in-flight job in that pane is lost. Repeated calls are *not* idempotent — each call kills a new process.
+
+The tool belongs to `execute`: it terminates one pane process and starts
+another, even when the replacement command is omitted.
+
+Mitigations:
+
+- `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of an unintended kill — resolve via {tool}`list-panes` first.
+- Any `shell` argument is briefly visible in the OS process table and tmux's `pane_current_command` metadata before the spawned shell takes over; the audit log redacts `shell` payloads (see below), but do not pass credentials directly even with redaction.
+- The optional `environment` argument accepts either a mapping of string keys and values or a JSON object string, then maps each item to one tmux `-e KEY=VALUE` flag. For a mapping, the audit log keeps each *key* visible and replaces each *value* with a `{len, sha256_prefix}` digest. A JSON string is redacted as one scalar digest, so its keys are not retained in the audit record. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env.
+- The same self-pane guard that protects the kill commands also refuses to respawn the pane running the MCP server.
+
+### Raw pane input
+
+These can execute anything the pane's shell accepts. There is no shell-syntax
+allow-list. The server audit log stores a digest of the content, not the
+content itself, so a secret typed via {tooliconl}`send-keys` or
+{tooliconl}`send-keys-batch` does not land in that audit record.
+
+### History suppression is not secret transport
+
+`suppress_history` on {tooliconl}`run-command` asks the current shell not to persist one space-prefixed command event. `suppress_persistent_history=true` on the four spawn tools adds best-effort no-disk controls to a new environment. Shell behavior and startup files can defeat either request. History suppression does not isolate the process, does not clear in-memory history or scrollback, and does not hide the command from other observation surfaces:
+
+- **pane echo and scrollback:** the terminal can display input, tmux can retain it in pane history, and an attached terminal can keep its own scrollback.
+- **capture tools and piping:** {toolref}`capture-pane`, {toolref}`capture-since`, {toolref}`snapshot-pane`, {toolref}`search-panes`, and {toolref}`pipe-pane` can return or route displayed and retained text.
+- **hooks:** configured tmux hooks, including state visible through {tooliconl}`show-hooks`, and shell instrumentation can observe process or pane activity independently of shell history.
+- **process visibility:** command arguments and launch strings can appear in the tmux client argv. Environment values passed to {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` can also remain in a child process environment; {toolref}`create-session` retains them in tmux session state for future panes, where {toolref}`show-environment` can reveal them. MCP audit redaction does not hide any of these surfaces from host process or tmux environment inspection.
+- **MCP client transcripts:** clients can retain the original request and response outside the server's control.
+- **logs:** `libtmux_mcp.audit` records redacted arguments and whether the call succeeded or raised; it does not contain tool return values. Redaction applies only to these audit records and does not rewrite separate records emitted by libtmux, FastMCP, shells, or MCP clients. libtmux DEBUG or error records may contain shell-joined tmux arguments, while MCP client request logs and application logs remain outside the server's guarantee.
+
+Prefer credential references that a process resolves from a secret manager, scoped file descriptor, or preconfigured host lookup. Avoid literal credentials in `command`, raw `keys` or `text`, `shell`, and `environment` arguments; history suppression cannot retract a value after another surface records it.
+
+## Audit log
+
+Every tool call emits one `INFO` record on the `libtmux_mcp.audit` logger carrying:
+
+- `tool` — the tool name
+- `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 scalar keys (`keys`, `text`, `command`, `value`, `content`, `shell`, and string-form `environment`) are replaced by `{len, sha256_prefix}`. Mapping-form `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.
+
+## Tool annotations
+
+[MCP defines](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations)
+four standard hints for the behavior of the whole tool call.
+[Clients may use positive hints](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/)
+to skip confirmation or retry a call. This server can target an
+existing tmux server selected by each call, and it cannot establish that the
+server has no aliases or hooks or that another client will not add them. Every
+tool that requests a tmux operation therefore advertises the same conservative
+static hints:
+
+| readOnlyHint | destructiveHint | idempotentHint | openWorldHint |
+| --- | --- | --- | --- |
+| false | true | false | true |
+
+These values are hints, not authorization. They do not say that every call
+modifies state, destroys data, has an additional effect when repeated, or
+reaches outside tmux. They decline to promise otherwise for every target. The
+project-owned `inspect`, `manage`, `execute`, and `teardown` toolsets preserve
+the direct-operation distinctions that the standard hints cannot express here.
+Clients that ignore project tags cannot recover those distinctions from the
+four hints alone; they must use the tool name, schema, description, or an
+operator-selected tool surface.
+
+The optional `list_prompts` and `get_prompt` adapter tools do not contact tmux.
+They belong to `inspect` because they render server-local prompt text without
+changing tmux, and advertise `true`, `false`, `true`, `false` respectively.
diff --git a/pyproject.toml b/pyproject.toml
index 036f261a..fc928901 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,7 @@ include = [
dependencies = [
"libtmux>=0.62.0,<1.0",
"fastmcp>=3.4.2,<4.0.0",
+ "regex>=2024.11.6",
]
[project.urls]
@@ -56,9 +57,9 @@ libtmux-mcp = "libtmux_mcp:main"
[dependency-groups]
dev = [
# Docs
- "gp-sphinx==0.1.0a37",
- "sphinx-autodoc-api-style==0.1.0a37",
- "sphinx-autodoc-fastmcp==0.1.0a37",
+ "gp-sphinx==0.1.0a38",
+ "sphinx-autodoc-api-style==0.1.0a38",
+ "sphinx-autodoc-fastmcp==0.1.0a38",
"gp-libs>=0.0.19",
"sphinx-autobuild",
# Testing
@@ -79,12 +80,13 @@ dev = [
# Lint
"ruff>=0.16.1",
"mypy",
+ "types-regex",
]
docs = [
- "gp-sphinx==0.1.0a37",
- "sphinx-autodoc-api-style==0.1.0a37",
- "sphinx-autodoc-fastmcp==0.1.0a37",
+ "gp-sphinx==0.1.0a38",
+ "sphinx-autodoc-api-style==0.1.0a38",
+ "sphinx-autodoc-fastmcp==0.1.0a38",
"gp-libs>=0.0.19",
"sphinx-autobuild",
]
diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py
index ce6f063b..093f6a57 100755
--- a/scripts/mcp_swap.py
+++ b/scripts/mcp_swap.py
@@ -1400,6 +1400,22 @@ def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str:
return "other"
+def _replacement_env(
+ current: McpServerSpec | None, extra_env: dict[str, str]
+) -> dict[str, str]:
+ """Merge current env while requiring a replacement for retired safety."""
+ env = dict(current.env) if current else {}
+ retired_safety = env.pop("LIBTMUX_SAFETY", None)
+ env.update(extra_env)
+ if retired_safety is not None and "LIBTMUX_TOOLSETS" not in env:
+ msg = (
+ "LIBTMUX_SAFETY has been removed; rerun with "
+ "--env LIBTMUX_TOOLSETS="
+ )
+ raise RuntimeError(msg)
+ return env
+
+
def cmd_use_local(args: argparse.Namespace) -> int:
"""Rewrite each target CLI's config to run the repo's checkout via ``uv``.
@@ -1442,21 +1458,20 @@ def cmd_use_local(args: argparse.Namespace) -> int:
original_bytes = info.config_path.read_bytes()
config = load_config(info)
current = get_server(cli, config, server, repo, scope=scope)
+ base_env = _replacement_env(current, extra_env)
if (
current
and current.is_local_uv_directory()
and current.local_repo_path() == repo
- and all(current.env.get(k) == v for k, v in extra_env.items())
+ and current.env == base_env
):
print(f"[{label}] already local (this repo) — no change")
continue
# Preserve the existing entry's env on replacement. ``build_local_spec``
# writes an empty env, so without this merge a swap would silently drop
- # client-side settings (LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev
+ # client-side settings (LIBTMUX_TOOLSETS, LIBTMUX_SOCKET, custom dev
# knobs). Symmetric with ``_spec_from_entry`` which round-trips env on
# the read side.
- base_env = dict(current.env) if current else {}
- base_env.update(extra_env)
cli_spec = (
dataclasses.replace(spec, env=base_env)
if (current or extra_env)
@@ -1650,6 +1665,9 @@ def _env_pair(raw: str) -> tuple[str, str]:
if not sep or not key:
msg = f"--env expects KEY=VALUE, got {raw!r}"
raise argparse.ArgumentTypeError(msg)
+ if key == "LIBTMUX_SAFETY":
+ msg = "LIBTMUX_SAFETY has been removed; use LIBTMUX_TOOLSETS"
+ raise argparse.ArgumentTypeError(msg)
return key, value
diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py
index 587e2d85..3b9950c3 100644
--- a/src/libtmux_mcp/_utils.py
+++ b/src/libtmux_mcp/_utils.py
@@ -12,6 +12,7 @@
import logging
import os
import pathlib
+import re
import threading
import typing as t
@@ -35,7 +36,7 @@ class ExpectedToolError(ToolError):
Defaults the error's ``log_level`` to ``WARNING`` (honored by
fastmcp >= 3.3 when logging tool/resource failures) so routine
- validation errors, missing objects, and tier denials do not surface
+ validation errors, missing objects, and toolset denials do not surface
as ERROR records. Unexpected failures keep stock :class:`ToolError`
and its ERROR default — those are the ones operators must see.
@@ -194,9 +195,9 @@ def _compute_is_caller(pane: Pane) -> bool | None:
Uses :func:`_caller_is_strictly_on_server` rather than
:func:`_caller_is_on_server`: the kill-guard comparator is
- conservative-True-when-uncertain (right for blocking destructive
- actions, wrong for an informational annotation that should
- demand a positive match). The strict variant declines the
+ conservative-True-when-uncertain (right for blocking a kill, wrong
+ for an informational annotation that should demand a positive
+ match). The strict variant declines the
basename fallback, the unresolvable-target branch, and the
socket-path-unset branch so ambiguous cases resolve to ``False``.
"""
@@ -224,7 +225,7 @@ def _effective_socket_path(server: Server) -> str | None:
server — authoritative because tmux itself reports the path it
is actually using, regardless of our process environment.
Necessary on macOS where ``$TMUX_TMPDIR`` under launchd diverges
- from the interactive shell (see ``docs/topics/safety.md`` for
+ from the interactive shell (see ``docs/topics/trust.md`` for
the self-kill guard gap this closes).
3. Fallback: reconstruct from ``$TMUX_TMPDIR`` + euid + socket name.
This path is reached only when the target server is unreachable
@@ -273,8 +274,8 @@ def _caller_is_on_server(server: Server, caller: CallerIdentity | None) -> bool:
is possible.
* caller has a pane id but no socket path (e.g. ``TMUX_PANE`` set
without ``TMUX``) → ``True``. We can't rule out that the caller
- is on the target server, so err on the side of blocking a
- destructive action.
+ is on the target server, so err on the side of blocking the
+ kill.
* target server has no resolvable socket path → ``True``. Same
conservative reasoning.
* realpath of caller's socket path matches target's effective path
@@ -284,7 +285,7 @@ def _caller_is_on_server(server: Server, caller: CallerIdentity | None) -> bool:
last-chance block for env-mismatch scenarios where reconstruction
produced a wrong path but the name was authoritative on both
sides. Trades off one exotic false positive (two daemons with
- identical socket_name under different tmpdirs) for a real safety
+ identical socket_name under different tmpdirs) for a real correctness
property.
* Otherwise → ``False``.
@@ -319,7 +320,7 @@ def _caller_is_strictly_on_server(
Counterpart to :func:`_caller_is_on_server` for the informational
:attr:`~libtmux_mcp.models.PaneInfo.is_caller` annotation. The
- destructive-action guard is biased toward True-when-uncertain so a
+ kill guard is biased toward True-when-uncertain so a
macOS ``$TMUX_TMPDIR`` divergence cannot fool it into permitting
self-kill; the annotation cannot absorb that bias — ambiguous cases
are exactly the cross-socket false positives documented by
@@ -352,87 +353,60 @@ def _caller_is_strictly_on_server(
# ---------------------------------------------------------------------------
-# Safety tier tags
+# Toolsets
# ---------------------------------------------------------------------------
-TAG_READONLY = "readonly"
-TAG_MUTATING = "mutating"
-TAG_DESTRUCTIVE = "destructive"
+#: Request tmux state or terminal output, or render server-local prompt
+#: text. The built-in operation does not pass caller input as a tmux or
+#: shell command.
+#:
+#: Reading is not "safe" — a capture returns whatever a pane holds,
+#: including credentials and text written by a remote process — so this
+#: names what the tools *do*, not how much they are trusted.
+TOOLSET_INSPECT = "inspect"
-VALID_SAFETY_LEVELS = frozenset({TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE})
+#: Change tmux-managed structure, presentation, staging, or coordination
+#: state. The built-in operation does not supply a shell command, pane
+#: input, or a value tmux treats as executable configuration.
+TOOLSET_MANAGE = "manage"
-#: Non-tier marker tag for tools that enforce their own wall-clock
-#: ceiling internally and whose cost is therefore *duration*, not
-#: side effects.
-#:
-#: A tagged tool must never be re-driven by machinery that assumes a
-#: call is cheap:
-#:
-#: * :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` skips it,
-#: because the deadline is computed inside the tool body — a retry
-#: restarts the clock and doubles the ceiling.
-#: * The ``call_*_tools_batch`` wrappers reject it per-operation,
-#: because the batch loop is serial with no aggregate deadline and
-#: ``MAX_BATCH_OPERATIONS`` is 1000.
+#: Start a pane process, deliver input to one, or store state that can
+#: control later execution. The product lives here.
+TOOLSET_EXECUTE = "execute"
+
+#: Delete tmux objects or retained scrollback. Irreversible at the tmux
+#: level.
+TOOLSET_TEARDOWN = "teardown"
+
+#: The four toolsets, in the order startup reports them.
#:
-#: A TAG rather than a tool-name list on purpose: a name string is
-#: exactly what ``add_tool_transformation`` can rename out from under
-#: the exclusion. Tier resolution
-#: (:meth:`~libtmux_mcp.middleware.SafetyMiddleware._is_allowed`,
-#: ``batch_tools._tool_tier``) inspects only the three tier tags, so
-#: carrying this extra tag is inert everywhere else.
+#: An unordered set, deliberately. The ordered model this replaced accumulated
+#: upward, so the kill tools could not be enabled without also enabling
+#: the typing tools; ``LIBTMUX_TOOLSETS=inspect,teardown`` is a legal
+#: surface. They group tools by what they do, for inventory
+#: configuration, context reduction, and client routing. They control MCP
+#: tool calls, not tmux authority or containment: an enabled execute tool can
+#: type the equivalent of anything hidden.
+VALID_TOOLSETS: tuple[str, ...] = (
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_EXECUTE,
+ TOOLSET_TEARDOWN,
+)
+
TAG_SELF_BOUNDED = "self-bounded"
# ---------------------------------------------------------------------------
# Reusable annotation presets for tool registration
# ---------------------------------------------------------------------------
-ANNOTATIONS_RO: dict[str, bool] = {
- "readOnlyHint": True,
- "destructiveHint": False,
- "idempotentHint": True,
- "openWorldHint": False,
-}
-ANNOTATIONS_MUTATING: dict[str, bool] = {
- "readOnlyHint": False,
- "destructiveHint": False,
- "idempotentHint": True,
- "openWorldHint": False,
-}
-ANNOTATIONS_CREATE: dict[str, bool] = {
- "readOnlyHint": False,
- "destructiveHint": False,
- "idempotentHint": False,
- "openWorldHint": False,
-}
-#: Annotations for tools that move user-supplied payloads into a shell
-#: context. Six consumers today:
-#:
-#: * ``send_keys``, ``run_command``, ``paste_text``, ``pipe_pane`` — the
-#: canonical shell-driving tools; caller's keys/command/text/stream
-#: reaches the shell prompt or pipes into an external command
-#: respectively.
-#: * ``load_buffer``, ``paste_buffer`` — ``load_buffer`` stages content
-#: into a tmux paste buffer; ``paste_buffer`` pushes that content
-#: into a target pane where the shell receives it as input. The two
-#: are split into a stage/fire pair so callers can validate before
-#: paste, but both participate in the same open-world transfer.
-#:
-#: Distinguished from :data:`ANNOTATIONS_CREATE` by ``openWorldHint=True``:
-#: the effects of these tools extend into whatever command or content
-#: the caller supplies, which is the canonical open-world MCP
-#: interaction.
-ANNOTATIONS_SHELL: dict[str, bool] = {
- "readOnlyHint": False,
- "destructiveHint": False,
- "idempotentHint": False,
- "openWorldHint": True,
-}
-ANNOTATIONS_DESTRUCTIVE: dict[str, bool] = {
+#: Conservative MCP defaults for calls into programmable tmux servers.
+#: Aliases and hooks can replace or extend the requested operation.
+ANNOTATIONS_AMBIENT_UNKNOWN: dict[str, bool] = {
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": False,
- "openWorldHint": False,
+ "openWorldHint": True,
}
#: Per-tool MCP ``meta`` payload that hints clients to keep this tool
@@ -442,32 +416,100 @@ def _caller_is_strictly_on_server(
#: documented at https://code.claude.com/docs/en/mcp (v2.1.121+).
#:
#: Best-effort by design — safe no-op for clients that don't index the
-#: ``anthropic/*`` namespace. Apply only to read-tier discovery anchors
+#: ``anthropic/*`` namespace. Apply only to inspect discovery anchors
#: (``list_panes``, ``list_windows``, ``snapshot_pane``); each
#: always-loaded tool consumes a fixed schema budget in clients that
#: honour the hint, so widening the set has a real cost.
DISCOVERY_META: dict[str, t.Any] = {
"anthropic/alwaysLoad": True,
}
-#: Annotations for tools that stay in the ``mutating`` tier (so they remain
-#: visible to default-profile agents) but whose default behaviour can
-#: terminate processes or otherwise lose state.
-#:
-#: Canonical users include ``respawn_pane`` and ``clear_pane``:
-#: tier=mutating because shell recovery and scrollback cleanup are part
-#: of normal agent workflows, while the hints still disclose process
-#: termination or state loss.
-#:
-#: Distinct from :data:`ANNOTATIONS_DESTRUCTIVE` (same hint values) because
-#: the tier tag differs: ``ANNOTATIONS_DESTRUCTIVE`` is paired with
-#: ``TAG_DESTRUCTIVE`` everywhere it is used; this preset is paired with
-#: ``TAG_MUTATING``. The distinct name documents intent at the call site.
-ANNOTATIONS_MUTATING_DESTRUCTIVE: dict[str, bool] = {
- "readOnlyHint": False,
- "destructiveHint": True,
- "idempotentHint": False,
- "openWorldHint": False,
-}
+
+
+#: A maximal run of ``#``, plus the ``[`` that may follow it. tmux reads a
+#: ``#``-run by what comes next, so the run is the unit to escape, not the
+#: individual ``#``.
+_TMUX_HASH_RUN = re.compile(r"(#+)(\[?)")
+
+
+def _escape_tmux_format(value: str) -> str:
+ """Escape ``value`` so tmux's format expander reproduces it literally.
+
+ Doubling every ``#`` is the obvious escape and it is wrong. A ``#``-run
+ followed by ``[`` is a style sequence reserved for ``format_draw``, and
+ the expander copies the run through verbatim rather than collapsing it,
+ so doubling there corrupts the value. Leave those runs alone and double
+ the rest.
+
+ This covers the ``#`` expander only. A caller reaching an argument tmux
+ expands with ``format_expand_time`` must escape ``%`` for ``strftime``
+ as well — see :func:`~libtmux_mcp.tools.pane_tools.pipe.pipe_pane`.
+
+ Parameters
+ ----------
+ value : str
+ Text to pass through a tmux format argument.
+
+ Returns
+ -------
+ str
+ ``value`` escaped for one pass of tmux format expansion.
+
+ Examples
+ --------
+ >>> _escape_tmux_format("/srv/#(id)")
+ '/srv/##(id)'
+ >>> _escape_tmux_format("/srv/#[x]")
+ '/srv/#[x]'
+ >>> _escape_tmux_format("issue #42")
+ 'issue ##42'
+ """
+
+ def escape_run(match: re.Match[str]) -> str:
+ run, bracket = match.group(1), match.group(2)
+ return f"{run}{bracket}" if bracket else run * 2
+
+ return _TMUX_HASH_RUN.sub(escape_run, value)
+
+
+def _prepare_start_directory(start_directory: str | None) -> str | None:
+ """Resolve a caller path to the directory tmux will actually use.
+
+ tmux expands ``-c`` as a format before using it as a working
+ directory, then silently falls back to ``$HOME``, or ``/``, when the
+ result cannot be entered — so an unresolvable path starts the pane
+ somewhere else instead of failing. Resolving here leaves no format for
+ tmux to run and turns a bad path into an error the caller can correct.
+
+ Parameters
+ ----------
+ start_directory : str or None
+ Caller-supplied working directory, or ``None``.
+
+ Returns
+ -------
+ str or None
+ Absolute path escaped for tmux, or ``None`` when none was given.
+
+ Raises
+ ------
+ ExpectedToolError
+ If the path does not name an existing directory.
+
+ Examples
+ --------
+ >>> _prepare_start_directory(None) is None
+ True
+ >>> _prepare_start_directory("/")
+ '/'
+ """
+ if start_directory is None:
+ return None
+
+ resolved = pathlib.Path(start_directory).expanduser().resolve()
+ if not resolved.is_dir():
+ msg = f"start_directory is not an existing directory: {str(resolved)!r}"
+ raise ExpectedToolError(msg)
+ return _escape_tmux_format(str(resolved))
def _tmux_argv(server: Server, *tmux_args: str) -> list[str]:
@@ -1087,11 +1129,8 @@ def handle_tool_errors(
at WARNING), the unexpected catch-all as stock ``ToolError``
(logged at ERROR).
- The re-raise chains the original exception via ``from e``. Keep it
- single-level: :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware`
- matches :exc:`libtmux.exc.LibTmuxException` by inspecting exactly
- one ``__cause__`` hop, so wrapping the mapped error again would
- silently disable readonly retries.
+ The re-raise chains the original exception via ``from e`` so logs and
+ debuggers retain the libtmux cause behind the caller-facing error.
Use :func:`handle_tool_errors_async` for ``async def`` tools — this
wrapper only supports plain sync callables.
@@ -1123,8 +1162,7 @@ def handle_tool_errors_async(
error classes as the sync decorator (expected failures as
:class:`ExpectedToolError` at WARNING, the unexpected catch-all as
stock ``ToolError`` at ERROR) by delegating to a shared helper,
- and chains the original exception via the same single-level
- ``from e`` that readonly retries depend on.
+ and chains the original exception via ``from e`` for diagnostics.
"""
@functools.wraps(fn)
diff --git a/src/libtmux_mcp/_wait_policy.py b/src/libtmux_mcp/_wait_policy.py
index 5460958c..a3998f44 100644
--- a/src/libtmux_mcp/_wait_policy.py
+++ b/src/libtmux_mcp/_wait_policy.py
@@ -61,7 +61,7 @@
def _resolve_wait_max_seconds(value: str | None) -> float:
"""Return the effective wait ceiling for a ``LIBTMUX_MCP_WAIT_MAX_SECONDS``.
- Mirrors :func:`libtmux_mcp.server._resolve_safety_level`: never
+ Mirrors :func:`libtmux_mcp.server._resolve_toolsets`: never
raises, warns on a bad value, falls back to a safe default.
Parameters
diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py
index 9226bf2a..d1d807e8 100644
--- a/src/libtmux_mcp/middleware.py
+++ b/src/libtmux_mcp/middleware.py
@@ -2,9 +2,9 @@
Provides the project's middleware infrastructure, in definition order:
-* :class:`SafetyMiddleware` gates tools by safety tier based on the
- ``LIBTMUX_SAFETY`` environment variable. Tools tagged above the
- configured tier are hidden from listing and blocked from execution.
+* :class:`ToolsetMiddleware` rechecks the configured surface for any
+ tool that reaches dispatch. FastMCP visibility makes tools outside
+ that surface unavailable on the wire first.
* :class:`ToolErrorResultMiddleware` converts tool-call failures into
``ToolResult(is_error=True)`` results that carry the clean error
message plus a structured ``meta`` payload, instead of fastmcp's
@@ -14,9 +14,6 @@
invocation (name, duration, outcome, client/request ids, and a
summary of arguments with payload-bearing fields redacted to a
length + SHA-256 prefix).
-* :class:`ReadonlyRetryMiddleware` retries transient libtmux failures,
- but only for readonly tools — re-running a mutating tool would
- silently double side effects.
* :class:`TailPreservingResponseLimitingMiddleware` is a backstop cap
for oversized tool output. Unlike FastMCP's stock
``ResponseLimitingMiddleware`` it preserves the **tail** of the
@@ -27,85 +24,134 @@
from __future__ import annotations
+import contextlib
+import contextvars
import hashlib
import logging
import time
import typing as t
from fastmcp.server.middleware import Middleware, MiddlewareContext
-from fastmcp.server.middleware.error_handling import (
- ErrorHandlingMiddleware,
- RetryMiddleware,
-)
+from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware
+from fastmcp.server.transforms import Transform, Visibility
from fastmcp.tools.base import ToolResult
-from libtmux import exc as libtmux_exc
from mcp.types import CallToolRequestParams, TextContent
from pydantic import ValidationError as PydanticValidationError
-from libtmux_mcp._utils import (
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
- TAG_SELF_BOUNDED,
- ExpectedToolError,
+from libtmux_mcp._utils import TOOLSET_INSPECT, VALID_TOOLSETS, ExpectedToolError
+
+if t.TYPE_CHECKING:
+ from fastmcp.server.transforms import GetToolNext
+ from fastmcp.tools.base import Tool
+ from fastmcp.utilities.versions import VersionSpec
+
+
+_NESTED_READ_TOOL = contextvars.ContextVar[str | None](
+ "libtmux_mcp_nested_read_tool",
+ default=None,
)
-_TIER_LEVELS: dict[str, int] = {
- TAG_READONLY: 0,
- TAG_MUTATING: 1,
- TAG_DESTRUCTIVE: 2,
-}
+@contextlib.contextmanager
+def _allow_nested_read_tool(name: str) -> t.Iterator[None]:
+ """Scope one nested read-batch operation to ``name``."""
+ token = _NESTED_READ_TOOL.set(name)
+ try:
+ yield
+ finally:
+ _NESTED_READ_TOOL.reset(token)
-class SafetyMiddleware(Middleware):
- """Gate tools by safety tier.
+
+class _NestedReadToolVisibility(Transform):
+ """Expose the current read-batch operation to FastMCP lookup."""
+
+ def __init__(self, exclude_tools: t.AbstractSet[str]) -> None:
+ self.exclude_tools = frozenset(exclude_tools)
+
+ async def get_tool(
+ self,
+ name: str,
+ call_next: GetToolNext,
+ *,
+ version: VersionSpec | None = None,
+ ) -> Tool | None:
+ """Enable only the tool scoped by the read-batch validator."""
+ if name != _NESTED_READ_TOOL.get() or name in self.exclude_tools:
+ return await call_next(name, version=version)
+ return await Visibility(
+ True,
+ names={name},
+ components={"tool"},
+ ).get_tool(name, call_next, version=version)
+
+
+class ToolsetMiddleware(Middleware):
+ """Filter tools to the enabled toolsets.
+
+ Filtering controls which MCP tool calls this server advertises and
+ accepts. It does not confine tmux: an enabled ``execute`` tool can type
+ the equivalent of any hidden tool.
Parameters
----------
- max_tier : str
- Maximum allowed tier. One of ``TAG_READONLY``, ``TAG_MUTATING``,
- or ``TAG_DESTRUCTIVE``.
+ toolsets : set of str
+ Enabled toolsets. A tool tagged with none of them is refused.
+ tools : set of str
+ Tool names enabled regardless of toolset.
+ exclude_tools : set of str
+ Tool names refused regardless of every enable above.
"""
- def __init__(self, max_tier: str = TAG_MUTATING) -> None:
- self.max_level = _TIER_LEVELS.get(max_tier, 0)
+ def __init__(
+ self,
+ toolsets: t.AbstractSet[str],
+ tools: t.AbstractSet[str] = frozenset(),
+ exclude_tools: t.AbstractSet[str] = frozenset(),
+ ) -> None:
+ self.toolsets = frozenset(toolsets)
+ self.tools = frozenset(tools)
+ self.exclude_tools = frozenset(exclude_tools)
- def _is_allowed(self, tags: set[str]) -> bool:
- """Return True if the tool's tags fall within the allowed tier.
+ def _is_enabled(self, name: str, tags: set[str]) -> bool:
+ """Return whether a tool is part of the advertised surface.
- Fail-closed: tools without a recognized tier tag are denied.
+ Fail-closed: a tool carrying no recognized toolset is refused,
+ so adding one without classifying it cannot expose it.
"""
- found_tier = False
- for tier, level in _TIER_LEVELS.items():
- if tier in tags:
- found_tier = True
- if level > self.max_level:
- return False
- return found_tier
+ toolsets = tags & set(VALID_TOOLSETS)
+ if not toolsets or name in self.exclude_tools:
+ return False
+ if name in self.tools:
+ return True
+ if name == _NESTED_READ_TOOL.get() and TOOLSET_INSPECT in toolsets:
+ return True
+ return bool(self.toolsets & toolsets)
async def on_list_tools(
self,
context: MiddlewareContext,
call_next: t.Any,
) -> t.Any:
- """Filter tools above the safety tier from the listing."""
+ """Drop tools outside the enabled surface from the listing."""
tools = await call_next(context)
- return [tool for tool in tools if self._is_allowed(tool.tags)]
+ return [tool for tool in tools if self._is_enabled(tool.name, tool.tags)]
async def on_call_tool(
self,
context: MiddlewareContext,
call_next: t.Any,
) -> t.Any:
- """Block execution of tools above the safety tier."""
+ """Refuse a tool outside the enabled surface."""
if context.fastmcp_context:
- tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name)
- if tool and not self._is_allowed(tool.tags):
+ name = context.message.name
+ tool = await context.fastmcp_context.fastmcp.get_tool(name)
+ if tool and not self._is_enabled(name, tool.tags):
+ enabled = ", ".join(sorted(self.toolsets)) or "(none)"
msg = (
- f"Tool '{context.message.name}' is not available at the "
- f"current safety level. Set LIBTMUX_SAFETY=destructive "
- f"to enable destructive tools."
+ f"Tool {name!r} is not in this server's enabled "
+ f"toolsets ({enabled}). Set LIBTMUX_TOOLSETS to include "
+ f"it, or LIBTMUX_TOOLS to enable it by name."
)
raise ExpectedToolError(msg)
return await call_next(context)
@@ -219,7 +265,7 @@ def install_fastmcp_validation_log_filter() -> None:
#: from other clients stay loud. Contrast MemPalace/mempalace#322,
#: which strips the key, and #647, which whitelists arguments against
#: the schema — silent dropping would let a mis-named flag on a
-#: mutating tool (e.g. ``enter`` on send_keys) run with defaults.
+#: writing tool (e.g. ``enter`` on send_keys) run with defaults.
_CLIENT_SCHEDULING_FLAG = "wait_for_previous"
@@ -375,13 +421,11 @@ class ToolErrorResultMiddleware(ErrorHandlingMiddleware):
synthesized suggestion telling the agent which names to drop or
fix (see :func:`_error_tool_result`).
- Ordering invariant: must sit **outside** ``AuditMiddleware``,
- ``ReadonlyRetryMiddleware``, and ``SafetyMiddleware``. All three
- depend on exception semantics — audit detects failures by catching,
- retry matches ``LibTmuxException`` via ``__cause__``, and safety's
- tier denials must propagate as exceptions for audit to record them
- — so converting the exception to a result any deeper in the stack
- would silently break all three.
+ Ordering invariant: must sit **outside** ``AuditMiddleware`` and
+ ``ToolsetMiddleware``. Both depend on exception semantics: audit detects
+ failures by catching them, and toolset denials must propagate as exceptions
+ for audit to record them. Converting the exception to a result any deeper in
+ the stack would silently break both.
"""
def _log_error(self, error: Exception, context: MiddlewareContext) -> None:
@@ -463,7 +507,7 @@ async def on_call_tool(
#: audit log only. ``respawn_pane(shell="env SECRET=... bash")`` and
#: ``environment={"AWS_SECRET_KEY": "..."}`` may briefly expose the values
#: via the OS process table and tmux's ``pane_current_command`` metadata
-#: until the spawned shell takes over — see ``docs/topics/safety.md``.
+#: until the spawned shell takes over — see ``docs/topics/trust.md``.
_SENSITIVE_ARG_NAMES: frozenset[str] = frozenset(
{"keys", "text", "command", "value", "content", "shell", "environment"}
)
@@ -680,132 +724,6 @@ async def on_call_tool(
DEFAULT_RESPONSE_LIMIT_BYTES = 1_000_000
-#: Failures a retry cannot fix. Each one names a thing that is not there, or a
-#: request that cannot succeed as written, so re-running it buys a second tmux
-#: round-trip and a backoff window in order to fail identically. They all
-#: descend from :exc:`libtmux.exc.LibTmuxException`, which is the retry
-#: trigger, so without this set they would all be retried.
-#:
-#: Order the entries most-general-first when reading: ``ObjectDoesNotExist``
-#: already covers :exc:`libtmux.exc.TmuxObjectDoesNotExist`.
-NON_RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = (
- libtmux_exc.ObjectDoesNotExist,
- libtmux_exc.MultipleObjectsReturned,
- libtmux_exc.PaneNotFound,
- libtmux_exc.NoWindowsExist,
- libtmux_exc.BadSessionName,
- libtmux_exc.TmuxSessionExists,
- libtmux_exc.TmuxCommandNotFound,
-)
-
-
-class _SkipDeterministicFailures(RetryMiddleware):
- """A :class:`RetryMiddleware` that declines to retry what cannot succeed.
-
- fastmcp decides whether to retry in ``_should_retry``, matching the error
- and, one hop down, its ``__cause__`` -- which is where the real failure
- lives, because ``handle_tool_errors`` re-raises every libtmux error as an
- ``ExpectedToolError`` chained off the original. This narrows that decision
- with :data:`NON_RETRYABLE_EXCEPTIONS`, checking the same two places.
- """
-
- def _should_retry(self, error: Exception) -> bool:
- """Return ``False`` for a failure a second attempt cannot change."""
- cause = error.__cause__
- if isinstance(error, NON_RETRYABLE_EXCEPTIONS) or (
- cause is not None and isinstance(cause, NON_RETRYABLE_EXCEPTIONS)
- ):
- return False
- return super()._should_retry(error)
-
-
-class ReadonlyRetryMiddleware(Middleware):
- """Retry transient libtmux failures, but only for readonly tools.
-
- Wraps fastmcp's :class:`fastmcp.server.middleware.error_handling.RetryMiddleware`
- so retries are bounded by the safety tier the tool is registered
- under. Mutating and destructive tools (``send_keys``,
- ``create_session``, ``kill_server``, …) pass straight through —
- re-running them on a transient socket error would silently double
- side effects, which is unacceptable. Readonly tools
- (``list_sessions``, ``capture_pane``, ``snapshot_pane``, …) are
- safe to retry because they observe state without mutating it.
-
- Default retry trigger is :exc:`libtmux.exc.LibTmuxException` —
- libtmux wraps the subprocess failures we actually want to retry
- (socket EAGAIN, transient connect errors). The fastmcp default
- ``(ConnectionError, TimeoutError)`` does NOT match these, so the
- upstream defaults would be a silent no-op.
-
- That trigger is a whole family, though, and most of it is not
- transient: a pane that does not exist will not exist on the second
- look. :data:`NON_RETRYABLE_EXCEPTIONS` carves those out, so a stale
- id fails once instead of costing a backoff window and a second tmux
- round-trip to fail again.
-
- Place this in the middleware stack **inside** ``AuditMiddleware``
- (so retried calls are audited once each) and **outside**
- ``SafetyMiddleware`` (so tier-denied tools never reach retry).
- """
-
- def __init__(
- self,
- max_retries: int = 1,
- base_delay: float = 0.1,
- max_delay: float = 1.0,
- backoff_multiplier: float = 2.0,
- retry_exceptions: tuple[type[Exception], ...] = (libtmux_exc.LibTmuxException,),
- logger_: logging.Logger | None = None,
- ) -> None:
- """Configure the underlying retry policy.
-
- Defaults are deliberately small. ``max_retries=1`` keeps audit
- log noise minimal (one retry per failed call, not three). The
- 100 ms / 1 s backoff window matches the expected duration of a
- transient libtmux socket hiccup — a longer backoff would just
- delay a real failure without adding meaningful retry headroom.
-
- ``logger_`` defaults to ``logging.getLogger("libtmux_mcp.retry")``
- when not supplied — keeps retry events on the project's
- ``libtmux_mcp.*`` namespace so operators routing the audit
- stream capture them. Without this default, fastmcp's stock
- ``RetryMiddleware`` would log to ``fastmcp.retry`` and miss
- any project-namespace log routing.
- """
- if logger_ is None:
- logger_ = logging.getLogger("libtmux_mcp.retry")
- self._retry = _SkipDeterministicFailures(
- max_retries=max_retries,
- base_delay=base_delay,
- max_delay=max_delay,
- backoff_multiplier=backoff_multiplier,
- retry_exceptions=retry_exceptions,
- logger=logger_,
- )
-
- async def on_call_tool(
- self,
- context: MiddlewareContext,
- call_next: t.Any,
- ) -> t.Any:
- """Delegate to the upstream retry only for retry-eligible readonly tools.
-
- ``TAG_SELF_BOUNDED`` tools are excluded even though they are
- readonly. Their deadline is computed inside the tool body, so a
- retry restarts the clock: a transient ``LibTmuxException`` at
- t=29s of a 30s wait would produce a ~59s call and make the wait
- ceiling a lie. :class:`_SkipDeterministicFailures` cannot cover
- this — it carves out failures by exception type, and the
- offending exception here is a genuinely transient one that a
- retry *would* fix for any other tool.
- """
- if context.fastmcp_context:
- tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name)
- if tool and TAG_READONLY in tool.tags and TAG_SELF_BOUNDED not in tool.tags:
- return await self._retry.on_request(context, call_next)
- return await call_next(context)
-
-
#: Header prefixed to a truncated response. Intentionally matches the
#: format used by the per-tool ``capture_pane`` truncation so clients
#: see a consistent marker regardless of which layer fired.
@@ -823,13 +741,13 @@ class TailPreservingResponseLimitingMiddleware(ResponseLimitingMiddleware):
drop the head instead, prefixing a single truncation-header line
so callers can detect the cap fired.
- Used as a global backstop for :func:`libtmux_mcp.tools.pane_tools.capture_pane`,
+ Used as a backstop for :func:`libtmux_mcp.tools.pane_tools.capture_pane`,
:func:`libtmux_mcp.tools.pane_tools.capture_since`,
:func:`libtmux_mcp.tools.pane_tools.snapshot_pane`, and
- :func:`libtmux_mcp.tools.pane_tools.search_panes`. Per-tool
- caps at the tool layer fire first under normal operation; this
- middleware catches pathological output from future tools that
- forget to declare their own bounds.
+ :func:`libtmux_mcp.tools.pane_tools.search_panes`, plus
+ :func:`libtmux_mcp.tools.buffer_tools.show_buffer`. Per-tool caps at
+ the tool layer fire first under normal operation; this middleware
+ catches a missed bound within that configured high-volume set.
Error results keep their ``is_error`` flag through truncation.
The stock truncation path rebuilds the result without it, which
diff --git a/src/libtmux_mcp/prompts/__init__.py b/src/libtmux_mcp/prompts/__init__.py
index 1ff3ee1d..dadeb46e 100644
--- a/src/libtmux_mcp/prompts/__init__.py
+++ b/src/libtmux_mcp/prompts/__init__.py
@@ -14,7 +14,13 @@
import os
import typing as t
+from collections.abc import Sequence
+from fastmcp.server.transforms import GetToolNext, Transform
+from fastmcp.utilities.versions import VersionSpec
+from mcp.types import ToolAnnotations
+
+from libtmux_mcp._utils import TOOLSET_INSPECT
from libtmux_mcp.prompts.recipes import (
build_dev_workspace,
diagnose_failing_pane,
@@ -24,15 +30,54 @@
if t.TYPE_CHECKING:
from fastmcp import FastMCP
+ from fastmcp.tools.base import Tool
#: Env-var gate that enables exposing prompts as tools for clients that
#: do not speak the MCP prompts protocol. Off by default — a sprawling
#: prompt catalog is not the goal.
ENV_PROMPTS_AS_TOOLS = "LIBTMUX_MCP_PROMPTS_AS_TOOLS"
+_PROMPT_TOOL_NAMES = frozenset({"list_prompts", "get_prompt"})
+_PROMPT_TOOL_ANNOTATIONS = ToolAnnotations(
+ readOnlyHint=True,
+ destructiveHint=False,
+ idempotentHint=True,
+ openWorldHint=False,
+)
+
__all__ = ["ENV_PROMPTS_AS_TOOLS", "register_prompts"]
+class _PromptToolMetadata(Transform):
+ """Classify generated prompt adapters as server-local inspect tools."""
+
+ @staticmethod
+ def _decorate(tool: Tool) -> Tool:
+ if tool.name not in _PROMPT_TOOL_NAMES:
+ return tool
+ return tool.model_copy(
+ update={
+ "tags": {*tool.tags, TOOLSET_INSPECT},
+ "annotations": _PROMPT_TOOL_ANNOTATIONS,
+ }
+ )
+
+ async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
+ """Decorate generated adapters in tool listings."""
+ return [self._decorate(tool) for tool in tools]
+
+ async def get_tool(
+ self,
+ name: str,
+ call_next: GetToolNext,
+ *,
+ version: VersionSpec | None = None,
+ ) -> Tool | None:
+ """Decorate a generated adapter fetched for a call."""
+ tool = await call_next(name, version=version)
+ return self._decorate(tool) if tool is not None else None
+
+
def register_prompts(mcp: FastMCP) -> None:
"""Register the narrow prompt set with the MCP instance.
@@ -50,3 +95,4 @@ def register_prompts(mcp: FastMCP) -> None:
from fastmcp.server.transforms import PromptsAsTools
mcp.add_transform(PromptsAsTools(mcp))
+ mcp.add_transform(_PromptToolMetadata())
diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py
index c5772f1b..6168dca8 100644
--- a/src/libtmux_mcp/server.py
+++ b/src/libtmux_mcp/server.py
@@ -6,7 +6,6 @@
from __future__ import annotations
import contextlib
-import logging
import os
import shutil
import typing as t
@@ -14,19 +13,16 @@
from fastmcp import FastMCP
from fastmcp.server.middleware.timing import TimingMiddleware
-if t.TYPE_CHECKING:
- from libtmux.server import Server
-
from libtmux_mcp.__about__ import __version__
from libtmux_mcp._history import (
_configure_history_defaults,
_resolve_suppress_history,
)
from libtmux_mcp._utils import (
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
- VALID_SAFETY_LEVELS,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ VALID_TOOLSETS,
_server_cache,
)
from libtmux_mcp._wait_policy import (
@@ -37,22 +33,15 @@
from libtmux_mcp.middleware import (
DEFAULT_RESPONSE_LIMIT_BYTES,
AuditMiddleware,
- ReadonlyRetryMiddleware,
- SafetyMiddleware,
TailPreservingResponseLimitingMiddleware,
ToolErrorResultMiddleware,
+ ToolsetMiddleware,
+ _NestedReadToolVisibility,
install_fastmcp_validation_log_filter,
)
-from libtmux_mcp.tools.buffer_tools import _MCP_BUFFER_PREFIX
-logger = logging.getLogger(__name__)
install_fastmcp_validation_log_filter()
-#: Cache-key shape used by :data:`_server_cache` and the GC helper.
-#: ``(socket_name, socket_path, tmux_bin)`` — see
-#: :func:`libtmux_mcp._utils._get_server`.
-_ServerCacheKey: t.TypeAlias = tuple[str | None, str | None, str | None]
-
# ---------------------------------------------------------------------------
# _BASE_INSTRUCTIONS — composed from named segments.
#
@@ -101,7 +90,7 @@
_INSTR_READ_TOOLS = (
"Prefer snapshot_pane over capture_pane + get_pane_info; capture_since "
- "for repeated observation/tailing; display_message for tmux formats."
+ "for repeated observation/tailing; display_message for tmux variables."
)
_INSTR_WAIT_NOT_POLL = (
@@ -116,7 +105,7 @@
#: comment above for when to add another ``_GAP`` segment vs. push the
#: explanation into a tool description.
_INSTR_HOOKS_GAP = (
- "HOOKS ARE READ-ONLY: inspect via show_hooks/show_hook. "
+ "NO DEDICATED HOOK-WRITE TOOLS: use show_hooks/show_hook. "
"Write hooks survive process death; keep them in your tmux config file."
)
@@ -140,12 +129,19 @@
_INSTRUCTIONS_MAX_BYTES = 2048
+#: Enabled when ``LIBTMUX_TOOLSETS`` is unset. ``teardown`` is not in it:
+#: this server still reaches whichever tmux server the environment points
+#: at, so deletion stays something an operator asks for by name.
+DEFAULT_TOOLSETS: frozenset[str] = frozenset(
+ {TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_EXECUTE}
+)
+
def _build_instructions(
- safety_level: str = TAG_MUTATING,
+ toolsets: frozenset[str] = DEFAULT_TOOLSETS,
suppress_history: bool = True,
) -> str:
- """Build server instructions with agent context and safety level.
+ """Build server instructions with agent context and toolsets.
When the MCP server process runs inside a tmux pane, ``TMUX_PANE`` and
``TMUX`` environment variables are available. This function appends that
@@ -153,8 +149,8 @@ def _build_instructions(
Parameters
----------
- safety_level : str
- Active safety tier (readonly, mutating, or destructive).
+ toolsets : frozenset of str
+ Enabled toolsets.
suppress_history : bool
Effective MCP default for semantic shell-command suppression.
@@ -165,11 +161,12 @@ def _build_instructions(
"""
parts: list[str] = [_BASE_INSTRUCTIONS]
- # Safety tier context
+ # Toolset context
parts.append(
- f"\n\nSafety level: {safety_level} "
- "(values: readonly, mutating, destructive). "
- "Set LIBTMUX_SAFETY; off-tier tools are hidden."
+ "\n\nToolsets: "
+ + (", ".join(sorted(toolsets)) or "(none)")
+ + f" (of {', '.join(VALID_TOOLSETS)}), set by LIBTMUX_TOOLSETS. "
+ "Hiding one shapes this list, not what a pane can run."
)
history_default = "true" if suppress_history else "false"
parts.append(
@@ -177,15 +174,12 @@ def _build_instructions(
"raw send/batch/paste and spawn do not."
)
- # Tier-conditioned discoverability hint. False-positive activation is
- # cheap on readonly (worst case: an extra list_panes call) and
- # expensive on mutating/destructive (where kill_* is one mis-routed
- # query away). Reuse the existing safety axis instead of shipping a
- # separate LIBTMUX_DISCOVERABILITY knob.
- if safety_level == TAG_READONLY:
- parts.append(
- "\n\nReadonly mode: probe snapshot_pane/list_panes/search_panes if unsure."
- )
+ # Only when nothing but inspect is enabled: a wrong guess costs one
+ # extra capture, where the same nudge on a surface holding kill_* or
+ # send_keys could cost a pane. Keyed on the enabled toolsets rather
+ # than a separate discoverability variable.
+ if toolsets == frozenset({TOOLSET_INSPECT}):
+ parts.append("\n\nProbe snapshot_pane/list_panes/search_panes if unsure.")
instructions = "".join(parts)
if len(instructions.encode("utf-8")) > _INSTRUCTIONS_MAX_BYTES:
@@ -263,21 +257,69 @@ def _build_instructions(
return instructions
-def _resolve_safety_level(value: str | None) -> str:
- """Return the effective safety level for a ``LIBTMUX_SAFETY`` value."""
+def _resolve_toolsets(value: str | None) -> frozenset[str]:
+ """Return the enabled toolsets for a ``LIBTMUX_TOOLSETS`` value.
+
+ Parameters
+ ----------
+ value : str or None
+ Comma-separated toolset names. ``None`` takes the default; an
+ empty string enables none, which is legal.
+
+ Returns
+ -------
+ frozenset of str
+ Enabled toolsets.
+
+ Raises
+ ------
+ RuntimeError
+ If a name is not a toolset. A typo silently falling back is how
+ a narrowed surface quietly becomes a wider one.
+ """
if value is None:
- return TAG_MUTATING
- if value in VALID_SAFETY_LEVELS:
- return value
- logger.warning(
- "invalid LIBTMUX_SAFETY=%r, falling back to %s",
- value,
- TAG_READONLY,
+ return DEFAULT_TOOLSETS
+ names = [part.strip() for part in value.split(",") if part.strip()]
+ unknown = [name for name in names if name not in VALID_TOOLSETS]
+ if unknown:
+ msg = (
+ f"LIBTMUX_TOOLSETS names unknown toolsets: {', '.join(unknown)}. "
+ f"Valid toolsets: {', '.join(VALID_TOOLSETS)}."
+ )
+ raise RuntimeError(msg)
+ return frozenset(names)
+
+
+def _resolve_tool_names(value: str | None) -> frozenset[str]:
+ """Return a comma-separated tool-name list as a set."""
+ if not value:
+ return frozenset()
+ return frozenset(part.strip() for part in value.split(",") if part.strip())
+
+
+def _reject_retired_safety_env() -> None:
+ """Fail startup when ``LIBTMUX_SAFETY`` is still set.
+
+ The former setting selected an ordered ladder that read as a
+ permission system and was not one. Ignoring the variable would
+ silently widen a surface an operator believes is narrow.
+ """
+ if "LIBTMUX_SAFETY" not in os.environ:
+ return
+ msg = (
+ "LIBTMUX_SAFETY has been removed. Tools are grouped into the "
+ f"unordered toolsets {', '.join(VALID_TOOLSETS)}; select them with "
+ "LIBTMUX_TOOLSETS. The nearest equivalents are "
+ "LIBTMUX_TOOLSETS=inspect, LIBTMUX_TOOLSETS=inspect,manage,execute, "
+ "and LIBTMUX_TOOLSETS=inspect,manage,execute,teardown."
)
- return TAG_READONLY
+ raise RuntimeError(msg)
-_safety_level = _resolve_safety_level(os.environ.get("LIBTMUX_SAFETY"))
+_reject_retired_safety_env()
+_toolsets = _resolve_toolsets(os.environ.get("LIBTMUX_TOOLSETS"))
+_extra_tools = _resolve_tool_names(os.environ.get("LIBTMUX_TOOLS"))
+_excluded_tools = _resolve_tool_names(os.environ.get("LIBTMUX_EXCLUDE_TOOLS"))
_suppress_history = _resolve_suppress_history(
os.environ.get("LIBTMUX_SUPPRESS_HISTORY")
)
@@ -301,63 +343,48 @@ async def _lifespan(_app: FastMCP) -> t.AsyncIterator[None]:
Startup
-------
- Verifies that a ``tmux`` binary is on ``PATH``. Without this
- probe, tools fail at first call with a generic ``TmuxCommandNotFound``
- deep inside libtmux. Failing at server start instead surfaces a
- clear cold-start error before any tool traffic arrives.
+ Validates named tool includes and exclusions against the transformed
+ catalog, then verifies that a ``tmux`` binary is on ``PATH``. Without
+ the binary probe, tools fail at first call with a generic
+ ``TmuxCommandNotFound`` deep inside libtmux. Failing at server start
+ instead surfaces a clear cold-start error before tool traffic arrives.
Shutdown
--------
- Clears the process-wide :data:`_server_cache` so repeated test runs
- don't share stale Server references and HTTP-transport reload
- cycles start clean. Also best-effort GC's any leftover
- ``libtmux_mcp_*`` paste buffers on every cached server — agents
- are supposed to ``delete_buffer`` after use, but an interrupted
- call chain can leak. Note: FastMCP lifespan teardown runs on
- SIGTERM / SIGINT only; ``kill -9`` and OOM bypass it, so this path
- must not be relied on for any invariant that must survive a hard
- crash (see the hook_tools module docstring for why write-hooks
- are explicitly NOT gated on lifespan cleanup).
+ Clears the process-wide :data:`_server_cache` so repeated test runs don't
+ share stale Server references and HTTP-transport reload cycles start clean.
+ Shutdown sends no tmux commands. Buffer tools expose explicit cleanup, and
+ a process-wide prefix does not prove which MCP instance owns a buffer.
"""
+ registered_tool_names = {
+ tool.name for tool in await super(FastMCP, _app).list_tools()
+ }
+ for variable, names in (
+ ("LIBTMUX_TOOLS", _extra_tools),
+ ("LIBTMUX_EXCLUDE_TOOLS", _excluded_tools),
+ ):
+ # FastMCP.list_tools() hides disabled tools. Its provider lookup
+ # retains them and applies the same transforms, including optional
+ # prompt-as-tool adapters.
+ unknown = sorted(names - registered_tool_names)
+ if unknown:
+ msg = f"{variable} names unknown tools: {', '.join(unknown)}"
+ raise RuntimeError(msg)
+
if shutil.which("tmux") is None:
msg = "tmux binary not found on PATH"
raise RuntimeError(msg)
try:
yield
finally:
- _gc_mcp_buffers(_server_cache)
_server_cache.clear()
-def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None:
- """Best-effort delete of leaked ``libtmux_mcp_*`` paste buffers.
-
- Iterates every cached tmux Server, lists buffer names, and deletes
- anything matching the MCP prefix. Never raises: tmux may be
- unreachable, buffers may vanish mid-scan, and none of that should
- block lifespan shutdown. Logs at debug level so operators can
- still surface leaks via verbose logging.
- """
- for server in cache.values():
- try:
- result = server.cmd("list-buffers", "-F", "#{buffer_name}")
- except Exception as err:
- logger.debug("buffer GC: list-buffers failed: %s", err)
- continue
- for name in result.stdout:
- if not name.startswith(_MCP_BUFFER_PREFIX):
- continue
- try:
- server.delete_buffer(buffer_name=name)
- except Exception as err:
- logger.debug("buffer GC: delete-buffer %s failed: %s", name, err)
-
-
mcp = FastMCP(
name="tmux",
version=__version__,
instructions=_build_instructions(
- safety_level=_safety_level,
+ toolsets=_toolsets,
suppress_history=_suppress_history,
),
website_url="https://libtmux-mcp.git-pull.com/",
@@ -372,22 +399,17 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None:
# 3. ToolErrorResultMiddleware — converts tool-call failures to
# rich ToolResult(is_error=True) results and transforms
# resource errors to MCP code -32002. Must stay OUTSIDE the
- # audit + retry + safety trio: all three depend on exception
- # semantics (audit catches to record outcome=error, retry
- # matches LibTmuxException via __cause__, and safety's tier
- # denials must propagate as exceptions for audit to record
- # them), so converting the exception to a result any deeper
- # would silently break all three.
- # 4. AuditMiddleware — outside SafetyMiddleware so tier-denial
+ # audit + toolset pair: both depend on exception semantics
+ # (audit catches to record outcome=error, and toolset denials
+ # must propagate as exceptions for audit to record them), so
+ # converting the exception to a result any deeper would
+ # silently break both.
+ # 4. AuditMiddleware — outside ToolsetMiddleware so refusal
# events (which raise ExpectedToolError before call_next inside
- # Safety) are still logged with outcome=error. Without this
+ # Toolset) are still logged with outcome=error. Without this
# ordering, denied access attempts would silently bypass the
# audit log — a security-observability gap.
- # 5. ReadonlyRetryMiddleware — inside Audit so retries are
- # audited once each, outside Safety so tier-denied tools
- # never reach retry. Only readonly tools are retried;
- # mutating/destructive tools pass straight through.
- # 6. SafetyMiddleware — innermost gate (fail-closed). Denials
+ # 5. ToolsetMiddleware — innermost gate (fail-closed). Refusals
# never reach the tool, but the audit record above captures
# them for forensic review.
middleware=[
@@ -398,8 +420,7 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None:
),
ToolErrorResultMiddleware(transform_errors=True),
AuditMiddleware(),
- ReadonlyRetryMiddleware(),
- SafetyMiddleware(max_tier=_safety_level),
+ ToolsetMiddleware(_toolsets, _extra_tools, _excluded_tools),
],
on_duplicate="error",
)
@@ -431,20 +452,22 @@ def _register_all() -> None:
def _enable_allowed_tools() -> None:
- """Apply the native FastMCP visibility gate for the active safety tier."""
+ """Apply FastMCP's visibility gate for the enabled toolsets."""
global _mcp_visibility_configured
if _mcp_visibility_configured:
return
- # Use FastMCP's native visibility system as primary gate,
- # with the SafetyMiddleware as a secondary layer for clear error messages.
- allowed_tags = {TAG_READONLY}
- if _safety_level in {TAG_MUTATING, TAG_DESTRUCTIVE}:
- allowed_tags.add(TAG_MUTATING)
- if _safety_level == TAG_DESTRUCTIVE:
- allowed_tags.add(TAG_DESTRUCTIVE)
+ # FastMCP's tag and name visibility is the primary wire filter;
+ # ToolsetMiddleware repeats classification as defense in depth for
+ # tools that reach dispatch.
mcp.disable(components={"tool"})
- mcp.enable(tags=allowed_tags, components={"tool"})
+ if _toolsets:
+ mcp.enable(tags=set(_toolsets), components={"tool"})
+ for name in _extra_tools:
+ mcp.enable(components={"tool"}, names={name})
+ if _excluded_tools:
+ mcp.disable(components={"tool"}, names=set(_excluded_tools))
+ mcp.add_transform(_NestedReadToolVisibility(_excluded_tools))
_mcp_visibility_configured = True
diff --git a/src/libtmux_mcp/tools/batch_tools.py b/src/libtmux_mcp/tools/batch_tools.py
index 2091f4b4..0336d7e0 100644
--- a/src/libtmux_mcp/tools/batch_tools.py
+++ b/src/libtmux_mcp/tools/batch_tools.py
@@ -11,15 +11,13 @@
from pydantic import BaseModel
from libtmux_mcp._utils import (
- ANNOTATIONS_RO,
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
TAG_SELF_BOUNDED,
+ TOOLSET_INSPECT,
ExpectedToolError,
handle_tool_errors_async,
)
-from libtmux_mcp.middleware import DEFAULT_RESPONSE_LIMIT_BYTES
+from libtmux_mcp.middleware import DEFAULT_RESPONSE_LIMIT_BYTES, _allow_nested_read_tool
from libtmux_mcp.models import (
ToolCallBatchResult,
ToolCallOperation,
@@ -31,19 +29,7 @@
_OnError: t.TypeAlias = t.Literal["stop", "continue"]
-_TIER_LEVELS: dict[str, int] = {
- TAG_READONLY: 0,
- TAG_MUTATING: 1,
- TAG_DESTRUCTIVE: 2,
-}
-
-_BATCH_TOOL_NAMES: frozenset[str] = frozenset(
- {
- "call_readonly_tools_batch",
- "call_mutating_tools_batch",
- "call_destructive_tools_batch",
- }
-)
+_BATCH_TOOL_NAMES: frozenset[str] = frozenset({"call_read_tools_batch"})
MAX_BATCH_OPERATIONS = 1_000
@@ -54,13 +40,6 @@
}
]
-_ANNOTATIONS_BATCH_SIDE_EFFECTS: dict[str, bool] = {
- "readOnlyHint": False,
- "destructiveHint": True,
- "idempotentHint": False,
- "openWorldHint": True,
-}
-
def _content_block_to_dict(block: t.Any) -> dict[str, t.Any]:
"""Return a JSON-ready representation of an MCP content block."""
@@ -87,38 +66,12 @@ def _result_error_text(result: ToolResult) -> str | None:
return None
-def _tool_tier(tool_name: str, tags: set[str]) -> str:
- """Return the highest recognized safety tier for a registered tool."""
- found = [tier for tier in _TIER_LEVELS if tier in tags]
- if not found:
- msg = f"Tool {tool_name!r} has no recognized safety tier tag."
- raise ExpectedToolError(msg)
- return max(found, key=lambda tier: _TIER_LEVELS[tier])
-
-
-def _check_operation_allowed(
- *,
- tool_name: str,
- tool_tier: str,
- max_tier: str,
-) -> None:
- """Raise when a nested tool exceeds this batch wrapper's tier."""
- if _TIER_LEVELS[tool_tier] <= _TIER_LEVELS[max_tier]:
- return
- msg = (
- f"Tool {tool_name!r} has tier {tool_tier!r}, which exceeds "
- f"batch tier {max_tier}."
- )
- raise ExpectedToolError(msg)
-
-
-async def _get_allowed_tool_tier(
+async def _check_operation_allowed(
*,
fastmcp: FastMCP,
operation: ToolCallOperation,
- max_tier: str,
) -> None:
- """Validate that one nested operation targets an allowed tool."""
+ """Validate that one nested operation targets an ``inspect`` tool."""
if operation.tool in _BATCH_TOOL_NAMES:
msg = "Batch tools cannot call batch tools recursively."
raise ExpectedToolError(msg)
@@ -128,13 +81,11 @@ async def _get_allowed_tool_tier(
msg = f"Unknown tool: {operation.tool!r}"
raise ExpectedToolError(msg)
- # ``max_tier`` is a CEILING, so a readonly tool is reachable through
- # every batch wrapper, not only the readonly one. The batch loop is
- # serial with no aggregate deadline and ``MAX_BATCH_OPERATIONS`` is
- # 1000, so a self-bounded wait batched N times costs N x its
- # ceiling. Reject per-operation (not pre-loop) so the raise becomes
- # a ``success=False`` row and ``on_error='continue'`` isolation is
- # preserved.
+ # The batch loop is serial with no aggregate deadline and
+ # ``MAX_BATCH_OPERATIONS`` is 1000, so a self-bounded wait batched N
+ # times costs N x its ceiling. Reject per-operation, not pre-loop, so
+ # the raise becomes a ``success=False`` row and ``on_error='continue'``
+ # isolation is preserved.
if TAG_SELF_BOUNDED in tool.tags:
msg = (
f"Tool {operation.tool!r} enforces its own wait ceiling and "
@@ -143,12 +94,12 @@ async def _get_allowed_tool_tier(
)
raise ExpectedToolError(msg)
- tool_tier = _tool_tier(operation.tool, tool.tags)
- _check_operation_allowed(
- tool_name=operation.tool,
- tool_tier=tool_tier,
- max_tier=max_tier,
- )
+ if TOOLSET_INSPECT not in tool.tags:
+ msg = (
+ f"Tool {operation.tool!r} is not an 'inspect' tool, so it "
+ "cannot run in a read batch. Call it directly."
+ )
+ raise ExpectedToolError(msg)
def _ensure_tool_result(tool_name: str, result: t.Any) -> ToolResult:
@@ -212,25 +163,24 @@ async def _call_one_tool(
fastmcp: FastMCP,
operation: ToolCallOperation,
index: int,
- max_tier: str,
) -> ToolCallOperationResult:
"""Call one nested tool and convert its outcome to a batch result row."""
start = time.monotonic()
try:
- await _get_allowed_tool_tier(
- fastmcp=fastmcp,
- operation=operation,
- max_tier=max_tier,
- )
+ with _allow_nested_read_tool(operation.tool):
+ await _check_operation_allowed(
+ fastmcp=fastmcp,
+ operation=operation,
+ )
- result = _ensure_tool_result(
- operation.tool,
- await fastmcp.call_tool(
+ result = _ensure_tool_result(
operation.tool,
- operation.arguments,
- run_middleware=True,
- ),
- )
+ await fastmcp.call_tool(
+ operation.tool,
+ operation.arguments,
+ run_middleware=True,
+ ),
+ )
error = _result_error_text(result)
return ToolCallOperationResult(
@@ -257,7 +207,6 @@ async def _call_tools_batch(
*,
operations: list[ToolCallOperation],
on_error: _OnError,
- max_tier: str,
ctx: Context | None,
) -> ToolCallBatchResult:
"""Execute nested MCP tool calls serially through FastMCP."""
@@ -281,7 +230,6 @@ async def _call_tools_batch(
fastmcp=ctx.fastmcp,
operation=operation,
index=index,
- max_tier=max_tier,
)
results.append(result)
if not result.success and on_error == "stop":
@@ -301,63 +249,25 @@ async def _call_tools_batch(
@handle_tool_errors_async
-async def call_readonly_tools_batch(
- operations: list[ToolCallOperation],
- on_error: _OnError = "stop",
- ctx: Context | None = None,
-) -> ToolCallBatchResult:
- """Call readonly MCP tools serially and return per-tool results.
-
- Use when several read-only observations should be made in one agent
- turn. Each nested call still goes through FastMCP validation,
- middleware, and safety checks. Mutating and destructive tools are
- rejected even if the server process itself is running at a higher
- safety tier.
- """
- return await _call_tools_batch(
- operations=operations,
- on_error=on_error,
- max_tier=TAG_READONLY,
- ctx=ctx,
- )
-
-
-@handle_tool_errors_async
-async def call_mutating_tools_batch(
+async def call_read_tools_batch(
operations: list[ToolCallOperation],
on_error: _OnError = "stop",
ctx: Context | None = None,
) -> ToolCallBatchResult:
- """Call readonly or mutating MCP tools serially and return per-tool results.
+ """Call several `inspect` tools serially and return per-tool results.
- Use for ordered tmux workflows where every step is still an existing
- typed MCP tool. Destructive tools are rejected regardless of the
- process-wide safety tier.
- """
- return await _call_tools_batch(
- operations=operations,
- on_error=on_error,
- max_tier=TAG_MUTATING,
- ctx=ctx,
- )
-
-
-@handle_tool_errors_async
-async def call_destructive_tools_batch(
- operations: list[ToolCallOperation],
- on_error: _OnError = "stop",
- ctx: Context | None = None,
-) -> ToolCallBatchResult:
- """Call readonly, mutating, or destructive MCP tools serially.
+ Use when one agent turn needs several observations. Each nested call
+ still goes through FastMCP validation and this server's middleware.
+ Only `inspect` tools are accepted; anything that changes tmux state
+ is refused, whatever this server has enabled.
- This wrapper preserves the normal per-tool schemas and middleware
- but its tier permits destructive nested operations. Prefer the
- narrower readonly or mutating wrappers whenever possible.
+ This wrapper aggregates authority under its own name: a client rule
+ keyed on a nested tool's name does not fire for a call made through
+ it. Read it as authority to invoke any `inspect` tool.
"""
return await _call_tools_batch(
operations=operations,
on_error=on_error,
- max_tier=TAG_DESTRUCTIVE,
ctx=ctx,
)
@@ -365,17 +275,7 @@ async def call_destructive_tools_batch(
def register(mcp: FastMCP) -> None:
"""Register generic MCP batch tools."""
mcp.tool(
- title="Call Readonly Tools Batch",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY},
- )(call_readonly_tools_batch)
- mcp.tool(
- title="Call Mutating Tools Batch",
- annotations=_ANNOTATIONS_BATCH_SIDE_EFFECTS,
- tags={TAG_MUTATING},
- )(call_mutating_tools_batch)
- mcp.tool(
- title="Call Destructive Tools Batch",
- annotations=_ANNOTATIONS_BATCH_SIDE_EFFECTS,
- tags={TAG_DESTRUCTIVE},
- )(call_destructive_tools_batch)
+ title="Call Read Tools Batch",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(call_read_tools_batch)
diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py
index 6663d7e7..0707214e 100644
--- a/src/libtmux_mcp/tools/buffer_tools.py
+++ b/src/libtmux_mcp/tools/buffer_tools.py
@@ -17,7 +17,7 @@
:func:`~libtmux_mcp.tools.buffer_tools.show_buffer`, and
:func:`~libtmux_mcp.tools.buffer_tools.delete_buffer` without ambiguity.
-``list_buffers`` is **not** exposed in the default safety tier —
+``list_buffers`` is **not** exposed at all —
buffer contents often include the user's OS clipboard history (passwords,
private snippets), and a blanket enumeration would leak that to the
agent. Callers track the buffers they own via the
@@ -35,11 +35,11 @@
import uuid
from libtmux_mcp._utils import (
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_RO,
- ANNOTATIONS_SHELL,
- TAG_MUTATING,
- TAG_READONLY,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
ExpectedToolError,
_get_server,
_resolve_pane,
@@ -380,25 +380,26 @@ def delete_buffer(
def register(mcp: FastMCP) -> None:
"""Register buffer tools with the MCP instance.
- ``load_buffer`` is tagged with
- :data:`~libtmux_mcp._utils.ANNOTATIONS_SHELL` because its ``content``
- argument is arbitrary user text that may carry interactive-environment
- side effects (commands about to be pasted into a shell). Other buffer
- tools are plain mutating ops on the tmux buffer store.
+ ``load_buffer`` stages inert content, while ``paste_buffer`` delivers
+ that content to a pane's program. Their toolsets preserve that boundary.
"""
mcp.tool(
- title="Load tmux Buffer", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING}
+ title="Load tmux Buffer",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(load_buffer)
mcp.tool(
title="Paste tmux Buffer",
- annotations=ANNOTATIONS_SHELL,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(paste_buffer)
- mcp.tool(title="Show tmux Buffer", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- show_buffer
- )
+ mcp.tool(
+ title="Show tmux Buffer",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(show_buffer)
mcp.tool(
title="Delete tmux Buffer",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_TEARDOWN},
)(delete_buffer)
diff --git a/src/libtmux_mcp/tools/env_tools.py b/src/libtmux_mcp/tools/env_tools.py
index 16407c4e..39200947 100644
--- a/src/libtmux_mcp/tools/env_tools.py
+++ b/src/libtmux_mcp/tools/env_tools.py
@@ -5,10 +5,9 @@
import typing as t
from libtmux_mcp._utils import (
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_RO,
- TAG_MUTATING,
- TAG_READONLY,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
_get_server,
_resolve_session,
handle_tool_errors,
@@ -71,18 +70,22 @@ def set_environment(
Use to set variables that will be inherited by new panes and windows.
Changes do not affect already-running processes.
+ Some variables control later shell execution. Bash sources the file named
+ by ``BASH_ENV``, POSIX shells may source the file named by ``ENV``, and
+ Bash evaluates ``PROMPT_COMMAND`` before displaying a prompt.
+
.. warning::
Values set here propagate into **every** shell tmux later spawns
in the targeted scope — including panes the user opens manually,
not just panes the agent drives. A caller that writes ``PATH``,
``LD_PRELOAD``, or ``AWS_*`` variables can influence future
commands the human user types directly. Treat this as
- elevated-risk within the ``mutating`` safety tier. The audit log
+ elevated-risk within ``execute``. The audit log
redacts the ``value`` argument, but the side effects persist on
disk/memory until tmux is restarted. Prefer ``env VAR=value
command`` via :func:`~libtmux_mcp.tools.pane_tools.send_keys`
when you only need the override for a single command. See
- :doc:`/topics/safety`.
+ :doc:`/topics/trust`.
Parameters
----------
@@ -121,11 +124,11 @@ def register(mcp: FastMCP) -> None:
"""Register environment tools with the MCP instance."""
mcp.tool(
title="Show tmux Environment",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(show_environment)
mcp.tool(
title="Set tmux Environment",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(set_environment)
diff --git a/src/libtmux_mcp/tools/hook_tools.py b/src/libtmux_mcp/tools/hook_tools.py
index c7821dad..17cc883f 100644
--- a/src/libtmux_mcp/tools/hook_tools.py
+++ b/src/libtmux_mcp/tools/hook_tools.py
@@ -1,29 +1,8 @@
-"""Read-only MCP tools for tmux hook introspection.
-
-Why read-only only
-------------------
-Write-hooks (``set-hook`` / ``unset-hook``) are deliberately excluded.
-The reason is side-effect leakage: tmux servers outlive the MCP
-process, so if an MCP agent installs a hook that runs arbitrary shell
-on ``pane-exited`` or ``command-error`` and then the MCP server is
-``kill -9``'d, OOM'd, or crashes via a C-extension fault, the hook
-**stays installed** in the user's persistent tmux server and fires
-forever.
-
-FastMCP ``lifespan`` teardown only runs on graceful SIGTERM/SIGINT, so
-a soft "track what we installed and unset on shutdown" registry cannot
-close this gap. Three plausible future paths are open:
-
-* Install a tmux-side meta-hook on ``client-detached`` that self-cleans
- all ``libtmux_mcp_*``-namespaced hooks when the MCP client disconnects.
- Survives hard crashes because tmux enforces it.
-* Require ``LIBTMUX_SAFETY=destructive`` for write-hooks so leakage is
- an explicit opt-in with user awareness.
-* Expose ``run_hook`` (one-shot fire) but not ``set_hook`` (persistent
- install) — narrows the risk surface to transient events.
-
-Until one is implemented, the surface here is deliberately visibility
-only.
+"""MCP tools for inspecting tmux hooks.
+
+Hooks can outlive the MCP process and run tmux command lists. Cleanup cannot be
+guaranteed after SIGKILL, OOM, or a native crash, so this server exposes no
+dedicated hook-write tool. Keep intentional persistent hooks in tmux configuration.
"""
from __future__ import annotations
@@ -34,8 +13,8 @@
from libtmux.constants import OptionScope
from libtmux_mcp._utils import (
- ANNOTATIONS_RO,
- TAG_READONLY,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
+ TOOLSET_INSPECT,
ExpectedToolError,
_get_server,
_resolve_pane,
@@ -260,10 +239,14 @@ def show_hook(
def register(mcp: FastMCP) -> None:
- """Register read-only hook tools with the MCP instance."""
- mcp.tool(title="Show tmux Hooks", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- show_hooks
- )
- mcp.tool(title="Show tmux Hook", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- show_hook
- )
+ """Register hook inspection tools with the MCP instance."""
+ mcp.tool(
+ title="Show tmux Hooks",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(show_hooks)
+ mcp.tool(
+ title="Show tmux Hook",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(show_hook)
diff --git a/src/libtmux_mcp/tools/option_tools.py b/src/libtmux_mcp/tools/option_tools.py
index e992fd8f..1e25dc7e 100644
--- a/src/libtmux_mcp/tools/option_tools.py
+++ b/src/libtmux_mcp/tools/option_tools.py
@@ -7,11 +7,11 @@
from libtmux.constants import OptionScope
from libtmux_mcp._utils import (
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_RO,
- TAG_MUTATING,
- TAG_READONLY,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
ExpectedToolError,
+ _escape_tmux_format,
_get_server,
_resolve_pane,
_resolve_session,
@@ -94,7 +94,9 @@ def show_option(
Option name and its value.
"""
obj, opt_scope = _resolve_option_target(socket_name, scope, target)
- value = obj.show_option(option, global_=global_, scope=opt_scope)
+ value = obj.show_option(
+ _escape_tmux_format(option), global_=global_, scope=opt_scope
+ )
return OptionResult(option=option, value=value)
@@ -109,6 +111,13 @@ def set_option(
) -> OptionSetResult:
"""Set a tmux option value.
+ Some option values are executable. tmux runs a ``#(...)`` job inside
+ the status formats when it draws them, and repeats it on the status
+ interval; ``default-command`` and ``default-shell`` decide what every
+ future pane runs, and ``command-alias`` rewrites later commands. The
+ value is stored verbatim, so an option that interprets it later runs
+ what is stored, not what this call did.
+
Use to change tmux behavior at runtime. Common uses: adjusting
history-limit, enabling mouse support, changing status bar format.
@@ -135,15 +144,19 @@ def set_option(
Confirmation with option name, value, and status.
"""
obj, opt_scope = _resolve_option_target(socket_name, scope, target)
- obj.set_option(option, value, global_=global_, scope=opt_scope)
+ obj.set_option(_escape_tmux_format(option), value, global_=global_, scope=opt_scope)
return OptionSetResult(option=option, value=value, status="set")
def register(mcp: FastMCP) -> None:
"""Register option tools with the MCP instance."""
- mcp.tool(title="Show tmux Option", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- show_option
- )
mcp.tool(
- title="Set tmux Option", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING}
+ title="Show tmux Option",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(show_option)
+ mcp.tool(
+ title="Set tmux Option",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(set_option)
diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py
index dfb86324..f8b81d01 100644
--- a/src/libtmux_mcp/tools/pane_tools/__init__.py
+++ b/src/libtmux_mcp/tools/pane_tools/__init__.py
@@ -12,17 +12,13 @@
import typing as t
from libtmux_mcp._utils import (
- ANNOTATIONS_CREATE,
- ANNOTATIONS_DESTRUCTIVE,
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_MUTATING_DESTRUCTIVE,
- ANNOTATIONS_RO,
- ANNOTATIONS_SHELL,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
DISCOVERY_META,
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
TAG_SELF_BOUNDED,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
)
from libtmux_mcp.tools.pane_tools.capture_since import capture_since
from libtmux_mcp.tools.pane_tools.copy_mode import enter_copy_mode, exit_copy_mode
@@ -83,13 +79,15 @@
def register(mcp: FastMCP) -> None:
"""Register pane-level tools with the MCP instance."""
- mcp.tool(title="Send Keys", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})(
- send_keys
- )
+ mcp.tool(
+ title="Send Keys",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
+ )(send_keys)
mcp.tool(
title="Send Keys Batch",
- annotations=ANNOTATIONS_SHELL,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(send_keys_batch)
# run_command blocks on ``tmux wait-for`` under the same wait
# ceiling as the wait tools, so TAG_SELF_BOUNDED excludes it from
@@ -97,84 +95,104 @@ def register(mcp: FastMCP) -> None:
# the operation count. Use send_keys_batch for command sequences.
mcp.tool(
title="Run Command",
- annotations=ANNOTATIONS_SHELL,
- tags={TAG_MUTATING, TAG_SELF_BOUNDED},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE, TAG_SELF_BOUNDED},
)(run_command)
- mcp.tool(title="Capture Pane", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- capture_pane
- )
- mcp.tool(title="Capture Since", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- capture_since
- )
- mcp.tool(
- title="Resize Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING}
+ mcp.tool(
+ title="Capture Pane",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(capture_pane)
+ mcp.tool(
+ title="Capture Since",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(capture_since)
+ mcp.tool(
+ title="Resize Pane",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(resize_pane)
mcp.tool(
title="Kill Pane",
- annotations=ANNOTATIONS_DESTRUCTIVE,
- tags={TAG_DESTRUCTIVE},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_TEARDOWN},
)(kill_pane)
mcp.tool(
title="Respawn Pane",
- annotations=ANNOTATIONS_MUTATING_DESTRUCTIVE,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(respawn_pane)
mcp.tool(
- title="Set Pane Title", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING}
+ title="Set Pane Title",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(set_pane_title)
- mcp.tool(title="Get Pane Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- get_pane_info
- )
+ mcp.tool(
+ title="Get Pane Info",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(get_pane_info)
mcp.tool(
title="Find Pane By Position",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(find_pane_by_position)
mcp.tool(
title="Clear Pane",
- annotations=ANNOTATIONS_MUTATING_DESTRUCTIVE,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_TEARDOWN},
)(clear_pane)
- mcp.tool(title="Search Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
- search_panes
- )
- # TAG_SELF_BOUNDED excludes this tool from retry and from batch
- # wrappers: both would multiply the wait ceiling it enforces.
+ mcp.tool(
+ title="Search Panes",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )(search_panes)
+ # TAG_SELF_BOUNDED excludes this tool from batch wrappers, which
+ # would multiply the wait ceiling it enforces.
mcp.tool(
title="Wait For Text",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY, TAG_SELF_BOUNDED},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED},
)(wait_for_text)
mcp.tool(
title="Snapshot Pane",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
meta=DISCOVERY_META,
)(snapshot_pane)
mcp.tool(
- title="Select Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING}
+ title="Select Pane",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(select_pane)
- mcp.tool(title="Swap Pane", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})(
- swap_pane
- )
- mcp.tool(title="Pipe Pane", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})(
- pipe_pane
- )
+ mcp.tool(
+ title="Swap Pane",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
+ )(swap_pane)
+ mcp.tool(
+ title="Pipe Pane",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
+ )(pipe_pane)
mcp.tool(
title="Evaluate tmux Format String",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(display_message)
mcp.tool(
title="Enter Copy Mode",
- annotations=ANNOTATIONS_CREATE,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(enter_copy_mode)
mcp.tool(
title="Exit Copy Mode",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(exit_copy_mode)
- mcp.tool(title="Paste Text", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})(
- paste_text
- )
+ mcp.tool(
+ title="Paste Text",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
+ )(paste_text)
diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py
index 56c702c4..fdf1f36f 100644
--- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py
+++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py
@@ -8,8 +8,10 @@
from libtmux_mcp._utils import (
ExpectedToolError,
_caller_is_on_server,
+ _escape_tmux_format,
_get_caller_identity,
_get_server,
+ _prepare_start_directory,
_resolve_pane,
_resolve_window,
_serialize_pane,
@@ -118,8 +120,8 @@ def respawn_pane(
the ``shell`` parameter on :func:`split_window` and the
eventual upstream ``Pane.respawn(shell=)`` API.
start_directory : str, optional
- Working directory for the relaunched command (maps to
- ``respawn-pane -c``).
+ Existing directory to start in. ``~`` expands; a relative path
+ resolves against the MCP server process's directory.
environment : dict or str, optional
Environment variables to set for the relaunched process. Each
item becomes one ``-e KEY=VALUE`` flag (tmux's
@@ -165,7 +167,7 @@ def respawn_pane(
raise ExpectedToolError(msg)
pane.respawn(
kill=kill,
- start_directory=start_directory,
+ start_directory=_prepare_start_directory(start_directory),
environment=spawn_environment,
shell=shell,
)
@@ -216,7 +218,7 @@ def set_pane_title(
session_id=session_id,
window_id=window_id,
)
- pane.set_title(title)
+ pane.set_title(_escape_tmux_format(title))
return _serialize_pane(pane)
diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py
index 953f4d94..4bfb0d78 100644
--- a/src/libtmux_mcp/tools/pane_tools/meta.py
+++ b/src/libtmux_mcp/tools/pane_tools/meta.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import re
+
from libtmux_mcp._utils import (
ExpectedToolError,
_coerce_bool,
@@ -19,6 +21,12 @@
_truncate_lines_tail,
)
+#: One ``#{name}`` reference. The name grammar excludes ``:``, which every
+#: tmux format modifier needs — ``#{E:x}`` re-expands ``x``'s value, where a
+#: job the caller never typed can be waiting. Validating what is allowed,
+#: rather than scanning for what is not, is what makes that unreachable.
+_FORMAT_VARIABLE = re.compile(r"#\{[A-Za-z_@][A-Za-z0-9_-]*\}")
+
@handle_tool_errors
def display_message(
@@ -29,17 +37,26 @@ def display_message(
window_id: str | None = None,
socket_name: str | None = None,
) -> str:
- """Evaluate a tmux format string against a target and return the expanded value.
+ """Read tmux variables against a target and return the substituted text.
- Read-only introspection tool — expands any tmux format variable
- against a target pane and returns the substituted text. Use this
- when no dedicated tool covers the field you want, e.g.
+ Use this when no dedicated tool covers the field you want, e.g.
'#{window_zoomed_flag}', '#{pane_dead}', '#{client_activity}'.
+ Accepts literal text and '#{variable}' references. Format modifiers,
+ conditionals, and jobs are refused: tmux expands '#{E:x}' by running
+ x's *value* through the expander again, and a value can arrive from a
+ pane rather than from the caller. For raw format syntax, run
+ 'tmux display-message -p' through run_command, where the caller
+ supplies it on a surface labelled execution.
+
+ Returned values can carry text a pane chose, such as a working
+ directory or a running command.
+
Parameters
----------
format_string : str
- tmux format string (e.g. '#{cursor_x} #{cursor_y}').
+ Literal text and '#{variable}' references (e.g.
+ '#{cursor_x} #{cursor_y}').
pane_id : str, optional
Pane ID (e.g. '%1').
session_name : str, optional
@@ -55,9 +72,21 @@ def display_message(
-------
str
Expanded format string result.
+
+ Raises
+ ------
+ ExpectedToolError
+ If ``format_string`` carries tmux format syntax other than
+ ``#{variable}`` references.
"""
- if "#(" in format_string:
- msg = "tmux format jobs (#(...)) are not allowed in display_message"
+ remainder = _FORMAT_VARIABLE.sub("", format_string)
+ if "#" in remainder:
+ msg = (
+ "format_string accepts literal text and '#{variable}' references "
+ f"only; {format_string!r} carries other tmux format syntax. "
+ "Modifiers such as '#{E:...}' expand a variable's value a second "
+ "time, and a job '#(...)' runs a shell command."
+ )
raise ExpectedToolError(msg)
server = _get_server(socket_name=socket_name)
diff --git a/src/libtmux_mcp/tools/pane_tools/pipe.py b/src/libtmux_mcp/tools/pane_tools/pipe.py
index cd87ed40..91716b9b 100644
--- a/src/libtmux_mcp/tools/pane_tools/pipe.py
+++ b/src/libtmux_mcp/tools/pane_tools/pipe.py
@@ -2,74 +2,31 @@
from __future__ import annotations
-import re
import shlex
from libtmux_mcp._utils import (
ExpectedToolError,
+ _escape_tmux_format,
_get_server,
_resolve_pane,
handle_tool_errors,
)
-#: A maximal run of ``#``, plus the ``[`` that may follow it. tmux
-#: treats a ``#``-run by what comes next, so the run is the unit that
-#: has to be escaped -- not the individual ``#``.
-_TMUX_HASH_RUN = re.compile(r"(#+)(\[?)")
-
-
-def _escape_tmux_format(value: str) -> str:
- """Escape ``value`` so tmux's format expander reproduces it literally.
-
- ``pipe-pane`` runs its argument through the format expander before
- handing it to ``/bin/sh``, so :func:`shlex.quote` alone is not
- enough -- it guards the shell layer while tmux has already rewritten
- the string.
-
- There are TWO expansions to escape, not one. ``cmd-pipe-pane.c``
- calls ``format_expand_time()``, which runs the argument through
- ``strftime`` as well as the ``#``-format expander, so a ``%`` is as
- dangerous as a ``#``: ``100%done.log`` became ``10025one.log``
- (``%d`` -> day of month) and ``date-%Y.log`` became
- ``date-2026.log``. ``%%`` is strftime's literal escape and is safe
- to apply to every ``%``.
-
- Doubling every ``#`` is the obvious escape and it is wrong. A
- ``#``-run followed by ``[`` is a style sequence, reserved for
- ``format_draw``, and the expander copies the whole run through
- verbatim without ever collapsing it. Doubling there corrupts the
- path in exactly the way this function exists to prevent. Measured
- against tmux 3.7b:
-
- ================== ================== ==========================
- input expands to note
- ================== ================== ==========================
- ``#{pane_id}`` ``%0`` substituted
- ``##{pane_id}`` ``#{pane_id}`` run doubling escapes it
- ``####{a}`` ``##{a}`` composes for longer runs
- ``#S`` *(session name)* legacy single-char alias
- ``#(echo hi)`` *(command job)* substituted away
- ``##(echo hi)`` ``#(echo hi)`` run doubling escapes it
- ``#[fg=red]`` ``#[fg=red]`` verbatim
- ``##[fg=red]`` ``##[fg=red]`` verbatim -- never collapses
- ``issue ##42`` ``issue #42`` ordinary run collapses
- ================== ================== ==========================
-
- The legacy aliases are the easiest to trip over by accident: a log
- named ``#Session.log`` loses its ``#S`` to the session name and
- lands on ``ession.log``.
-
- So: leave a run alone when ``[`` follows it, double it otherwise,
- and double every ``%`` for strftime.
- """
- def _escape_run(match: re.Match[str]) -> str:
- run, bracket = match.group(1), match.group(2)
- if bracket:
- return f"{run}{bracket}"
- return run * 2
+def _escape_pipe_target(value: str) -> str:
+ """Escape ``value`` for the two expansions ``pipe-pane`` applies.
+
+ ``cmd-pipe-pane.c`` calls ``format_expand_time()``, so the argument goes
+ through ``strftime`` as well as the ``#``-format expander: ``%`` is as
+ dangerous as ``#``. ``100%done.log`` became ``10025one.log`` (``%d`` ->
+ day of month) and ``date-%Y.log`` became ``date-2026.log``. ``%%`` is
+ strftime's literal escape.
- return _TMUX_HASH_RUN.sub(_escape_run, value).replace("%", "%%")
+ The ``#`` half is :func:`~libtmux_mcp._utils._escape_tmux_format`, which
+ every format-bearing argument needs; only the ``%`` half is specific to
+ the arguments tmux expands for time.
+ """
+ return _escape_tmux_format(value).replace("%", "%%")
@handle_tool_errors
@@ -95,11 +52,10 @@ def pipe_pane(
This tool writes to arbitrary filesystem paths chosen by the MCP
client. There is no allow-list; the server will create files
anywhere the server process has write access. Treat this as
- elevated-risk even though it sits in the ``mutating`` safety
- tier — it is the broadest-reach tool in that tier. If you run
- libtmux-mcp on untrusted input, consider
- ``LIBTMUX_SAFETY=readonly`` or run the server under a user with
- a scoped home directory. See :doc:`/topics/safety` for the full
+ elevated-risk: it is the broadest-reach tool in ``execute``. If
+ you run libtmux-mcp on untrusted input, consider
+ ``LIBTMUX_TOOLSETS=inspect`` or run the server under a user with
+ a scoped home directory. See :doc:`/topics/trust` for the full
footgun list.
Parameters
@@ -144,6 +100,6 @@ def pipe_pane(
redirect = ">>" if append else ">"
# Two layers rewrite this string, so it needs two escapes: tmux
# expands its own formats first, then /bin/sh parses what is left.
- quoted = _escape_tmux_format(shlex.quote(output_path))
+ quoted = _escape_pipe_target(shlex.quote(output_path))
pane.pipe(f"cat {redirect} {quoted}")
return f"Piping pane {pane.pane_id} to {output_path}"
diff --git a/src/libtmux_mcp/tools/pane_tools/search.py b/src/libtmux_mcp/tools/pane_tools/search.py
index 21569e4a..ea78a4d3 100644
--- a/src/libtmux_mcp/tools/pane_tools/search.py
+++ b/src/libtmux_mcp/tools/pane_tools/search.py
@@ -3,6 +3,10 @@
from __future__ import annotations
import re
+import time
+
+# Aliased: the ``regex`` tool parameter below shadows the module name.
+import regex as regex_engine
from libtmux_mcp._utils import (
ExpectedToolError,
@@ -22,11 +26,69 @@
#: (most-recent) matches so the agent sees what's currently on screen.
SEARCH_DEFAULT_MAX_LINES_PER_PANE = 50
+#: Cap on a caller's pattern. Compilation happens before any match, so
+#: the wall-clock ceiling below does not cover it; a length bound does.
+SEARCH_MAX_PATTERN_LENGTH = 1_000
+
+#: Wall-clock ceiling for matching a caller's pattern across every
+#: captured line. A literal scan of 20,000 lines costs ~3.5 ms, so this
+#: is a ceiling reached only by a pattern that backtracks, never a
+#: budget a real search spends.
+SEARCH_MATCH_MAX_SECONDS = 2.0
+
#: Default maximum number of matching panes returned in one call.
#: Pagination via ``offset``/``limit`` lets the caller page forward.
SEARCH_DEFAULT_LIMIT = 500
+def _match_lines(
+ compiled: regex_engine.Pattern[str],
+ lines: list[str],
+ deadline: float,
+) -> list[str]:
+ """Return the lines matching ``compiled``, giving up at ``deadline``.
+
+ A caller-supplied pattern can backtrack for longer than the age of
+ the universe on an ordinary pane line, so every match shares one
+ wall-clock budget rather than each line getting its own.
+
+ Parameters
+ ----------
+ compiled : regex_engine.Pattern
+ Caller's pattern, already compiled.
+ lines : list of str
+ Captured pane content.
+ deadline : float
+ :func:`time.monotonic` value at which to give up.
+
+ Returns
+ -------
+ list of str
+ Lines that matched.
+
+ Raises
+ ------
+ ExpectedToolError
+ If the budget runs out before every line has been tried.
+ """
+ matched: list[str] = []
+ for line in lines:
+ # A zero budget raises TimeoutError too, so an exhausted deadline
+ # and a single slow line report through one path.
+ remaining = max(deadline - time.monotonic(), 0.0)
+ try:
+ if compiled.search(line, timeout=remaining):
+ matched.append(line)
+ except TimeoutError as e:
+ msg = (
+ f"Matching took longer than {SEARCH_MATCH_MAX_SECONDS}s. "
+ f"Nested quantifiers such as '(a+)+' backtrack exponentially; "
+ f"anchor the pattern or search for a literal instead."
+ )
+ raise ExpectedToolError(msg) from e
+ return matched
+
+
def _pane_id_sort_key(m: PaneContentMatch) -> tuple[int, str]:
"""Sort panes numerically by their tmux id.
@@ -142,12 +204,35 @@ def search_panes(
SearchPanesResult
Paginated match list with ``truncated`` / ``truncated_panes``
/ ``total_panes_matched`` / ``offset`` / ``limit`` fields.
+
+ Raises
+ ------
+ ExpectedToolError
+ If ``pattern`` is over ``SEARCH_MAX_PATTERN_LENGTH``, does not
+ compile, or if matching it against the captured lines exceeds
+ ``SEARCH_MATCH_MAX_SECONDS`` in total. A pattern with nested
+ quantifiers such as ``(a+)+`` can backtrack for hours on one
+ ordinary line; anchor it or search for a literal.
+
+ Notes
+ -----
+ The deadline bounds pattern matching across the call, not the call:
+ capturing each pane is a separate tmux round-trip outside it, and the
+ work still grows with the number of panes in scope.
"""
- search_pattern = pattern if regex else re.escape(pattern)
- flags = 0 if match_case else re.IGNORECASE
+ if len(pattern) > SEARCH_MAX_PATTERN_LENGTH:
+ msg = (
+ f"pattern is longer than {SEARCH_MAX_PATTERN_LENGTH} characters. "
+ f"Compilation runs before the match deadline applies, so the "
+ f"length is bounded instead."
+ )
+ raise ExpectedToolError(msg)
+
+ search_pattern = pattern if regex else regex_engine.escape(pattern)
+ flags = 0 if match_case else regex_engine.IGNORECASE
try:
- compiled = re.compile(search_pattern, flags)
- except re.error as e:
+ compiled = regex_engine.compile(search_pattern, flags)
+ except regex_engine.error as e:
msg = f"Invalid regex pattern: {e}"
raise ExpectedToolError(msg) from e
@@ -229,6 +314,7 @@ def search_panes(
# tail-truncated to keep the most recent matches.
all_matches: list[PaneContentMatch] = []
per_pane_truncated = False
+ matching_seconds_left = SEARCH_MATCH_MAX_SECONDS
for pane_id_str in matching_pane_ids:
pane = server.panes.get(pane_id=pane_id_str, default=None)
if pane is None:
@@ -239,7 +325,13 @@ def search_panes(
end=content_end,
join_wrapped=True,
)
- matched_lines = [line for line in lines if compiled.search(line)]
+ match_started = time.monotonic()
+ matched_lines = _match_lines(
+ compiled,
+ lines,
+ match_started + matching_seconds_left,
+ )
+ matching_seconds_left -= time.monotonic() - match_started
if not matched_lines:
continue
diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py
index d9ab55af..02a71313 100644
--- a/src/libtmux_mcp/tools/server_tools.py
+++ b/src/libtmux_mcp/tools/server_tools.py
@@ -13,18 +13,18 @@
from libtmux_mcp._history import _prepare_spawn_environment
from libtmux_mcp._utils import (
- ANNOTATIONS_CREATE,
- ANNOTATIONS_DESTRUCTIVE,
- ANNOTATIONS_RO,
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_TEARDOWN,
ExpectedToolError,
_apply_filters,
_caller_is_on_server,
+ _escape_tmux_format,
_get_caller_identity,
_get_server,
_invalidate_server,
+ _prepare_start_directory,
_serialize_session,
handle_tool_errors,
)
@@ -91,7 +91,8 @@ def create_session(
window_name : str, optional
Name for the initial window.
start_directory : str, optional
- Working directory for the session.
+ Existing directory to start in. ``~`` expands; a relative path
+ resolves against the MCP server process's directory.
x : int, optional
Width of the initial window.
y : int, optional
@@ -127,11 +128,12 @@ def create_session(
server = _get_server(socket_name=socket_name)
kwargs: dict[str, t.Any] = {}
if session_name is not None:
- kwargs["session_name"] = session_name
+ kwargs["session_name"] = _escape_tmux_format(session_name)
if window_name is not None:
- kwargs["window_name"] = window_name
- if start_directory is not None:
- kwargs["start_directory"] = start_directory
+ kwargs["window_name"] = _escape_tmux_format(window_name)
+ prepared_start_directory = _prepare_start_directory(start_directory)
+ if prepared_start_directory is not None:
+ kwargs["start_directory"] = prepared_start_directory
if x is not None:
kwargs["x"] = x
if y is not None:
@@ -288,12 +290,7 @@ def _probe_server_by_path(socket_path: pathlib.Path) -> ServerInfo | None:
#: discovery-style tool, append it here AND update the prose in
#: ``_BASE_INSTRUCTIONS`` so the two stay in lockstep.
SOCKET_NAME_EXEMPT: frozenset[str] = frozenset(
- {
- "call_destructive_tools_batch",
- "call_mutating_tools_batch",
- "call_readonly_tools_batch",
- "list_servers",
- }
+ {"call_read_tools_batch", "list_servers"}
)
@@ -363,21 +360,27 @@ def list_servers(
def register(mcp: FastMCP) -> None:
"""Register server-level tools with the MCP instance."""
mcp.tool(
- title="List tmux Sessions", annotations=ANNOTATIONS_RO, tags={TAG_READONLY}
+ title="List tmux Sessions",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(list_sessions)
mcp.tool(
- title="List tmux Servers", annotations=ANNOTATIONS_RO, tags={TAG_READONLY}
+ title="List tmux Servers",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(list_servers)
mcp.tool(
title="Create tmux Session",
- annotations=ANNOTATIONS_CREATE,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(create_session)
mcp.tool(
title="Kill tmux Server",
- annotations=ANNOTATIONS_DESTRUCTIVE,
- tags={TAG_DESTRUCTIVE},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_TEARDOWN},
)(kill_server)
mcp.tool(
- title="Get tmux Server Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY}
+ title="Get tmux Server Info",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(get_server_info)
diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py
index 7805c3ff..6dbfbdf0 100644
--- a/src/libtmux_mcp/tools/session_tools.py
+++ b/src/libtmux_mcp/tools/session_tools.py
@@ -8,19 +8,19 @@
from libtmux_mcp._history import _prepare_spawn_environment
from libtmux_mcp._utils import (
- ANNOTATIONS_CREATE,
- ANNOTATIONS_DESTRUCTIVE,
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_RO,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
DISCOVERY_META,
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
ExpectedToolError,
_apply_filters,
_caller_is_on_server,
+ _escape_tmux_format,
_get_caller_identity,
_get_server,
+ _prepare_start_directory,
_resolve_session,
_serialize_session,
_serialize_window,
@@ -135,7 +135,8 @@ def create_window(
window_name : str, optional
Name for the new window.
start_directory : str, optional
- Working directory for the new window.
+ Existing directory to start in. ``~`` expands; a relative path
+ resolves against the MCP server process's directory.
attach : bool, optional
Whether to make the new window active.
direction : str, optional
@@ -168,9 +169,10 @@ def create_window(
session = _resolve_session(server, session_name=session_name, session_id=session_id)
kwargs: dict[str, t.Any] = {}
if window_name is not None:
- kwargs["window_name"] = window_name
- if start_directory is not None:
- kwargs["start_directory"] = start_directory
+ kwargs["window_name"] = _escape_tmux_format(window_name)
+ prepared_start_directory = _prepare_start_directory(start_directory)
+ if prepared_start_directory is not None:
+ kwargs["start_directory"] = prepared_start_directory
kwargs["attach"] = attach
if direction is not None:
direction_map: dict[str, WindowDirection] = {
@@ -219,7 +221,7 @@ def rename_session(
"""
server = _get_server(socket_name=socket_name)
session = _resolve_session(server, session_name=session_name, session_id=session_id)
- session = session.rename_session(new_name)
+ session = session.rename_session(_escape_tmux_format(new_name))
return _serialize_session(session)
@@ -350,28 +352,32 @@ def register(mcp: FastMCP) -> None:
"""Register session-level tools with the MCP instance."""
mcp.tool(
title="List tmux Windows",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
meta=DISCOVERY_META,
)(list_windows)
mcp.tool(
- title="Get tmux Session Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY}
+ title="Get tmux Session Info",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(get_session_info)
mcp.tool(
- title="Create tmux Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING}
+ title="Create tmux Window",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(create_window)
mcp.tool(
title="Rename tmux Session",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(rename_session)
mcp.tool(
title="Kill tmux Session",
- annotations=ANNOTATIONS_DESTRUCTIVE,
- tags={TAG_DESTRUCTIVE},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_TEARDOWN},
)(kill_session)
mcp.tool(
title="Select tmux Window",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(select_window)
diff --git a/src/libtmux_mcp/tools/wait_for_tools.py b/src/libtmux_mcp/tools/wait_for_tools.py
index 04699bb2..f987facc 100644
--- a/src/libtmux_mcp/tools/wait_for_tools.py
+++ b/src/libtmux_mcp/tools/wait_for_tools.py
@@ -8,7 +8,7 @@
then calls :func:`wait_for_channel` which blocks server-side until the
signal fires.
-Wait channel safety
+Wait channel scope
-------------------
``tmux wait-for`` without a timeout blocks indefinitely at the OS level.
If the shell command that was supposed to emit the signal crashes
@@ -42,9 +42,9 @@
from libtmux_mcp._tmux_proc import _run_tmux_bounded
from libtmux_mcp._utils import (
- ANNOTATIONS_MUTATING,
- TAG_MUTATING,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
TAG_SELF_BOUNDED,
+ TOOLSET_MANAGE,
ExpectedToolError,
_get_server,
_tmux_argv,
@@ -261,10 +261,11 @@ async def signal_channel(
channel: str,
socket_name: str | None = None,
) -> str:
- """Signal a tmux ``wait-for`` channel, waking any blocked waiters.
+ """Signal a tmux ``wait-for`` channel, waking blocked waiters.
- Signalling an unwaited channel is a no-op that still returns
- successfully — safe to call defensively.
+ With no current waiter, tmux records one pending signal for the
+ next waiter to consume. Signalling that channel again clears it.
+ A woken waiter continues whatever work follows its wait.
Parameters
----------
@@ -284,8 +285,8 @@ async def signal_channel(
# Deliberately still a worker thread, unlike every other tmux call
# in this package. The orphan-on-cancel defect that pushed the
# waits onto ``_run_tmux_bounded`` needs a child that blocks for a
- # caller-chosen duration; ``wait-for -S`` is edge-triggered and
- # returns in milliseconds, so the worst case here is a 5 s child
+ # caller-chosen duration; ``wait-for -S`` does not block, so the
+ # worst case here is a 5 s child
# against an already-wedged tmux — and that bound is ours, not the
# caller's. Converting it would buy nothing and change this tool's
# error messages.
@@ -320,11 +321,11 @@ def register(mcp: FastMCP) -> None:
# which is what the tag asserts.
mcp.tool(
title="Wait For tmux Channel",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING, TAG_SELF_BOUNDED},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE, TAG_SELF_BOUNDED},
)(wait_for_channel)
mcp.tool(
title="Signal tmux Channel",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(signal_channel)
diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py
index 0a8461b3..a170de44 100644
--- a/src/libtmux_mcp/tools/window_tools.py
+++ b/src/libtmux_mcp/tools/window_tools.py
@@ -8,19 +8,19 @@
from libtmux_mcp._history import _prepare_spawn_environment
from libtmux_mcp._utils import (
- ANNOTATIONS_CREATE,
- ANNOTATIONS_DESTRUCTIVE,
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_RO,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
DISCOVERY_META,
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
ExpectedToolError,
_apply_filters,
_caller_is_on_server,
+ _escape_tmux_format,
_get_caller_identity,
_get_server,
+ _prepare_start_directory,
_resolve_pane,
_resolve_session,
_resolve_window,
@@ -190,7 +190,8 @@ def split_window(
Size of the new pane. Use a string with '%%' suffix for
percentage (e.g. '50%%') or an integer for lines/columns.
start_directory : str, optional
- Working directory for the new pane.
+ Existing directory to start in. ``~`` expands; a relative path
+ resolves against the MCP server process's directory.
shell : str, optional
Shell command to run in the new pane.
socket_name : str, optional
@@ -227,12 +228,13 @@ def split_window(
msg = f"Invalid direction: {direction!r}. Valid: {valid}"
raise ExpectedToolError(msg)
+ prepared_start_directory = _prepare_start_directory(start_directory)
if pane_id is not None:
pane = _resolve_pane(server, pane_id=pane_id)
new_pane = pane.split(
direction=pane_dir,
size=size,
- start_directory=start_directory,
+ start_directory=prepared_start_directory,
shell=shell,
environment=spawn_environment,
)
@@ -247,7 +249,7 @@ def split_window(
new_pane = window.split(
direction=pane_dir,
size=size,
- start_directory=start_directory,
+ start_directory=prepared_start_directory,
shell=shell,
environment=spawn_environment,
)
@@ -296,7 +298,7 @@ def rename_window(
session_name=session_name,
session_id=session_id,
)
- window.rename_window(new_name)
+ window.rename_window(_escape_tmux_format(new_name))
return _serialize_window(window)
@@ -498,38 +500,42 @@ def register(mcp: FastMCP) -> None:
"""Register window-level tools with the MCP instance."""
mcp.tool(
title="List tmux Panes",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
meta=DISCOVERY_META,
)(list_panes)
mcp.tool(
- title="Get tmux Window Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY}
+ title="Get tmux Window Info",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
)(get_window_info)
mcp.tool(
- title="Split tmux Window", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING}
+ title="Split tmux Window",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_EXECUTE},
)(split_window)
mcp.tool(
title="Rename tmux Window",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(rename_window)
mcp.tool(
title="Kill tmux Window",
- annotations=ANNOTATIONS_DESTRUCTIVE,
- tags={TAG_DESTRUCTIVE},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_TEARDOWN},
)(kill_window)
mcp.tool(
title="Select tmux Layout",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(select_layout)
mcp.tool(
title="Resize tmux Window",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(resize_window)
mcp.tool(
title="Move tmux Window",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)(move_window)
diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py
index 7577cbc9..955d3013 100644
--- a/tests/docs/test_topic_contracts.py
+++ b/tests/docs/test_topic_contracts.py
@@ -2,11 +2,15 @@
from __future__ import annotations
+import asyncio
import pathlib
import typing as t
import pytest
+from libtmux_mcp._utils import VALID_TOOLSETS
+from libtmux_mcp.tools import register_tools
+
class TopicContractFixture(t.NamedTuple):
"""Fixture for forbidden stale docs claims."""
@@ -212,7 +216,7 @@ def test_create_session_page_warns_against_literal_credentials(
assert "Values persist in the new session" in normalized
assert "initial pane and future panes" in normalized
assert "Pass credential references instead" in normalized
- assert "{ref}`safety`" in caution_block
+ assert "{ref}`trust`" in caution_block
assert "```{fastmcp-tool-input} server_tools.create_session" in text
@@ -238,11 +242,11 @@ def test_run_command_page_documents_effective_history_policy(
assert "`true` unless {envvar}`LIBTMUX_SUPPRESS_HISTORY` is `0`" in text
-def test_safety_docs_name_history_non_goals_and_secret_reference_guidance(
+def test_trust_docs_name_history_non_goals_and_secret_reference_guidance(
docs_dir: pathlib.Path,
) -> None:
- """Safety guidance does not present history suppression as secret transport."""
- text = (docs_dir / "topics" / "safety.md").read_text(encoding="utf-8")
+ """Trust guidance does not present history suppression as secret transport."""
+ text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8")
for surface in (
"pane echo",
@@ -304,6 +308,85 @@ def test_safety_docs_name_history_non_goals_and_secret_reference_guidance(
assert boundary in process_visibility
+def test_trust_docs_state_ambient_tmux_execution(
+ docs_dir: pathlib.Path,
+) -> None:
+ """The trust page separates MCP requests from programmable tmux state."""
+ text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8")
+ heading = "## The tmux server is programmable"
+ assert heading in text
+ section = text.split(heading, 1)[1].split("\n## ", 1)[0]
+
+ for route in (
+ "command-alias",
+ "after-*",
+ "#(...)",
+ "Hierarchy resource reads",
+ "startup and shutdown",
+ "does not retry",
+ ):
+ assert route in section
+ assert "without any MCP call" in section
+ assert "process confinement" in section
+
+ resources = (docs_dir / "resources.md").read_text(encoding="utf-8")
+ assert "`LIBTMUX_TOOLSETS` filters tools" in resources
+ assert "{ref}`trust`" in resources
+
+
+def test_trust_toolset_labels_are_badged_and_copyable(
+ docs_dir: pathlib.Path,
+) -> None:
+ """Trust inventory badges keep their labels available to selection."""
+ text = (docs_dir / "topics" / "trust.md").read_text(encoding="utf-8")
+ section = text.split("## Toolsets gate MCP tool calls, not tmux", 1)[1].split(
+ "\n## ", 1
+ )[0]
+
+ for toolset in VALID_TOOLSETS:
+ assert f"{{badge}}`{toolset}`:" in section
+
+ css = (docs_dir / "_static" / "css" / "project-badges.css").read_text(
+ encoding="utf-8"
+ )
+ normalized_css = " ".join(css.split())
+ assert (
+ "#toolsets-gate-mcp-tool-calls-not-tmux > p"
+ " > .gp-sphinx-badge .gp-sphinx-badge__label" in normalized_css
+ )
+ assert "user-select: text" in css
+ assert "-webkit-user-select: text" in css
+
+
+def test_tool_catalog_groups_every_registered_tool_by_its_wire_tag(
+ docs_dir: pathlib.Path,
+) -> None:
+ """The tool overview derives its grouping contract from MCP metadata."""
+ from fastmcp import Client, FastMCP
+
+ mcp = FastMCP(name="docs-tool-catalog")
+ register_tools(mcp)
+
+ async def _list_tools() -> list[t.Any]:
+ async with Client(mcp) as client:
+ return list(await client.list_tools())
+
+ tools = asyncio.run(_list_tools())
+ text = (docs_dir / "tools" / "index.md").read_text(encoding="utf-8")
+ sections = {
+ toolset: text.split(f"## {toolset.title()}", 1)[1].split("\n## ", 1)[0]
+ for toolset in VALID_TOOLSETS
+ }
+
+ for tool in tools:
+ dumped = tool.model_dump(mode="json", by_alias=True, exclude_none=True)
+ tags = set(dumped.get("_meta", {}).get("fastmcp", {}).get("tags", []))
+ [toolset] = tags & set(VALID_TOOLSETS)
+ marker = f":::{'{'}grid-item-card{'}'} {tool.name}\n"
+ assert text.count(marker) == 1, tool.name
+ assert marker in sections[toolset], tool.name
+
+
def test_respawn_page_distinguishes_environment_audit_shapes(
docs_dir: pathlib.Path,
) -> None:
@@ -377,7 +460,7 @@ def test_a17_changelog_summarizes_history_features(
assert "`suppress_persistent_history=true`" in history_entry
assert "Session controls reach the initial and future panes" in history_entry
assert "{ref}`history-hygiene`" in history_entry
- assert "{ref}`safety`" in history_entry
+ assert "{ref}`trust`" in history_entry
assert "space-prefixed" not in history_entry
assert "{tooliconl}`create-window`" in environment_entry
@@ -389,4 +472,4 @@ def test_a17_changelog_summarizes_history_features(
assert "{tooliconl}`respawn-pane`" in environment_entry
assert "same JSON object form" in environment_entry
assert "credential references, not literal credentials" in environment_entry
- assert "{ref}`safety`" in environment_entry
+ assert "{ref}`trust`" in environment_entry
diff --git a/tests/test_batch_tools.py b/tests/test_batch_tools.py
index 61d2ffc7..fb81a1d5 100644
--- a/tests/test_batch_tools.py
+++ b/tests/test_batch_tools.py
@@ -9,15 +9,14 @@
import pytest
from libtmux_mcp._utils import (
- ANNOTATIONS_DESTRUCTIVE,
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_RO,
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
+ ANNOTATIONS_AMBIENT_UNKNOWN,
TAG_SELF_BOUNDED,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
+ VALID_TOOLSETS,
)
-from tests.conftest import wire_annotations
if t.TYPE_CHECKING:
from fastmcp import FastMCP
@@ -32,7 +31,7 @@ class BatchResponseLimitFixture(t.NamedTuple):
BATCH_RESPONSE_LIMIT_FIXTURES: list[BatchResponseLimitFixture] = [
BatchResponseLimitFixture(
- test_id="two_large_readonly_results",
+ test_id="two_large_read_results",
payload_size=300_000,
),
]
@@ -53,37 +52,6 @@ class BatchOperationLimitFixture(t.NamedTuple):
]
-class BatchAnnotationFixture(t.NamedTuple):
- """Test fixture for generic batch wrapper annotations."""
-
- test_id: str
- tool_name: str
- read_only_hint: bool
- destructive_hint: bool
- idempotent_hint: bool
- open_world_hint: bool
-
-
-BATCH_ANNOTATION_FIXTURES: list[BatchAnnotationFixture] = [
- BatchAnnotationFixture(
- test_id="mutating_batch_warns_destructive_open_world",
- tool_name="call_mutating_tools_batch",
- read_only_hint=False,
- destructive_hint=True,
- idempotent_hint=False,
- open_world_hint=True,
- ),
- BatchAnnotationFixture(
- test_id="destructive_batch_warns_destructive_open_world",
- tool_name="call_destructive_tools_batch",
- read_only_hint=False,
- destructive_hint=True,
- idempotent_hint=False,
- open_world_hint=True,
- ),
-]
-
-
def _content_block_to_wire(block: t.Any) -> dict[str, t.Any]:
if hasattr(block, "model_dump"):
dumped = block.model_dump(mode="json", by_alias=True, exclude_none=True)
@@ -101,45 +69,49 @@ def _call_tool_result_wire(result: t.Any) -> dict[str, t.Any]:
def _batch_probe_server() -> FastMCP:
- """Build a small FastMCP server with batch tools and tiered probes."""
+ """Build a small FastMCP server with batch tools and tagged probes."""
from fastmcp import FastMCP
- from libtmux_mcp.middleware import SafetyMiddleware, ToolErrorResultMiddleware
+ from libtmux_mcp.middleware import ToolErrorResultMiddleware, ToolsetMiddleware
from libtmux_mcp.tools.batch_tools import register as register_batch_tools
mcp = FastMCP(
name="batch-probe",
middleware=[
ToolErrorResultMiddleware(transform_errors=True),
- SafetyMiddleware(max_tier=TAG_DESTRUCTIVE),
+ ToolsetMiddleware(set(VALID_TOOLSETS)),
],
)
register_batch_tools(mcp)
- @mcp.tool(title="Readonly Probe", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})
- def readonly_probe(value: str) -> dict[str, str]:
+ @mcp.tool(
+ title="Inspect Probe",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT},
+ )
+ def inspect_probe(value: str) -> dict[str, str]:
return {"value": value}
@mcp.tool(
- title="Mutating Probe",
- annotations=ANNOTATIONS_MUTATING,
- tags={TAG_MUTATING},
+ title="Manage Probe",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_MANAGE},
)
- def mutating_probe(value: str) -> dict[str, str]:
+ def manage_probe(value: str) -> dict[str, str]:
return {"value": value}
@mcp.tool(
- title="Destructive Probe",
- annotations=ANNOTATIONS_DESTRUCTIVE,
- tags={TAG_DESTRUCTIVE},
+ title="Teardown Probe",
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_TEARDOWN},
)
- def destructive_probe(value: str) -> dict[str, str]:
+ def teardown_probe(value: str) -> dict[str, str]:
return {"value": value}
@mcp.tool(
title="Self Bounded Probe",
- annotations=ANNOTATIONS_RO,
- tags={TAG_READONLY, TAG_SELF_BOUNDED},
+ annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
+ tags={TOOLSET_INSPECT, TAG_SELF_BOUNDED},
)
def self_bounded_probe(value: str) -> dict[str, str]:
return {"value": value}
@@ -154,7 +126,7 @@ def _self_bounded_batch_call(wrapper: str, on_error: str = "stop") -> t.Any:
async def _call() -> t.Any:
async with Client(_batch_probe_server()) as client:
return await client.call_tool(
- wrapper,
+ "call_read_tools_batch",
{
"on_error": on_error,
"operations": [
@@ -163,7 +135,7 @@ async def _call() -> t.Any:
"arguments": {"value": "should-not-run"},
},
{
- "tool": "readonly_probe",
+ "tool": "inspect_probe",
"arguments": {"value": "kept-going"},
},
],
@@ -174,25 +146,15 @@ async def _call() -> t.Any:
return asyncio.run(_call())
-@pytest.mark.parametrize(
- "wrapper",
- [
- "call_readonly_tools_batch",
- "call_mutating_tools_batch",
- "call_destructive_tools_batch",
- ],
-)
-def test_batch_rejects_self_bounded_tool_in_every_wrapper(wrapper: str) -> None:
- """A ``TAG_SELF_BOUNDED`` tool is rejected by ALL three batch wrappers.
-
- ``max_tier`` is a *ceiling* (``_TIER_LEVELS[tool_tier] <=
- _TIER_LEVELS[max_tier]``), so a readonly tool is reachable through
- the mutating and destructive wrappers too. The batch loop is serial
- with no aggregate deadline and ``MAX_BATCH_OPERATIONS`` is 1000, so
- a wait tool batched N times would cost N x its ceiling — the batch
- wrapper is a cap amplifier unless every wrapper rejects it.
+def test_batch_rejects_a_self_bounded_tool() -> None:
+ """A ``TAG_SELF_BOUNDED`` tool is rejected by the batch wrapper.
+
+ The batch loop is serial with no aggregate deadline and
+ ``MAX_BATCH_OPERATIONS`` is 1000, so a wait tool batched N times
+ would cost N x its ceiling — the wrapper is a cap amplifier unless
+ it rejects one.
"""
- result = _self_bounded_batch_call(wrapper)
+ result = _self_bounded_batch_call("call_read_tools_batch")
assert result.structured_content["failed"] == 1
rows = result.structured_content["results"]
@@ -209,7 +171,7 @@ def test_batch_self_bounded_rejection_preserves_continue_isolation() -> None:
the request. The raise happens inside ``_call_one_tool``'s try
block, so it becomes a ``success=False`` row instead.
"""
- result = _self_bounded_batch_call("call_readonly_tools_batch", on_error="continue")
+ result = _self_bounded_batch_call("call_read_tools_batch", on_error="continue")
assert result.is_error is False
assert result.structured_content["failed"] == 1
@@ -220,57 +182,47 @@ def test_batch_self_bounded_rejection_preserves_continue_isolation() -> None:
def test_run_command_is_registered_self_bounded_and_unbatchable() -> None:
- """``run_command`` carries ``TAG_SELF_BOUNDED`` on the real server.
-
- ``run_command`` clamps its ``timeout`` to the same wait ceiling as
- the wait tools, so batching it amplifies that ceiling exactly the
- same way. Assert against the real registration rather than a probe,
- and drive ``_get_allowed_tool_tier`` at every wrapper's ``max_tier``
- because ``max_tier`` is a ceiling: a mutating tool is reachable
- through the mutating and destructive wrappers both.
+ """``run_command`` enforces its own ceiling, so a batch cannot multiply it.
+
+ Assert against the real registration rather than a probe: the tag is
+ what keeps the batch loop, which has no aggregate deadline, from
+ running it a thousand times.
"""
from fastmcp import FastMCP
from libtmux_mcp._utils import ExpectedToolError
from libtmux_mcp.models import ToolCallOperation
from libtmux_mcp.tools import register_tools
- from libtmux_mcp.tools.batch_tools import _get_allowed_tool_tier
+ from libtmux_mcp.tools.batch_tools import _check_operation_allowed
mcp = FastMCP(name="run-command-self-bounded-audit")
register_tools(mcp)
tool = asyncio.run(mcp.get_tool("run_command"))
assert tool is not None
- assert TAG_MUTATING in tool.tags
+ assert TOOLSET_EXECUTE in tool.tags
assert TAG_SELF_BOUNDED in tool.tags
operation = ToolCallOperation(tool="run_command", arguments={})
- for max_tier in (TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE):
- with pytest.raises(ExpectedToolError, match="cannot be batched"):
- asyncio.run(
- _get_allowed_tool_tier(
- fastmcp=mcp,
- operation=operation,
- max_tier=max_tier,
- )
- )
+ with pytest.raises(ExpectedToolError, match="cannot be batched"):
+ asyncio.run(_check_operation_allowed(fastmcp=mcp, operation=operation))
-def test_call_readonly_tools_batch_preserves_structured_results() -> None:
- """The readonly batch wrapper returns per-tool structured content."""
+def test_call_read_tools_batch_preserves_structured_results() -> None:
+ """The read batch wrapper returns per-tool structured content."""
from fastmcp import Client
async def _call() -> t.Any:
async with Client(_batch_probe_server()) as client:
return await client.call_tool(
- "call_readonly_tools_batch",
+ "call_read_tools_batch",
{
"operations": [
{
- "tool": "readonly_probe",
+ "tool": "inspect_probe",
"arguments": {"value": "alpha"},
},
{
- "tool": "readonly_probe",
+ "tool": "inspect_probe",
"arguments": {"value": "beta"},
},
],
@@ -287,7 +239,7 @@ async def _call() -> t.Any:
first, second = result.structured_content["results"]
assert first == {
"index": 0,
- "tool": "readonly_probe",
+ "tool": "inspect_probe",
"success": True,
"error": None,
"content": [{"type": "text", "text": '{"value":"alpha"}'}],
@@ -297,7 +249,7 @@ async def _call() -> t.Any:
}
assert second == {
"index": 1,
- "tool": "readonly_probe",
+ "tool": "inspect_probe",
"success": True,
"error": None,
"content": [{"type": "text", "text": '{"value":"beta"}'}],
@@ -314,7 +266,7 @@ async def _call() -> t.Any:
BATCH_RESPONSE_LIMIT_FIXTURES,
ids=[fixture.test_id for fixture in BATCH_RESPONSE_LIMIT_FIXTURES],
)
-def test_call_readonly_tools_batch_caps_aggregate_response(
+def test_call_read_tools_batch_caps_aggregate_response(
test_id: str,
payload_size: int,
) -> None:
@@ -329,15 +281,15 @@ def test_call_readonly_tools_batch_caps_aggregate_response(
async def _call() -> t.Any:
async with Client(_batch_probe_server()) as client:
return await client.call_tool(
- "call_readonly_tools_batch",
+ "call_read_tools_batch",
{
"operations": [
{
- "tool": "readonly_probe",
+ "tool": "inspect_probe",
"arguments": {"value": first_payload},
},
{
- "tool": "readonly_probe",
+ "tool": "inspect_probe",
"arguments": {"value": second_payload},
},
],
@@ -366,7 +318,7 @@ async def _call() -> t.Any:
first, second = structured["results"]
assert first["index"] == 0
- assert first["tool"] == "readonly_probe"
+ assert first["tool"] == "inspect_probe"
assert first["success"] is True
assert first["structured_content"] is None
assert first["content"] == [
@@ -389,7 +341,7 @@ async def _call() -> t.Any:
BATCH_OPERATION_LIMIT_FIXTURES,
ids=[fixture.test_id for fixture in BATCH_OPERATION_LIMIT_FIXTURES],
)
-def test_call_readonly_tools_batch_rejects_oversized_operation_count(
+def test_call_read_tools_batch_rejects_oversized_operation_count(
test_id: str,
operation_count: int,
) -> None:
@@ -403,7 +355,7 @@ def test_call_readonly_tools_batch_rejects_oversized_operation_count(
async def _call() -> t.Any:
async with Client(_batch_probe_server()) as client:
return await client.call_tool(
- "call_readonly_tools_batch",
+ "call_read_tools_batch",
{
"operations": [
{
@@ -430,97 +382,27 @@ async def _call() -> t.Any:
assert "operations must contain at most" in serialized
-def test_call_readonly_tools_batch_rejects_mutating_inner_tool() -> None:
- """Readonly batching does not tunnel a mutating tool call."""
- from fastmcp import Client
+def test_the_read_batch_rejects_a_tool_outside_inspect() -> None:
+ """A batch that could carry a write would launder it past client policy.
- async def _call() -> t.Any:
- async with Client(_batch_probe_server()) as client:
- return await client.call_tool(
- "call_readonly_tools_batch",
- {
- "operations": [
- {
- "tool": "mutating_probe",
- "arguments": {"value": "changed"},
- }
- ],
- },
- raise_on_error=False,
- )
-
- result = asyncio.run(_call())
-
- assert result.is_error is False
- assert result.structured_content["succeeded"] == 0
- assert result.structured_content["failed"] == 1
- assert result.structured_content["stopped_at"] == 0
- [operation] = result.structured_content["results"]
- assert operation["success"] is False
- assert "exceeds batch tier readonly" in operation["error"]
-
-
-def test_call_mutating_tools_batch_rejects_destructive_inner_tool() -> None:
- """Mutating batching does not tunnel a destructive tool call."""
- from fastmcp import Client
-
- async def _call() -> t.Any:
- async with Client(_batch_probe_server()) as client:
- return await client.call_tool(
- "call_mutating_tools_batch",
- {
- "operations": [
- {
- "tool": "destructive_probe",
- "arguments": {"value": "destroy"},
- }
- ],
- },
- raise_on_error=False,
- )
-
- result = asyncio.run(_call())
-
- assert result.is_error is False
- [operation] = result.structured_content["results"]
- assert operation["success"] is False
- assert "exceeds batch tier mutating" in operation["error"]
-
-
-def test_call_mutating_tools_batch_continues_after_error() -> None:
- """Continue mode attempts later operations after a failed tool call."""
+ The wrapper aggregates authority under its own name, so a rule keyed
+ on a nested tool's name never fires. Keeping the batch to `inspect`
+ is what stops that mattering.
+ """
from fastmcp import Client
async def _call() -> t.Any:
async with Client(_batch_probe_server()) as client:
return await client.call_tool(
- "call_mutating_tools_batch",
- {
- "on_error": "continue",
- "operations": [
- {
- "tool": "missing_probe",
- "arguments": {},
- },
- {
- "tool": "mutating_probe",
- "arguments": {"value": "kept-going"},
- },
- ],
- },
+ "call_read_tools_batch",
+ {"operations": [{"tool": "manage_probe", "arguments": {}}]},
raise_on_error=False,
)
- result = asyncio.run(_call())
+ payload = asyncio.run(_call()).structured_content
- assert result.is_error is False
- assert result.structured_content["succeeded"] == 1
- assert result.structured_content["failed"] == 1
- assert result.structured_content["stopped_at"] is None
- first, second = result.structured_content["results"]
- assert first["success"] is False
- assert second["success"] is True
- assert second["structured_content"] == {"value": "kept-going"}
+ assert payload["succeeded"] == 0
+ assert "not an 'inspect' tool" in payload["results"][0]["error"]
def test_call_tools_batch_rejects_self_invocation() -> None:
@@ -530,11 +412,11 @@ def test_call_tools_batch_rejects_self_invocation() -> None:
async def _call() -> t.Any:
async with Client(_batch_probe_server()) as client:
return await client.call_tool(
- "call_destructive_tools_batch",
+ "call_read_tools_batch",
{
"operations": [
{
- "tool": "call_destructive_tools_batch",
+ "tool": "call_read_tools_batch",
"arguments": {"operations": []},
}
],
@@ -548,28 +430,3 @@ async def _call() -> t.Any:
[operation] = result.structured_content["results"]
assert operation["success"] is False
assert "cannot call batch tools recursively" in operation["error"]
-
-
-@pytest.mark.parametrize(
- BatchAnnotationFixture._fields,
- BATCH_ANNOTATION_FIXTURES,
- ids=[fixture.test_id for fixture in BATCH_ANNOTATION_FIXTURES],
-)
-def test_batch_wrappers_advertise_worst_case_annotations(
- test_id: str,
- tool_name: str,
- read_only_hint: bool,
- destructive_hint: bool,
- idempotent_hint: bool,
- open_world_hint: bool,
-) -> None:
- """Batch wrappers advertise the strongest hint from their allowed tools."""
- mcp = _batch_probe_server()
-
- tool = asyncio.run(mcp.get_tool(tool_name))
- assert tool is not None, f"{tool_name} should be registered"
- assert tool.annotations is not None, f"{tool_name} should carry annotations"
- assert wire_annotations(tool).get("readOnlyHint") is read_only_hint
- assert wire_annotations(tool).get("destructiveHint") is destructive_hint
- assert wire_annotations(tool).get("idempotentHint") is idempotent_hint
- assert wire_annotations(tool).get("openWorldHint") is open_world_hint
diff --git a/tests/test_history.py b/tests/test_history.py
index 57ce68ff..04d27d30 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -428,12 +428,12 @@ async def main():
assert payload["schema"]["default"] is expected
assert "anyOf" not in payload["schema"]
assert payload["annotations"] == {
- "destructiveHint": False,
+ "destructiveHint": True,
"idempotentHint": False,
"openWorldHint": True,
"readOnlyHint": False,
}
- assert sorted(payload["tags"]) == ["mutating", "self-bounded"]
+ assert sorted(payload["tags"]) == ["execute", "self-bounded"]
assert payload["raw_defaults"] == {
"send_keys": False,
"send_keys_batch": False,
@@ -589,7 +589,7 @@ async def _exercise() -> None:
assert output.read_text() == f"{marker}\n"
batch = await client.call_tool(
- "call_mutating_tools_batch",
+ "call_read_tools_batch",
{
"operations": [
{
diff --git a/tests/test_hook_tools.py b/tests/test_hook_tools.py
index 4545c3ed..0995ddc4 100644
--- a/tests/test_hook_tools.py
+++ b/tests/test_hook_tools.py
@@ -1,4 +1,4 @@
-"""Tests for read-only tmux hook introspection tools."""
+"""Tests for tmux hook inspection tools."""
from __future__ import annotations
diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py
index 75c7b853..6aae0206 100644
--- a/tests/test_mcp_swap.py
+++ b/tests/test_mcp_swap.py
@@ -251,17 +251,7 @@ def test_load_config_tolerates_empty_json(tmp_path: pathlib.Path) -> None:
def test_use_local_preserves_existing_env_when_replacing(
fake_home: pathlib.Path, fake_repo: pathlib.Path
) -> None:
- """Existing ``env`` on a replaced entry survives ``use-local``.
-
- Regression: ``cmd_use_local`` previously constructed the replacement
- spec via ``build_local_spec`` (env={}) and wrote it directly,
- silently dropping client-side settings like ``LIBTMUX_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.
- """
+ """Replacement keeps current env entries and drops retired settings."""
info = mcp_swap.CLIS["cursor"]
_write_json(
info.config_path,
@@ -270,7 +260,11 @@ def test_use_local_preserves_existing_env_when_replacing(
"libtmux": {
"command": "uvx",
"args": ["libtmux-mcp==0.1.0a2"],
- "env": {"LIBTMUX_SAFETY": "readonly", "FOO": "bar"},
+ "env": {
+ "LIBTMUX_SAFETY": "destructive",
+ "LIBTMUX_TOOLSETS": "inspect",
+ "FOO": "bar",
+ },
}
}
},
@@ -289,7 +283,7 @@ def test_use_local_preserves_existing_env_when_replacing(
"run",
"libtmux-mcp",
]
- assert entry["env"] == {"LIBTMUX_SAFETY": "readonly", "FOO": "bar"}
+ assert entry["env"] == {"LIBTMUX_TOOLSETS": "inspect", "FOO": "bar"}
def test_use_local_with_no_prior_entry_writes_empty_env(
@@ -1552,7 +1546,7 @@ def test_use_local_env_flag_wins_over_preserved_env(
"libtmux": {
"command": "uvx",
"args": ["libtmux-mcp==0.1.0a2"],
- "env": {"LIBTMUX_SAFETY": "readonly", "KEEP": "me"},
+ "env": {"LIBTMUX_TOOLSETS": "inspect", "KEEP": "me"},
}
}
},
@@ -1566,13 +1560,16 @@ def test_use_local_env_flag_wins_over_preserved_env(
"--cli",
"cursor",
"--env",
- "LIBTMUX_SAFETY=destructive",
+ "LIBTMUX_TOOLSETS=inspect,manage,execute,teardown",
]
)
assert mcp_swap.cmd_use_local(args) == 0
entry = json.loads(info.config_path.read_text())["mcpServers"]["libtmux"]
- assert entry["env"] == {"LIBTMUX_SAFETY": "destructive", "KEEP": "me"}
+ assert entry["env"] == {
+ "LIBTMUX_TOOLSETS": "inspect,manage,execute,teardown",
+ "KEEP": "me",
+ }
def test_env_pair_rejects_malformed() -> None:
@@ -1581,6 +1578,58 @@ def test_env_pair_rejects_malformed() -> None:
mcp_swap.build_parser().parse_args(["use-local", "--env", "NOEQUALS"])
+def test_env_pair_rejects_retired_safety(capsys: pytest.CaptureFixture[str]) -> None:
+ """The swap command names the current setting for a retired env key."""
+ with pytest.raises(SystemExit):
+ mcp_swap.build_parser().parse_args(
+ ["use-local", "--env", "LIBTMUX_SAFETY=destructive"]
+ )
+ assert "LIBTMUX_TOOLSETS" in capsys.readouterr().err
+
+
+def test_use_local_removes_retired_env_from_already_local_entry(
+ fake_home: pathlib.Path, fake_repo: pathlib.Path
+) -> None:
+ """A stale setting prevents the already-local no-op."""
+ info = mcp_swap.CLIS["cursor"]
+ spec = _local_entry(fake_repo)
+ spec["env"] = {
+ "LIBTMUX_SAFETY": "destructive",
+ "LIBTMUX_TOOLSETS": "inspect",
+ "KEEP": "me",
+ }
+ _write_json(info.config_path, {"mcpServers": {"libtmux": spec}})
+
+ args = mcp_swap.build_parser().parse_args(
+ ["use-local", "--repo", str(fake_repo), "--cli", "cursor"]
+ )
+ assert mcp_swap.cmd_use_local(args) == 0
+
+ entry = json.loads(info.config_path.read_text())["mcpServers"]["libtmux"]
+ assert entry["env"] == {"LIBTMUX_TOOLSETS": "inspect", "KEEP": "me"}
+
+
+def test_use_local_refuses_retired_env_without_replacement(
+ fake_home: pathlib.Path,
+ fake_repo: pathlib.Path,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """A stale setting cannot silently widen to the branch default."""
+ info = mcp_swap.CLIS["cursor"]
+ spec = _local_entry(fake_repo)
+ spec["env"] = {"LIBTMUX_SAFETY": "readonly"}
+ _write_json(info.config_path, {"mcpServers": {"libtmux": spec}})
+ before = info.config_path.read_bytes()
+
+ args = mcp_swap.build_parser().parse_args(
+ ["use-local", "--repo", str(fake_repo), "--cli", "cursor"]
+ )
+ assert mcp_swap.cmd_use_local(args) == 1
+
+ assert info.config_path.read_bytes() == before
+ assert "LIBTMUX_TOOLSETS" in capsys.readouterr().err
+
+
def test_use_local_env_written_on_already_local_entry(
fake_home: pathlib.Path, fake_repo: pathlib.Path
) -> None:
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 495e1532..19931ead 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -1,4 +1,4 @@
-"""Tests for libtmux MCP safety + audit middleware."""
+"""Tests for libtmux MCP toolset and audit middleware."""
from __future__ import annotations
@@ -9,129 +9,105 @@
import pydantic
import pytest
-from fastmcp.exceptions import ToolError
from fastmcp.server.middleware import MiddlewareContext
-from libtmux import exc as libtmux_exc
from mcp.types import CallToolRequestParams
-from libtmux_mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY
+from libtmux_mcp._utils import (
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
+)
from libtmux_mcp.middleware import (
AuditMiddleware,
- ReadonlyRetryMiddleware,
- SafetyMiddleware,
+ ToolsetMiddleware,
_client_label,
_redact_digest,
_summarize_args,
)
-class SafetyAllowedFixture(t.NamedTuple):
- """Test fixture for SafetyMiddleware._is_allowed."""
+class ToolsetEnabledFixture(t.NamedTuple):
+ """One membership decision for :class:`ToolsetMiddleware`."""
test_id: str
- max_tier: str
+ toolsets: set[str]
+ tool_name: str
tool_tags: set[str]
- expected_allowed: bool
+ expected_enabled: bool
-SAFETY_ALLOWED_FIXTURES: list[SafetyAllowedFixture] = [
- # readonly tier: only readonly tools allowed
- SafetyAllowedFixture(
- test_id="readonly_allows_readonly",
- max_tier=TAG_READONLY,
- tool_tags={TAG_READONLY},
- expected_allowed=True,
- ),
- SafetyAllowedFixture(
- test_id="readonly_blocks_mutating",
- max_tier=TAG_READONLY,
- tool_tags={TAG_MUTATING},
- expected_allowed=False,
- ),
- SafetyAllowedFixture(
- test_id="readonly_blocks_destructive",
- max_tier=TAG_READONLY,
- tool_tags={TAG_DESTRUCTIVE},
- expected_allowed=False,
- ),
- # mutating tier: readonly + mutating allowed
- SafetyAllowedFixture(
- test_id="mutating_allows_readonly",
- max_tier=TAG_MUTATING,
- tool_tags={TAG_READONLY},
- expected_allowed=True,
+TOOLSET_ENABLED_FIXTURES: list[ToolsetEnabledFixture] = [
+ ToolsetEnabledFixture(
+ test_id="tool_in_an_enabled_toolset",
+ toolsets={TOOLSET_INSPECT},
+ tool_name="capture_pane",
+ tool_tags={TOOLSET_INSPECT},
+ expected_enabled=True,
),
- SafetyAllowedFixture(
- test_id="mutating_allows_mutating",
- max_tier=TAG_MUTATING,
- tool_tags={TAG_MUTATING},
- expected_allowed=True,
+ ToolsetEnabledFixture(
+ test_id="tool_outside_the_enabled_toolsets",
+ toolsets={TOOLSET_INSPECT},
+ tool_name="send_keys",
+ tool_tags={TOOLSET_EXECUTE},
+ expected_enabled=False,
),
- SafetyAllowedFixture(
- test_id="mutating_blocks_destructive",
- max_tier=TAG_MUTATING,
- tool_tags={TAG_DESTRUCTIVE},
- expected_allowed=False,
+ # An ordered ladder could not express this: teardown without execute.
+ ToolsetEnabledFixture(
+ test_id="teardown_without_execute",
+ toolsets={TOOLSET_INSPECT, TOOLSET_TEARDOWN},
+ tool_name="kill_pane",
+ tool_tags={TOOLSET_TEARDOWN},
+ expected_enabled=True,
),
- # destructive tier: all allowed
- SafetyAllowedFixture(
- test_id="destructive_allows_readonly",
- max_tier=TAG_DESTRUCTIVE,
- tool_tags={TAG_READONLY},
- expected_allowed=True,
+ ToolsetEnabledFixture(
+ test_id="execute_stays_out_of_that_surface",
+ toolsets={TOOLSET_INSPECT, TOOLSET_TEARDOWN},
+ tool_name="run_command",
+ tool_tags={TOOLSET_EXECUTE},
+ expected_enabled=False,
),
- SafetyAllowedFixture(
- test_id="destructive_allows_mutating",
- max_tier=TAG_DESTRUCTIVE,
- tool_tags={TAG_MUTATING},
- expected_allowed=True,
- ),
- SafetyAllowedFixture(
- test_id="destructive_allows_destructive",
- max_tier=TAG_DESTRUCTIVE,
- tool_tags={TAG_DESTRUCTIVE},
- expected_allowed=True,
- ),
- # untagged tools are denied (fail-closed)
- SafetyAllowedFixture(
- test_id="untagged_denied_at_readonly",
- max_tier=TAG_READONLY,
+ ToolsetEnabledFixture(
+ test_id="untagged_tool_is_refused",
+ toolsets={TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_EXECUTE},
+ tool_name="mystery",
tool_tags=set(),
- expected_allowed=False,
+ expected_enabled=False,
),
]
@pytest.mark.parametrize(
- SafetyAllowedFixture._fields,
- SAFETY_ALLOWED_FIXTURES,
- ids=[f.test_id for f in SAFETY_ALLOWED_FIXTURES],
+ ToolsetEnabledFixture._fields,
+ TOOLSET_ENABLED_FIXTURES,
+ ids=[f.test_id for f in TOOLSET_ENABLED_FIXTURES],
)
-def test_safety_middleware_is_allowed(
+def test_toolset_middleware_membership(
test_id: str,
- max_tier: str,
+ toolsets: set[str],
+ tool_name: str,
tool_tags: set[str],
- expected_allowed: bool,
+ expected_enabled: bool,
) -> None:
- """SafetyMiddleware._is_allowed gates tools by tier."""
- mw = SafetyMiddleware(max_tier=max_tier)
- assert mw._is_allowed(tool_tags) is expected_allowed
+ """A tool is advertised when one of its toolsets is enabled."""
+ mw = ToolsetMiddleware(toolsets)
+ assert mw._is_enabled(tool_name, tool_tags) is expected_enabled
-def test_safety_middleware_default_tier() -> None:
- """SafetyMiddleware defaults to mutating tier."""
- mw = SafetyMiddleware()
- assert mw._is_allowed({TAG_READONLY}) is True
- assert mw._is_allowed({TAG_MUTATING}) is True
- assert mw._is_allowed({TAG_DESTRUCTIVE}) is False
+def test_named_tools_join_and_exclusions_win() -> None:
+ """Named includes override selection, not missing classification."""
+ mw = ToolsetMiddleware(
+ {TOOLSET_INSPECT},
+ tools={"mystery", "run_command", "send_keys"},
+ exclude_tools={"capture_pane", "send_keys"},
+ )
-def test_safety_middleware_invalid_tier_falls_back() -> None:
- """SafetyMiddleware falls back to readonly for unknown tiers."""
- mw = SafetyMiddleware(max_tier="nonexistent")
- assert mw._is_allowed({TAG_READONLY}) is True
- assert mw._is_allowed({TAG_MUTATING}) is False
- assert mw._is_allowed({TAG_DESTRUCTIVE}) is False
+ assert mw._is_enabled("send_keys", {TOOLSET_EXECUTE}) is False
+ assert mw._is_enabled("capture_pane", {TOOLSET_INSPECT}) is False
+ assert mw._is_enabled("list_panes", {TOOLSET_INSPECT}) is True
+ assert mw._is_enabled("run_command", {TOOLSET_EXECUTE}) is True
+ assert mw._is_enabled("mystery", set()) is False
# ---------------------------------------------------------------------------
@@ -636,26 +612,21 @@ def test_server_middleware_stack_order() -> None:
The ordering is load-bearing (see server.py comment):
TimingMiddleware must be outermost so it observes total wall
- time; AuditMiddleware must sit *outside* SafetyMiddleware so
- tier-denial events (which raise ``ExpectedToolError`` before
+ time; AuditMiddleware must sit *outside* ToolsetMiddleware so
+ toolset-denial events (which raise ``ExpectedToolError`` before
``call_next``) are still recorded — without this ordering,
forbidden-access attempts silently bypass the audit log. A
- refactor that swaps Audit and Safety would degrade
+ refactor that swaps Audit and Toolset would degrade
security observability without an obvious test failure, so pin
the sequence explicitly.
-
- ReadonlyRetryMiddleware sits between Audit and Safety so retried
- calls are audited once each (Audit wraps the retry loop) and
- tier-denied tools never reach retry (Safety stops them first).
"""
from fastmcp.server.middleware.timing import TimingMiddleware
from libtmux_mcp.middleware import (
AuditMiddleware,
- ReadonlyRetryMiddleware,
- SafetyMiddleware,
TailPreservingResponseLimitingMiddleware,
ToolErrorResultMiddleware,
+ ToolsetMiddleware,
)
from libtmux_mcp.server import mcp
@@ -663,16 +634,48 @@ def test_server_middleware_stack_order() -> None:
# FastMCP auto-appends an internal DereferenceRefsMiddleware at the
# end of the stack; we care about the ordering of the middleware
# *we* configured. Slice off the suffix before comparing.
- assert types[:6] == [
+ assert types[:5] == [
TimingMiddleware,
TailPreservingResponseLimitingMiddleware,
ToolErrorResultMiddleware,
AuditMiddleware,
- ReadonlyRetryMiddleware,
- SafetyMiddleware,
+ ToolsetMiddleware,
]
+def test_failed_inspect_call_is_not_retried(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A failed observation runs once because tmux may add effects."""
+ from fastmcp import Client
+ from libtmux import Server, exc as libtmux_exc
+
+ from libtmux_mcp.server import build_mcp_server
+
+ calls = 0
+
+ def _fail(_self: Server) -> list[t.Any]:
+ nonlocal calls
+ calls += 1
+ msg = "forced failure"
+ raise libtmux_exc.LibTmuxException(msg)
+
+ monkeypatch.setattr(Server, "sessions", property(_fail))
+
+ async def _call() -> t.Any:
+ async with Client(build_mcp_server()) as client:
+ return await client.call_tool(
+ "list_sessions",
+ {"socket_name": "no-retry-boundary"},
+ raise_on_error=False,
+ )
+
+ result = asyncio.run(_call())
+
+ assert result.is_error is True
+ assert calls == 1
+
+
def test_error_handling_middleware_transforms_errors() -> None:
"""ToolErrorResultMiddleware is configured with transform_errors=True.
@@ -693,15 +696,15 @@ def test_error_handling_middleware_transforms_errors() -> None:
assert err_mw.transform_errors is True
-def test_audit_records_safety_denial(
+def test_audit_records_a_toolset_refusal(
caplog: pytest.LogCaptureFixture,
) -> None:
- """A tool denied by SafetyMiddleware still appears in the audit log.
+ """A tool denied by ToolsetMiddleware still appears in the audit log.
- Composes Audit and Safety in the production order (Audit outside
- Safety) by manually nesting their ``on_call_tool`` handlers: the
- inner ``call_next`` from Audit dispatches to Safety, which raises
- ``ExpectedToolError`` for an over-tier tool. Audit should record
+ Composes Audit and Toolset in the production order (Audit outside
+ Toolset) by manually nesting their ``on_call_tool`` handlers: the
+ inner ``call_next`` from Audit dispatches to Toolset, which raises
+ ``ExpectedToolError`` for a disabled tool. Audit should record
that as ``outcome=error error_type=ExpectedToolError`` rather
than skipping the record. Without this ordering, denied access
attempts would silently bypass forensic logging.
@@ -711,24 +714,24 @@ def test_audit_records_safety_denial(
audit = AuditMiddleware()
ctx = _fake_context(name="kill_server", arguments={})
- # SafetyMiddleware.on_call_tool consults
+ # ToolsetMiddleware.on_call_tool consults
# context.fastmcp_context.fastmcp.get_tool(...). With
- # fastmcp_context=None the safety check short-circuits, so we
+ # fastmcp_context=None the toolset check short-circuits, so we
# simulate the denial more directly: ``call_next`` is a coroutine
- # that raises the same ``ExpectedToolError`` SafetyMiddleware
- # would when blocking an over-tier call. The test's invariant is
- # that the AuditMiddleware sitting *outside* Safety still records
+ # that raises the same ``ExpectedToolError`` ToolsetMiddleware
+ # would when blocking a disabled tool. The test's invariant is
+ # that the AuditMiddleware sitting *outside* Toolset still records
# the attempt with outcome=error.
- msg = "Tool 'kill_server' is not available at the current safety level."
+ msg = "Tool 'kill_server' is not in this server's enabled toolsets."
- async def _safety_denial(_ctx: t.Any) -> None:
+ async def _toolset_refusal(_ctx: t.Any) -> None:
raise ExpectedToolError(msg)
with (
caplog.at_level(logging.INFO, logger="libtmux_mcp.audit"),
- pytest.raises(ExpectedToolError, match="not available"),
+ pytest.raises(ExpectedToolError, match="not in this server's enabled"),
):
- asyncio.run(audit.on_call_tool(ctx, _safety_denial))
+ asyncio.run(audit.on_call_tool(ctx, _toolset_refusal))
rendered = "\n".join(rec.getMessage() for rec in caplog.records)
assert "tool=kill_server" in rendered
@@ -736,316 +739,6 @@ async def _safety_denial(_ctx: t.Any) -> None:
assert "error_type=ExpectedToolError" in rendered
-# ---------------------------------------------------------------------------
-# ReadonlyRetryMiddleware tests
-# ---------------------------------------------------------------------------
-
-
-class _StubTool:
- """Minimal tool stand-in for ``get_tool`` lookups."""
-
- def __init__(self, tags: set[str]) -> None:
- self.tags = tags
-
-
-class _StubFastMCP:
- """Awaitable ``get_tool`` returning a single stub tool."""
-
- def __init__(self, tool: _StubTool) -> None:
- self._tool = tool
-
- async def get_tool(self, _name: str) -> _StubTool:
- return self._tool
-
-
-class _StubFastMCPContext:
- """Just enough to satisfy ``context.fastmcp_context.fastmcp.get_tool``."""
-
- def __init__(self, fastmcp: _StubFastMCP) -> None:
- self.fastmcp = fastmcp
-
-
-def _retry_context(tags: set[str]) -> MiddlewareContext[CallToolRequestParams]:
- """Build a MiddlewareContext that returns a tool with the given tags."""
- fastmcp_context = _StubFastMCPContext(_StubFastMCP(_StubTool(tags)))
- return MiddlewareContext(
- message=CallToolRequestParams(name="x", arguments={}),
- fastmcp_context=t.cast("t.Any", fastmcp_context),
- )
-
-
-class _FlakyCallNext:
- """Async callable that raises N times before succeeding."""
-
- def __init__(self, raises_n_times: int, exception: Exception) -> None:
- self.exception = exception
- self.remaining = raises_n_times
- self.calls = 0
-
- async def __call__(self, _context: t.Any) -> str:
- self.calls += 1
- if self.remaining > 0:
- self.remaining -= 1
- raise self.exception
- return "ok"
-
-
-def test_readonly_retry_recovers_from_libtmux_exception() -> None:
- """Readonly tool is retried once on ``LibTmuxException`` and succeeds.
-
- Models the production scenario the middleware exists to fix: a
- transient socket error from libtmux on the first call, then a
- successful call after the cache evicts the dead Server. Without
- the retry the agent would see an expected tool error on the first
- ``list_sessions``-style call.
- """
- from libtmux import exc as libtmux_exc
-
- middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0)
- ctx = _retry_context(tags={TAG_READONLY})
- call_next = _FlakyCallNext(
- raises_n_times=1,
- exception=libtmux_exc.LibTmuxException("transient socket error"),
- )
-
- result = asyncio.run(middleware.on_call_tool(ctx, call_next))
-
- assert result == "ok"
- assert call_next.calls == 2 # initial failure + one retry
-
-
-def test_readonly_retry_skips_mutating_tool() -> None:
- """Mutating tool is NOT retried on ``LibTmuxException``.
-
- Critical safety property: re-running ``send_keys``,
- ``create_session``, or any other mutating call on a transient
- error would silently double the side effect. This test pins the
- "no retry for non-readonly" gate.
- """
- from libtmux import exc as libtmux_exc
-
- middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0)
- ctx = _retry_context(tags={TAG_MUTATING})
- call_next = _FlakyCallNext(
- raises_n_times=1,
- exception=libtmux_exc.LibTmuxException("transient socket error"),
- )
-
- with pytest.raises(libtmux_exc.LibTmuxException, match="transient"):
- asyncio.run(middleware.on_call_tool(ctx, call_next))
-
- assert call_next.calls == 1 # no retry — fail on first call
-
-
-def test_readonly_retry_skips_self_bounded_tool() -> None:
- """A readonly + self-bounded tool is NOT retried.
-
- ``wait_for_text`` computes its deadline inside the tool body, so a
- retry restarts the clock: a transient ``LibTmuxException`` at t=29s
- of a 30s wait would produce a ~59s call and turn the wait ceiling
- into a lie. ``_SkipDeterministicFailures`` cannot cover this — it
- carves out failures by exception *type*, and this exception is a
- genuinely transient one a retry would fix for any other tool. The
- exclusion has to be tool-level, and it is a TAG rather than a name
- list because ``add_tool_transformation`` can rename a tool.
- """
- from libtmux import exc as libtmux_exc
-
- from libtmux_mcp._utils import TAG_SELF_BOUNDED
-
- middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0)
- ctx = _retry_context(tags={TAG_READONLY, TAG_SELF_BOUNDED})
- call_next = _FlakyCallNext(
- raises_n_times=1,
- exception=libtmux_exc.LibTmuxException("transient socket error"),
- )
-
- with pytest.raises(libtmux_exc.LibTmuxException, match="transient"):
- asyncio.run(middleware.on_call_tool(ctx, call_next))
-
- assert call_next.calls == 1 # no retry — the wait budget is not doubled
-
-
-def test_readonly_retry_skips_non_libtmux_exception() -> None:
- """Even readonly tools don't retry on exceptions outside the trigger set.
-
- Default ``retry_exceptions=(LibTmuxException,)`` is narrow on
- purpose — a ``ValueError`` from caller-side input is a
- programming error, not a transient socket hiccup, and retrying
- it would just delay the real failure.
- """
- middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0)
- ctx = _retry_context(tags={TAG_READONLY})
- call_next = _FlakyCallNext(
- raises_n_times=1,
- exception=ValueError("bad caller input"),
- )
-
- with pytest.raises(ValueError, match="bad caller input"):
- asyncio.run(middleware.on_call_tool(ctx, call_next))
-
- assert call_next.calls == 1 # no retry — wrong exception type
-
-
-def test_readonly_retry_recovers_on_decorated_tool(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """End-to-end: retry fires through the production decorator wrap path.
-
- Regression guard for the fastmcp <3.2.4 production no-op where
- ``RetryMiddleware._should_retry`` did not walk ``__cause__``.
- Every libtmux-mcp tool is wrapped by ``handle_tool_errors`` /
- ``handle_tool_errors_async``, which converts ``LibTmuxException``
- to ``ExpectedToolError(...) from LibTmuxException``. At the
- middleware layer the exception type is ``ExpectedToolError``, not
- ``LibTmuxException`` — so the retry decision must walk
- ``__cause__`` to see the real failure type.
-
- The unit tests above use ``_FlakyCallNext`` which raises
- ``LibTmuxException`` directly, bypassing the decorator. They
- pass on every fastmcp version. This test invokes the real
- ``list_sessions`` tool through the middleware, exercising the
- decorator wrap path that broke in production:
-
- * On fastmcp 3.2.3: would fail with ``calls == 1`` (no retry).
- * On fastmcp >= 3.2.4: passes with ``calls == 2``.
-
- The ``pyproject.toml`` floor (``fastmcp>=3.2.4``) keeps this
- test green; an accidental downgrade would re-introduce the bug
- and fail this test loudly.
- """
- from libtmux import Server, exc as libtmux_exc
-
- from libtmux_mcp.tools.server_tools import list_sessions
-
- calls = {"count": 0}
-
- def _flaky_sessions(_self: Server) -> list[t.Any]:
- calls["count"] += 1
- if calls["count"] == 1:
- msg = "transient socket error"
- raise libtmux_exc.LibTmuxException(msg)
- return [] # second call succeeds with no tmux required
-
- monkeypatch.setattr(Server, "sessions", property(_flaky_sessions))
-
- middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0)
- ctx = _retry_context(tags={TAG_READONLY})
-
- async def real_call_next(_context: t.Any) -> t.Any:
- # ``list_sessions`` is sync + ``@handle_tool_errors`` decorated.
- # Wrapping the sync call in an async function is enough — the
- # exception path is what we care about.
- return list_sessions(socket_name="retry-integration-smoke")
-
- result = asyncio.run(middleware.on_call_tool(ctx, real_call_next))
-
- assert result == []
- assert calls["count"] == 2, (
- f"retry did not fire (calls={calls['count']}). Likely cause: "
- f"fastmcp<3.2.4 RetryMiddleware._should_retry not walking "
- f"__cause__. Bump pyproject.toml fastmcp pin to >=3.2.4."
- )
-
-
-@pytest.mark.parametrize(
- "raised",
- [
- libtmux_exc.TmuxObjectDoesNotExist("@99"),
- libtmux_exc.MultipleObjectsReturned(count=2, query={"pane_id": "%0"}),
- libtmux_exc.PaneNotFound("%99"),
- libtmux_exc.NoWindowsExist,
- libtmux_exc.BadSessionName(reason="contains periods", session_name="a.b"),
- libtmux_exc.TmuxSessionExists("session exists"),
- ],
- ids=lambda e: type(e).__name__ if isinstance(e, Exception) else e.__name__,
-)
-def test_readonly_retry_skips_deterministic_failures(raised: Exception) -> None:
- """A failure a second attempt cannot change is not retried.
-
- Every one of these descends from ``LibTmuxException``, which is the retry
- trigger — so without :data:`NON_RETRYABLE_EXCEPTIONS` they would all be
- retried. None of them can succeed on the second look: a pane that is not
- there will not appear during a backoff window, and an ambiguous match does
- not become unambiguous. Retrying buys a second tmux round-trip and 100 ms
- of latency in order to fail identically.
- """
- middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0)
- ctx = _retry_context(tags={TAG_READONLY})
- call_next = _FlakyCallNext(raises_n_times=1, exception=raised)
-
- with pytest.raises(libtmux_exc.LibTmuxException):
- asyncio.run(middleware.on_call_tool(ctx, call_next))
-
- assert call_next.calls == 1, (
- f"{type(raised).__name__} was retried. It descends from LibTmuxException, "
- f"so it must be listed in NON_RETRYABLE_EXCEPTIONS."
- )
-
-
-def test_readonly_retry_skips_not_found_on_decorated_tool(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """End-to-end: a stale id is not retried through the production wrap path.
-
- The unit test above raises straight into the middleware. This one goes
- through ``handle_tool_errors``, which re-raises every libtmux failure as an
- ``ExpectedToolError`` chained off the original — so at the middleware layer
- the exception is an ``ExpectedToolError`` and the real failure is only
- visible one hop down, on ``__cause__``. The retry decision walks that hop
- (which is what makes retries work at all), so the *skip* decision has to
- walk it too, or it never fires in production.
-
- Guards the shape libtmux tmux-python/libtmux#718 introduced:
- ``TmuxObjectDoesNotExist`` became a ``LibTmuxException``, which silently
- made every stale session or window id retryable.
- """
- from libtmux import Server
-
- from libtmux_mcp.tools.server_tools import list_sessions
-
- calls = {"count": 0}
-
- def _missing_sessions(_self: Server) -> list[t.Any]:
- calls["count"] += 1
- raise libtmux_exc.TmuxObjectDoesNotExist(
- obj_key="session_id",
- obj_id="$99",
- list_cmd="list-sessions",
- list_extra_args=None,
- )
-
- monkeypatch.setattr(Server, "sessions", property(_missing_sessions))
-
- middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0)
- ctx = _retry_context(tags={TAG_READONLY})
-
- async def real_call_next(_context: t.Any) -> t.Any:
- return list_sessions(socket_name="retry-skip-smoke")
-
- with pytest.raises(ToolError):
- asyncio.run(middleware.on_call_tool(ctx, real_call_next))
-
- assert calls["count"] == 1, (
- f"a missing object was retried (calls={calls['count']}). The skip must "
- f"walk __cause__, because handle_tool_errors wraps it in ExpectedToolError."
- )
-
-
-def test_readonly_retry_logger_uses_project_namespace() -> None:
- """Retry warnings route through ``libtmux_mcp.retry``, not ``fastmcp.retry``.
-
- Operators routing logs by the ``libtmux_mcp.*`` namespace prefix
- (matching ``libtmux_mcp.audit``) need retry events to appear on
- the same channel. fastmcp's stock ``RetryMiddleware`` defaults
- to ``fastmcp.retry`` (``error_handling.py:181``); without an
- explicit override, retry warnings would silently bypass any
- project-namespace audit-stream routing.
- """
- middleware = ReadonlyRetryMiddleware()
- assert middleware._retry.logger.name == "libtmux_mcp.retry"
-
-
# ---------------------------------------------------------------------------
# ToolErrorResultMiddleware tests
# ---------------------------------------------------------------------------
@@ -1055,7 +748,7 @@ def _error_probe_server() -> t.Any:
"""Build a minimal FastMCP instance wired like the production stack.
Only ``ToolErrorResultMiddleware`` is installed — the assertions
- target error-result conversion, not the audit/retry/safety trio.
+ target error-result conversion, not the audit/toolset pair.
Tools mirror the three production raise shapes: an expected
failure with a chained cause (decorator-mapped libtmux error), an
expected failure with a recovery suggestion, and an unexpected
@@ -1621,28 +1314,29 @@ def _probe_fn(count: int) -> int:
assert "some clients (e.g. Gemini CLI)" in meta["suggestion"]
-def test_wait_for_text_is_registered_self_bounded_and_still_readonly() -> None:
- """``wait_for_text`` carries both tags, and the extra tag is inert.
+def test_wait_for_text_carries_a_marker_tag_that_does_not_gate_it() -> None:
+ """``wait_for_text`` is in ``inspect``; its extra marker tag is inert.
- ``SafetyMiddleware._is_allowed`` inspects only the three tier tags,
- so an additional marker tag must not change tier visibility at any
- safety level.
+ ``ToolsetMiddleware`` intersects a tool's tags with the toolsets, so
+ a marker tag that names no toolset cannot change what is advertised.
"""
from fastmcp import FastMCP
from libtmux_mcp._utils import TAG_SELF_BOUNDED
- from libtmux_mcp.middleware import SafetyMiddleware
+ from libtmux_mcp.middleware import ToolsetMiddleware
from libtmux_mcp.tools import register_tools
mcp = FastMCP(name="self-bounded-audit")
register_tools(mcp)
tool = asyncio.run(mcp.get_tool("wait_for_text"))
assert tool is not None
- assert TAG_READONLY in tool.tags
+ assert TOOLSET_INSPECT in tool.tags
assert TAG_SELF_BOUNDED in tool.tags
- for tier in (TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE):
- assert SafetyMiddleware(max_tier=tier)._is_allowed(set(tool.tags)) is True
+ enabled = ToolsetMiddleware({TOOLSET_INSPECT})
+ assert enabled._is_enabled("wait_for_text", set(tool.tags)) is True
+ without = ToolsetMiddleware({TOOLSET_MANAGE, TOOLSET_TEARDOWN})
+ assert without._is_enabled("wait_for_text", set(tool.tags)) is False
class ClientLabelFieldFixture(t.NamedTuple):
diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py
index 1e3d2ff4..6f482b06 100644
--- a/tests/test_pane_tools.py
+++ b/tests/test_pane_tools.py
@@ -39,6 +39,7 @@
pipe_pane,
resize_pane,
respawn_pane,
+ search,
search_panes,
select_pane,
send_keys,
@@ -47,7 +48,6 @@
swap_pane,
wait_for_text,
)
-from tests.conftest import wire_annotations
if t.TYPE_CHECKING:
from libtmux.pane import Pane
@@ -111,7 +111,7 @@ class RunCommandHistoryFixture(t.NamedTuple):
RUN_COMMAND_STATUS_ISOLATION_FIXTURES: list[RunCommandStatusIsolationFixture] = [
RunCommandStatusIsolationFixture(
- "path_mutation",
+ "path_change",
"PATH=/tmp; printf 'RUN_COMMAND_PATH_OK\\n'",
0,
"RUN_COMMAND_PATH_OK",
@@ -1383,7 +1383,7 @@ def _hlimit_locked() -> bool:
"for i in $(seq 1 120); do printf 'CAPTURE_SINCE_TRIM_%03d\\n' \"$i\"; done"
)
_signal_after_shell_payload(mcp_server, fresh_pane, payload)
- # Guarantee anchor destruction: tmux 3.6 can retain the original
+ # Guarantee anchor removal: tmux 3.6 can retain the original
# prompt hash in scrollback even after flooding past history-limit.
fresh_pane.cmd("clear-history")
_signal_after_shell_payload(
@@ -1957,9 +1957,16 @@ def test_respawn_pane_replaces_shell(mcp_server: Server, mcp_session: Session) -
socket_name=mcp_server.socket_name,
)
assert result.pane_id == new_pane.pane_id
- # pane_current_command reflects the relaunched command.
- assert result.pane_current_command is not None
- assert "sleep" in result.pane_current_command
+
+ # tmux reports the shell briefly while the child crosses exec(2).
+ command = result.pane_current_command
+ deadline = time.monotonic() + 1.0
+ while (command is None or "sleep" not in command) and time.monotonic() < deadline:
+ time.sleep(0.01)
+ new_pane.refresh()
+ command = new_pane.pane_current_command
+ assert command is not None
+ assert "sleep" in command
new_pane.kill()
@@ -2311,6 +2318,69 @@ def test_search_panes_invalid_regex(mcp_server: Server, mcp_session: Session) ->
)
+def test_search_panes_bounds_matching_time(
+ mcp_server: Server,
+ mcp_pane: Pane,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A pattern that backtracks exponentially stops at the deadline."""
+ monkeypatch.setattr(search, "SEARCH_MATCH_MAX_SECONDS", 0.2)
+ mcp_pane.send_keys("echo " + "a" * 40 + "b", enter=True)
+ retry_until(
+ lambda: "a" * 40 in "\n".join(mcp_pane.capture_pane()),
+ 2,
+ raises=True,
+ )
+
+ started = time.monotonic()
+ with pytest.raises(ExpectedToolError, match="took longer than"):
+ search_panes(
+ pattern=r"(a|a)+$",
+ regex=True,
+ socket_name=mcp_server.socket_name,
+ )
+ assert time.monotonic() - started < 5
+
+
+def test_search_panes_excludes_capture_time_from_matching_deadline(
+ mcp_server: Server,
+ mcp_session: Session,
+ mcp_pane: Pane,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A delayed pane capture does not spend the regex matching budget."""
+ marker = "CAPTURE_OUTSIDE_MATCH_DEADLINE"
+
+ def delayed_capture(*args: object, **kwargs: object) -> list[str]:
+ time.sleep(0.05)
+ return [marker]
+
+ monkeypatch.setattr(search, "SEARCH_MATCH_MAX_SECONDS", 0.02)
+ monkeypatch.setattr(type(mcp_pane), "capture_pane", delayed_capture)
+
+ result = search_panes(
+ pattern=marker,
+ regex=True,
+ session_id=mcp_session.session_id,
+ content_start=0,
+ socket_name=mcp_server.socket_name,
+ )
+
+ assert [match.pane_id for match in result.matches] == [mcp_pane.pane_id]
+
+
+def test_search_panes_rejects_an_oversized_pattern(
+ mcp_server: Server,
+) -> None:
+ """Compilation is bounded by length, which no match deadline covers."""
+ with pytest.raises(ExpectedToolError, match="pattern is longer than"):
+ search_panes(
+ pattern="a" * (search.SEARCH_MAX_PATTERN_LENGTH + 1),
+ regex=True,
+ socket_name=mcp_server.socket_name,
+ )
+
+
def test_search_panes_pagination_limit_and_offset(
mcp_server: Server, mcp_session: Session, mcp_pane: Pane
) -> None:
@@ -5479,108 +5549,6 @@ def test_paste_text_does_not_leak_named_buffer(
)
-# ---------------------------------------------------------------------------
-# Registration-time annotation verification
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.parametrize(
- ("tool_name", "expected_open_world"),
- [
- # Shell-driving tools: the command the caller sends can reach
- # arbitrary external state, so the interaction is open-world.
- ("send_keys", True),
- ("send_keys_batch", True),
- ("run_command", True),
- ("paste_text", True),
- ("pipe_pane", True),
- # Create-style tools: allocate tmux objects only. Not open-world
- # even though they share the old ANNOTATIONS_CREATE preset.
- ("swap_pane", False),
- ("enter_copy_mode", False),
- ],
-)
-def test_pane_tool_open_world_hint_registration(
- tool_name: str, expected_open_world: bool
-) -> None:
- """Pane tools advertise ``openWorldHint`` matching their real semantics.
-
- Regression guard for the shared-preset trap: the old
- ``ANNOTATIONS_CREATE`` preset was applied to both shell-driving and
- non-shell-driving tools, so every caller saw ``openWorldHint=False``.
- A new ``ANNOTATIONS_SHELL`` preset now carries ``openWorldHint=True``
- for the three shell-driving tools only, leaving the other
- ``ANNOTATIONS_CREATE`` users unchanged.
- """
- import asyncio
-
- from fastmcp import FastMCP
-
- from libtmux_mcp.tools import pane_tools
-
- mcp = FastMCP(name="test-pane-annotations")
- pane_tools.register(mcp)
-
- tool = asyncio.run(mcp.get_tool(tool_name))
- assert tool is not None, f"{tool_name} should be registered"
- assert tool.annotations is not None, (
- f"{tool_name} registration should carry annotations"
- )
- assert wire_annotations(tool).get("openWorldHint") is expected_open_world
-
-
-def test_respawn_pane_advertises_destructive_non_idempotent() -> None:
- """``respawn_pane`` registers as mutating-tier with destructive hints.
-
- Default ``kill=True`` sends ``SPAWN_KILL`` to the running process
- (`cmd-respawn-pane.c:78-79`); repeated calls kill repeated processes.
- The MCP spec defines ``destructiveHint`` as "may perform destructive
- updates" and ``idempotentHint`` as "calling repeatedly will have no
- additional effect" (`mcp/types.py:1268-1282`). The default
- ``ANNOTATIONS_MUTATING`` preset (``destructiveHint=False``,
- ``idempotentHint=True``) would lie to the agent. The new
- ``ANNOTATIONS_MUTATING_DESTRUCTIVE`` preset stays in ``TAG_MUTATING``
- so the recovery use case remains visible to default-profile clients,
- while honestly advertising destructive non-idempotent semantics.
- """
- import asyncio
-
- from fastmcp import FastMCP
-
- from libtmux_mcp.tools import pane_tools
-
- mcp = FastMCP(name="test-respawn-annotations")
- pane_tools.register(mcp)
-
- tool = asyncio.run(mcp.get_tool("respawn_pane"))
- assert tool is not None, "respawn_pane should be registered"
- assert tool.annotations is not None, (
- "respawn_pane registration should carry annotations"
- )
- assert wire_annotations(tool).get("destructiveHint") is True
- assert wire_annotations(tool).get("idempotentHint") is False
- assert wire_annotations(tool).get("readOnlyHint") is False
-
-
-def test_clear_pane_advertises_destructive_non_idempotent() -> None:
- """``clear_pane`` registers as mutating-tier with destructive hints."""
- import asyncio
-
- from fastmcp import FastMCP
-
- from libtmux_mcp.tools import pane_tools
-
- mcp = FastMCP(name="test-clear-pane-annotations")
- pane_tools.register(mcp)
-
- tool = asyncio.run(mcp.get_tool("clear_pane"))
- assert tool is not None, "clear_pane should be registered"
- assert tool.annotations is not None, "clear_pane should carry annotations"
- assert wire_annotations(tool).get("destructiveHint") is True
- assert wire_annotations(tool).get("idempotentHint") is False
- assert wire_annotations(tool).get("readOnlyHint") is False
-
-
# ---------------------------------------------------------------------------
# Typed-output regression guard
# ---------------------------------------------------------------------------
diff --git a/tests/test_prompts.py b/tests/test_prompts.py
index c3ed30de..524a4036 100644
--- a/tests/test_prompts.py
+++ b/tests/test_prompts.py
@@ -4,12 +4,16 @@
import ast
import asyncio
+import typing as t
import pytest
from fastmcp import FastMCP
+from libtmux_mcp._utils import TOOLSET_INSPECT
from libtmux_mcp.prompts import ENV_PROMPTS_AS_TOOLS, register_prompts
+from .conftest import wire_annotations
+
@pytest.fixture
def mcp_with_prompts() -> FastMCP:
@@ -40,14 +44,37 @@ def test_prompts_as_tools_gated_off_by_default(mcp_with_prompts: FastMCP) -> Non
def test_prompts_as_tools_enabled_by_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """Setting the env var installs PromptsAsTools."""
+ """The opt-in adapters survive production-style tool visibility."""
+ from fastmcp import Client
+
+ from libtmux_mcp.middleware import ToolsetMiddleware
+
monkeypatch.setenv(ENV_PROMPTS_AS_TOOLS, "1")
- mcp = FastMCP(name="test-prompts-as-tools")
+ mcp = FastMCP(
+ name="test-prompts-as-tools",
+ middleware=[ToolsetMiddleware({TOOLSET_INSPECT})],
+ )
register_prompts(mcp)
- tools = asyncio.run(mcp.list_tools())
- names = {tool.name for tool in tools}
- assert "list_prompts" in names
- assert "get_prompt" in names
+ mcp.disable(components={"tool"})
+ mcp.enable(tags={TOOLSET_INSPECT}, components={"tool"})
+
+ tools = {tool.name: tool for tool in asyncio.run(mcp.list_tools())}
+ expected = {
+ "readOnlyHint": True,
+ "destructiveHint": False,
+ "idempotentHint": True,
+ "openWorldHint": False,
+ }
+ for name in ("list_prompts", "get_prompt"):
+ assert tools[name].tags == {TOOLSET_INSPECT}
+ assert wire_annotations(tools[name]) == expected
+
+ async def _call_adapter() -> t.Any:
+ async with Client(mcp) as client:
+ return await client.call_tool("list_prompts", {}, raise_on_error=False)
+
+ result = asyncio.run(_call_adapter())
+ assert result.is_error is False
def test_run_and_wait_returns_string_template() -> None:
diff --git a/tests/test_retired_vocabulary.py b/tests/test_retired_vocabulary.py
new file mode 100644
index 00000000..97dbdb48
--- /dev/null
+++ b/tests/test_retired_vocabulary.py
@@ -0,0 +1,128 @@
+"""Retired toolset identifiers, configuration, and headings stay retired."""
+
+from __future__ import annotations
+
+import pathlib
+import re
+
+RETIRED_IDENTIFIERS = frozenset(
+ {
+ "ANNOTATIONS_ALLOCATE",
+ "ANNOTATIONS_CHANGE",
+ "ANNOTATIONS_DEFERRED_EXEC",
+ "ANNOTATIONS_DELETE",
+ "ANNOTATIONS_OBSERVE",
+ "ANNOTATIONS_OBSERVE_CONTENT",
+ "ANNOTATIONS_PANE_INPUT",
+ "ANNOTATIONS_SPAWN",
+ "InspectRetryMiddleware",
+ "ReadonlyRetryMiddleware",
+ "SafetyMiddleware",
+ "TAG_DESTRUCTIVE",
+ "TAG_MUTATING",
+ "TAG_READONLY",
+ "VALID_SAFETY_LEVELS",
+ "call_destructive_tools_batch",
+ "call_mutating_tools_batch",
+ "call_readonly_tools_batch",
+ }
+)
+
+RETIRED_CONFIG_KEYS = frozenset({"LIBTMUX_SAFETY"})
+
+RETIRED_HEADINGS = frozenset(
+ {
+ "Discovery vs. mutation",
+ "Safety levels",
+ "Safety tiers",
+ }
+)
+
+_RETIRED_TERM_PATTERN = re.compile(
+ r"\b(?:safety[ -]tiers?|mutating tools?|default tiers?)\b",
+ re.IGNORECASE,
+)
+
+HISTORICAL_FILES = frozenset({pathlib.Path("CHANGES"), pathlib.Path("MIGRATION.md")})
+
+CONFIG_REJECTION_FILES = frozenset(
+ {
+ pathlib.Path("scripts/mcp_swap.py"),
+ pathlib.Path("src/libtmux_mcp/server.py"),
+ pathlib.Path("tests/test_mcp_swap.py"),
+ pathlib.Path("tests/test_server.py"),
+ }
+)
+
+_ROOT = pathlib.Path(__file__).resolve().parent.parent
+_IDENTIFIER_PATTERN = re.compile(
+ rf"\b(?:{'|'.join(map(re.escape, sorted(RETIRED_IDENTIFIERS)))})\b"
+)
+_CONFIG_PATTERN = re.compile(
+ rf"\b(?:{'|'.join(map(re.escape, sorted(RETIRED_CONFIG_KEYS)))})\b"
+)
+_HEADING_PATTERN = re.compile(
+ rf"^#{{1,6}}\s+(?:{'|'.join(map(re.escape, sorted(RETIRED_HEADINGS)))})\s*$",
+ re.IGNORECASE | re.MULTILINE,
+)
+
+
+def _tracked_sources() -> list[pathlib.Path]:
+ """Return source, tests, scripts, skills, and user documentation."""
+ paths: set[pathlib.Path] = set()
+ for pattern in (
+ ".agents/**/*.md",
+ ".github/**/*.md",
+ "src/**/*.py",
+ "tests/**/*.py",
+ "scripts/**/*.py",
+ "docs/**/*.md",
+ "*.md",
+ ):
+ paths.update(
+ path
+ for path in _ROOT.glob(pattern)
+ if "_build" not in path.parts and path != pathlib.Path(__file__).resolve()
+ )
+ paths.add(_ROOT / "CHANGES")
+ paths.add(_ROOT / "pyproject.toml")
+ return sorted(paths)
+
+
+def test_retired_contract_names_do_not_return() -> None:
+ """Removed API names stay gone without banning ordinary security prose."""
+ offenders: list[str] = []
+
+ for path in _tracked_sources():
+ relative = path.relative_to(_ROOT)
+ if relative in HISTORICAL_FILES:
+ continue
+ text = path.read_text(encoding="utf-8")
+ offenders.extend(
+ f"{relative}: term {match.group(0)}"
+ for match in _RETIRED_TERM_PATTERN.finditer(text)
+ )
+ offenders.extend(
+ f"{relative}: identifier {match.group(0)}"
+ for match in _IDENTIFIER_PATTERN.finditer(text)
+ )
+ if relative not in CONFIG_REJECTION_FILES:
+ offenders.extend(
+ f"{relative}: config {match.group(0)}"
+ for match in _CONFIG_PATTERN.finditer(text)
+ )
+ if path.suffix == ".md":
+ offenders.extend(
+ f"{relative}: heading {match.group(0)}"
+ for match in _HEADING_PATTERN.finditer(text)
+ )
+
+ assert not offenders, "Retired contract names returned:\n" + "\n".join(offenders)
+
+
+def test_retired_term_pattern_uses_whole_tokens() -> None:
+ """Ordinary words containing the retired stems remain legal."""
+ assert _RETIRED_TERM_PATTERN.search("a safety tier")
+ assert _RETIRED_TERM_PATTERN.search("a mutating tool")
+ assert _RETIRED_TERM_PATTERN.search("the default tier")
+ assert _RETIRED_TERM_PATTERN.search("entire mutations") is None
diff --git a/tests/test_server.py b/tests/test_server.py
index 43218da3..886a7cce 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -4,6 +4,7 @@
import json
import os
+import pathlib
import subprocess
import sys
import textwrap
@@ -11,201 +12,360 @@
import pytest
-from libtmux_mcp._utils import TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY
-from libtmux_mcp.server import _BASE_INSTRUCTIONS, _build_instructions
-
-if t.TYPE_CHECKING:
- from libtmux.server import Server
-
- from libtmux_mcp.server import _ServerCacheKey
+from libtmux_mcp._utils import TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN
+from libtmux_mcp.server import (
+ _BASE_INSTRUCTIONS,
+ DEFAULT_TOOLSETS,
+ _build_instructions,
+)
class BuildInstructionsFixture(t.NamedTuple):
"""Test fixture for _build_instructions."""
test_id: str
- safety_level: str
+ toolsets: frozenset[str]
tmux_pane_env: str | None
tmux_env: str | None
expect_agent_context: bool
expect_pane_id_in_text: str | None
expect_socket_name: str | None
- expect_safety_in_text: str | None
+ expect_toolsets_in_text: str
BUILD_INSTRUCTIONS_FIXTURES: list[BuildInstructionsFixture] = [
BuildInstructionsFixture(
test_id="inside_tmux_full_context",
- safety_level=TAG_MUTATING,
+ toolsets=DEFAULT_TOOLSETS,
tmux_pane_env="%42",
tmux_env="/tmp/tmux-1000/default,12345,0",
expect_agent_context=True,
expect_pane_id_in_text="%42",
expect_socket_name="default",
- expect_safety_in_text="mutating",
+ expect_toolsets_in_text="execute, inspect, manage",
),
BuildInstructionsFixture(
test_id="outside_tmux_no_context",
- safety_level=TAG_MUTATING,
+ toolsets=DEFAULT_TOOLSETS,
tmux_pane_env=None,
tmux_env=None,
expect_agent_context=False,
expect_pane_id_in_text=None,
expect_socket_name=None,
- expect_safety_in_text="mutating",
+ expect_toolsets_in_text="execute, inspect, manage",
),
BuildInstructionsFixture(
test_id="pane_only_no_tmux_env",
- safety_level=TAG_MUTATING,
+ toolsets=DEFAULT_TOOLSETS,
tmux_pane_env="%99",
tmux_env=None,
expect_agent_context=True,
expect_pane_id_in_text="%99",
expect_socket_name=None,
- expect_safety_in_text="mutating",
+ expect_toolsets_in_text="execute, inspect, manage",
),
BuildInstructionsFixture(
- test_id="readonly_safety_level",
- safety_level=TAG_READONLY,
+ test_id="inspect_only",
+ toolsets=frozenset({TOOLSET_INSPECT}),
tmux_pane_env=None,
tmux_env=None,
expect_agent_context=False,
expect_pane_id_in_text=None,
expect_socket_name=None,
- expect_safety_in_text="readonly",
+ expect_toolsets_in_text="inspect",
),
+ # The ladder could not express this: deletion without the typing tools.
BuildInstructionsFixture(
- test_id="destructive_safety_level",
- safety_level=TAG_DESTRUCTIVE,
+ test_id="inspect_and_teardown",
+ toolsets=frozenset({TOOLSET_INSPECT, TOOLSET_TEARDOWN}),
tmux_pane_env=None,
tmux_env=None,
expect_agent_context=False,
expect_pane_id_in_text=None,
expect_socket_name=None,
- expect_safety_in_text="destructive",
+ expect_toolsets_in_text="inspect, teardown",
),
]
-class SafetyLevelFixture(t.NamedTuple):
- """Test fixture for server safety-level resolution."""
+class ToolsetsFixture(t.NamedTuple):
+ """One ``LIBTMUX_TOOLSETS`` value and the surface it selects."""
test_id: str
env_value: str | None
- expected_level: str
+ expected: frozenset[str]
-SAFETY_LEVEL_FIXTURES: list[SafetyLevelFixture] = [
- SafetyLevelFixture("unset_defaults_mutating", None, TAG_MUTATING),
- SafetyLevelFixture("valid_readonly", TAG_READONLY, TAG_READONLY),
- SafetyLevelFixture("valid_mutating", TAG_MUTATING, TAG_MUTATING),
- SafetyLevelFixture("valid_destructive", TAG_DESTRUCTIVE, TAG_DESTRUCTIVE),
- SafetyLevelFixture("invalid_fails_closed", "read", TAG_READONLY),
+TOOLSETS_FIXTURES: list[ToolsetsFixture] = [
+ ToolsetsFixture("unset_takes_the_default", None, DEFAULT_TOOLSETS),
+ ToolsetsFixture("single", "inspect", frozenset({"inspect"})),
+ ToolsetsFixture(
+ "teardown_without_execute",
+ "inspect,teardown",
+ frozenset({"inspect", "teardown"}),
+ ),
+ ToolsetsFixture(
+ "whitespace_tolerated", " inspect , manage ", frozenset({"inspect", "manage"})
+ ),
+ ToolsetsFixture("empty_is_legal", "", frozenset()),
]
@pytest.mark.parametrize(
- BuildInstructionsFixture._fields,
- BUILD_INSTRUCTIONS_FIXTURES,
- ids=[f.test_id for f in BUILD_INSTRUCTIONS_FIXTURES],
+ ToolsetsFixture._fields,
+ TOOLSETS_FIXTURES,
+ ids=[f.test_id for f in TOOLSETS_FIXTURES],
)
-def test_build_instructions(
- monkeypatch: pytest.MonkeyPatch,
+def test_resolve_toolsets(
test_id: str,
- safety_level: str,
- tmux_pane_env: str | None,
- tmux_env: str | None,
- expect_agent_context: bool,
- expect_pane_id_in_text: str | None,
- expect_socket_name: str | None,
- expect_safety_in_text: str | None,
+ env_value: str | None,
+ expected: frozenset[str],
) -> None:
- """_build_instructions includes agent context and safety level."""
- if tmux_pane_env is not None:
- monkeypatch.setenv("TMUX_PANE", tmux_pane_env)
- else:
- monkeypatch.delenv("TMUX_PANE", raising=False)
+ """``LIBTMUX_TOOLSETS`` selects an unordered surface."""
+ from libtmux_mcp.server import _resolve_toolsets
- if tmux_env is not None:
- monkeypatch.setenv("TMUX", tmux_env)
- else:
- monkeypatch.delenv("TMUX", raising=False)
+ assert test_id
+ assert _resolve_toolsets(env_value) == expected
- result = _build_instructions(safety_level=safety_level)
- # Base instructions are always present
- assert _BASE_INSTRUCTIONS in result
+def test_an_unknown_toolset_fails_startup() -> None:
+ """A typo must not silently widen or narrow the surface."""
+ from libtmux_mcp.server import _resolve_toolsets
- if expect_agent_context:
- assert "Agent context" in result
- else:
- assert "Agent context" not in result
+ with pytest.raises(RuntimeError, match="unknown toolsets: bogus"):
+ _resolve_toolsets("inspect,bogus")
+
+
+@pytest.mark.parametrize(
+ ("variable", "tool_name"),
+ [
+ ("LIBTMUX_TOOLS", "definitely_not_a_tool"),
+ ("LIBTMUX_EXCLUDE_TOOLS", "definitely_not_a_tool"),
+ ("LIBTMUX_TOOLS", "get_prompt"),
+ ],
+ ids=["include-typo", "exclude-typo", "disabled-prompt-adapter"],
+)
+def test_an_unknown_tool_name_fails_server_startup(
+ variable: str,
+ tool_name: str,
+) -> None:
+ """A typo in an individual include or exclude fails closed."""
+ code = textwrap.dedent(
+ """
+ import asyncio
+
+ from fastmcp import Client
+
+ from libtmux_mcp.server import build_mcp_server
+
+
+ async def main():
+ async with Client(build_mcp_server()):
+ pass
+
+
+ asyncio.run(main())
+ """
+ )
+ env = {**os.environ, variable: tool_name}
+ proc = subprocess.run(
+ [sys.executable, "-c", code],
+ check=False,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
- if expect_pane_id_in_text is not None:
- assert expect_pane_id_in_text in result
+ assert proc.returncode != 0
+ assert f"{variable} names unknown tools: {tool_name}" in proc.stderr
- if expect_socket_name is not None:
- assert expect_socket_name in result
- if expect_safety_in_text is not None:
- assert f"Safety level: {expect_safety_in_text}" in result
+@pytest.mark.parametrize("variable", ["LIBTMUX_TOOLS", "LIBTMUX_EXCLUDE_TOOLS"])
+def test_generated_prompt_tool_names_validate_when_enabled(variable: str) -> None:
+ """Validation includes tools produced by the prompt adapter transform."""
+ code = textwrap.dedent(
+ """
+ import asyncio
+
+ from fastmcp import Client
+
+ from libtmux_mcp.server import build_mcp_server
+
+
+ async def main():
+ async with Client(build_mcp_server()):
+ pass
+
+
+ asyncio.run(main())
+ """
+ )
+ env = {
+ **os.environ,
+ "LIBTMUX_MCP_PROMPTS_AS_TOOLS": "1",
+ variable: "get_prompt",
+ }
+ proc = subprocess.run(
+ [sys.executable, "-c", code],
+ check=False,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+
+ assert proc.returncode == 0, proc.stderr
@pytest.mark.parametrize(
- SafetyLevelFixture._fields,
- SAFETY_LEVEL_FIXTURES,
- ids=[f.test_id for f in SAFETY_LEVEL_FIXTURES],
+ ("tool_name", "selection"),
+ [
+ ("send_keys", {"LIBTMUX_TOOLSETS": "inspect"}),
+ ("list_sessions", {"LIBTMUX_EXCLUDE_TOOLS": "list_sessions"}),
+ ],
+ ids=["toolset-omission", "explicit-exclusion"],
)
-def test_resolve_safety_level(
- test_id: str,
- env_value: str | None,
- expected_level: str,
+def test_a_hidden_tool_is_unknown_on_the_production_wire(
+ tool_name: str,
+ selection: dict[str, str],
) -> None:
- """Safety env values resolve to the server's effective tier."""
- from libtmux_mcp.server import _resolve_safety_level
+ """FastMCP visibility rejects hidden tools before tool dispatch."""
+ code = textwrap.dedent(
+ f"""
+ import asyncio
- assert test_id
- assert _resolve_safety_level(env_value) == expected_level
+ from fastmcp import Client
+
+ from libtmux_mcp.server import build_mcp_server
+
+
+ async def main():
+ async with Client(build_mcp_server()) as client:
+ result = await client.call_tool(
+ {tool_name!r},
+ {{}},
+ raise_on_error=False,
+ )
+ print(result.content[0].text)
-def test_invalid_safety_env_hides_mutating_tools() -> None:
- """Invalid ``LIBTMUX_SAFETY`` values expose readonly tools only."""
+ asyncio.run(main())
+ """
+ )
+ env = {**os.environ, **selection}
+ proc = subprocess.run(
+ [sys.executable, "-c", code],
+ check=False,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+
+ assert proc.returncode == 0
+ assert f"Unknown tool: '{tool_name}'" in proc.stdout
+
+
+@pytest.mark.parametrize(
+ ("excluded_tool", "nested_success"),
+ [("", True), ("list_servers", False)],
+ ids=["named-wrapper-authority", "explicit-exclusion"],
+)
+def test_named_read_batch_reaches_only_unexcluded_inspect_tools(
+ excluded_tool: str,
+ nested_success: bool,
+ tmp_path: pathlib.Path,
+) -> None:
+ """A named batch carries inspect authority without exposing nested tools."""
code = textwrap.dedent(
"""
import asyncio
import json
+ from fastmcp import Client
+
from libtmux_mcp.server import build_mcp_server
+
async def main():
- tools = await build_mcp_server().list_tools()
- names = {tool.name for tool in tools}
- print(json.dumps({
- "list_sessions": "list_sessions" in names,
- "send_keys": "send_keys" in names,
- "send_keys_batch": "send_keys_batch" in names,
- "kill_pane": "kill_pane" in names,
- }))
+ async with Client(build_mcp_server()) as client:
+ tools = await client.list_tools()
+ batch = await client.call_tool(
+ "call_read_tools_batch",
+ {
+ "operations": [
+ {
+ "tool": "list_servers",
+ "arguments": {},
+ }
+ ]
+ },
+ raise_on_error=False,
+ )
+ direct = await client.call_tool(
+ "list_servers",
+ {},
+ raise_on_error=False,
+ )
+ row = batch.structured_content["results"][0]
+ print(
+ json.dumps(
+ {
+ "tools": [tool.name for tool in tools],
+ "nested_success": row["success"],
+ "nested_error": row["error"],
+ "direct_error": direct.content[0].text,
+ }
+ )
+ )
+
asyncio.run(main())
"""
)
- env = {**os.environ, "LIBTMUX_SAFETY": "read"}
+ env = {
+ **os.environ,
+ "LIBTMUX_TOOLSETS": "",
+ "LIBTMUX_TOOLS": "call_read_tools_batch",
+ "LIBTMUX_EXCLUDE_TOOLS": excluded_tool,
+ "TMUX_TMPDIR": str(tmp_path),
+ }
proc = subprocess.run(
[sys.executable, "-c", code],
- check=True,
+ check=False,
capture_output=True,
+ text=True,
env=env,
+ )
+
+ assert proc.returncode == 0, proc.stderr
+ payload = json.loads(proc.stdout)
+ assert payload["tools"] == ["call_read_tools_batch"]
+ assert payload["nested_success"] is nested_success
+ assert "Unknown tool: 'list_servers'" in payload["direct_error"]
+ if excluded_tool:
+ assert "Unknown tool: 'list_servers'" in payload["nested_error"]
+ else:
+ assert payload["nested_error"] is None
+
+
+def test_the_retired_safety_variable_fails_startup() -> None:
+ """`LIBTMUX_SAFETY` is gone; ignoring it could widen a surface."""
+ code = textwrap.dedent(
+ """
+ import libtmux_mcp.server
+ """
+ )
+ env = {**os.environ, "LIBTMUX_SAFETY": "destructive"}
+ proc = subprocess.run(
+ [sys.executable, "-c", code],
+ check=False,
+ capture_output=True,
text=True,
+ env=env,
)
- result = json.loads(proc.stdout)
- assert result == {
- "list_sessions": True,
- "send_keys": False,
- "send_keys_batch": False,
- "kill_pane": False,
- }
+ assert proc.returncode != 0
+ assert "LIBTMUX_SAFETY has been removed" in proc.stderr
+ assert "LIBTMUX_TOOLSETS" in proc.stderr
def test_run_server_pins_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -272,13 +432,13 @@ def test_base_instructions_prefer_typed_completion_over_polling() -> None:
def test_base_instructions_document_hook_boundary() -> None:
- """_BASE_INSTRUCTIONS explains hooks are read-only by design.
+ """_BASE_INSTRUCTIONS explains hooks are inspection-only by design.
Without this sentence agents waste a turn asking for ``set_hook`` or
trying to write hooks through a nonexistent tool. Naming the
boundary heads off the exploratory call.
"""
- assert "HOOKS ARE READ-ONLY" in _BASE_INSTRUCTIONS
+ assert "NO DEDICATED HOOK-WRITE TOOLS" in _BASE_INSTRUCTIONS
assert "show_hooks" in _BASE_INSTRUCTIONS
assert "tmux config file" in _BASE_INSTRUCTIONS
@@ -385,24 +545,30 @@ def test_build_instructions_documents_is_caller_workflow_inside_tmux(
# Outside tmux: the workflow sentence must NOT appear.
monkeypatch.delenv("TMUX_PANE", raising=False)
monkeypatch.delenv("TMUX", raising=False)
- outside = _build_instructions(safety_level=TAG_MUTATING)
+ outside = _build_instructions(toolsets=frozenset({TOOLSET_MANAGE}))
assert "whoami tool" not in outside
assert "is_caller=true" not in outside
# Inside tmux: the workflow sentence appears.
monkeypatch.setenv("TMUX_PANE", "%42")
monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,12345,0")
- inside = _build_instructions(safety_level=TAG_MUTATING)
+ inside = _build_instructions(toolsets=frozenset({TOOLSET_MANAGE}))
assert "is_caller=true" in inside
assert "whoami tool" in inside
assert "list_panes" in inside
-def test_build_instructions_always_includes_safety() -> None:
- """_build_instructions always includes the safety level."""
- result = _build_instructions(safety_level=TAG_MUTATING)
- assert "Safety level:" in result
- assert "LIBTMUX_SAFETY" in result
+def test_build_instructions_always_names_the_toolsets() -> None:
+ """Instructions name the enabled toolsets and how to change them.
+
+ They also say what hiding one does not do, because a model reading a
+ short list is otherwise free to infer that the rest is unreachable.
+ """
+ result = _build_instructions(toolsets=frozenset({TOOLSET_MANAGE}))
+
+ assert "Toolsets: manage" in result
+ assert "LIBTMUX_TOOLSETS" in result
+ assert "not what a pane can run" in result
@pytest.mark.parametrize(
@@ -439,39 +605,39 @@ def test_build_instructions_defaults_semantic_history_suppression_on() -> None:
ids=["history-disabled", "history-enabled"],
)
@pytest.mark.parametrize(
- ("tier", "tmux_pane", "tmux_env"),
+ ("toolset", "tmux_pane", "tmux_env"),
[
- (TAG_READONLY, "%42", "/tmp/tmux-1000/default,12345,0"),
- (TAG_MUTATING, "%42", "/tmp/tmux-1000/default,12345,0"),
- (TAG_DESTRUCTIVE, "%42", "/tmp/tmux-1000/default,12345,0"),
- (TAG_READONLY, "", ""),
- (TAG_MUTATING, "", ""),
- (TAG_DESTRUCTIVE, "", ""),
+ (TOOLSET_INSPECT, "%42", "/tmp/tmux-1000/default,12345,0"),
+ (TOOLSET_MANAGE, "%42", "/tmp/tmux-1000/default,12345,0"),
+ (TOOLSET_TEARDOWN, "%42", "/tmp/tmux-1000/default,12345,0"),
+ (TOOLSET_INSPECT, "", ""),
+ (TOOLSET_MANAGE, "", ""),
+ (TOOLSET_TEARDOWN, "", ""),
# Variable-length stress: longer socket name + multi-digit pane id.
# Guards against future text additions tipping a realistic case
# over the 2KB budget. Exercises BOTH axes — a multi-digit pane id
# (TMUX_PANE) and a longer socket name (LIBTMUX_SOCKET). Margin
# ~2 bytes; if a future text addition trips this, either trim
# further or fall back to a tighter compression form (drop spaces
- # around ``/`` in HOOKS, drop spaces after colons in the safety
+ # around ``/`` in HOOKS, drop spaces after colons in the toolset
# paragraph) for additional bytes of margin.
- (TAG_READONLY, "%99", "/tmp/tmux-1000/dev-prod,12345,0"),
+ (TOOLSET_INSPECT, "%99", "/tmp/tmux-1000/dev-prod,12345,0"),
],
)
-def test_full_instructions_under_2kb_across_tiers_and_tmux_pane(
+def test_full_instructions_under_2kb_across_toolsets_and_tmux_pane(
monkeypatch: pytest.MonkeyPatch,
suppress_history: bool,
- tier: str,
+ toolset: str,
tmux_pane: str,
tmux_env: str,
) -> None:
"""The transmitted instructions= string fits Claude Code's 2KB budget.
The static ``_BASE_INSTRUCTIONS`` length is not the contract —
- ``_build_instructions`` appends a safety-tier block, an optional
- readonly-tier hint, and an optional ``$TMUX_PANE`` agent-context
+ ``_build_instructions`` appends a toolset block, an optional
+ `inspect`-only hint, and an optional ``$TMUX_PANE`` agent-context
block. The full transmitted string must be ≤ 2048 bytes for every
- (tier, tmux_pane) combination, otherwise Claude Code silently
+ (toolset, tmux_pane) combination, otherwise Claude Code silently
truncates the agent-context block — the only server-side fix for
"current window" anaphora.
@@ -488,12 +654,12 @@ def test_full_instructions_under_2kb_across_tiers_and_tmux_pane(
monkeypatch.delenv("TMUX", raising=False)
instructions = _build_instructions(
- safety_level=tier,
+ toolsets=frozenset({toolset}),
suppress_history=suppress_history,
)
size = len(instructions.encode())
assert size <= 2048, (
- f"tier={tier} tmux_pane={tmux_pane!r}: "
+ f"toolset={toolset} tmux_pane={tmux_pane!r}: "
f"{size} bytes exceeds Claude Code's 2KB ceiling"
)
@@ -512,7 +678,7 @@ def test_instruction_budget_drops_oversized_socket_before_required_text(
monkeypatch.setenv("TMUX", f"/tmp/tmux-1000/{socket_name},12345,0")
instructions = _build_instructions(
- safety_level=TAG_READONLY,
+ toolsets=frozenset({TOOLSET_INSPECT}),
suppress_history=True,
)
@@ -536,7 +702,7 @@ def test_instruction_budget_can_drop_all_oversized_optional_context(
monkeypatch.setenv("TMUX", f"/tmp/tmux-1000/{'s' * 4096},12345,0")
instructions = _build_instructions(
- safety_level=TAG_READONLY,
+ toolsets=frozenset({TOOLSET_INSPECT}),
suppress_history=False,
)
@@ -591,24 +757,24 @@ def test_scope_segment_carries_anti_triggers() -> None:
assert "clarifying question" in _INSTR_SCOPE
-@pytest.mark.parametrize("tier", [TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE])
-def test_readonly_hint_visible_only_on_readonly_tier(
- monkeypatch: pytest.MonkeyPatch, tier: str
+@pytest.mark.parametrize("toolset", [TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN])
+def test_probe_hint_visible_only_on_an_inspect_only_surface(
+ monkeypatch: pytest.MonkeyPatch, toolset: str
) -> None:
- """The 'Readonly mode:' investigation hint appears only on readonly.
+ """The investigation hint appears only when `inspect` is all there is.
- False-positive activation is cheap on readonly (worst case: an
- extra ``list_panes`` call) and expensive on mutating/destructive
- (where ``kill_*`` is one mis-routed query away). Reuse the existing
- safety axis instead of shipping a separate discoverability knob.
+ A wrong guess is cheap there (worst case: an
+ extra ``list_panes`` call) and expensive on a surface holding teardown
+ tools (where ``kill_*`` is one mis-routed query away). Reuse the existing
+ toolset classification instead of shipping a separate discoverability knob.
"""
monkeypatch.delenv("TMUX_PANE", raising=False)
monkeypatch.delenv("TMUX", raising=False)
- instructions = _build_instructions(safety_level=tier)
- if tier == TAG_READONLY:
- assert "Readonly mode:" in instructions
+ instructions = _build_instructions(toolsets=frozenset({toolset}))
+ if toolset == TOOLSET_INSPECT:
+ assert "Probe snapshot_pane" in instructions
else:
- assert "Readonly mode:" not in instructions
+ assert "Probe snapshot_pane" not in instructions
# ---------------------------------------------------------------------------
@@ -695,7 +861,7 @@ def test_readonly_hint_visible_only_on_readonly_tier(
#: Discovery anchors that carry the ``anthropic/alwaysLoad`` per-tool
-#: meta hint. Read-only only — best-effort hint to Claude Code that
+#: meta hint. Inspect only — best-effort hint to Claude Code that
#: keeps a tiny tmux vocabulary always-visible without preloading
#: every tool's schema.
_ALWAYS_LOAD_ANCHORS = frozenset(["list_panes", "list_windows", "snapshot_pane"])
@@ -918,7 +1084,7 @@ def test_lifespan_missing_tmux_raises_runtime_error(
"""Startup raises a clear RuntimeError when tmux is not on PATH."""
import asyncio
- from libtmux_mcp.server import _lifespan
+ from libtmux_mcp.server import _lifespan, mcp
def _missing_tmux(_name: str) -> None:
return None
@@ -926,7 +1092,7 @@ def _missing_tmux(_name: str) -> None:
monkeypatch.setattr("libtmux_mcp.server.shutil.which", _missing_tmux)
async def _enter() -> None:
- async with _lifespan(_app=None): # type: ignore[arg-type]
+ async with _lifespan(mcp):
pytest.fail("lifespan should have raised before yielding")
with pytest.raises(RuntimeError, match="tmux binary not found"):
@@ -934,23 +1100,31 @@ async def _enter() -> None:
def test_lifespan_clears_server_cache_on_exit() -> None:
- """Clean lifespan exit empties the process-wide ``_server_cache``."""
+ """Clean lifespan exit drops cached clients without touching tmux."""
import asyncio
from libtmux_mcp._utils import _server_cache
- from libtmux_mcp.server import _lifespan
+ from libtmux_mcp.server import _lifespan, mcp
+
+ class RecordingServer:
+ def __init__(self) -> None:
+ self.commands: list[tuple[object, ...]] = []
+
+ def cmd(self, *args: object) -> object:
+ self.commands.append(args)
+ return t.cast("t.Any", object())
- # Seed the cache with a sentinel entry — the actual value doesn't
- # matter; we're checking that exit clears it.
- _server_cache[("sentinel_socket", None, None)] = t.cast("t.Any", object())
+ server = RecordingServer()
+ _server_cache[("sentinel_socket", None, None)] = t.cast("t.Any", server)
async def _cycle() -> None:
- async with _lifespan(_app=None): # type: ignore[arg-type]
+ async with _lifespan(mcp):
# While the lifespan is active the cache still holds state.
assert _server_cache
asyncio.run(_cycle())
assert _server_cache == {}
+ assert server.commands == []
def test_server_constructed_with_lifespan() -> None:
@@ -958,62 +1132,3 @@ def test_server_constructed_with_lifespan() -> None:
from libtmux_mcp.server import _lifespan, mcp
assert mcp._lifespan is _lifespan
-
-
-@pytest.mark.usefixtures("mcp_session")
-def test_gc_mcp_buffers_deletes_mcp_prefixed_and_spares_others(
- mcp_server: Server,
-) -> None:
- """``_gc_mcp_buffers`` deletes ``libtmux_mcp_*`` buffers only.
-
- Best-effort lifespan GC for leaked paste buffers — agents are
- supposed to ``delete_buffer`` after use, but an interrupted call
- chain can leak. The GC must NEVER touch non-MCP buffers (OS
- clipboard sync, user-authored buffers) — those are the human user's
- content.
- """
- from libtmux_mcp.server import _gc_mcp_buffers
- from libtmux_mcp.tools.buffer_tools import load_buffer
-
- # Seed: one MCP-owned buffer via the canonical path, one human-owned
- # buffer directly via tmux so it is outside the MCP prefix.
- ref = load_buffer(
- content="agent-staged",
- logical_name="leaky",
- socket_name=mcp_server.socket_name,
- )
- mcp_server.cmd("set-buffer", "-b", "human_buffer", "user-content")
-
- names_before = mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout
- assert ref.buffer_name in names_before
- assert "human_buffer" in names_before
-
- _gc_mcp_buffers({(mcp_server.socket_name, None, None): mcp_server})
-
- names_after = mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout
- assert ref.buffer_name not in names_after, "GC must delete MCP-namespaced buffers"
- assert "human_buffer" in names_after, "GC must not touch non-MCP buffers"
-
- # Clean up the human buffer so the fixture teardown stays tidy.
- mcp_server.cmd("delete-buffer", "-b", "human_buffer")
-
-
-def test_gc_mcp_buffers_swallows_errors() -> None:
- """GC logs but never raises when tmux is unreachable."""
- from libtmux_mcp.server import _gc_mcp_buffers
-
- class _BrokenServer:
- def cmd(self, *_a: object, **_kw: object) -> object:
- msg = "tmux is dead"
- raise RuntimeError(msg)
-
- # Must not raise — lifespan shutdown cannot tolerate exceptions here.
- # Cast is needed because _BrokenServer only implements ``cmd``; the
- # real cache stores full Server instances, but GC is best-effort and
- # consumes only the ``cmd`` method so a partial stub is sufficient.
- _gc_mcp_buffers(
- t.cast(
- "t.Mapping[_ServerCacheKey, t.Any]",
- {(None, None, None): _BrokenServer()},
- )
- )
diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py
index b6387bf5..3065541e 100644
--- a/tests/test_spawn_tools_history.py
+++ b/tests/test_spawn_tools_history.py
@@ -370,65 +370,46 @@ def _spawn_history_server(enabled: bool) -> FastMCP:
return mcp
-def test_generic_batch_spawn_ignores_command_default_unless_opted_in(
+def test_a_spawn_cannot_be_laundered_through_the_read_batch(
mcp_server: Server,
mcp_session: Session,
) -> None:
- """Nested spawn calls retain their explicit persistent-history option."""
- supplied = "private-nested-history-path"
- before = {window.window_id for window in mcp_session.windows}
- base = {
- "session_name": mcp_session.session_name,
- "environment": {"HISTFILE": supplied},
- "socket_name": mcp_server.socket_name,
- }
+ """The batch refuses a spawn instead of running it under its own name.
+
+ A batch gives every nested call the wrapper's name, so a client rule
+ keyed on ``create_window`` would not fire. Keeping the wrapper to
+ ``inspect`` is what makes that unreachable.
+ """
+ from fastmcp import Client
async def _exercise() -> t.Any:
async with Client(_spawn_history_server(True)) as client:
return await client.call_tool(
- "call_mutating_tools_batch",
+ "call_read_tools_batch",
{
- "on_error": "continue",
"operations": [
{
"tool": "create_window",
"arguments": {
- **base,
- "window_name": "batch_spawn_omitted",
+ "session_name": mcp_session.session_name,
+ "socket_name": mcp_server.socket_name,
+ "window_name": "batch_spawn_refused",
},
- },
- {
- "tool": "create_window",
- "arguments": {
- **base,
- "window_name": "batch_spawn_explicit_true_conflict",
- "suppress_persistent_history": True,
- },
- },
- ],
+ }
+ ]
},
raise_on_error=False,
)
+ before = {window.window_id for window in mcp_session.windows}
result = asyncio.run(_exercise())
- assert result.is_error is False
assert result.structured_content is not None
- assert result.structured_content["succeeded"] == 1
- assert result.structured_content["failed"] == 1
- assert result.structured_content["stopped_at"] is None
- succeeded, failed = result.structured_content["results"]
- assert succeeded["success"] is True
- assert succeeded["structured_content"]["window_name"] == "batch_spawn_omitted"
- assert failed["success"] is False
- assert failed["error"] == (
- "environment variable HISTFILE conflicts with "
- "suppress_persistent_history=True; "
- "omit it or set it to an empty string"
- )
- assert supplied not in failed["error"]
- created = {window.window_id for window in mcp_session.windows} - before
- assert len(created) == 1
+ assert result.structured_content["succeeded"] == 0
+ [operation] = result.structured_content["results"]
+ assert "not an 'inspect' tool" in operation["error"]
+ mcp_session.refresh()
+ assert {window.window_id for window in mcp_session.windows} == before
def test_spawn_conflict_is_absent_from_tool_results_and_logs(
diff --git a/tests/test_tmux_format_arguments.py b/tests/test_tmux_format_arguments.py
new file mode 100644
index 00000000..5804e4aa
--- /dev/null
+++ b/tests/test_tmux_format_arguments.py
@@ -0,0 +1,304 @@
+"""Tests for caller text reaching a tmux argument that tmux expands.
+
+tmux expands several argument values as formats, where ``#H`` becomes the
+hostname and ``#(cmd)`` runs a shell job. Every such argument is covered
+here, so a new one cannot be added without a matching row.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import typing as t
+
+import pytest
+
+from libtmux_mcp._utils import ExpectedToolError
+from libtmux_mcp.tools.pane_tools import respawn_pane
+from libtmux_mcp.tools.server_tools import create_session
+from libtmux_mcp.tools.session_tools import create_window
+from libtmux_mcp.tools.window_tools import split_window
+
+if t.TYPE_CHECKING:
+ from libtmux.pane import Pane
+ from libtmux.server import Server
+ from libtmux.session import Session
+ from libtmux.window import Window
+
+#: Spawn tools keyed by name, each called with only ``start_directory``
+#: and the target it needs, and each returning the resulting pane ID.
+_SPAWNERS: dict[str, t.Callable[[Server, Session, Pane, str], str]] = {
+ "create_session": lambda server, session, pane, cwd: t.cast(
+ "str",
+ create_session(
+ session_name="sd-probe",
+ start_directory=cwd,
+ socket_name=server.socket_name,
+ ).active_pane_id,
+ ),
+ "create_window": lambda server, session, pane, cwd: t.cast(
+ "str",
+ create_window(
+ session_id=session.session_id,
+ start_directory=cwd,
+ socket_name=server.socket_name,
+ ).active_pane_id,
+ ),
+ "split_window": lambda server, session, pane, cwd: (
+ split_window(
+ pane_id=pane.pane_id,
+ start_directory=cwd,
+ socket_name=server.socket_name,
+ ).pane_id
+ ),
+ "respawn_pane": lambda server, session, pane, cwd: (
+ respawn_pane(
+ pane_id=t.cast("str", pane.pane_id),
+ start_directory=cwd,
+ socket_name=server.socket_name,
+ ).pane_id
+ ),
+}
+
+
+def _current_path(server: Server, pane_id: str) -> str:
+ """Return a pane's working directory as tmux reports it."""
+ pane = server.panes.get(pane_id=pane_id)
+ assert pane is not None
+ pane.refresh()
+ return t.cast("str", pane.pane_current_path)
+
+
+@pytest.mark.parametrize("spawner", list(_SPAWNERS))
+def test_start_directory_does_not_run_tmux_format_jobs(
+ mcp_server: Server,
+ mcp_session: Session,
+ mcp_pane: Pane,
+ tmp_path: pathlib.Path,
+ spawner: str,
+) -> None:
+ """A ``#(...)`` job in ``start_directory`` is refused, not executed."""
+ marker = tmp_path / "executed"
+
+ with pytest.raises(ExpectedToolError):
+ _SPAWNERS[spawner](mcp_server, mcp_session, mcp_pane, f"#(touch {marker})")
+
+ assert not marker.exists()
+
+
+@pytest.mark.parametrize("spawner", list(_SPAWNERS))
+@pytest.mark.parametrize(
+ "name",
+ ["plain", "has#hash", "job#(id)", "var#{x}", "style#[x]", "run##[x]"],
+)
+def test_start_directory_uses_the_directory_named(
+ mcp_server: Server,
+ mcp_session: Session,
+ mcp_pane: Pane,
+ tmp_path: pathlib.Path,
+ spawner: str,
+ name: str,
+) -> None:
+ """A directory whose real name contains ``#`` is used verbatim."""
+ target = tmp_path / name
+ target.mkdir()
+
+ pane_id = _SPAWNERS[spawner](mcp_server, mcp_session, mcp_pane, str(target))
+
+ assert _current_path(mcp_server, pane_id) == str(target)
+
+
+def test_start_directory_rejects_a_missing_directory(
+ mcp_server: Server,
+ mcp_pane: Pane,
+ tmp_path: pathlib.Path,
+) -> None:
+ """A path that does not exist fails instead of falling back to ``$HOME``."""
+ with pytest.raises(ExpectedToolError, match="not an existing directory"):
+ split_window(
+ pane_id=mcp_pane.pane_id,
+ start_directory=str(tmp_path / "absent"),
+ socket_name=mcp_server.socket_name,
+ )
+
+
+#: Text exercising each way a tmux format rewrites an argument: a
+#: single-character alias, a variable, and a command job.
+FORMAT_BEARING_NAMES = ["plain", "host#Hname", "sess#Sname", "job#(id)"]
+
+
+@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES)
+def test_rename_session_stores_the_name_given(
+ mcp_server: Server,
+ mcp_session: Session,
+ text: str,
+) -> None:
+ """``tmux rename-session`` expands its argument; the stored name matches."""
+ from libtmux_mcp.tools.session_tools import rename_session
+
+ renamed = rename_session(
+ new_name=text,
+ session_id=mcp_session.session_id,
+ socket_name=mcp_server.socket_name,
+ )
+
+ assert renamed.session_name == text
+
+
+@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES)
+def test_rename_window_stores_the_name_given(
+ mcp_server: Server,
+ mcp_window: Window,
+ text: str,
+) -> None:
+ """``tmux rename-window`` expands its argument; the stored name matches."""
+ from libtmux_mcp.tools.window_tools import rename_window
+
+ renamed = rename_window(
+ new_name=text,
+ window_id=mcp_window.window_id,
+ socket_name=mcp_server.socket_name,
+ )
+
+ assert renamed.window_name == text
+
+
+@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES)
+def test_set_pane_title_stores_the_title_given(
+ mcp_server: Server,
+ mcp_pane: Pane,
+ text: str,
+) -> None:
+ """``tmux select-pane -T`` expands its argument; the stored title matches."""
+ from libtmux_mcp.tools.pane_tools import set_pane_title
+
+ set_pane_title(
+ title=text,
+ pane_id=t.cast("str", mcp_pane.pane_id),
+ socket_name=mcp_server.socket_name,
+ )
+
+ mcp_pane.refresh()
+ assert mcp_pane.pane_title == text
+
+
+def test_set_option_stores_the_option_named(
+ mcp_server: Server,
+ mcp_session: Session,
+) -> None:
+ """``set-option`` and ``show-options`` both expand the option name."""
+ from libtmux_mcp.tools.option_tools import set_option
+
+ name = "@probe#Hopt"
+ set_option(
+ option=name,
+ value="1",
+ global_=True,
+ socket_name=mcp_server.socket_name,
+ )
+
+ stored = mcp_server.cmd("show-options", "-g").stdout
+ assert any(line.startswith(f"{name} ") for line in stored), stored
+
+
+@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES)
+def test_create_session_stores_the_names_given(
+ mcp_server: Server,
+ text: str,
+) -> None:
+ """``new-session`` expands both ``-s`` and ``-n``."""
+ created = create_session(
+ session_name=text,
+ window_name=text,
+ socket_name=mcp_server.socket_name,
+ )
+
+ assert created.session_name == text
+ session = mcp_server.sessions.get(session_id=created.session_id)
+ assert session is not None
+ assert session.active_window.window_name == text
+
+
+@pytest.mark.parametrize("text", FORMAT_BEARING_NAMES)
+def test_create_window_stores_the_name_given(
+ mcp_server: Server,
+ mcp_session: Session,
+ text: str,
+) -> None:
+ """``new-window`` expands ``-n``."""
+ created = create_window(
+ session_id=mcp_session.session_id,
+ window_name=text,
+ socket_name=mcp_server.socket_name,
+ )
+
+ assert created.window_name == text
+
+
+@pytest.mark.parametrize(
+ "format_string",
+ [
+ "#(touch /tmp/evil)",
+ "#{E:@opt}",
+ "#{E:pane_current_path}",
+ "#{T:status-left}",
+ "#S",
+ "##",
+ ],
+)
+def test_display_message_accepts_only_variable_references(
+ mcp_server: Server,
+ mcp_pane: Pane,
+ format_string: str,
+) -> None:
+ """Anything but ``#{name}`` is refused, including a second expansion.
+
+ ``#{E:...}`` re-expands a variable's *value*, and a pane's own working
+ directory reaches ``pane_current_path`` unsanitized, so a blocklist on
+ the caller's text cannot see what would run.
+ """
+ from libtmux_mcp.tools.pane_tools import display_message
+
+ with pytest.raises(ExpectedToolError):
+ display_message(
+ format_string=format_string,
+ pane_id=t.cast("str", mcp_pane.pane_id),
+ socket_name=mcp_server.socket_name,
+ )
+
+
+def test_display_message_expands_plain_and_hyphenated_variables(
+ mcp_server: Server,
+ mcp_pane: Pane,
+) -> None:
+ """A format of bare ``#{name}`` references still works."""
+ from libtmux_mcp.tools.pane_tools import display_message
+
+ mcp_server.cmd("set-option", "-g", "history-limit", "4321")
+ mcp_server.cmd("set-option", "-g", "@plugin-option", "sentinel")
+ result = display_message(
+ format_string=(
+ "id=#{pane_id} history=#{history-limit} plugin=#{@plugin-option}"
+ ),
+ pane_id=t.cast("str", mcp_pane.pane_id),
+ socket_name=mcp_server.socket_name,
+ )
+
+ assert result == f"id={mcp_pane.pane_id} history=4321 plugin=sentinel"
+
+
+def test_the_hash_escaper_owns_only_the_hash_expander() -> None:
+ """Escaping is per interpreter, and applying it twice is not a no-op.
+
+ ``pipe_pane`` adds ``%`` doubling because ``pipe-pane`` runs its
+ argument through ``strftime`` as well; ``-c`` and the name arguments
+ do not, so a percent there is literal. Whoever owns a value escapes
+ it once, at the boundary that knows which expanders it will meet.
+ """
+ from libtmux_mcp._utils import _escape_tmux_format
+
+ assert _escape_tmux_format("100%done") == "100%done"
+ assert _escape_tmux_format("log-%Y.txt") == "log-%Y.txt"
+ assert _escape_tmux_format("a#b") == "a##b"
+ assert _escape_tmux_format("a##b") == "a####b"
+ assert _escape_tmux_format(_escape_tmux_format("a#b")) == "a####b"
+ assert _escape_tmux_format("") == ""
diff --git a/tests/test_tool_annotations.py b/tests/test_tool_annotations.py
new file mode 100644
index 00000000..0159ae5e
--- /dev/null
+++ b/tests/test_tool_annotations.py
@@ -0,0 +1,159 @@
+"""Tests for standard MCP hints and project-owned toolsets."""
+
+from __future__ import annotations
+
+import typing as t
+
+import pytest
+
+from libtmux_mcp._utils import (
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
+ VALID_TOOLSETS,
+)
+from libtmux_mcp.tools import register_tools
+
+from .conftest import wire_annotations
+
+CONSERVATIVE_ANNOTATIONS = {
+ "readOnlyHint": False,
+ "destructiveHint": True,
+ "idempotentHint": False,
+ "openWorldHint": True,
+}
+
+EXPECTED_TOOLS_BY_TOOLSET = {
+ TOOLSET_INSPECT: frozenset(
+ {
+ "call_read_tools_batch",
+ "capture_pane",
+ "capture_since",
+ "display_message",
+ "find_pane_by_position",
+ "get_pane_info",
+ "get_server_info",
+ "get_session_info",
+ "get_window_info",
+ "list_panes",
+ "list_servers",
+ "list_sessions",
+ "list_windows",
+ "search_panes",
+ "show_buffer",
+ "show_environment",
+ "show_hook",
+ "show_hooks",
+ "show_option",
+ "snapshot_pane",
+ "wait_for_text",
+ }
+ ),
+ TOOLSET_MANAGE: frozenset(
+ {
+ "enter_copy_mode",
+ "exit_copy_mode",
+ "load_buffer",
+ "move_window",
+ "rename_session",
+ "rename_window",
+ "resize_pane",
+ "resize_window",
+ "select_layout",
+ "select_pane",
+ "select_window",
+ "set_pane_title",
+ "signal_channel",
+ "swap_pane",
+ "wait_for_channel",
+ }
+ ),
+ TOOLSET_EXECUTE: frozenset(
+ {
+ "create_session",
+ "create_window",
+ "paste_buffer",
+ "paste_text",
+ "pipe_pane",
+ "respawn_pane",
+ "run_command",
+ "send_keys",
+ "send_keys_batch",
+ "set_environment",
+ "set_option",
+ "split_window",
+ }
+ ),
+ TOOLSET_TEARDOWN: frozenset(
+ {
+ "clear_pane",
+ "delete_buffer",
+ "kill_pane",
+ "kill_server",
+ "kill_session",
+ "kill_window",
+ }
+ ),
+}
+
+
+@pytest.fixture(scope="module")
+def advertised_tools() -> dict[str, t.Any]:
+ """Return every registered tool keyed by name, as clients see it.
+
+ Registers into a fresh server rather than the production one, whose
+ toolset filter is fixed at import and may hide tools.
+ """
+ import asyncio
+
+ from fastmcp import Client, FastMCP
+
+ mcp = FastMCP(name="test-tool-annotations")
+ register_tools(mcp)
+
+ async def _list_tools() -> list[t.Any]:
+ async with Client(mcp) as client:
+ return list(await client.list_tools())
+
+ tools = asyncio.run(_list_tools())
+ return {tool.name: tool for tool in tools}
+
+
+def _wire_tags(tool: t.Any) -> set[str]:
+ dumped = tool.model_dump(mode="json", by_alias=True, exclude_none=True)
+ return set(dumped.get("_meta", {}).get("fastmcp", {}).get("tags", []))
+
+
+def test_every_tool_advertises_conservative_mcp_hints(
+ advertised_tools: dict[str, t.Any],
+) -> None:
+ """No static hint promises how a programmable tmux target will behave."""
+ for name, tool in advertised_tools.items():
+ assert wire_annotations(tool) == CONSERVATIVE_ANNOTATIONS, name
+
+
+def test_every_tool_keeps_one_project_owned_toolset(
+ advertised_tools: dict[str, t.Any],
+) -> None:
+ """The direct-operation taxonomy remains visible on the MCP wire."""
+ known = set(VALID_TOOLSETS)
+ for name, tool in advertised_tools.items():
+ assert len(_wire_tags(tool) & known) == 1, name
+
+
+@pytest.mark.parametrize(
+ ("toolset", "expected"),
+ EXPECTED_TOOLS_BY_TOOLSET.items(),
+ ids=EXPECTED_TOOLS_BY_TOOLSET,
+)
+def test_toolset_memberships_match_direct_operations(
+ advertised_tools: dict[str, t.Any],
+ toolset: str,
+ expected: frozenset[str],
+) -> None:
+ """Each toolset keeps its reviewed direct-operation inventory."""
+ actual = {
+ name for name, tool in advertised_tools.items() if toolset in _wire_tags(tool)
+ }
+ assert actual == expected
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d5ee8f3d..2e0222ca 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -10,15 +10,11 @@
from libtmux import exc
from libtmux_mcp._utils import (
- ANNOTATIONS_CREATE,
- ANNOTATIONS_DESTRUCTIVE,
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_RO,
- ANNOTATIONS_SHELL,
- TAG_DESTRUCTIVE,
- TAG_MUTATING,
- TAG_READONLY,
- VALID_SAFETY_LEVELS,
+ TOOLSET_EXECUTE,
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_TEARDOWN,
+ VALID_TOOLSETS,
_apply_filters,
_get_server,
_invalidate_server,
@@ -384,7 +380,7 @@ def test_effective_socket_path_prefers_display_message_query(
being able to reach the server, so if the MCP process's
``$TMUX_TMPDIR`` diverges from the running tmux's, the query
fails and we fall back. The full structural fix requires
- consulting the caller's ``$TMUX`` path — see ``docs/topics/safety.md``.
+ consulting the caller's ``$TMUX`` path — see ``docs/topics/trust.md``.
"""
from libtmux_mcp._utils import _effective_socket_path
@@ -639,75 +635,22 @@ def test_serialize_pane_is_caller_requires_tmux_env_not_just_pane(
assert _serialize_pane(mcp_pane).is_caller is False
-# ---------------------------------------------------------------------------
-# Annotation and tag constants tests
-# ---------------------------------------------------------------------------
-
-_ANNOTATION_KEYS = {
- "readOnlyHint",
- "destructiveHint",
- "idempotentHint",
- "openWorldHint",
-}
-
-
-def test_annotation_presets_have_correct_keys() -> None:
- """All annotation presets contain exactly the four MCP annotation keys."""
- for preset in (
- ANNOTATIONS_RO,
- ANNOTATIONS_MUTATING,
- ANNOTATIONS_CREATE,
- ANNOTATIONS_SHELL,
- ANNOTATIONS_DESTRUCTIVE,
- ):
- assert set(preset.keys()) == _ANNOTATION_KEYS
-
-
-def test_annotations_ro_is_readonly() -> None:
- """ANNOTATIONS_RO marks tools as read-only."""
- assert ANNOTATIONS_RO["readOnlyHint"] is True
- assert ANNOTATIONS_RO["destructiveHint"] is False
-
-
-def test_annotations_destructive_is_destructive() -> None:
- """ANNOTATIONS_DESTRUCTIVE marks tools as destructive."""
- assert ANNOTATIONS_DESTRUCTIVE["destructiveHint"] is True
- assert ANNOTATIONS_DESTRUCTIVE["readOnlyHint"] is False
-
-
-def test_annotations_shell_is_open_world() -> None:
- """ANNOTATIONS_SHELL marks shell-driving tools as open-world.
-
- Shell-driving tools (``send_keys``, ``paste_text``, ``pipe_pane``)
- interact with arbitrary external state through whatever command the
- caller runs — the canonical open-world MCP interaction.
- """
- assert ANNOTATIONS_SHELL["openWorldHint"] is True
- assert ANNOTATIONS_SHELL["readOnlyHint"] is False
- assert ANNOTATIONS_SHELL["destructiveHint"] is False
- assert ANNOTATIONS_SHELL["idempotentHint"] is False
-
-
-def test_annotations_create_is_closed_world() -> None:
- """ANNOTATIONS_CREATE does NOT set openWorldHint.
-
- Create-style mutating tools (``create_session``, ``create_window``,
- ``split_window``, ``swap_pane``, ``enter_copy_mode``) allocate tmux
- objects but do not interact with an open-ended environment. The
- shell-driving case is separately handled by ``ANNOTATIONS_SHELL``.
- """
- assert ANNOTATIONS_CREATE["openWorldHint"] is False
-
-
def test_tag_constants() -> None:
- """Safety tier tag constants are distinct strings."""
- tags = {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE}
+ """Toolset constants are distinct strings."""
+ tags = {TOOLSET_INSPECT, TOOLSET_MANAGE, TOOLSET_TEARDOWN}
assert len(tags) == 3
-def test_valid_safety_levels_matches_tags() -> None:
- """VALID_SAFETY_LEVELS contains all tag constants."""
- assert {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE} == VALID_SAFETY_LEVELS
+def test_valid_toolsets_lists_every_toolset() -> None:
+ """The advertised set is exactly the four toolset constants."""
+ assert set(VALID_TOOLSETS) == {
+ TOOLSET_INSPECT,
+ TOOLSET_MANAGE,
+ TOOLSET_EXECUTE,
+ TOOLSET_TEARDOWN,
+ }
+ # Order is reported at startup, so it is part of the contract.
+ assert VALID_TOOLSETS[0] == TOOLSET_INSPECT
# ---------------------------------------------------------------------------
diff --git a/tests/test_wait_for_tools.py b/tests/test_wait_for_tools.py
index bd81a98a..d0aa70f3 100644
--- a/tests/test_wait_for_tools.py
+++ b/tests/test_wait_for_tools.py
@@ -59,22 +59,51 @@ def test_channel_tools_are_coroutines() -> None:
@pytest.mark.usefixtures("mcp_session")
-def test_signal_channel_no_waiter_is_noop(mcp_server: Server) -> None:
- """``tmux wait-for -S`` on an unwaited channel returns successfully.
+def test_signal_channel_before_wait_records_one_signal(mcp_server: Server) -> None:
+ """One pre-signal is retained; a second clears the channel.
The ``mcp_session`` fixture is required even though the test does
not touch it — the bare ``mcp_server`` fixture only constructs an
unstarted Server instance, so ``mcp_session`` is what actually
boots the tmux process.
"""
- result = asyncio.run(
+ asyncio.run(
signal_channel(
- channel="wf_test_noop",
+ channel="wf_test_pending",
+ socket_name=mcp_server.socket_name,
+ )
+ )
+ result = asyncio.run(
+ wait_for_channel(
+ channel="wf_test_pending",
+ timeout=2.0,
socket_name=mcp_server.socket_name,
)
)
+
assert "signalled" in result
+ asyncio.run(
+ signal_channel(
+ channel="wf_test_pending",
+ socket_name=mcp_server.socket_name,
+ )
+ )
+ asyncio.run(
+ signal_channel(
+ channel="wf_test_pending",
+ socket_name=mcp_server.socket_name,
+ )
+ )
+ with pytest.raises(ToolError, match="was not signalled"):
+ asyncio.run(
+ wait_for_channel(
+ channel="wf_test_pending",
+ timeout=0.1,
+ socket_name=mcp_server.socket_name,
+ )
+ )
+
@pytest.mark.usefixtures("mcp_session")
def test_wait_for_channel_returns_when_signalled(mcp_server: Server) -> None:
diff --git a/tests/test_wait_policy.py b/tests/test_wait_policy.py
index 7cfb3a82..d8030a74 100644
--- a/tests/test_wait_policy.py
+++ b/tests/test_wait_policy.py
@@ -50,9 +50,8 @@
def test_resolve_wait_max_seconds(raw: str | None, expected: float) -> None:
"""Env resolution clamps and degrades instead of raising.
- Mirrors ``_resolve_safety_level``: an operator typo must not stop
- the server from starting, and an out-of-range value must not
- silently become an unbounded wait.
+ An operator typo must not stop the server from starting, and an
+ out-of-range value must not silently become an unbounded wait.
"""
assert _resolve_wait_max_seconds(raw) == expected
diff --git a/tests/test_window_tools.py b/tests/test_window_tools.py
index ba893d51..4f9654fd 100644
--- a/tests/test_window_tools.py
+++ b/tests/test_window_tools.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import asyncio
+import shlex
import typing as t
import pytest
@@ -19,6 +21,8 @@
)
if t.TYPE_CHECKING:
+ import pathlib
+
from libtmux.server import Server
from libtmux.session import Session
@@ -35,6 +39,54 @@ def test_list_panes(mcp_server: Server, mcp_session: Session) -> None:
assert result[0].pane_id is not None
+def test_list_panes_can_activate_target_alias_and_hook(
+ mcp_server: Server,
+ mcp_session: Session,
+ tmp_path: pathlib.Path,
+) -> None:
+ """The target server can add effects around an inspect operation."""
+ alias_marker = tmp_path / "alias"
+ hook_marker = tmp_path / "hook"
+ alias_command = shlex.join(["touch", str(alias_marker)])
+ hook_command = shlex.join(["touch", str(hook_marker)])
+
+ mcp_server.cmd(
+ "set-option",
+ "-s",
+ "command-alias[999]",
+ f"list-panes=run-shell {shlex.quote(alias_command)} ; list-panes",
+ )
+ mcp_server.cmd(
+ "set-hook",
+ "-g",
+ "after-list-panes",
+ f"run-shell {shlex.quote(hook_command)}",
+ )
+
+ from fastmcp import Client
+
+ from libtmux_mcp.server import build_mcp_server
+
+ window = mcp_session.active_window
+
+ async def _call() -> t.Any:
+ async with Client(build_mcp_server()) as client:
+ return await client.call_tool(
+ "list_panes",
+ {
+ "window_id": window.window_id,
+ "socket_name": mcp_server.socket_name,
+ },
+ raise_on_error=False,
+ )
+
+ result = asyncio.run(_call())
+
+ assert result.is_error is False
+ assert alias_marker.exists()
+ assert hook_marker.exists()
+
+
def test_get_window_info(mcp_server: Server, mcp_session: Session) -> None:
"""get_window_info returns a WindowInfo for a single window."""
window = mcp_session.active_window
diff --git a/uv.lock b/uv.lock
index 5cb618d2..a0eae3bc 100644
--- a/uv.lock
+++ b/uv.lock
@@ -976,7 +976,7 @@ server = [
[[package]]
name = "gp-furo-theme"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "accessible-pygments" },
@@ -986,9 +986,9 @@ dependencies = [
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sphinx-basic-ng" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/40/80/94765d195ad569993be6242e257359cfe363424342153a04e97de1ec7e7d/gp_furo_theme-0.1.0a37.tar.gz", hash = "sha256:647484cc6559ff178737b95ae43747d5b1c17aabd54dbc749b985e713640d0e0", size = 34311, upload-time = "2026-07-27T01:16:51.046Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cf/46/851013714eae29f91ada1f3106426ea5bc6d5652ce008d17519708b6cb67/gp_furo_theme-0.1.0a38.tar.gz", hash = "sha256:8e2bd1c698eb43b3938862ef3bdae34a5455d35ce59c1024d4b13ecc839a4b60", size = 34335, upload-time = "2026-08-30T14:08:14.426Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/93/0d/3c26e02ed68e3fe46342964db53ebc1ed4631cc5000785e9802fbc68a4d2/gp_furo_theme-0.1.0a37-py3-none-any.whl", hash = "sha256:4d24d097aab626979c843ba73021286dc36b44ae56981451e32bcdf262e80713", size = 43661, upload-time = "2026-07-27T01:16:26.069Z" },
+ { url = "https://files.pythonhosted.org/packages/03/41/a0ac760f2d48de3907d743027d3fc5ee485c60c7633d339ea5818ea84ecd/gp_furo_theme-0.1.0a38-py3-none-any.whl", hash = "sha256:1086ba262dab3f748c10a96787d72628218c8df9390ef280d74e5c04b142da05", size = 43690, upload-time = "2026-08-30T14:07:52.466Z" },
]
[[package]]
@@ -1007,7 +1007,7 @@ wheels = [
[[package]]
name = "gp-sphinx"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
@@ -1029,9 +1029,9 @@ dependencies = [
{ name = "sphinx-inline-tabs" },
{ name = "sphinxext-rediraffe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0b/d6/c0b67f83123b10b759020d68c3865027d9fbec82a6755a8b24681e5e39da/gp_sphinx-0.1.0a37.tar.gz", hash = "sha256:2333b433f004d7830b370649163e4fd9666408df60e32701d614ab85abaf5ac5", size = 20707, upload-time = "2026-07-27T01:16:51.849Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ea/62/815379d9d7826137eab5369666c180028420be2264310065e7f56a60ff32/gp_sphinx-0.1.0a38.tar.gz", hash = "sha256:90cdf7599f2a927626162e85331be7b5fada77334b20b2d542a9d9fd601182ce", size = 20708, upload-time = "2026-08-30T14:08:15.378Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cf/6b/7f16626746f7a6ab7b4c687f966a311acbc2d017e155125f0861e9d70eba/gp_sphinx-0.1.0a37-py3-none-any.whl", hash = "sha256:cca4bab96fb556e96b7cc6935b0d855a99526a11881017f6c8acf3514f55e337", size = 21299, upload-time = "2026-07-27T01:16:27.65Z" },
+ { url = "https://files.pythonhosted.org/packages/49/c5/7d7bdb3604dea1fc2afd3f34b7d6ece149e6ea269a48716062bb5826684a/gp_sphinx-0.1.0a38-py3-none-any.whl", hash = "sha256:7ab1eb83570b6d206d40e7f4c3692c8af2494c2ccd69eb8f46bc08c0d4211db9", size = 21300, upload-time = "2026-08-30T14:07:53.749Z" },
]
[[package]]
@@ -1413,6 +1413,7 @@ source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
{ name = "libtmux" },
+ { name = "regex" },
]
[package.dev-dependencies]
@@ -1440,6 +1441,7 @@ dev = [
{ name = "sphinx-autodoc-fastmcp" },
{ name = "syrupy" },
{ name = "tomlkit" },
+ { name = "types-regex" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
docs = [
@@ -1468,6 +1470,7 @@ testing = [
requires-dist = [
{ name = "fastmcp", specifier = ">=3.4.2,<4.0.0" },
{ name = "libtmux", specifier = ">=0.62.0,<1.0" },
+ { name = "regex", specifier = ">=2024.11.6" },
]
[package.metadata.requires-dev]
@@ -1480,7 +1483,7 @@ dev = [
{ name = "codecov" },
{ name = "coverage" },
{ name = "gp-libs", specifier = ">=0.0.19" },
- { name = "gp-sphinx", specifier = "==0.1.0a37" },
+ { name = "gp-sphinx", specifier = "==0.1.0a38" },
{ name = "mypy" },
{ name = "pytest", specifier = ">=9.1.0" },
{ name = "pytest-cov" },
@@ -1490,18 +1493,19 @@ dev = [
{ name = "pytest-xdist" },
{ name = "ruff", specifier = ">=0.16.1" },
{ name = "sphinx-autobuild" },
- { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a37" },
- { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a37" },
+ { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a38" },
+ { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a38" },
{ name = "syrupy", specifier = ">=5.1.0" },
{ name = "tomlkit", specifier = ">=0.13" },
+ { name = "types-regex" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
docs = [
{ name = "gp-libs", specifier = ">=0.0.19" },
- { name = "gp-sphinx", specifier = "==0.1.0a37" },
+ { name = "gp-sphinx", specifier = "==0.1.0a38" },
{ name = "sphinx-autobuild" },
- { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a37" },
- { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a37" },
+ { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a38" },
+ { name = "sphinx-autodoc-fastmcp", specifier = "==0.1.0a38" },
]
lint = [
{ name = "mypy" },
@@ -2327,6 +2331,127 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
+[[package]]
+name = "regex"
+version = "2026.7.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/13/bbf7d9d1887fe4a3693527c6caa232c197ea9da91f1212e9672eff60329d/regex-2026.7.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b", size = 494009, upload-time = "2026-07-19T00:16:13.602Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/19/783688e75a2bec15d50aec0d5e7e317d363808bc82a6eb6750b897bfcd7b/regex-2026.7.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52", size = 295287, upload-time = "2026-07-19T00:16:15.702Z" },
+ { url = "https://files.pythonhosted.org/packages/16/01/cefe4f051302ca298d3f3e79ed6dbd933ac84485b9515acdd6a52d70cef7/regex-2026.7.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665", size = 290633, upload-time = "2026-07-19T00:16:17.182Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/1e/1045ca2cabb12e8ec41ad0d138e9f3ff1eb079d30a6f51ccf7d709b44aad/regex-2026.7.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0", size = 785300, upload-time = "2026-07-19T00:16:18.505Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4a/20e5bca184e90bf1bd187efdb53363f4a7b7b34f01d54ced5740caf104bd/regex-2026.7.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951", size = 854079, upload-time = "2026-07-19T00:16:19.909Z" },
+ { url = "https://files.pythonhosted.org/packages/09/9b/5a2e59678be3b24aa6a42b2c6d66a48daa212593e9f4096fe7ba577fa9b1/regex-2026.7.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3", size = 899496, upload-time = "2026-07-19T00:16:21.453Z" },
+ { url = "https://files.pythonhosted.org/packages/48/9a/7317f14ed8ed9fd998d1978b4802b07bc4d79216353c435dbcc1ddd1301f/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c", size = 793541, upload-time = "2026-07-19T00:16:22.991Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/53/833c2db3e274d3c191f4c42fe5bfa358e4c8b617d5d7312d31334965fc46/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6", size = 785515, upload-time = "2026-07-19T00:16:24.654Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b9/efb2f9fa151d71db09d4015e1fb92fee47416f01c12164836bbd23e2f3c2/regex-2026.7.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175", size = 769556, upload-time = "2026-07-19T00:16:26.207Z" },
+ { url = "https://files.pythonhosted.org/packages/81/4d/45610c263f8eadb84e4a1fabd904d81d5176226faa9104ef498bf8a8b285/regex-2026.7.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6", size = 774130, upload-time = "2026-07-19T00:16:27.786Z" },
+ { url = "https://files.pythonhosted.org/packages/40/95/1b40d87c7a9e5480bec7a87bce9fd67fc3f14b5f106c8ee66d660249072f/regex-2026.7.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095", size = 848694, upload-time = "2026-07-19T00:16:29.412Z" },
+ { url = "https://files.pythonhosted.org/packages/17/8b/bb45968addd5b394ef9cd9184bd9c65ade1a819dbb2b92b71ad52a0c7907/regex-2026.7.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0", size = 758505, upload-time = "2026-07-19T00:16:31.006Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/6f/33386c672fbf43e21602135a0f29a97ee251a483f007fe51d10e9b2dbc93/regex-2026.7.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a", size = 836985, upload-time = "2026-07-19T00:16:32.459Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f1/9112b86e9bb075619862e8e42b604794389f1958faa68fb69bde505dd90e/regex-2026.7.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902", size = 782610, upload-time = "2026-07-19T00:16:33.857Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/f9/13d460d8a385ca0b0be9e6be80a90968b9293b3e30895543ad2d1d1653e4/regex-2026.7.19-cp310-cp310-win32.whl", hash = "sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e", size = 266772, upload-time = "2026-07-19T00:16:35.194Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/90/29addd7a03e1aea402c1f31467e25c80caabc8c3735b88a23cf73b0aa9c2/regex-2026.7.19-cp310-cp310-win_amd64.whl", hash = "sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db", size = 277967, upload-time = "2026-07-19T00:16:36.847Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ba/ecfce06fe66c122bc6f77ae284887a9282e4411fe1e6268c5266611ca054/regex-2026.7.19-cp310-cp310-win_arm64.whl", hash = "sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6", size = 276963, upload-time = "2026-07-19T00:16:38.315Z" },
+ { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" },
+ { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" },
+ { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" },
+ { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" },
+ { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" },
+ { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" },
+ { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" },
+ { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" },
+ { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" },
+ { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" },
+ { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" },
+ { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" },
+ { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" },
+ { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" },
+ { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" },
+ { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" },
+ { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" },
+ { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" },
+ { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" },
+ { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" },
+ { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" },
+ { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" },
+ { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" },
+ { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" },
+ { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" },
+ { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" },
+ { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" },
+ { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" },
+ { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" },
+ { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" },
+ { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" },
+ { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" },
+ { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" },
+ { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" },
+ { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" },
+ { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" },
+]
+
[[package]]
name = "requests"
version = "2.34.2"
@@ -2813,7 +2938,7 @@ wheels = [
[[package]]
name = "sphinx-autodoc-api-style"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
@@ -2821,14 +2946,14 @@ dependencies = [
{ name = "sphinx-ux-autodoc-layout" },
{ name = "sphinx-ux-badges" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8f/cd/c6180e600d4d7acc9d786eaa34f6b8f853f7f5246d261cf9db5ffdd2548c/sphinx_autodoc_api_style-0.1.0a37.tar.gz", hash = "sha256:e7cec5b2c95a514536f5c30be1e2df4351ae4d9344cab33709cb08b724826e52", size = 9451, upload-time = "2026-07-27T01:16:52.688Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c8/1a/df9f6f4ae05ebe7bc46ee60a776a63caebdd2f2e39433dc1e60c3ac1162f/sphinx_autodoc_api_style-0.1.0a38.tar.gz", hash = "sha256:f3a6c801356f49718a23008b4031208a55feac228ea3b038487543bae6df4c55", size = 9453, upload-time = "2026-08-30T14:08:16.279Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/95/f1/f8e63d4c979c7a11180bb6a72a204cbc5a1706e5e7178ab09ae27eeb65a6/sphinx_autodoc_api_style-0.1.0a37-py3-none-any.whl", hash = "sha256:2a135e8ec86cf56e4c8883f6f07839d2ae0268d9e3f5548f8b0e7433b396fa60", size = 9569, upload-time = "2026-07-27T01:16:28.807Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/3b/0245d388fde2c91c45eef6c746c39c6799c3726429bdf0efba564d4100f5/sphinx_autodoc_api_style-0.1.0a38-py3-none-any.whl", hash = "sha256:e71d75041b1f5b49c8cd3f8d0d76b49788c2aa8bf1bb0233ad8fd41212791839", size = 9568, upload-time = "2026-08-30T14:07:55.059Z" },
]
[[package]]
name = "sphinx-autodoc-fastmcp"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
@@ -2837,22 +2962,22 @@ dependencies = [
{ name = "sphinx-ux-autodoc-layout" },
{ name = "sphinx-ux-badges" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4c/51/82b02636d5e3cf0441174b500874fda415ffba22a24d6d6dd3f8a4f0245c/sphinx_autodoc_fastmcp-0.1.0a37.tar.gz", hash = "sha256:3c44e874a34371b0aa6f24bdc5a6629c5f8fd732fd306dd8b9494d5f1a1a8ec4", size = 28754, upload-time = "2026-07-27T01:16:55.262Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/91/f8/a773f21c4cb87cbb0a8c61e4a35067d689ca8d3571eed6212b3f185ae1cf/sphinx_autodoc_fastmcp-0.1.0a38.tar.gz", hash = "sha256:d92622a3cf54982daa36efa00bb7f07d06e29ca64673e1f2cb3de748d39415b7", size = 32821, upload-time = "2026-08-30T14:08:18.783Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/41/1c9bb3e976a633e3bc12a51b08b0d8b6b93251467150093ce266352594f8/sphinx_autodoc_fastmcp-0.1.0a37-py3-none-any.whl", hash = "sha256:45d537ee4940397130153b6ef1a46fcb22a5d00ddd28b54efb9cb878694532e2", size = 32595, upload-time = "2026-07-27T01:16:32.586Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/75/fdf982ec19eb9b7b0ebec5254558dc5880b83882b79e887750b445ed6ec1/sphinx_autodoc_fastmcp-0.1.0a38-py3-none-any.whl", hash = "sha256:39c6173d06d51158e7a8df4f3532cfb80284455badb981f41da5c96801289db2", size = 36740, upload-time = "2026-08-30T14:07:58.736Z" },
]
[[package]]
name = "sphinx-autodoc-typehints-gp"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/83/a4/667e4a81d8752c0a103c8bc211859aed313cc857906134ad142ce128efb0/sphinx_autodoc_typehints_gp-0.1.0a37.tar.gz", hash = "sha256:d80b1714d28fc7305c58655f610b0c15db0a93ce61752eeb96ae9b5b1f19e0db", size = 52328, upload-time = "2026-07-27T01:16:58.538Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/ba/489c1092e46222a90a2a78bddac2199ec12111dd7e0e2370befc379ecc08/sphinx_autodoc_typehints_gp-0.1.0a38.tar.gz", hash = "sha256:d40ce89f68d47bfa2af4f031028b52315f6ca6aaa00cca8b20f486febf9d3d3c", size = 52327, upload-time = "2026-08-30T14:08:21.329Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/51/c2/1a384b17f70660c397a17e1f7787b5890ec45de20ed3a5e5eb9e12b83e17/sphinx_autodoc_typehints_gp-0.1.0a37-py3-none-any.whl", hash = "sha256:826d318b1eb968edf547fe2d68081cd81ea4028b2017273823d50c0e89917113", size = 54907, upload-time = "2026-07-27T01:16:37.278Z" },
+ { url = "https://files.pythonhosted.org/packages/19/60/ebec25c2485237d4e4bd1ae54f64c9594b8953aea70e07e209bf35d64eab/sphinx_autodoc_typehints_gp-0.1.0a38-py3-none-any.whl", hash = "sha256:4348a841fbe0644cc61063b0b00a38b1223570b82a1164aad0694b3f58ac6f9f", size = 54907, upload-time = "2026-08-30T14:08:02.494Z" },
]
[[package]]
@@ -2917,68 +3042,68 @@ wheels = [
[[package]]
name = "sphinx-fonts"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/dc/64f039ba71475e568eaa070a56168acbc7d2aa33a7027328f3e23db19c83/sphinx_fonts-0.1.0a37.tar.gz", hash = "sha256:b6b72c5516b5152ab7acb50799c99ac1cb72103d8bbfd16ce2dd2171f826026f", size = 5875, upload-time = "2026-07-27T01:16:59.393Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/22/9e0d99c6fa8811c117574ab4ce119c6a070c18fa5d7f69a7af66648babab/sphinx_fonts-0.1.0a38.tar.gz", hash = "sha256:ed0a8dc4c1cb3b9389ac502aad8b29410c90480a3df2830caf85b54ce76d66a2", size = 5875, upload-time = "2026-08-30T14:08:22.234Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cd/a2/2acc6db3faf163a8f6356ea5bfccc6ec46f435a2f42e764a7df934db9d94/sphinx_fonts-0.1.0a37-py3-none-any.whl", hash = "sha256:1fbf5cdc1bbf5f1c2961b79f0324909c910e69072b3ae5b3d38227bf4a7a3f6b", size = 4445, upload-time = "2026-07-27T01:16:38.615Z" },
+ { url = "https://files.pythonhosted.org/packages/98/de/edd6c56399a5cc479f94ef855914d084ac9c83bd5b2ef477732a95027e54/sphinx_fonts-0.1.0a38-py3-none-any.whl", hash = "sha256:cef8297682da5d48c41f4bcdbbf8db6d1fb44b55a27987823eb98e256e616671", size = 4446, upload-time = "2026-08-30T14:08:03.678Z" },
]
[[package]]
name = "sphinx-gp-llms"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/24/5a/e1dc00eb4ef5efc007737090b930ba415e00d4f25eb78954b619d0d61be7/sphinx_gp_llms-0.1.0a37.tar.gz", hash = "sha256:1ff4ce301374d31a7d36a740db71792bcd4fa1a23f1a92d854c719ad7b3064c5", size = 9996, upload-time = "2026-07-27T01:17:00.954Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/b0/765da2b46c9b074ee4f03d8a06214edc8d348ee4486ab5abb97a78755d18/sphinx_gp_llms-0.1.0a38.tar.gz", hash = "sha256:7a8b25cc9f2df87bd5202012bd8aedb2bcf67657df9f976cbac65f13911db36d", size = 9996, upload-time = "2026-08-30T14:08:23.72Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/86/c7/189e3ff63ac0c54dfd3bb00ae690ade5dd1ae980faba8b30126ebe95aa38/sphinx_gp_llms-0.1.0a37-py3-none-any.whl", hash = "sha256:a8c437be5bdce3c15cdc83444c3c66277e0785a9f94153fa5c7dd21883680bc5", size = 12730, upload-time = "2026-07-27T01:16:41.093Z" },
+ { url = "https://files.pythonhosted.org/packages/da/10/d08a192e740eeb68692854c8479b9a0a10f7bfcaa66c6bcb5fc0c1cadd37/sphinx_gp_llms-0.1.0a38-py3-none-any.whl", hash = "sha256:01e174d9da450851361707cb422dbbf53d6cbce85d718cb26f9c972e2b813b1a", size = 12728, upload-time = "2026-08-30T14:08:05.742Z" },
]
[[package]]
name = "sphinx-gp-opengraph"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4a/e9/c23843c9fb980691b301e1a4000a7f989f4993354e3b2f93378179c49926/sphinx_gp_opengraph-0.1.0a37.tar.gz", hash = "sha256:70036e4d160f514c0f13a3700d9da7bfe20f3976d245b84ca520c522e5af3731", size = 11954, upload-time = "2026-07-27T01:17:02.641Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/bd7463de5c72850ac8f4c5b5870af345b2e232dfda06a1d5eb5f30c9eea3/sphinx_gp_opengraph-0.1.0a38.tar.gz", hash = "sha256:a69da7d69281c1650505c8aa6901f506378869512fb01c886c7c2b1acda99564", size = 11953, upload-time = "2026-08-30T14:08:25.34Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/15/73fd166b9b9690c53f045203e538914bbf6e82cfeb629c0380a31f7008a3/sphinx_gp_opengraph-0.1.0a37-py3-none-any.whl", hash = "sha256:5db38ea07a7a3d422c03a15ccdc1825c21002f3b78efca252ae88d6c78d9da26", size = 12191, upload-time = "2026-07-27T01:16:43.413Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/91e0017631ed003f3de8c2f9f467a8d4b980c646e8d7cfcdd2dd6d0e8f95/sphinx_gp_opengraph-0.1.0a38-py3-none-any.whl", hash = "sha256:0adce2c3011eb0855a82a4330013362e1f013f72e600389b4b9070374d8f2db5", size = 12193, upload-time = "2026-08-30T14:08:07.774Z" },
]
[[package]]
name = "sphinx-gp-sitemap"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/bc/ba3082d503177046f0481e5c3f537fab75f5c2c63b95b6c8a3cb02083530/sphinx_gp_sitemap-0.1.0a37.tar.gz", hash = "sha256:74e3f717ef936b0567026e316607f7ed28b80d404261685d1246da86b269850a", size = 9957, upload-time = "2026-07-27T01:17:03.435Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e5/aabd8d9bde187607c254313e2e5b3a5fa7273e4f9e4b7a16876f634ee9b8/sphinx_gp_sitemap-0.1.0a38.tar.gz", hash = "sha256:08ada6b2f797c473bcf194b9bca033f8effe490a45e92b9ca419978332c95236", size = 9957, upload-time = "2026-08-30T14:08:26.109Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8e/d8/edcd2d5c282940556e79e97c6eaab5ba876d80eca32aa280f591a9bda26d/sphinx_gp_sitemap-0.1.0a37-py3-none-any.whl", hash = "sha256:90c696793279708599e865d5b581852820d9a6db0a60d3cdfcd3c5cb3bb5f055", size = 8983, upload-time = "2026-07-27T01:16:44.833Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/32/40aaee9df67cc27fad4f7f2623571fbf2a856f7b924c837eb065c9ad1763/sphinx_gp_sitemap-0.1.0a38-py3-none-any.whl", hash = "sha256:1ac494cf06644b22983edee2ba4ba7c0a747b661fd69d6bfc4a48d9bc5853588", size = 8982, upload-time = "2026-08-30T14:08:08.85Z" },
]
[[package]]
name = "sphinx-gp-theme"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gp-furo-theme" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/64/738adee88ecec4c3a8286da75d701062ac711c88b01b9713275329f7bc28/sphinx_gp_theme-0.1.0a37.tar.gz", hash = "sha256:6bd1248433d197aadaa2c6ceb01975b71ba9f8395db1f3e6bfd3ce98913bf4f7", size = 18489, upload-time = "2026-07-27T01:17:04.275Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/a1/1668f568d0f767968377da3873a519048e0eade7d0b80c0dac0fff13d0b0/sphinx_gp_theme-0.1.0a38.tar.gz", hash = "sha256:483b1cca1a1f245c32b98eb6972eabb9b4f6df3835983f4586599c8dab25265c", size = 18489, upload-time = "2026-08-30T14:08:26.916Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/65/47/5901ff6adc10cdf2812a8b0bb138e65f6d123ee89703b20a82ed54354fae/sphinx_gp_theme-0.1.0a37-py3-none-any.whl", hash = "sha256:afac9135c2d9eec3caa3dbff862509ea64802294c62191590c98ad9ebe9da2cc", size = 20107, upload-time = "2026-07-27T01:16:46.102Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/35/189bd8ba777710fa238874a239c9849a76c23f86456fdfcbf336e40c0718/sphinx_gp_theme-0.1.0a38-py3-none-any.whl", hash = "sha256:047515d4682168717b377c3b3b8b00e793be48e119dbee1629d5861358cc2f01", size = 20104, upload-time = "2026-08-30T14:08:09.925Z" },
]
[[package]]
@@ -2996,28 +3121,28 @@ wheels = [
[[package]]
name = "sphinx-ux-autodoc-layout"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3e/27/0b0af2d36310cc21a73fd8c58616577ead37aa420936e8111c173cbffcb4/sphinx_ux_autodoc_layout-0.1.0a37.tar.gz", hash = "sha256:3a0c40033797abfab785b01be1f0daea9c354a2d125ab959521f8c9cc96dc907", size = 30837, upload-time = "2026-07-27T01:17:05.135Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4b/77/efd705589f62ad6d1af2d6a58490b4076b9a6d8c8fca3e7cd39a7e667bbf/sphinx_ux_autodoc_layout-0.1.0a38.tar.gz", hash = "sha256:321bf86d2a2d722fbad512db06e3fce2ea9a6cccb07c5657b19daf9fc0327bb3", size = 30837, upload-time = "2026-08-30T14:08:27.723Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/be/7e/ce06f05cd096a564b718d8efe073934f6299feb72af6611773ab065ab23d/sphinx_ux_autodoc_layout-0.1.0a37-py3-none-any.whl", hash = "sha256:ef19d501718b1de586658006fe7d125daa73fc742afbec56a7dd635bc73205e7", size = 35257, upload-time = "2026-07-27T01:16:47.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/eb/fcdfe267966a724d3069e9d8ae49f653531ee79987f4b4ef0a2f42302494/sphinx_ux_autodoc_layout-0.1.0a38-py3-none-any.whl", hash = "sha256:cc265a05501ffc2166a48db55769ba37fa53003f4608b2e24cea362e331b2fd3", size = 35256, upload-time = "2026-08-30T14:08:10.975Z" },
]
[[package]]
name = "sphinx-ux-badges"
-version = "0.1.0a37"
+version = "0.1.0a38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2c/dc/66f3b29bcb3be2132f5fed91f6e0ce9a23febcc79e499a4e07145a4aab74/sphinx_ux_badges-0.1.0a37.tar.gz", hash = "sha256:1e2f9e4908a090c9586aba83f1e5c262a3aa30875adb19afe5773fb44305d8cf", size = 16737, upload-time = "2026-07-27T01:17:05.929Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/d3/80a597e5052db0813d6372079548086453ab37fda44a2baffe9e606e9499/sphinx_ux_badges-0.1.0a38.tar.gz", hash = "sha256:7f35305d51529b728d6d389d1941b07cf6a2951841fe8da922b929a040e2e3ec", size = 16888, upload-time = "2026-08-30T14:08:28.71Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/73/c5/7947392920f26a579fb6c3f9a2f7f04773f26f2feaee8282e79a62f140c2/sphinx_ux_badges-0.1.0a37-py3-none-any.whl", hash = "sha256:5e4140c518a0c532018900f5beb965a1f470df71cd7bac1c1bd39dc0edd10cb2", size = 17503, upload-time = "2026-07-27T01:16:48.597Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/e963c1c8ef14760d44f25047cea41b6aa5e699e674067789ff9ed0ea7d61/sphinx_ux_badges-0.1.0a38-py3-none-any.whl", hash = "sha256:ee3220c4146044501f769ea9eaf7adbacdf034bf30d3d9b1ecc27514cda1075b", size = 17658, upload-time = "2026-08-30T14:08:12.196Z" },
]
[[package]]
@@ -3188,6 +3313,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
]
+[[package]]
+name = "types-regex"
+version = "2026.7.19.20260720"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/bc/825471e08455b037c40d2c80fe9f93a574fcfef0b2e6c6fc2e248ada4adb/types_regex-2026.7.19.20260720.tar.gz", hash = "sha256:49ab8261bd46fa215245b9cb0d2fed1c1056fbdc763303714f6132208909afcf", size = 13473, upload-time = "2026-07-20T05:27:00.203Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/1a/9bbd3eace00c2562556570d2464afe843ef5f8118df17aee5f36fed5948f/types_regex-2026.7.19.20260720-py3-none-any.whl", hash = "sha256:70e76f05d8ef60f00227e50351f1cb81552d1cdf3fdf77350f7063c911ee26da", size = 11147, upload-time = "2026-07-20T05:26:59.245Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.16.0"