Middleware

Middleware for libtmux MCP server.

Provides three pieces of infrastructure:

  • 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.

  • AuditMiddleware emits a structured log record for each tool invocation (name, duration, outcome, client/request ids, and a summary of arguments with payload-bearing fields redacted to a length + SHA-256 prefix).

  • TailPreservingResponseLimitingMiddleware is a backstop cap for oversized tool output. Unlike FastMCP’s stock ResponseLimitingMiddleware it preserves the tail of the response — terminal scrollback has its active prompt and the output agents actually need at the bottom, so dropping the head is always the correct direction.

class libtmux_mcp.middleware.SafetyMiddleware
class libtmux_mcp.middleware.SafetyMiddleware

Bases: Middleware

Gate tools by safety tier.

Parameters:

max_tier (str) – Maximum allowed tier. One of TAG_READONLY, TAG_MUTATING, or TAG_DESTRUCTIVE.

libtmux_mcp.middleware._SENSITIVE_ARG_NAMES: frozenset[str] = frozenset({'content', 'environment', 'keys', 'shell', 'text', 'value'})
data
data
libtmux_mcp.middleware._SENSITIVE_ARG_NAMES: frozenset[str] = frozenset({'content', 'environment', 'keys', 'shell', 'text', 'value'})

Argument names that carry user-supplied payloads we never want in logs. keys (send_keys), text (paste_text), value (set_environment), content (load_buffer), shell (respawn_pane), and environment (respawn_pane) can contain commands, secrets, or arbitrary large strings. Matched by exact name, case-sensitive, to mirror the tool signatures.

environment is dict-shaped (dict[str, str]); the redaction logic in _summarize_args() recognises this and digests each value while leaving the keys (env var names like DATABASE_URL) visible — env var names are operator-debug-useful, but their values are the secret. All other entries are scalar strings; mixing the two is intentional.

Note on shell and environment redaction: this redacts the MCP audit log only. respawn_pane(shell="env SECRET=... bash") and environment={"AWS_SECRET_KEY": "..."} may briefly expose the values via the OS process table and tmux’s pane_current_command metadata until the spawned shell takes over — see docs/topics/safety.md.

libtmux_mcp.middleware._MAX_LOGGED_STR_LEN: int = 200
data
data
libtmux_mcp.middleware._MAX_LOGGED_STR_LEN: int = 200

String arguments longer than this get truncated in the log summary to keep records bounded. Non-sensitive strings only — sensitive ones are replaced entirely by their digest.

libtmux_mcp.middleware._redact_digest(value)
function[source]
function[source]
libtmux_mcp.middleware._redact_digest(value)

Return a length + SHA-256 prefix summary of value.

The digest is stable and deterministic, which lets operators correlate the same payload across log lines without ever recording the payload itself.

Examples

>>> _redact_digest("hello")
{'len': 5, 'sha256_prefix': '2cf24dba5fb0'}
>>> _redact_digest("")
{'len': 0, 'sha256_prefix': 'e3b0c44298fc'}
Parameters:

value (str)

Return type:

dict[str, Any]

libtmux_mcp.middleware._summarize_args(args)
function[source]
function[source]
libtmux_mcp.middleware._summarize_args(args)

Summarize tool arguments for audit logging.

Sensitive keys get replaced by a digest; over-long strings get truncated with a marker; everything else passes through as-is. Sensitive values that are dict-shaped (e.g. environment on respawn_pane) have each value digested while keys remain visible — env-var-name-like keys are operator-debug-useful and rarely sensitive, while their values usually are.

Examples

Non-sensitive scalars pass through unchanged:

>>> _summarize_args({"pane_id": "%1", "bracket": True})
{'pane_id': '%1', 'bracket': True}

Sensitive payload names are replaced by a digest dict:

>>> _summarize_args({"keys": "rm -rf /"})["keys"]["len"]
8

Sensitive dict-shaped payloads keep their keys but digest values:

>>> redacted = _summarize_args({"environment": {"FOO": "bar"}})
>>> redacted["environment"]["FOO"]["len"]
3
>>> "bar" in str(redacted)
False
Parameters:

args (dict[str, Any])

Return type:

dict[str, Any]

class libtmux_mcp.middleware.AuditMiddleware
class libtmux_mcp.middleware.AuditMiddleware

Bases: Middleware

Emit a structured log record per tool invocation.

Records carry: tool name, outcome (ok/error), duration in ms, error type on failure, the fastmcp client_id / request_id when available, and a redacted argument summary. The logger name is libtmux_mcp.audit by default so operators can route it independently (e.g. to a JSON file) without touching the module libtmux_mcp logger.

Parameters:

logger_name (str) – Name of the logging logger used for audit records.

libtmux_mcp.middleware.DEFAULT_RESPONSE_LIMIT_BYTES = 50000
data
data
libtmux_mcp.middleware.DEFAULT_RESPONSE_LIMIT_BYTES = 50000

Default byte ceiling for TailPreservingResponseLimitingMiddleware. Chosen strictly above the per-tool max_lines caps (500 lines x ~100 bytes/line) so normal operation does not trip the middleware — it only fires when a tool forgot to declare its own cap or the user opted out via max_lines=None.

class libtmux_mcp.middleware.ReadonlyRetryMiddleware
class libtmux_mcp.middleware.ReadonlyRetryMiddleware

Bases: Middleware

Retry transient libtmux failures, but only for readonly tools.

Wraps fastmcp’s 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 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.

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).

libtmux_mcp.middleware._TRUNCATION_HEADER_TEMPLATE = '[... truncated {dropped} bytes ...]\n'
data
data
libtmux_mcp.middleware._TRUNCATION_HEADER_TEMPLATE = '[... truncated {dropped} bytes ...]\n'

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.

class libtmux_mcp.middleware.TailPreservingResponseLimitingMiddleware
class libtmux_mcp.middleware.TailPreservingResponseLimitingMiddleware

Bases: ResponseLimitingMiddleware

Response-limiter that keeps the tail of oversized output.

FastMCP’s stock ResponseLimitingMiddleware truncates the tail of the response (keeps the start, appends a suffix). That’s exactly wrong for terminal scrollback, where the active shell prompt and most recent command output live at the bottom of the buffer. This subclass overrides _truncate_to_result to drop the head instead, prefixing a single truncation-header line so callers can detect the cap fired.

Used as a global backstop for libtmux_mcp.tools.pane_tools.io.capture_pane(), libtmux_mcp.tools.pane_tools.meta.snapshot_pane(), and libtmux_mcp.tools.pane_tools.search.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.