Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e71c20a
py(deps) Bump fastmcp 3.2.4 -> 3.4.0
tony Jun 6, 2026
74b98b2
mcp(feat[errors]): Log expected tool failures at WARNING via Expected…
tony Jun 6, 2026
0f2793f
mcp(feat[middleware]): Return rich ToolResult errors via ToolErrorRes…
tony Jun 6, 2026
c4d8d8e
docs(CHANGES) fastmcp 3.4 error-handling release notes
tony Jun 6, 2026
127b3aa
mcp(fix[middleware]): Lazy %-formatting in ToolErrorResultMiddleware.…
tony Jun 6, 2026
5bcddf6
docs(middleware): Order module docstring bullets by definition
tony Jun 6, 2026
f667ac4
docs(_utils): Document ExpectedToolError mapping in error decorators
tony Jun 6, 2026
2081328
docs(topics/architecture): Document the full middleware stack and two…
tony Jun 6, 2026
9f831e7
docs(src): Name ExpectedToolError in Raises sections at converted rai…
tony Jun 6, 2026
b93234f
docs(middleware,server): Count all three dependents in the ordering i…
tony Jun 6, 2026
5034fd2
docs(topics/architecture): Fix ToolErrorResultMiddleware stack-positi…
tony Jun 6, 2026
79d6425
docs(_utils): Add Parameters section to ExpectedToolError docstring
tony Jun 6, 2026
99c1ac9
mcp(fix[middleware]): Classify argument-schema validation failures as…
tony Jun 6, 2026
e893f32
mcp(fix[middleware]): Preserve is_error through response-limit trunca…
tony Jun 6, 2026
8cc3d36
py(types[middleware]): Satisfy CallNext protocol in limiter capture c…
tony Jun 6, 2026
a15ad34
docs(fix[architecture]): Correct middleware error-flow contract
tony Jun 6, 2026
f8e3c0b
test(fix[pane_tools]): Synchronize snapshot truncation setup
tony Jun 6, 2026
4b58f97
docs(fix[errors]): Use expected tool error terminology
tony Jun 6, 2026
4fd7464
mcp(feat[middleware]): Suggest dropping unrecognized arguments in val…
tony Jun 6, 2026
96f8f61
docs(errors): Link expected-tool-error mentions to the autodoc entry
tony Jun 6, 2026
957f05a
docs(CHANGES) Unknown-argument hint release note
tony Jun 6, 2026
4e1a0b2
docs(errors): Refresh error-result recovery prose
tony Jun 6, 2026
5e3520d
test(docs[middleware]): Refresh expected-error terminology
tony Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Dependencies

**Minimum `fastmcp>=3.4.0`** (was `>=3.2.4`). Enables the error-result and log-level improvements below; the bump alone also restores resource titles on the `tmux://` hierarchy and brings MCP-compliant telemetry span attributes.

### What's new

**Tool errors arrive clean, with structured detail and recovery hints**

Failed tool calls now return their message exactly as raised — previously every failure was prefixed with `Internal error:`. Error results carry a structured `_meta` payload (`error_type`, `expected`, `suggestion`), and not-found errors point at {tooliconl}`list-sessions` / {tooliconl}`list-windows` / {tooliconl}`list-panes` to resolve stale or guessed ids. (#64)

**Unknown tool arguments get a do-this-next hint**

When a call includes arguments a tool doesn't accept, the error result now names exactly which argument(s) to remove or fix. When the stray key is `wait_for_previous` — a scheduling flag Gemini CLI can leak into batched tool calls — the hint says so and names the connected client, so agents recover in one read instead of re-deriving the problem from a validation traceback. (#64)

**Expected tool failures log at WARNING, not ERROR**

Agent-correctable failures — unknown ids, invalid arguments, safety-tier denials, transient tmux errors — now log at WARNING in the server's log stream. A missing tmux binary and unexpected exceptions stay at ERROR, so ERROR records are always worth attention. (#64)

## libtmux-mcp 0.1.0a10 (2026-05-24)

### What's new
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/buffer/delete-buffer.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ across MCP restarts.

**Side effects:** Removes the named buffer from the tmux server.
Subsequent {tooliconl}`paste-buffer` calls against the deleted name
return `ToolError`.
return an {exc}`~libtmux_mcp._utils.ExpectedToolError`.

```{fastmcp-tool-input} buffer_tools.delete_buffer
```
7 changes: 4 additions & 3 deletions docs/tools/hook/show-hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
```

**Use when** you know which hook you want to inspect by name. Returns
empty when the hook is unset; raises `ToolError` for unknown hook
names (typos, wrong scope) so input mistakes don't masquerade as
"nothing configured".
empty when the hook is unset; raises an
{exc}`~libtmux_mcp._utils.ExpectedToolError` for
unknown hook names (typos, wrong scope) so input mistakes don't
masquerade as "nothing configured".

**Side effects:** None. Readonly.

Expand Down
9 changes: 5 additions & 4 deletions docs/tools/pane/capture-since.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ Read only content since that cursor:
```

The cursor carries the original pane id, so the follow-up call does not need
`pane_id`. If you pass both, they must match; a cursor for another pane raises a
tool error instead of silently reading the wrong process.
`pane_id`. If you pass both, they must match; a cursor for another pane raises
an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of silently reading the
wrong process.

If nothing new was written after the cursor, `lines` is empty and the response
still includes a fresh cursor for the same pane. If the cursor row scrolled into
Expand All @@ -73,8 +74,8 @@ needed to compute an exact delta. In that case, `lines` is a conservative
current visible capture and the response includes a fresh cursor.

Pane lifecycle is part of the cursor contract. If the pane dies or is respawned,
the call raises a tool error instead of reading from a different process that
reused the same pane id.
the call raises an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of
reading from a different process that reused the same pane id.

`truncated`, `truncated_lines`, and `truncated_bytes` are structured metadata.
No truncation marker is injected into `lines`, so clients can display terminal
Expand Down
5 changes: 3 additions & 2 deletions docs/tools/pane/wait-for-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ to poll for an output marker instead; state-polling is structurally
safer than edge-triggered signalling for fragile commands.

**Side effects:** Blocks the call up to `timeout` seconds (default 30).
Mandatory subprocess timeout — a crashed signaller raises `ToolError`
rather than blocking indefinitely.
Mandatory subprocess timeout — a crashed signaller raises an
{exc}`~libtmux_mcp._utils.ExpectedToolError` rather than blocking
indefinitely.

```{fastmcp-tool-input} wait_for_tools.wait_for_channel
```
2 changes: 1 addition & 1 deletion docs/tools/pane/wait-for-content-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ specific pattern.
precise and avoids false positives from unrelated output.

**Side effects:** None. Readonly. Blocks until content changes or timeout.
Raises a tool error if the pane dies or is respawned while waiting, because the
Raises an {exc}`~libtmux_mcp._utils.ExpectedToolError` if the pane dies or is respawned while waiting, because the
entry content baseline no longer describes the same pane process.

**Example:**
Expand Down
26 changes: 20 additions & 6 deletions docs/topics/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ src/libtmux_mcp/
server.py # FastMCP instance and configuration
_utils.py # Server caching, resolvers, serializers, error handling
models.py # Pydantic output models
middleware.py # Safety tier middleware
middleware.py # Safety, audit, retry, and error-result middleware
tools/
server_tools.py # list_sessions, create_session, kill_server, get_server_info
session_tools.py # list_windows, create_window, rename_session, kill_session
Expand All @@ -27,14 +27,22 @@ src/libtmux_mcp/

## Request flow

Middleware wraps tool calls outermost-first (full ordering rationale in
the `server.py` stack comment):

```
MCP Client (Claude, Cursor, etc.)
→ stdio transport
→ FastMCP server (server.py)
→ SafetyMiddleware (middleware.py)
→ Tool function (tools/*.py)
→ libtmux Server/Session/Window/Pane
→ tmux binary (via subprocess)
→ TimingMiddleware (wall-time observer)
→ 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 Server/Session/Window/Pane
→ tmux binary (via subprocess)
```

## Key design decisions
Expand All @@ -60,7 +68,13 @@ Tools use resolver functions (`_resolve_session`, `_resolve_window`, `_resolve_p

### Error handling

The `@handle_tool_errors` decorator wraps all tool functions, catching libtmux exceptions and converting them to `ToolError` with descriptive messages.
Three boundaries split the work:

1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping libtmux exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or stock `ToolError` (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 `LibTmuxException` causes.
2. **Schema classification** — FastMCP validates tool arguments before tool code runs, so Pydantic 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 `ToolResult(is_error=True)` 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`.

## References

Expand Down
14 changes: 14 additions & 0 deletions docs/topics/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,17 @@ The `suppress_history` parameter on `send_keys` prepends a space before the comm
`clear_pane` runs two tmux commands in sequence: `send-keys -R` (reset terminal) then `clear-history` (clear scrollback). There is a brief gap between them where partial content may be visible.

For most use cases this is not a problem. If you need guaranteed clean state, add a small delay before the next `capture_pane`.

## Gemini CLI injects `wait_for_previous` into tool arguments

When Gemini CLI batches several tool calls in one turn, its scheduler merges the internal sequencing flag of the later calls into the MCP tool's arguments:

```json
{"tool": "get_pane_info", "arguments": {"wait_for_previous": true, "pane_id": "%0"}}
```

This has been observed with stock Gemini CLI behavior (no extensions involved). In local non-interactive `gemini -p` harness runs, batching can front-load the topic tool and the first MCP calls into one parallel turn, which is where the leaked flag usually appears; interactive sessions tend to schedule more sequentially and trigger it less often.

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_<name>: ... 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 `send_keys`, say) must fail loudly, not run with defaults.
17 changes: 17 additions & 0 deletions docs/topics/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ are:
{exc}`libtmux.exc.LibTmuxException`.
- ``libtmux_mcp.server`` / ``libtmux_mcp.tools.*`` / etc. — ad-hoc
warnings and debug messages from the codebase.
- ``fastmcp.errors`` — one record per failed tool call, emitted by
{class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware`.

## Error levels

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,
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``
binary, or any unexpected exception escaping a tool.

A WARNING-heavy, ERROR-quiet log stream is therefore healthy — it
means agents are probing and recovering. ERROR records are the ones
worth alerting on.

## Level control

Expand Down
2 changes: 1 addition & 1 deletion docs/topics/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Unlike other `mutating` tools, the registration carries `destructiveHint=True` a

Mitigations:

- `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get a `ToolError` instead of an unintended kill — resolve via {tool}`list-panes` first.
- `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 (`dict[str, str]`) maps to one tmux `-e KEY=VALUE` flag per item. The audit log redacts each *value* via a `{len, sha256_prefix}` digest while keeping the *keys* visible — env var names like `DATABASE_URL` are usually operator-debug-useful, but their values are the secret. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env.
- The same self-pane guard that protects the destructive kill commands also refuses to respawn the pane running the MCP server.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ include = [

dependencies = [
"libtmux>=0.58.0,<1.0",
"fastmcp>=3.2.4,<4.0.0",
"fastmcp>=3.4.0,<4.0.0",
]

[project.urls]
Expand Down
120 changes: 106 additions & 14 deletions src/libtmux_mcp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,69 @@
logger = logging.getLogger(__name__)


class ExpectedToolError(ToolError):
"""``ToolError`` for expected, agent-correctable failures.

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
as ERROR records. Unexpected failures keep stock :class:`ToolError`
and its ERROR default — those are the ones operators must see.

Parameters
----------
*args : object
Positional arguments forwarded to :class:`ToolError`
(typically the error message).
log_level : int
Level fastmcp's server layer logs this failure at. Defaults
to ``logging.WARNING``.
suggestion : str, optional
Agent-facing recovery hint.
:class:`~libtmux_mcp.middleware.ToolErrorResultMiddleware`
appends it to the error result's text and mirrors it into the
result's ``meta``.

Examples
--------
>>> import logging
>>> ExpectedToolError("Pane not found: %5").log_level == logging.WARNING
True

An explicit level still wins:

>>> err = ExpectedToolError("noisy", log_level=logging.INFO)
>>> err.log_level == logging.INFO
True

Catch sites that handle ``ToolError`` keep working — this is a
plain subclass:

>>> isinstance(ExpectedToolError("x"), ToolError)
True

An optional ``suggestion`` carries an agent-facing recovery hint;
:class:`libtmux_mcp.middleware.ToolErrorResultMiddleware` surfaces
it in the error result's text and ``meta``:

>>> err = ExpectedToolError("Pane not found: %5",
... suggestion="Call list_panes to discover valid pane ids.")
>>> err.suggestion
'Call list_panes to discover valid pane ids.'
>>> ExpectedToolError("no hint").suggestion is None
True
"""

def __init__(
self,
*args: object,
log_level: int = logging.WARNING,
suggestion: str | None = None,
) -> None:
super().__init__(*args, log_level=log_level)
self.suggestion = suggestion


@dataclasses.dataclass(frozen=True)
class CallerIdentity:
"""Identity of the tmux pane hosting this MCP server process.
Expand Down Expand Up @@ -714,7 +777,7 @@ def _coerce_dict_arg(

Raises
------
ToolError
ExpectedToolError
If ``value`` is a string that is not valid JSON, or decodes to
a JSON value that is not an object.
"""
Expand All @@ -725,10 +788,10 @@ def _coerce_dict_arg(
decoded = json.loads(value)
except (json.JSONDecodeError, ValueError) as e:
msg = f"Invalid {name} JSON: {e}"
raise ToolError(msg) from e
raise ExpectedToolError(msg) from e
if not isinstance(decoded, dict):
msg = f"{name} must be a JSON object, got {type(decoded).__name__}"
raise ToolError(msg) from None
raise ExpectedToolError(msg) from None
return decoded
return value

Expand Down Expand Up @@ -758,7 +821,7 @@ def _apply_filters(

Raises
------
ToolError
ExpectedToolError
If a filter key uses an invalid lookup operator.
"""
coerced = _coerce_dict_arg("filters", filters)
Expand All @@ -775,7 +838,7 @@ def _apply_filters(
f"Invalid filter operator '{op}' in '{key}'. "
f"Valid operators: {', '.join(valid_ops)}"
)
raise ToolError(msg)
raise ExpectedToolError(msg)

filtered = items.filter(**filters)
return [serializer(item) for item in filtered]
Expand Down Expand Up @@ -922,20 +985,34 @@ def _map_exception_to_tool_error(fn_name: str, e: BaseException) -> ToolError:

Shared between the sync and async ``handle_tool_errors*`` decorators
so the two paths stay byte-for-byte identical in what agents see.

Expected, agent-correctable failures map to
:class:`ExpectedToolError` (logged at WARNING). Two cases stay at
ERROR: a missing tmux binary (operator-environment fault that must
be loud) and the unexpected catch-all (potential bug in this
server).
"""
if isinstance(e, exc.TmuxCommandNotFound):
msg = "tmux binary not found. Ensure tmux is installed and in PATH."
return ToolError(msg)
if isinstance(e, exc.TmuxSessionExists):
return ToolError(str(e))
return ExpectedToolError(str(e))
if isinstance(e, exc.BadSessionName):
return ToolError(str(e))
return ExpectedToolError(str(e))
if isinstance(e, exc.TmuxObjectDoesNotExist):
return ToolError(f"Object not found: {e}")
return ExpectedToolError(
f"Object not found: {e}",
suggestion=(
"Call list_sessions / list_windows / list_panes to discover valid ids."
),
)
if isinstance(e, exc.PaneNotFound):
return ToolError(f"Pane not found: {e}")
return ExpectedToolError(
f"Pane not found: {e}",
suggestion="Call list_panes to discover valid pane ids.",
)
if isinstance(e, exc.LibTmuxException):
return ToolError(f"tmux error: {e}")
return ExpectedToolError(f"tmux error: {e}")
logger.exception("unexpected error in MCP tool %s", fn_name)
return ToolError(f"Unexpected error: {type(e).__name__}: {e}")

Expand All @@ -945,8 +1022,19 @@ def handle_tool_errors(
) -> t.Callable[P, R]:
"""Decorate synchronous MCP tool functions with standardized error handling.

Catches libtmux exceptions and re-raises as ``ToolError`` so that
MCP responses have ``isError=True`` with a descriptive message.
Catches libtmux exceptions and re-raises them through
:func:`_map_exception_to_tool_error` so MCP responses have
``isError=True`` with a descriptive message — expected,
agent-correctable failures as :class:`ExpectedToolError` (logged
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.

Use :func:`handle_tool_errors_async` for ``async def`` tools — this
wrapper only supports plain sync callables.
"""
Expand All @@ -973,8 +1061,12 @@ def handle_tool_errors_async(
``report_progress``/``elicit``/``read_resource`` methods are
coroutines that only run inside ``async def`` tools.

Maps the same libtmux exception set to the same ``ToolError``
messages as the sync decorator by delegating to a shared helper.
Maps the same libtmux exception set to the same messages and
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.
"""

@functools.wraps(fn)
Expand Down
Loading