Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Optional feature requirements

`load_buffer`, nonempty `paste_text` and exact named-buffer deletion require
tmux 3.4 before native effects, because a named buffer a 3.3a daemon cannot
delete would accumulate there. Ordinary input, capture, polling and completion
channels retain 3.3a. Owned subprocesses enforce libtmux's literal command
policy, including the older layout error boundary. Startup and tool dispatch
refuse a libtmux without `libtmux._internal.support.require_commands`.

### Compatibility

Core operations require tmux 3.3a and libtmux 0.63.0 or newer.
Expand Down
40 changes: 32 additions & 8 deletions docs/reference/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ semantics; this does not make those threads interruptible.
Higher requirements for individual features are documented separately from
the core minimum. Baseline polling does not require control mode.

## Optional operations

Ordinary input, pane capture, output polling, completion channels and
`run_command` retain the 3.3a core floor. The selected daemon determines
eligibility for each operation.

| Operation | Requirement |
| --- | --- |
| `load_buffer`, which stages a named buffer, and named buffer deletion or rename | tmux 3.4+, before dispatch; a 3.3a daemon creates no buffer |
| Nonempty `paste_text` | tmux 3.4+, before loading its temporary buffer; failure cleanup needs exact named deletion |
| Empty `paste_text`, `show_buffer` of an existing buffer and direct `paste_buffer` | Retain 3.3a |
| Literal `select-layout` on 3.3a | Validate the preset or serialized header before dispatch; ordinary valid layouts remain available |

The backend must provide `libtmux._internal.support.require_commands`; startup and
tools refuse a libtmux without it before work. Owned subprocess helpers also
enforce the parent's literal command policy, which a package pin by itself
cannot cover for commands that bypass libtmux's dispatcher.

`run_command` sends an authored command to a pane and observes completion. It
keeps 3.3a support independently of the parent library's captured foreground
`run-shell` output, which requires 3.5. Polling tools do not acquire control.
These optional refusals do not raise the whole MCP server's minimum.

## Dependencies

| Package | Required version |
Expand All @@ -43,15 +66,16 @@ the core minimum. Baseline polling does not require control mode.
| [FastMCP](https://github.com/jlowin/fastmcp) | >= 4.0.2, < 5.0.0 |
| [regex](https://github.com/mrabarnett/mrab-regex) | >= 2024.11.6 |

The libtmux backend must publish `libtmux._internal.support`, which first ships in
libtmux 0.63.0; the dependency range names that release, so an installer never
selects the 0.62 series. Until libtmux 0.63.0 is released, this branch's uv
source override pins the implementation from
[Python core PR779](https://github.com/tmux-python/libtmux/pull/779) for
The libtmux backend must publish `libtmux._internal.support`, including
`require_commands`, which first ships in libtmux 0.63.0; the dependency range
names that release, so an installer never selects the 0.62 series. Until
libtmux 0.63.0 is released, this branch's uv source override pins the
implementation from
[Python feature PR780](https://github.com/tmux-python/libtmux/pull/780) for
development; ordinary package installers ignore that override. Startup and tool
dispatch refuse a backend without that module. Release libtmux first,
then drop the source override and merge this change. Use
`uv sync --all-extras --dev` for the development pair.
dispatch refuse a backend without those names. Release libtmux first, then drop
the source override and merge this change. Use `uv sync --all-extras --dev` for
the development pair.

## MCP clients

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ build-backend = "hatchling.build"

[tool.uv.sources]
# Coordinated version-check backend, pending its package release.
libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "f6e4630a15f342382487a60e1e1477abcd52294d" }
libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "5df9cb3e89647577b7dd8484f844ffe1053129bb" }

[tool.uv.exclude-newer-package]
# git-pull packages release in lockstep with their workspaces, so a
Expand Down
24 changes: 14 additions & 10 deletions src/libtmux_mcp/_tmux_proc.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def _backend() -> types.ModuleType:
raise exc.LibTmuxException(msg) from error
if not all(
callable(getattr(support, name, None))
for name in ("require_version", "check_fence")
for name in ("require_version", "check_fence", "require_commands")
) or not isinstance(getattr(support, "MINIMUM", None), str):
msg = "Unsupported libtmux backend: the tmux version check is unavailable"
raise exc.LibTmuxException(msg)
Expand Down Expand Up @@ -247,13 +247,17 @@ def _remaining(deadline: float) -> float:
return result


def _fence(argv: list[str], version: str, live: bool) -> tuple[list[str], str | None]:
_backend().require_version(version, minimum=_MINIMUM_TMUX)
def _fence(
argv: list[str], version: str, live: bool, *, minimum: str = _MINIMUM_TMUX
) -> tuple[list[str], str | None]:
policy = _backend()
policy.require_version(version, minimum=minimum)
_, commands = _parts(argv)
prefix = argv[: len(argv) - len(commands)] if commands else argv
marker = "libtmux-mcp-version-" + uuid.uuid4().hex
# These callers select one native command; preserve literal terminal text.
literal = [item[:-1] + "\\;" if item.endswith(";") else item for item in commands]
policy.require_commands(version, literal, repaired_break=False)
return [
*prefix,
"-u",
Expand All @@ -269,13 +273,13 @@ def _fence(argv: list[str], version: str, live: bool) -> tuple[list[str], str |


async def _prepare_bounded(
argv: list[str], deadline: float
argv: list[str], deadline: float, *, minimum: str = _MINIMUM_TMUX
) -> tuple[list[str], str | None]:
_backend()
selectors, _commands = _parts(argv)
cached = _VERSION_CACHE.get(_endpoint_key(argv[0], selectors))
if cached is not None:
return _fence(argv, cached, True)
return _fence(argv, cached, True, minimum=minimum)
probe, marker = _probe(argv[0], selectors)
code, output, error = await _run_tmux_unchecked(probe, timeout=_remaining(deadline))
live = not _absent(code, error)
Expand All @@ -287,17 +291,17 @@ async def _prepare_bounded(
[argv[0], "-V"], timeout=_remaining(deadline)
)
version = _version(output, error, code, binary=argv[0])
return _fence(argv, version, live)
return _fence(argv, version, live, minimum=minimum)


async def _run_tmux_bounded(
argv: list[str], *, timeout: float
argv: list[str], *, timeout: float, minimum: str = _MINIMUM_TMUX
) -> tuple[int, bytes, bytes]:
"""Check the version and dispatch within one cancellable process budget."""
deadline = time.monotonic() + timeout
selectors, _ = _parts(argv)
for attempt in (0, 1):
prepared, marker = await _prepare_bounded(argv, deadline)
prepared, marker = await _prepare_bounded(argv, deadline, minimum=minimum)
result = await _run_tmux_unchecked(prepared, timeout=_remaining(deadline))
stderr = result[2]
if attempt == 0 and _absent(result[0], stderr):
Expand All @@ -316,7 +320,7 @@ async def _run_tmux_bounded(


def _run_tmux_sync_bounded(
argv: list[str], *, timeout: float, check: bool = True
argv: list[str], *, timeout: float, check: bool = True, minimum: str = _MINIMUM_TMUX
) -> subprocess.CompletedProcess[bytes]:
"""Check the version and run a synchronous child within one remaining budget."""
policy = _backend()
Expand Down Expand Up @@ -345,7 +349,7 @@ def run(command: list[str]) -> subprocess.CompletedProcess[bytes]:
version = _version(
probe.stdout, probe.stderr, probe.returncode, binary=argv[0]
)
prepared, marker = _fence(argv, version, live)
prepared, marker = _fence(argv, version, live, minimum=minimum)
result = run(prepared)
if (
attempt == 0
Expand Down
9 changes: 8 additions & 1 deletion src/libtmux_mcp/tools/buffer_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ def load_buffer(
:attr:`~libtmux_mcp.models.BufferRef.buffer_name` on
subsequent paste/show/delete calls.

Requires tmux 3.4: the staged buffer is named, and exact named-buffer
deletion, the only way to remove it, needs 3.4. Older daemons refuse
before creating the buffer.

**When to use this vs. paste_text:** ``load_buffer`` is the
stage-then-fire path — you get a handle back and can inspect via
``show_buffer``, paste into multiple panes via ``paste_buffer``,
Expand Down Expand Up @@ -218,7 +222,7 @@ def load_buffer(
f.write(content)
argv = _tmux_argv(server, "load-buffer", "-b", buffer_name, tmppath)
try:
_run_tmux_sync_bounded(argv, timeout=5.0)
_run_tmux_sync_bounded(argv, timeout=5.0, minimum="3.4")
except subprocess.TimeoutExpired as e:
msg = f"load-buffer timeout after 5s for {buffer_name!r}"
raise ExpectedToolError(msg) from e
Expand Down Expand Up @@ -346,6 +350,9 @@ def delete_buffer(
) -> str:
"""Delete an MCP-owned buffer.

Requires tmux 3.4 for exact named-buffer deletion. Older daemons refuse
before modifying their buffer list.

Parameters
----------
buffer_name : str
Expand Down
8 changes: 7 additions & 1 deletion src/libtmux_mcp/tools/pane_tools/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,9 @@ def paste_text(
you need to stage content first, paste it into multiple panes, or
inspect it with ``show_buffer`` before pasting.

Nonempty text requires tmux 3.4 for reliable named-buffer cleanup on
failure. Empty text has no input effect on every supported release.

Parameters
----------
text : str
Expand Down Expand Up @@ -800,6 +803,9 @@ def paste_text(
window_id=window_id,
)

if not text:
return f"Text pasted to pane {pane.pane_id}"

# Use a unique named tmux buffer so we don't clobber the user's
# unnamed paste buffer, and so we can reliably clean up on error
# paths (paste-buffer -b NAME -d deletes the named buffer). The
Expand All @@ -819,7 +825,7 @@ def paste_text(
load_args = _tmux_argv(server, "load-buffer", "-b", buffer_name, tmppath)

try:
_run_tmux_sync_bounded(load_args, timeout=5.0)
_run_tmux_sync_bounded(load_args, timeout=5.0, minimum="3.4")
except subprocess.TimeoutExpired as e:
msg = f"load-buffer timeout after 5s for {buffer_name!r}"
raise ExpectedToolError(msg) from e
Expand Down
5 changes: 4 additions & 1 deletion src/libtmux_mcp/tools/window_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,13 +357,16 @@ def select_layout(
Choose from: even-horizontal, even-vertical, main-horizontal,
main-vertical, or tiled. Rearranges all panes in the window.

On tmux 3.3a, unknown names and malformed serialized headers are
refused before dispatch. Valid layouts retain the core minimum.

Parameters
----------
layout : str
Layout name or custom layout string. Built-in layouts:
'even-horizontal', 'even-vertical', 'main-horizontal',
'main-horizontal-mirrored', 'main-vertical',
'main-vertical-mirrored', 'tiled'.
'main-vertical-mirrored', 'tiled'. Mirrored layouts require tmux 3.5.
window_id : str, optional
Window ID (e.g. '@1').
window_index : str, optional
Expand Down
30 changes: 24 additions & 6 deletions tests/test_buffer_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pytest
from fastmcp.exceptions import ToolError
from libtmux._internal import support

from libtmux_mcp.middleware import _SENSITIVE_ARG_NAMES
from libtmux_mcp.models import BufferContent, BufferRef
Expand All @@ -23,6 +24,17 @@
from libtmux.server import Server


def _require_named_buffers(server: Server) -> None:
"""Skip workflows that stage a named buffer a 3.3a daemon cannot delete."""
if not support.is_at_least(server._get_version(), "3.4"):
pytest.skip("load_buffer stages a named buffer and needs tmux 3.4 to delete it")


def _assert_delete_policy(server: Server, name: str) -> None:
"""Delete an agent buffer exactly."""
delete_buffer(name, socket_name=server.socket_name)


@pytest.mark.parametrize("name", ["my-buffer", "clipboard.v2", "a", "x" * 64])
def test_logical_name_accepts_valid(name: str) -> None:
"""Valid logical names pass through."""
Expand Down Expand Up @@ -63,6 +75,7 @@ def test_load_buffer_allocates_unique_name(mcp_server: Server) -> None:
The UUID nonce in the allocated name prevents collisions even when
the caller reuses the same logical name.
"""
_require_named_buffers(mcp_server)
a = load_buffer(
content="first",
logical_name="clipboard",
Expand All @@ -81,13 +94,14 @@ def test_load_buffer_allocates_unique_name(mcp_server: Server) -> None:

# Clean up — buffers are server-global; leaking them would pollute
# the test run even though the libtmux Server fixture is torn down.
delete_buffer(a.buffer_name, socket_name=mcp_server.socket_name)
delete_buffer(b.buffer_name, socket_name=mcp_server.socket_name)
_assert_delete_policy(mcp_server, a.buffer_name)
_assert_delete_policy(mcp_server, b.buffer_name)


@pytest.mark.usefixtures("mcp_session")
def test_buffer_round_trip(mcp_server: Server) -> None:
"""Load -> show -> delete round-trip preserves content exactly."""
_require_named_buffers(mcp_server)
payload = "line1\nline2\nline3"
ref = load_buffer(
content=payload,
Expand Down Expand Up @@ -122,6 +136,7 @@ def test_paste_buffer_requires_mcp_namespace(

def test_paste_buffer_into_pane(mcp_server: Server, mcp_pane: Pane) -> None:
"""``paste_buffer`` pastes an MCP buffer into the target pane."""
_require_named_buffers(mcp_server)
ref = load_buffer(
content="echo PASTE_BUFFER_MARKER",
logical_name="smoke",
Expand All @@ -135,7 +150,7 @@ def test_paste_buffer_into_pane(mcp_server: Server, mcp_pane: Pane) -> None:
)
assert ref.buffer_name in result
finally:
delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name)
_assert_delete_policy(mcp_server, ref.buffer_name)


def test_content_is_in_sensitive_args() -> None:
Expand All @@ -155,6 +170,7 @@ def test_show_buffer_tail_preserves_on_truncation(mcp_server: Server) -> None:
``True``, and ``content_truncated_lines`` reports how many were
dropped so the caller can re-request with ``max_lines=None``.
"""
_require_named_buffers(mcp_server)
payload = "\n".join(f"line-{i}" for i in range(20))
ref = load_buffer(
content=payload,
Expand All @@ -173,12 +189,13 @@ def test_show_buffer_tail_preserves_on_truncation(mcp_server: Server) -> None:
kept = seen.content.splitlines()
assert kept == [f"line-{i}" for i in range(15, 20)]
finally:
delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name)
_assert_delete_policy(mcp_server, ref.buffer_name)


@pytest.mark.usefixtures("mcp_session")
def test_show_buffer_full_read_when_max_lines_none(mcp_server: Server) -> None:
"""``max_lines=None`` disables truncation for full-buffer recovery."""
_require_named_buffers(mcp_server)
payload = "\n".join(f"line-{i}" for i in range(50))
ref = load_buffer(
content=payload,
Expand All @@ -195,12 +212,13 @@ def test_show_buffer_full_read_when_max_lines_none(mcp_server: Server) -> None:
assert seen.content_truncated_lines == 0
assert seen.content.rstrip("\n") == payload
finally:
delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name)
_assert_delete_policy(mcp_server, ref.buffer_name)


@pytest.mark.usefixtures("mcp_session")
def test_show_buffer_no_truncation_under_cap(mcp_server: Server) -> None:
"""Small buffers are returned verbatim with truncation flags off."""
_require_named_buffers(mcp_server)
payload = "one\ntwo\nthree"
ref = load_buffer(
content=payload,
Expand All @@ -217,7 +235,7 @@ def test_show_buffer_no_truncation_under_cap(mcp_server: Server) -> None:
assert seen.content_truncated_lines == 0
assert seen.content.rstrip("\n") == payload
finally:
delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name)
_assert_delete_policy(mcp_server, ref.buffer_name)


@pytest.mark.parametrize(
Expand Down
20 changes: 19 additions & 1 deletion tests/test_pane_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5581,6 +5581,15 @@ def test_paste_text(mcp_server: Server, mcp_pane: Pane) -> None:
trailing newline forces execution, exercising the full
paste->execute->output round-trip.
"""
if mcp_server._get_version() == "3.3a":
with pytest.raises(ToolError, match=r"3\.4"):
paste_text(
text="echo PASTE_TEST_marker_xyz\n",
pane_id=mcp_pane.pane_id,
socket_name=mcp_server.socket_name,
)
assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout
return
result = paste_text(
text="echo PASTE_TEST_marker_xyz\n",
pane_id=mcp_pane.pane_id,
Expand Down Expand Up @@ -5618,9 +5627,18 @@ def test_paste_text_does_not_leak_named_buffer(
``list-buffers`` on that prefix sees every MCP-owned buffer.

The check is portable across every tmux version the CI matrix
tests (3.2a through master): ``list-buffers`` with a format string
tests (3.3a through master): ``list-buffers`` with a format string
returns buffer names without any version-specific behavior.
"""
if mcp_server._get_version() == "3.3a":
with pytest.raises(ToolError, match=r"3\.4"):
paste_text(
text="echo BUFFER_ISOLATION_test",
pane_id=mcp_pane.pane_id,
socket_name=mcp_server.socket_name,
)
assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout
return
paste_text(
text="echo BUFFER_ISOLATION_test",
pane_id=mcp_pane.pane_id,
Expand Down
Loading
Loading