Skip to content

The caller's tmux socket is advertised in the instructions but never targeted: an MCP inside tmux -L work queries default #94

Description

@tony

Summary

_build_instructions() parses $TMUX at import time to tell the agent "Agent context: this MCP runs inside tmux pane %N (socket work)". That socket name is cosmetic. _get_server() resolves the target socket only from LIBTMUX_SOCKET / LIBTMUX_SOCKET_PATH — never from $TMUX. With neither env var set it constructs a bare Server(), which targets tmux's default socket.

So an MCP server launched inside tmux -L work will:

  1. advertise (socket work) in its instructions,
  2. query default on every single tool call,
  3. compute is_caller: false on every pane (the check demands a confirmed realpath socket match, and the caller's pane isn't on default),
  4. and therefore make the instructions' own advertised recovery — "filter list_panes for is_caller=true" — return nothing.

The agent is told where it is, told how to confirm it, and both are wrong. Every self-referential and destructive-guard code path silently degrades.

Evidence

Advertised (display only):

  • server.py#L195_build_instructions reads os.environ["TMUX_PANE"]
  • server.py#L201 — hand-parses $TMUX to recover the socket name for the prose line

Targeted (the real behaviour):

  • _utils.py#L502_get_server() consults only LIBTMUX_SOCKET / LIBTMUX_SOCKET_PATH
  • _utils.py#L523 — with neither set, a bare Server()socket_name and socket_path both None → tmux resolves default

The consequence:

  • _utils.py#L186_compute_is_caller requires _caller_is_strictly_on_server, a realpath socket match. Caller is on work, panes are on defaultFalse for every row.

Upstream contributor: libtmux's socket_path auto-derivation in Server.__init__ is unreachable dead code — its conditions are mutually contradictory, so Server(socket_name=...) and bare Server() always leave socket_path is None:

This is exactly why libtmux-mcp had to grow _effective_socket_path() (_utils.py#L191), whose fallback shells out to tmux display-message -p '#{socket_path}'.

Reproduction

  1. Start a non-default tmux server and a shell in it:
    tmux -L work new-session -d -s repro
  2. Attach, and from inside a pane on that server launch an MCP client whose libtmux-mcp server inherits the environment ($TMUX will point at the work socket; LIBTMUX_SOCKET unset).
  3. Inspect the server instructions delivered at initialize.
    • Observed: Agent context: this MCP runs inside tmux pane %N (socket work).
  4. Call list_panes().
    • Observed: panes from the default socket (or [] if no default server exists) — not the work server the agent is actually sitting in.
    • Observed: is_caller is false on every returned row.
    • Expected: panes from work, with exactly one row is_caller: true.
  5. Teardown: tmux -L work kill-server.

Suggested fix

1. Make _get_server() follow the caller's socket. When LIBTMUX_SOCKET / LIBTMUX_SOCKET_PATH are unset and $TMUX is present, default to the caller's socket path:

caller = _get_caller_identity()
if caller is not None and caller.socket_path:
    return Server(socket_path=caller.socket_path)   # -S <path>

Using the socket path (-S) rather than the name (-L) sidesteps both libtmux's unreachable socket_path derivation and the macOS/launchd TMUX_TMPDIR divergence. Server(socket_path=...) is supported and re-injects -S on every command:

If the behaviour change is a concern, gate it behind LIBTMUX_FOLLOW_TMUX_SOCKET=1 initially — but note that the current behaviour is already incoherent with what the server tells the agent, so "no change" is not a neutral option.

2. Failing that, stop advertising a socket the tools don't use. If _get_server() will keep defaulting to default, the instructions must not claim (socket work). Say (querying socket default; set LIBTMUX_SOCKET to target your own).

3. Fix ServerInfo so the effective socket is knowable. get_server_info echoes server.socket_name / server.socket_path, which are both None for a bare Server() — even against a live server. Populate them from _effective_socket_path() so the agent can at least discover which socket it just queried.

Bonus defect found while investigating: os.environ["TMUX"] race

libtmux's new_session deletes os.environ["TMUX"] process-globally (restoring it in a finally) so a nested new-session doesn't inherit the parent server:

fastmcp runs sync tools in a threadpool (run_in_thread=True by default, never overridden here):

And _get_caller_identity() re-reads os.environ on every call rather than snapshotting at startup:

So a list_panes or kill_server running concurrently with a create_session can observe TMUX unset — degrading is_caller to None and, more seriously, stripping the self-kill guard's primary socket signal.

Fix: snapshot CallerIdentity once at process start. It cannot change for the life of the process, so there is no reason to re-read the environment per call — and doing so removes the race entirely.

Tests

  1. With LIBTMUX_SOCKET unset and $TMUX pointing at a non-default socket, assert _get_server() targets that socket (not default).
  2. On a non-default caller socket, assert list_panes() returns exactly one row with is_caller is True.
  3. Assert the instructions' advertised socket equals the socket _get_server() actually resolves (a coherence test — this is the invariant that is broken today).
  4. Race regression: with _get_caller_identity snapshotted, assert it still returns the caller's identity while os.environ["TMUX"] is transiently deleted.

Related

Environment

  • libtmux-mcp: 0.1.0a17
  • libtmux: 0.61.0
  • fastmcp: 3.4.3 / mcp: 1.28.1
  • tmux: 3.7b
  • Python: 3.14.0

Cross-references — filed together from a single audit of v0.1.0a17. The self-location gap is the root cause; the rest are what it exposed.

(← this issue: #94)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions