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

### Dependencies

**Minimum `libtmux>=0.56.0`** (was `>=0.55.1`). Unlocks the new tmux-command wrappers shipped in libtmux 0.56.0 — {meth}`~libtmux.Pane.respawn`, {meth}`~libtmux.Pane.copy_mode`, {meth}`~libtmux.Pane.pipe`, {meth}`~libtmux.Pane.swap`, {meth}`~libtmux.Pane.paste_buffer`, {meth}`~libtmux.Pane.clear_history`, {meth}`~libtmux.Pane.display_message`, {meth}`~libtmux.Server.delete_buffer`, and the {meth}`~libtmux.Session.next_window` / {meth}`~libtmux.Session.previous_window` / {meth}`~libtmux.Session.last_window` trio — so the MCP no longer falls back to raw `cmd()` calls for tmux commands the upstream API now covers. (#46)

## libtmux-mcp 0.1.0a6 (2026-05-09)

libtmux-mcp 0.1.0a6 is the activation and registration cleanup release. It makes the server much easier for MCP clients to discover from ordinary "pane", "window", and "session" prompts, standardizes new setup docs around the `tmux` registration slug, and adds migration guidance for existing `libtmux` registrations. Existing installs keep working; the release changes defaults and documentation so new installs line up with the tool prefix users actually see.
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ include = [
]

dependencies = [
"libtmux>=0.55.1,<1.0",
"libtmux>=0.56.0,<1.0",
"fastmcp>=3.2.4,<4.0.0",
]

Expand Down Expand Up @@ -118,6 +118,7 @@ build-backend = "hatchling.build"
# from any `exclude-newer` constraint (global or per-user) without
# committing a date that would age into the lockfile. Mirrors the
# pattern at vcspull/pyproject.toml for libvcs.
libtmux = false
gp-libs = false
gp-furo-theme = false
gp-sphinx = false
Expand Down
2 changes: 1 addition & 1 deletion src/libtmux_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None:
if not name.startswith(_MCP_BUFFER_PREFIX):
continue
try:
server.cmd("delete-buffer", "-b", name)
server.delete_buffer(buffer_name=name)
except Exception as err:
logger.debug("buffer GC: delete-buffer %s failed: %s", name, err)

Expand Down
6 changes: 1 addition & 5 deletions src/libtmux_mcp/tools/buffer_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,7 @@ def paste_buffer(
session_id=session_id,
window_id=window_id,
)
paste_args: list[str] = ["-b", cname]
if bracket:
paste_args.append("-p")
paste_args.extend(["-t", pane.pane_id or ""])
pane.cmd("paste-buffer", *paste_args)
pane.paste_buffer(buffer_name=cname, bracket=bracket)
return f"Buffer {cname!r} pasted to pane {pane.pane_id}"


Expand Down
18 changes: 7 additions & 11 deletions src/libtmux_mcp/tools/pane_tools/copy_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,13 @@ def enter_copy_mode(
session_id=session_id,
window_id=window_id,
)
# libtmux's Pane.cmd injects ``-t pane.pane_id``; passing it again
# produced the duplicated ``-t %X -t %X`` shape tmux silently accepted.
pane.cmd("copy-mode")
pane.copy_mode()
if scroll_up is not None and scroll_up > 0:
pane.cmd(
"send-keys",
"-X",
"-N",
str(scroll_up),
"scroll-up",
pane.send_keys(
"",
copy_mode_cmd="scroll-up",
repeat=scroll_up,
enter=False,
)
pane.refresh()
return _serialize_pane(pane)
Expand Down Expand Up @@ -109,7 +106,6 @@ def exit_copy_mode(
session_id=session_id,
window_id=window_id,
)
# See enter_copy_mode: Pane.cmd injects ``-t pane.pane_id`` already.
pane.cmd("send-keys", "-X", "cancel")
pane.send_keys("", copy_mode_cmd="cancel", enter=False)
pane.refresh()
return _serialize_pane(pane)
24 changes: 12 additions & 12 deletions src/libtmux_mcp/tools/pane_tools/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,15 @@ def clear_pane(
session_id=session_id,
window_id=window_id,
)
# Split into two cmd() calls — pane.reset() in libtmux <= 0.55.0 sends
# `send-keys -R \; clear-history` as one call, but subprocess doesn't
# interpret \; as a tmux command separator so clear-history never runs.
# Two separate calls — pane.reset() in libtmux 0.56.0 still sends
# `send-keys -R \; clear-history` as one call but subprocess doesn't
# interpret \; as a tmux command separator, so clear-history never
# runs. The bare `-R` send is left as a raw cmd() because
# Pane.send_keys requires a cmd string and would emit an extra
# empty key alongside the reset flag.
# See: https://github.com/tmux-python/libtmux/issues/650
pane.cmd("send-keys", "-R")
pane.cmd("clear-history")
pane.clear_history()
return f"Pane cleared: {pane.pane_id}"


Expand Down Expand Up @@ -325,20 +328,17 @@ def paste_text(
msg = f"load-buffer failed: {stderr or e}"
raise ToolError(msg) from e

# Paste from the named buffer. -d deletes only that named buffer,
# leaving any unnamed user buffer intact.
paste_args = ["-b", buffer_name, "-d"]
if bracket:
paste_args.append("-p") # bracketed paste mode
paste_args.extend(["-t", pane.pane_id or ""])
pane.cmd("paste-buffer", *paste_args)
# Paste from the named buffer. ``delete_after=True`` (``-d``)
# deletes only that named buffer, leaving any unnamed user
# buffer intact.
pane.paste_buffer(buffer_name=buffer_name, bracket=bracket, delete_after=True)
finally:
if tmppath is not None:
pathlib.Path(tmppath).unlink(missing_ok=True)
# Defensive: the buffer should already be gone (paste-buffer -d
# deletes it), but if paste-buffer failed before -d took effect
# we leak an entry in the tmux server. Best-effort delete.
with contextlib.suppress(Exception):
server.cmd("delete-buffer", "-b", buffer_name)
server.delete_buffer(buffer_name=buffer_name)

return f"Text pasted to pane {pane.pane_id}"
9 changes: 6 additions & 3 deletions src/libtmux_mcp/tools/pane_tools/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def resize_pane(
)
if zoom is not None:
window = pane.window
# ``window_zoomed_flag`` isn't declared on the libtmux Window
# dataclass in 0.56.0, and Window has no ``display_message``
# wrapper — keep the raw cmd() until upstream surfaces one.
result = window.cmd("display-message", "-p", "#{window_zoomed_flag}")
is_zoomed = bool(result.stdout) and result.stdout[0] == "1"
if zoom and not is_zoomed:
Expand Down Expand Up @@ -171,7 +174,7 @@ def select_pane(
idx = panes.index(active)
step = 1 if direction == "next" else -1
target_pane = panes[(idx + step) % len(panes)]
server.cmd("select-pane", target=target_pane.pane_id)
target_pane.select()

# Query the active pane ID directly from tmux to avoid stale cache
target = window.window_id or ""
Expand Down Expand Up @@ -216,8 +219,8 @@ def swap_pane(
server = _get_server(socket_name=socket_name)
# Validate both panes exist
source = _resolve_pane(server, pane_id=source_pane_id)
_resolve_pane(server, pane_id=target_pane_id)
target = _resolve_pane(server, pane_id=target_pane_id)

server.cmd("swap-pane", "-s", source_pane_id, "-t", target_pane_id)
source.swap(target=target)
source.refresh()
return _serialize_pane(source)
30 changes: 6 additions & 24 deletions src/libtmux_mcp/tools/pane_tools/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,30 +150,12 @@ def respawn_pane(
"Use a manual tmux command if intended."
)
raise ToolError(msg)
# Stopgap: ``libtmux>=0.55.1`` has no ``Pane.respawn()`` yet — the
# wrapper exists on the upstream ``tmux-parity`` branch (see
# ``libtmux/pane.py:respawn``) and mirrors this argv shape: ``-k``,
# ``-c <dir>``, repeated ``-e<KEY>=<VAL>`` (single-arg form, NOT
# split ``-e KEY=VAL`` — tmux's args parser accepts both but
# upstream emits the joined form), then optional trailing shell.
# When the release line picks it up, swap ``pane.cmd("respawn-pane",
# *argv)`` for ``pane.respawn(kill=kill, start_directory=
# start_directory, environment=environment, shell=shell)`` and drop
# the stderr branch — ``Pane.respawn`` raises ``LibTmuxException``.
argv: list[str] = []
if kill:
argv.append("-k")
if start_directory is not None:
argv.extend(["-c", start_directory])
if environment:
argv.extend(f"-e{k}={v}" for k, v in environment.items())
if shell is not None:
argv.append(shell)
result = pane.cmd("respawn-pane", *argv)
if result.stderr:
stderr = " ".join(result.stderr).strip()
msg = f"tmux respawn-pane failed: {stderr}"
raise ToolError(msg)
pane.respawn(
kill=kill,
start_directory=start_directory,
environment=environment,
shell=shell,
)
# Pick up fresh pane_pid and any command/path updates; tmux does
# not invalidate the underlying object on respawn.
pane.refresh()
Expand Down
8 changes: 4 additions & 4 deletions src/libtmux_mcp/tools/pane_tools/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ def display_message(
session_id=session_id,
window_id=window_id,
)
result = pane.cmd("display-message", "-p", format_string)
return "\n".join(result.stdout) if result.stdout else ""
stdout = pane.display_message(format_string, get_text=True)
return "\n".join(stdout) if stdout else ""


@handle_tool_errors
Expand Down Expand Up @@ -159,8 +159,8 @@ def snapshot_pane(
"#{pane_tty}",
]
fmt = _SEP.join(_FMT_VARS)
result = pane.cmd("display-message", "-p", fmt)
raw = result.stdout[0] if result.stdout else ""
stdout = pane.display_message(fmt, get_text=True)
raw = stdout[0] if stdout else ""
# Pad defensively to guarantee one slot per format var even if tmux
# drops an unknown variable on older versions.
parts = (raw.split(_SEP) + [""] * len(_FMT_VARS))[: len(_FMT_VARS)]
Expand Down
4 changes: 2 additions & 2 deletions src/libtmux_mcp/tools/pane_tools/pipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ def pipe_pane(
)

if output_path is None:
pane.cmd("pipe-pane")
pane.pipe()
return f"Piping stopped for pane {pane.pane_id}"

if not output_path.strip():
msg = "output_path must be a non-empty path, or None to stop piping."
raise ToolError(msg)

redirect = ">>" if append else ">"
pane.cmd("pipe-pane", f"cat {redirect} {shlex.quote(output_path)}")
pane.pipe(f"cat {redirect} {shlex.quote(output_path)}")
return f"Piping pane {pane.pane_id} to {output_path}"
28 changes: 11 additions & 17 deletions src/libtmux_mcp/tools/session_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,28 +305,22 @@ def select_window(
window.select()
return _serialize_window(window)

# Directional navigation: use the dedicated tmux subcommands so that
# libtmux's Session.cmd injects `-t $session_id` and the navigation
# stays scoped to this session (a bare `-t +` resolves against the
# attached client, not the target session).
# Directional navigation. Each Session method injects `-t
# $session_id`, returns the new active Window, and raises
# LibTmuxException on stderr — so the dispatch reduces to a
# straight lookup with no manual stderr handling.
session = _resolve_session(server, session_name=session_name, session_id=session_id)
_CMD_MAP = {
"next": "next-window",
"previous": "previous-window",
"last": "last-window",
_NAV = {
"next": session.next_window,
"previous": session.previous_window,
"last": session.last_window,
}
assert direction is not None
subcommand = _CMD_MAP.get(direction)
if subcommand is None:
fn = _NAV.get(direction)
if fn is None:
msg = f"Invalid direction: {direction!r}. Valid: next, previous, last"
raise ToolError(msg)
proc = session.cmd(subcommand)
if proc.stderr:
stderr = " ".join(proc.stderr).strip()
msg = f"tmux {subcommand} failed: {stderr}"
raise ToolError(msg)

active_window = session.active_window
active_window = fn()
return _serialize_window(active_window)


Expand Down
12 changes: 6 additions & 6 deletions tests/test_session_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,15 +271,15 @@ def test_select_window_last_on_single_window_session_raises(
) -> None:
"""select_window last with no prior window must surface the tmux error.

Regression guard: session.cmd("last-window") on a session that has
never had a previously-active window emits "no last window" on
stderr, but the tool previously discarded the return value and
returned the unchanged active window as if the navigation had
worked.
Regression guard: ``Session.last_window`` on a session that has
never had a previously-active window raises ``LibTmuxException``
with tmux's "no last window" stderr; the tool previously discarded
the return value and returned the unchanged active window as if
the navigation had worked.
"""
# The fixture session is freshly created: there is no previously-
# active window for last-window to jump back to.
with pytest.raises(ToolError, match="last-window"):
with pytest.raises(ToolError, match="no last window"):
select_window(
direction="last",
session_name=mcp_session.session_name,
Expand Down
9 changes: 5 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.