Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### What's new

**Tier-aware tool batching**

{tooliconl}`call-readonly-tools-batch`, {tooliconl}`call-mutating-tools-batch`, and {tooliconl}`call-destructive-tools-batch` run an ordered list of existing MCP tools in a single call and return a per-operation result for each, preserving every nested tool's own structured output. Each wrapper caps the safety tier of the calls it will make — the readonly wrapper refuses mutating or destructive operations, and the mutating wrapper refuses destructive ones — regardless of the server's `LIBTMUX_SAFETY` tier. Nested calls keep their normal schema validation, middleware, and safety checks, and `on_error` selects stop-at-first-failure or continue-and-report handling. Large aggregate results stay within the server's response limit — oversized nested payloads are dropped (with the truncation flagged in the result), and very large operation lists are rejected rather than allowed to overflow it. (#79)

## libtmux-mcp 0.1.0a13 (2026-06-13)

libtmux-mcp 0.1.0a13 adds {tooliconl}`send-keys-batch` for sending an ordered batch of raw key/text operations to tmux panes in a single call, with per-operation results, stop-or-continue error handling, and an optional timeout that bounds both the batch and each send. Argument-validation failures also stop echoing the rejected input into the server's logs and tool error results, so a secret-bearing argument can no longer surface there.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Give your AI agent hands inside the terminal — create sessions, run commands,
| 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` |
| **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_content_change`, `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` |
Expand Down
5 changes: 5 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any:
conf["myst_enable_extensions"] = [*conf["myst_enable_extensions"], "attrs_inline"]

conf["fastmcp_tool_modules"] = [
"libtmux_mcp.tools.batch_tools",
"libtmux_mcp.tools.server_tools",
"libtmux_mcp.tools.session_tools",
"libtmux_mcp.tools.window_tools",
Expand All @@ -129,6 +130,7 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any:
"libtmux_mcp.tools.hook_tools",
]
conf["fastmcp_area_map"] = {
"batch_tools": "batch/index",
"server_tools": "server/index",
"session_tools": "session/index",
"window_tools": "window/index",
Expand Down Expand Up @@ -159,6 +161,9 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any:
"SendKeysOperation",
"SendKeysOperationResult",
"SendKeysBatchResult",
"ToolCallOperation",
"ToolCallOperationResult",
"ToolCallBatchResult",
"HookEntry",
"HookListResult",
"BufferRef",
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/api/tools.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Tools

## Batch tools

```{eval-rst}
.. automodule:: libtmux_mcp.tools.batch_tools
:members:
:undoc-members:
:show-inheritance:
```

## Server tools

```{eval-rst}
Expand Down
32 changes: 32 additions & 0 deletions docs/tools/batch/call-destructive-tools-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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
```
41 changes: 41 additions & 0 deletions docs/tools/batch/call-mutating-tools-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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
```
34 changes: 34 additions & 0 deletions docs/tools/batch/call-readonly-tools-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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
```
31 changes: 31 additions & 0 deletions docs/tools/batch/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Batch tools

Batch tools coordinate existing MCP tool calls. They do not replace tmux
targeting: each nested tool call still supplies its own arguments,
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.
:::

::::

```{toctree}
:hidden:
:maxdepth: 1

call-readonly-tools-batch
call-mutating-tools-batch
call-destructive-tools-batch
```
30 changes: 29 additions & 1 deletion docs/tools/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

# Tools

All tools accept an optional `socket_name` parameter for multi-server support. It defaults to the {envvar}`LIBTMUX_SOCKET` env var. See {ref}`configuration`.
Targeted tmux tools accept an optional `socket_name` parameter for
multi-server support. It defaults to the {envvar}`LIBTMUX_SOCKET` env
var. {toolref}`list-servers` discovers sockets itself, and batch tools
leave socket selection inside each nested tool's arguments. See
{ref}`configuration`.

## Which tool do I want?

Expand Down Expand Up @@ -50,6 +54,11 @@ All tools accept an optional `socket_name` parameter for multi-server support. I
- Block until signalled → {tool}`wait-for-channel`
- 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`

**Staging multi-line input?**
- Stage content → {tool}`load-buffer`
- Push into pane → {tool}`paste-buffer`
Expand Down Expand Up @@ -138,6 +147,12 @@ Wait for text to appear in a pane.
Get tmux server info.
:::

:::{grid-item-card} call_readonly_tools_batch
:link: call-readonly-tools-batch
:link-type: ref
Call typed readonly tools in order.
:::

:::{grid-item-card} list_servers
:link: list-servers
:link-type: ref
Expand Down Expand Up @@ -237,6 +252,12 @@ Send several ordered raw-input operations.
Run a shell command and report exit status.
:::

:::{grid-item-card} call_mutating_tools_batch
:link: call-mutating-tools-batch
:link-type: ref
Call typed readonly or mutating tools in order.
:::

:::{grid-item-card} rename_session
:link: rename-session
:link-type: ref
Expand Down Expand Up @@ -396,6 +417,12 @@ 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
Expand All @@ -409,6 +436,7 @@ Delete an MCP-staged tmux paste buffer.
:caption: Tools by tmux scope

server/index
batch/index
session/index
window/index
pane/index
Expand Down
1 change: 1 addition & 0 deletions docs/topics/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ src/libtmux_mcp/
models.py # Pydantic output models
middleware.py # Safety, audit, retry, and error-result middleware
tools/
batch_tools.py # call_readonly_tools_batch, call_mutating_tools_batch, call_destructive_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
Expand Down
6 changes: 3 additions & 3 deletions docs/topics/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ libtmux-mcp uses a three-tier safety system to control which tools are available

| Tier | Label | Access | Use case |
|------|-------|--------|----------|
| `readonly` | {badge}`readonly` | List, capture, search, info | Monitoring, browsing |
| `mutating` (default) | {badge}`mutating` | + create, send_keys, send_keys_batch, rename, resize | Normal agent workflow |
| `destructive` | {badge}`destructive` | + kill_server, kill_session, kill_window, kill_pane | Full control |
| `readonly` | {badge}`readonly` | List, capture, search, info, readonly batches | Monitoring, browsing |
| `mutating` (default) | {badge}`mutating` | + create, send_keys, send_keys_batch, mutating batches, rename, resize | Normal agent workflow |
| `destructive` | {badge}`destructive` | + destructive batches, kill_server, kill_session, kill_window, kill_pane | Full control |

## Configuration

Expand Down
28 changes: 24 additions & 4 deletions src/libtmux_mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,9 +456,9 @@ async def on_call_tool(
)

#: Nested argument containers that may contain sensitive argument names.
#: ``operations`` is used by ``send_keys_batch``; preserving pane ids and
#: booleans is useful for audit trails, but each nested ``keys`` payload
#: must be digested the same way top-level ``send_keys(keys=...)`` is.
#: ``operations`` is used by ``send_keys_batch`` and the generic tool-batch
#: wrappers. Preserving routing metadata is useful for audit trails, but
#: nested payloads must be digested the same way top-level tool calls are.
_NESTED_ARG_LIST_NAMES: frozenset[str] = frozenset({"operations"})

_NONE_TYPE = type(None)
Expand Down Expand Up @@ -517,6 +517,26 @@ def _summarize_send_keys_operation_args(args: dict[str, t.Any]) -> dict[str, t.A
return summary


def _summarize_tool_batch_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]:
"""Summarize one generic tool-batch operation for audit logging."""
summary: dict[str, t.Any] = {}
for key, value in args.items():
if key == "tool" and isinstance(value, str):
summary[key] = value
elif key == "arguments" and isinstance(value, dict):
summary[key] = _summarize_args(value)
else:
summary[key] = _redacted_value_shape(value)
return summary


def _summarize_nested_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]:
"""Summarize a known nested operation shape."""
if "tool" in args or "arguments" in args:
return _summarize_tool_batch_operation_args(args)
return _summarize_send_keys_operation_args(args)


def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]:
"""Summarize tool arguments for audit logging.

Expand Down Expand Up @@ -558,7 +578,7 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]:
elif key in _NESTED_ARG_LIST_NAMES:
if isinstance(value, list):
summary[key] = [
_summarize_send_keys_operation_args(item)
_summarize_nested_operation_args(item)
if isinstance(item, dict)
else _redacted_value_shape(item)
for item in value
Expand Down
Loading