Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
517d0da
Mcp(fix[safety]): Name the tier a gated tool needs
tony Aug 25, 2026
aff097a
Mcp(fix[pane]): Stop reporting tmux failures as success
tony Aug 25, 2026
0325f14
Mcp(fix[filters]): Reject unknown filter fields
tony Aug 25, 2026
e312b46
Mcp(fix[errors]): Diagnose a newline in a path; stop doubling a prefix
tony Aug 25, 2026
d6d7873
Mcp(fix[server]): Tell an unreachable server from an absent one
tony Aug 25, 2026
b6c105e
Mcp(fix[wait]): Report a stop marker that was already on screen
tony Aug 25, 2026
e26b884
Mcp(fix[servers]): Give each list_servers row a complete identity
tony Aug 25, 2026
0a13a29
Mcp(fix[capture]): Report the loss when a pane laps its history limit
tony Aug 25, 2026
815c299
Mcp(fix[hooks]): Merge both hook trees on the default scope too
tony Aug 25, 2026
836ae62
Mcp(fix[limits]): Keep a truncated success schema-valid
tony Aug 25, 2026
0778479
Mcp(fix[search]): Say how much of each pane was read
tony Aug 25, 2026
fd33c33
Mcp(feat[options]): Answer what an option is actually set to
tony Aug 25, 2026
4568f8a
Mcp(fix[run-command]): Require a shell, and say a timeout did not cancel
tony Aug 25, 2026
7861804
Mcp(fix[errors]): Stop reporting a caller's mistake as an internal error
tony Aug 25, 2026
9901d2a
Mcp(fix[pane]): Report what happened, not what was asked
tony Aug 25, 2026
bf7d964
Tests(fix[isolation]): Give the truncation test its own window
tony Aug 25, 2026
d8b89b3
Mcp(fix[run-command]): Refuse a pager that has not reached the alt sc…
tony Aug 25, 2026
e898d96
Tests(revert): Undo an isolation fix for a mechanism that cannot occur
tony Aug 25, 2026
6ad12db
Docs(changes): Scope the flake work by pattern, not by filename
tony Aug 25, 2026
9460c6d
Docs(changes): Correct the wait-site share, and say why it spreads
tony Aug 25, 2026
a6af1eb
Mcp(fix[filters]): Make every field a tool returns filterable
tony Aug 25, 2026
8ba6a3a
Mcp(fix[run-command]): Refuse a pane running top
tony Aug 25, 2026
e75508c
Mcp(fix[filters]): Close three silent-empty paths in filtering
tony Aug 25, 2026
1cf5cf1
Tests(fix[flakes]): Synchronise on arming, not on a sleep
tony Aug 25, 2026
1c8f7ca
Mcp(fix[flag-injection]): Refuse caller strings tmux reads as flags
tony Aug 25, 2026
22e0555
Mcp(fix): Invalidate a capture cursor on reflow; refuse copy mode
tony Aug 25, 2026
904e345
Tests(fix[budgets]): Stop paying a ceiling that is spent on purpose
tony Aug 25, 2026
5b80930
Mcp(fix[resources]): Address a pane by its number, not a mangled id
tony Aug 25, 2026
bfa4a09
Perf: Probe sockets in parallel, and stop holding a lock across tmux
tony Aug 25, 2026
5a16edf
Tests(fix): Reap tmux daemons left by killed runs
tony Aug 25, 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
877 changes: 877 additions & 0 deletions CHANGES

Large diffs are not rendered by default.

384 changes: 360 additions & 24 deletions src/libtmux_mcp/_utils.py

Large diffs are not rendered by default.

229 changes: 212 additions & 17 deletions src/libtmux_mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import time
import typing as t

from fastmcp.exceptions import PromptError, ResourceError
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.server.middleware.error_handling import (
ErrorHandlingMiddleware,
Expand All @@ -40,7 +41,8 @@
from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware
from fastmcp.tools.base import ToolResult
from libtmux import exc as libtmux_exc
from mcp.types import CallToolRequestParams, TextContent
from mcp import McpError
from mcp.types import CallToolRequestParams, ErrorData, TextContent
from pydantic import ValidationError as PydanticValidationError

from libtmux_mcp._utils import (
Expand All @@ -51,25 +53,65 @@
ExpectedToolError,
)

#: Errors this server raises deliberately to describe a CALLER-caused
#: failure. They must never reach the ``-32603`` "Internal error" path,
#: whichever message kind raised them: ``ExpectedToolError`` from tools,
#: and fastmcp's ``ResourceError`` / ``PromptError`` from the resource
#: and prompt handlers, which this package raises only for a bad target
#: or a missing object.
_CALLER_CAUSED_ERRORS: tuple[type[Exception], ...] = (
ExpectedToolError,
ResourceError,
PromptError,
)

logger = logging.getLogger(__name__)

_TIER_LEVELS: dict[str, int] = {
TAG_READONLY: 0,
TAG_MUTATING: 1,
TAG_DESTRUCTIVE: 2,
}

#: Reverse of :data:`_TIER_LEVELS`, so a middleware configured with an
#: unrecognized tier can still *name* the tier it fell back to.
_LEVEL_TIERS: dict[int, str] = {level: tier for tier, level in _TIER_LEVELS.items()}


def _highest_tier(tags: t.Collection[str]) -> str | None:
"""Return the highest safety tier named in *tags*, or None if untagged."""
found = [tier for tier in _TIER_LEVELS if tier in tags]
if not found:
return None
return max(found, key=lambda tier: _TIER_LEVELS[tier])


class SafetyMiddleware(Middleware):
"""Gate tools by safety tier.
"""Explain tier denials that ``_enable_allowed_tools`` enforces.

FastMCP's native ``disable()`` is the enforcement gate and holds
even for a call that skips the middleware chain. It also makes
``get_tool()`` answer **None**, so FastMCP reports an off-tier call
as ``Unknown tool`` -- the server denying its own gated tool exists.
This gate names the required tier instead, resolving such names
against :meth:`_tier_snapshot` (the registry keeps disabled tools).

Denials must raise *here* so :class:`AuditMiddleware`, which sits
outside, records them as denials rather than unknown-tool errors.

Parameters
----------
max_tier : str
Maximum allowed tier. One of ``TAG_READONLY``, ``TAG_MUTATING``,
or ``TAG_DESTRUCTIVE``.
Maximum allowed tier. Unrecognized values fall back to
``TAG_READONLY``.
"""

def __init__(self, max_tier: str = TAG_MUTATING) -> None:
self.max_level = _TIER_LEVELS.get(max_tier, 0)
#: Normalized tier name, so a denial reports where the server
#: stands rather than echoing an unrecognized env value back.
self.max_tier = _LEVEL_TIERS[self.max_level]
self._tier_by_tool: dict[str, str] | None = None

def _is_allowed(self, tags: set[str]) -> bool:
"""Return True if the tool's tags fall within the allowed tier.
Expand All @@ -84,6 +126,55 @@ def _is_allowed(self, tags: set[str]) -> bool:
return False
return found_tier

def _denial_message(self, tool_name: str, required_tier: str | None) -> str:
"""Name the required tier and the active one.

The message this replaced hardcoded ``destructive`` for every
denial, so a readonly server answered ``send_keys`` by advising
kill_server rights in order to type into a pane.
"""
if required_tier is None:
return (
f"Tool {tool_name!r} declares no safety tier and is blocked. "
"This is a bug in the server, not a configuration problem; "
"please report it."
)
return (
f"Tool {tool_name!r} requires safety level {required_tier!r}, but "
f"this server is running at {self.max_tier!r}. Restart it with "
f"LIBTMUX_SAFETY={required_tier} to enable it."
)

async def _tier_snapshot(self, fastmcp: t.Any) -> dict[str, str]:
"""Map every registered tool name to its tier, disabled included.

Built once. ``_list_tools()`` is FastMCP-private, so a failure
degrades to an empty map: the call then falls through to the
stock ``NotFoundError``, losing the explanation but nothing
else. ``tests/test_server.py`` pins the behavior so a fastmcp
bump fails in CI rather than silently reverting this gate.
"""
if self._tier_by_tool is not None:
return self._tier_by_tool

snapshot: dict[str, str] = {}
try:
registered = await fastmcp._list_tools()
except Exception:
logger.warning(
"safety tier snapshot unavailable; off-tier calls will "
"report 'unknown tool'",
exc_info=True,
)
else:
for tool in registered:
tier = _highest_tier(tool.tags)
if tier is not None:
snapshot[tool.name] = tier

self._tier_by_tool = snapshot
return snapshot

async def on_list_tools(
self,
context: MiddlewareContext,
Expand All @@ -98,16 +189,36 @@ async def on_call_tool(
context: MiddlewareContext,
call_next: t.Any,
) -> t.Any:
"""Block execution of tools above the safety tier."""
if context.fastmcp_context:
tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name)
if tool and not self._is_allowed(tool.tags):
msg = (
f"Tool '{context.message.name}' is not available at the "
f"current safety level. Set LIBTMUX_SAFETY=destructive "
f"to enable destructive tools."
"""Block execution of tools above the safety tier.

Fail-closed except for a name the registry has never heard of,
which is a typo and deserves FastMCP's own ``NotFoundError``.
"""
tool_name = context.message.name

if context.fastmcp_context is None:
# No registry to consult: deny, since the top tier includes
# kill_server.
msg = (
f"Tool {tool_name!r} was called without a FastMCP context, so "
"its safety tier cannot be verified. Call it through MCP."
)
raise ExpectedToolError(msg)

fastmcp = context.fastmcp_context.fastmcp

tool = await fastmcp.get_tool(tool_name)
if tool is not None:
if not self._is_allowed(tool.tags):
raise ExpectedToolError(
self._denial_message(tool_name, _highest_tier(tool.tags))
)
raise ExpectedToolError(msg)
return await call_next(context)

# Invisible to ``get_tool``: gated by tier, or nonexistent.
gated_tier = (await self._tier_snapshot(fastmcp)).get(tool_name)
if gated_tier is not None:
raise ExpectedToolError(self._denial_message(tool_name, gated_tier))
return await call_next(context)


Expand Down Expand Up @@ -430,6 +541,35 @@ def _log_error(self, error: Exception, context: MiddlewareContext) -> None:
except Exception:
self.logger.exception("Error in error callback")

def _transform_error(
self,
error: Exception,
context: MiddlewareContext,
) -> Exception:
"""Keep caller-caused failures out of the ``-32603`` catch-all.

The base transform funnels every unrecognized exception into
``"Internal error: ..."``. That is right for a bug and wrong for
"that session does not exist". It also runs for EVERY message
kind, so intercepting only ``tools/call`` below left resources
reporting a caller's mistake as a server fault:
``tmux://sessions/nosuchsession`` answered
``Internal error: Session not found: nosuchsession``.

Fixed at the fork rather than by adding one resource hook. The
property is "an expected failure is never an internal error",
and it has to hold on every path this transform serves.
"""
if self.transform_errors and isinstance(error, _CALLER_CAUSED_ERRORS):
# Mirror the base class's own convention: -32002 is the MCP
# code for a resource miss, -32602 says the caller's
# arguments were wrong. Neither prefixes the message, which
# already names the object that was not found.
method = context.method or ""
code = -32002 if method.startswith("resources/") else -32602
return McpError(ErrorData(code=code, message=str(error)))
return super()._transform_error(error, context)

async def on_call_tool(
self,
context: MiddlewareContext,
Expand Down Expand Up @@ -812,6 +952,34 @@ async def on_call_tool(
_TRUNCATION_HEADER_TEMPLATE = "[... truncated {dropped} bytes ...]\n"


def _restructure_truncated(
original: dict[str, t.Any],
truncated: ToolResult,
) -> ToolResult | None:
"""Re-attach structured content to a truncated success result.

Only the single-string result shape is rebuildable: fastmcp wraps a
``-> str`` tool as ``{"result": "..."}``, so swapping in the
truncated text keeps the payload schema-valid. Model- and
list-shaped results carry the oversize inside their own fields and
cannot be trimmed from the flattened text, so they return None and
the caller reports a tool error instead of an invalid response.
"""
if set(original) != {"result"} or not isinstance(original["result"], str):
return None
text = next(
(block.text for block in truncated.content if isinstance(block, TextContent)),
None,
)
if text is None:
return None
return ToolResult(
content=truncated.content,
structured_content={"result": text},
meta=truncated.meta,
)


class TailPreservingResponseLimitingMiddleware(ResponseLimitingMiddleware):
"""Response-limiter that keeps the tail of oversized output.

Expand Down Expand Up @@ -860,15 +1028,42 @@ async def _capture(
return t.cast("ToolResult", inner)

result = await super().on_call_tool(context, _capture)
if result is not inner and isinstance(inner, ToolResult) and inner.is_error:
# The base class truncated and rebuilt the result; restore
# the error flag it dropped.
if result is inner or not isinstance(inner, ToolResult):
return result

# The base class truncated and rebuilt the result, dropping both
# ``is_error`` and ``structured_content``.
if inner.is_error:
return ToolResult(
content=result.content,
meta=result.meta,
is_error=True,
)
return result
if inner.structured_content is None:
return result

# A SUCCESSFUL oversized response is the other half of the same
# defect: the tool declares an output schema, the rebuilt result
# carries no structured content, and a spec-compliant client
# raises a transport-level error instead of delivering truncated
# data. That is worse than the truncation this middleware exists
# to perform, and worse than having no middleware at all.
rebuilt = _restructure_truncated(inner.structured_content, result)
if rebuilt is not None:
return rebuilt
# Shape we cannot rebuild: say so as a tool error the agent can
# act on, rather than emitting a response its client will reject.
msg = (
"Response exceeded the server's size limit and could not be "
"truncated while satisfying this tool's output schema. Re-run "
"with a narrower range (for example a smaller max_lines, or a "
"less negative start)."
)
return ToolResult(
content=[TextContent(type="text", text=msg)],
meta=result.meta,
is_error=True,
)

def _truncate_to_result(
self,
Expand Down
Loading
Loading