Skip to main content
Glama

tmux-mcp

Model Context Protocol (MCP) server for tmux, written in Python and distributed via uvx.

Exposes 37 granular tools covering sessions, windows, panes, layouts, status inspection, and secure command execution using event-driven pipe-pane + wait-for primitives.


Key Features & Security Design

  • No Shell Injection Vulnerabilities: All tmux interactions execute strictly via argv parameter arrays without shell=True.

  • Event-Driven Execution Engine: Uses pipe-pane streams and tmux wait-for events instead of polling or scrollback regex. Captures exact return codes and complete output streams (>50,000 lines tested without truncation).

  • Interactive App Guard: Automatically blocks non-shell panes (e.g. vim, htop, python) from accidental command injection, prompting the caller to use keypress tools (send_keys) instead.

  • Strict Protocol & Output Purity: Zero stray stdout prints to prevent corrupting the MCP stdio JSON-RPC transport stream (logging strictly routed to sys.stderr).

  • Portable Flat Tool Schemas: Compatible with all MCP clients (Claude Code, Claude Desktop, OpenCode, Cursor, Windsurf, Zed, Continue, VS Code) by enforcing flat JSON schemas without unsupported $ref/$defs/anyOf constructs.


Related MCP server: Ryan's Tmux MCP Server

Quick Start

Run instantly without installing Python dependencies using uvx:

# Run with standard profile (default 22 tools)
uvx tmux-mcp-server

# Run with full profile (all 37 tools including destructive actions)
uvx tmux-mcp-server --tools=full

# Run in read-only mode (13 read tools)
uvx tmux-mcp-server --read-only

# Keep production sessions readable but reject every write to them
uvx tmux-mcp-server --protect 'prod-*' --protect 'skinstrading:3*'

Driving a Remote tmux over ssh

One MCP server instance now drives tmux on any machine. --remote-host sets the default host for calls that do not name one, and every tool accepts an optional host argument to route that single call elsewhere over ssh. You only need one MCP entry:

{
  "mcpServers": {
    "tmux": {
      "command": "uvx",
      "args": [
        "tmux-mcp-server",
        "--remote-host", "prod-01",
        "--ssh-opt", "-p", "--ssh-opt", "2222",
        "--protect", "host:prod-*",
        "--allow-host", "prod-*",
        "--allow-host", "staging-*"
      ]
    }
  }
}

One entry, many machines -- the host travels with the call:

read_pane   { "target": "deploy:0.0", "host": "glinteco-website" }
run_command { "command": "systemctl is-active nginx", "host": "prod-2" }
read_pane   { "target": "%3" }                  // no host: the local tmux

BatchMode=yes is always set, so ssh authentication must be non-interactive: a key or an agent. A host that would prompt for a password fails immediately with a clear error rather than hanging until the timeout. --remote-host is also settable as TMUX_MCP_REMOTE_HOST.

Reading a remote pane this way beats reading one through a local pane that happens to hold an ssh session: capture-pane runs on the remote server, so the full scrollback is available instead of only the visible screen.

run_command works against a remote host too. Its capture and exit-code files are created and read on whichever machine the tmux server lives on, so the exit code you get back is the remote command's own. --remote-tmp-dir (default /tmp) chooses where those files go, since the local temporary directory does not exist over there.

Why --allow-host?

An agent holding an ssh agent key can otherwise reach every machine that key opens, and nothing in this server would have said no. --allow-host is a fnmatch allowlist that limits which hosts the server is willing to drive. An empty host (the server's default) always passes. When no --allow-host is given, any host is allowed. --allow-host is also settable as TMUX_MCP_ALLOWED_HOSTS.

When the remote socket is not where tmux looks

tmux derives its default socket from the current uid. If ssh lands you on the remote host as a different user than the one running tmux, the default socket is simply the wrong path and tmux reports "no server running" -- which reads like the server is down when it is merely somebody else's.

A real case: ssh glinteco-website arrives as root, while the tmux server there belongs to ubuntu on /tmp/tmux-1000/default. root can read that socket perfectly well once told where it is:

uvx tmux-mcp-server --host-socket 'glinteco-website=/tmp/tmux-1000/default'

--host-socket HOST=SOCKET is repeatable and the host side is an fnmatch pattern, so a fleet with a shared convention needs one line: --host-socket 'prod-*=/tmp/tmux-1000/default'. A value starting with / becomes -S, anything else becomes -L. It only applies to calls that name a host; the local tmux keeps using --socket-name / --socket-path as before. Also settable as TMUX_MCP_HOST_SOCKETS (comma-separated pairs).

Protecting a whole host

--protect matches by identity, just like target names. When a host is active, an identity of the form host:<name> is included, so:

uvx tmux-mcp-server --protect 'host:prod-*'

makes every mutating tool refuse on those machines while reads still work. This works even when the remote host is unreachable: the identity is checked before tmux is touched.


Protecting Specific Targets

--read-only is all-or-nothing: it strips every mutating tool from the server. When you want an agent that can freely drive scratch sessions but must never type into the one attached to production, protect that target by name instead:

uvx tmux-mcp-server --protect 'prod-*,skinstrading:3*'
# or
TMUX_MCP_PROTECTED_TARGETS='prod-*,skinstrading:3*' uvx tmux-mcp-server

Protected targets stay fully readable (read_pane, search_pane, get_pane_info); only mutating tools refuse them, and they refuse before tmux is invoked.

Matching is by identity, not by the string the caller happened to use. A target is resolved through tmux first, then every name it answers to is tested against the patterns as a shell glob:

Form

Example

session

skinstrading

session:window index

skinstrading:3

session:window name

skinstrading:skinner-server

session:window.pane

skinstrading:3.0

raw ids

%88, @51, $4

So --protect 'skinstrading:3*' also blocks a call that named the pane %88. An omitted target is checked too, since it resolves to tmux's current pane.


Tool Profiles

Profile

Tools Count

Description

standard (default)

22

All read, interact, split, resize, layout, and command execution tools (excludes destructive kill-* tools).

full

37

Complete set of 37 tools, including destructive operations (kill_session, kill_window, kill_pane).

read

13

Read-only inspection tools. Safe for unrestricted agent usage.

Custom

N

Comma-separated list of tool names (e.g. --tools=list_sessions,read_pane,run_command).


Client Configuration Examples

Watch the package name. This project is tmux-mcp-server. Do not configure "args": ["tmux-mcp"] — tmux-mcp is an unrelated, already-published PyPI package, and uvx would silently download and run that one instead.

Working on the code? Point the client at your checkout so edits take effect immediately:

"args": ["run", "--directory", "/absolute/path/to/tmux-mcp-server", "tmux-mcp-server"]

with "command": "uv". Note that uvx --from <path> caches the built wheel per version, so local edits are not picked up until you bump version in pyproject.toml — uv run --directory has no such caching.

1. Claude Code

Add to ~/.claude.json or .claude.json:

{
  "mcpServers": {
    "tmux": {
      "command": "uvx",
      "args": ["tmux-mcp-server"]
    }
  }
}

2. Claude Desktop

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "tmux": {
      "command": "uvx",
      "args": ["tmux-mcp-server"]
    }
  }
}

3. OpenCode

Add to ~/.config/opencode/opencode.json (or project .opencode/mcp.json):

{
  "mcpServers": {
    "tmux": {
      "command": "uvx",
      "args": ["tmux-mcp-server"]
    }
  }
}

4. Cursor

Add to Settings -> Features -> MCP:

  • Name: tmux

  • Type: command

  • Command: uvx tmux-mcp-server

5. Windsurf

Add to ~/.codeium/windsurf/mcp_config.json:

{
  "mcpServers": {
    "tmux": {
      "command": "uvx",
      "args": ["tmux-mcp-server"]
    }
  }
}

6. Zed

Add to ~/.config/zed/settings.json:

{
  "context_servers": {
    "tmux": {
      "command": {
        "path": "uvx",
        "args": ["tmux-mcp-server"]
      }
    }
  }
}

7. Continue

Add to ~/.continue/config.json:

{
  "mcpServers": [
    {
      "name": "tmux",
      "command": "uvx",
      "args": ["tmux-mcp-server"]
    }
  ]
}

8. VS Code (Roo Code / Cline)

Add to .vscode/mcp.json:

{
  "mcpServers": {
    "tmux": {
      "command": "uvx",
      "args": ["tmux-mcp-server"]
    }
  }
}

9. Generic Stdio Client

Any MCP-compliant client can launch via stdio:

{
  "command": "uvx",
  "args": ["tmux-mcp-server"]
}

Tool Reference (37 Tools)

Sessions (6)

  • list_sessions

  • get_session

  • create_session

  • rename_session

  • switch_client

  • kill_session ⚠️

create_session pins the size you ask for. tmux's default window-size latest would otherwise resize a detached window to whatever client is newest on the server and silently discard -x/-y. This matters when driving a TUI: the same screen captured at 320 columns costs roughly five times more to read back than at 120.

Windows (6)

  • list_windows

  • create_window

  • rename_window

  • select_window

  • move_window

  • kill_window ⚠️

Panes — Read & Interact (7)

  • list_panes

  • get_pane_info

  • read_pane

  • search_pane

  • send_keys

  • send_special_key

  • clear_pane

Full-screen apps and nested tmux. While a pane runs vim, htop, less, or a tmux attached over ssh, the terminal is on its alternate screen and tmux keeps no reachable scrollback for it — only the visible screen exists. Reading works normally (a nested tmux is just rendered text, at any nesting depth), but history does not, so:

  • get_pane_info reports alternate_on

  • read_pane returns scrollback_available: false, a warning, and sets truncated when history was requested and could not be delivered

  • search_pane reports searched_lines and warns that a zero result covers only the visible screen

read_pane(join_wrapped=true) rejoins lines the terminal split at pane width, so a long value broken across two rows comes back as one string.

Note that list_panes never sees panes inside a nested tmux: that inner server has its own socket, usually on another machine.

Panes someone is viewing. copy-mode belongs to the pane, not to a client — copy-mode has no -c flag, and every client watching the pane sees the same mode. A pane being scrolled and a pane being typed into are therefore mutually exclusive states, and typing into a pane in copy-mode does not fail cleanly. tmux routes each key into the mode's key table: leading characters are consumed as copy-mode commands, one of them cancels the mode, and the tail falls through to the shell and runs. Sending echo MARKER at a pane in copy-mode leaves bash: RKER: command not found.

send_keys, send_special_key, and run_command therefore refuse a pane in copy-mode. Pass exit_copy_mode=true to cancel the mode first — which yanks a human viewer's screen back to the live output, so it is opt-in rather than automatic.

To watch a pane's output while an agent keeps typing into it, do not scroll the pane itself. Tee it and read the copy instead:

tmux pipe-pane -t <agent-pane> -o 'cat >> /tmp/agent.log'
less +F /tmp/agent.log   # your own pane, your own scrollback

Large payloads. send-keys -l carries the whole text as one argv element, and tmux caps a client→server command message at 16KB: past roughly 16,300 bytes tmux answers command too long and nothing is typed at all. Below that ceiling a full-screen application can still lose part of a long burst, because the bytes reach it as thousands of individual keypresses and a TUI that re-renders between reads processes only some of them — the classic symptom is a prompt whose head is missing and whose tail arrived.

send_keys therefore switches to a tmux paste buffer above 200 bytes: load-buffer - takes the payload on stdin (no command-line limit at all) and paste-buffer -p wraps it in bracketed-paste markers when the application asked for them, so the app sees one paste event. The JSON reply reports which path ran as method: keys, paste or chunked (the fallback for a tmux without load-buffer -). Pass paste=true to force the paste path for short text. A 100KB payload arrives byte-identical; Enter is sent after a short settle delay so a pasting TUI submits it rather than absorbing it into the burst.

One limit this cannot lift: a pane whose foreground process reads the tty in canonical mode (cat, read, a password prompt) is capped by the line discipline at MAX_CANON (1024 bytes) per line, and the kernel discards the rest however the bytes were delivered.

Panes — Layout & Sizing (9)

  • split_pane

  • select_pane

  • resize_pane

  • zoom_pane

  • swap_panes

  • move_pane

  • break_pane

  • set_layout

  • kill_pane ⚠️

Command Execution (5)

  • run_command

  • get_command_result

  • wait_command

  • list_commands

  • cancel_command

Command history. run_command types into a real interactive shell, so every command it runs also lands in that shell's history. That history belongs to the human — it is what a prefix search walks through — and it cannot hold what an audit actually needs anyway: no exit code, no timestamp, no pane, no duration. So the server keeps its own log, tab-separated, one line per event:

ts                        event  command_id        pane  exit  elapsed  cwd     command
2026-08-17T00:49:30+0700  RUN    cmd_790182afe03d  %0    -     -        /repo   uv run pytest
2026-08-17T00:49:31+0700  OK     cmd_790182afe03d  %0    0     0.23s    /repo   uv run pytest

Dispatch is logged before the command finishes, so tail -f shows work in flight rather than only what has already completed. Events are RUN, OK, FAIL and CANCEL, which makes grep FAIL worth typing. pane is the resolved %id, not the string the caller passed, since session names get reused.

# default: /tmp/.tmux-mcp-server-history, so the OS reclaims it on its own schedule
uvx tmux-mcp-server --commands-history-file ~/.tmux-mcp/commands.log
uvx tmux-mcp-server --no-save-commands-history      # record nothing; wins over the flag above

The file is created if missing, opened append-only and mode 0600, and never followed through a symlink — the default sits in a world-writable /tmp, and these lines carry every command and working directory of the session. A write failure is logged once and never propagates: the history must not be able to break the command it is recording.

Server & Inspection (4)

  • server_info

  • list_clients

  • display_message

  • show_options


Development & Testing

Run all unit and integration tests (using an isolated test socket -L tmux-mcp-test):

uv run pytest

The suite runs under asyncio_mode = "auto": every test here is a coroutine driving tmux, so a missing @pytest.mark.asyncio can no longer make a test pass by never being awaited.

Lint runs on commit. Install the hook once:

uv run pre-commit install
uv run ruff check .          # same check, on demand

The hook shells out to uv run ruff, so it uses exactly the ruff pinned in the dev dependency group rather than a separate copy that could drift.


License

MIT

Available Tools

22 tools
create_sessionB

Create a new detached tmux session

Inputs: name, width, height, start_directory. Returns: result

create_windowA

Create a new window in target session

Inputs: name, select, target, start_directory. Returns: result

get_command_resultB
Read-onlyIdempotent

Poll status or fetch result of a tracked command execution

Inputs: command_id. Returns: result

get_pane_infoC
Read-onlyIdempotent

Get status, command, path, and dimensions of a pane

Inputs: target. Returns: result

get_sessionA
Read-onlyIdempotent

Get detailed info for a specific session by ID or name

Inputs: target. Returns: result

list_clientsA
Read-onlyIdempotent

List connected tmux clients

Inputs: no named parameters. Returns: result

list_commandsA
Read-onlyIdempotent

List tracked command executions in current session

Inputs: target. Returns: result

list_panesA
Read-onlyIdempotent

List tmux panes for a target window/session or all sessions

Inputs: target, all_panes. Returns: result

list_sessionsA
Read-onlyIdempotent

List active tmux sessions with details

Inputs: target. Returns: result

list_windowsA
Read-onlyIdempotent

List tmux windows for a session or across all sessions

Inputs: target, all_sessions. Returns: result

read_paneB
Read-onlyIdempotent

Read visible text and scrollback history from a pane

Inputs: end, lines, start, target, +2 more. Returns: result

resize_paneC

Resize pane relative or to absolute dimensions

Inputs: width, height, target, direction, +1 more. Returns: result

run_commandC

Run shell command with exact exit code and pipe-pane output capture

Inputs: wait, target, command, timeout. Returns: result

search_paneA
Read-onlyIdempotent

Search pane scrollback history using regex pattern

Inputs: target, pattern, max_results. Returns: result

select_paneA

Select active pane by ID or direction (L, R, U, D)

Inputs: target, direction. Returns: result

select_windowB

Select target window to become active

Inputs: target. Returns: result

send_keysA

Send literal string text to a target pane

Inputs: keys, enter, target. Returns: result

send_special_keyC

Send a special key shortcut (e.g. Enter, Up, C-c)

Inputs: key, repeat, target. Returns: result

server_infoA
Read-onlyIdempotent

Get tmux server version, socket info, and counts

Inputs: no named parameters. Returns: result

split_paneC

Split target pane horizontally or vertically

Inputs: size, target, direction, start_directory. Returns: result

wait_commandA
Read-onlyIdempotent

Wait for a running command execution to finish

Inputs: timeout, command_id. Returns: result

zoom_paneB

Toggle zoom state (maximize/restore) for target pane

Inputs: target. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.5/5.0

    Scored across 22 tools

    Disambiguation5/5

    Each tool targets a distinct resource and action, with no true overlaps. The only near pair is get_pane_info vs read_pane, but one reports metadata and the other reads content, making them easy to distinguish.

    Naming Consistency4/5

    Nearly all tools follow a consistent verb_noun snake_case pattern (e.g., list_sessions, create_window, send_keys). The single exception is server_info, which uses a noun phrase instead of get_server_info, breaking the pattern slightly.

    Tool Count4/5

    With 22 tools, the set is on the heavier side, but the tmux domain is broad and the tools are organized into clear clusters (sessions, windows, panes, command execution, server info). The count feels justified rather than bloated.

    Completeness4/5

    The tool surface covers major tmux interactions comprehensively, but it lacks explicit destroy or rename operations for sessions, windows, and panes. Since run_command can execute arbitrary shell commands (including tmux kill/rename commands), these gaps are workable.

    Maintenance

    ActivitySlowing
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      D
      maintenance
      MCP server for terminal control enabling AI to manage terminal sessions via iTerm2 or tmux, with features like screen reading, keyboard/mouse input, and background execution.
      9
      12 npm
      22
      MIT
    • A
      license
      B
      quality
      B
      maintenance
      A comprehensive MCP server for driving tmux sessions, windows, panes, sending keystrokes, and reading pane output locally or over SSH, enabling real-time collaborative pairing with AI.
      71
      20 PyPI
      6
      MIT