tmux-python / tmux-python/libtmux-mcp

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

Open
#94 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Python
Stars
13
Forks
0
Avg merge
13h 52m
Merged PRs (30d)
4

Description

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

  • #19 — is_caller false positives across sockets. This is the mirror-image defect: #19 was false positives; this is false negatives on every row.
  • Companion bug: filters={"is_caller": true} silently returns [].
  • Companion feature: whoami — which, if built on Server(socket_path=caller.socket_path), is immune to this bug by construction.
  • Upstream libtmux: unreachable socket_path derivation in Server.__init__.

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.

  • tmux-python/libtmux-mcp#92 — [bug] documented self-location recipe is unexecutable (filters={"is_caller": true}[])
  • tmux-python/libtmux-mcp#93 — [feat] where_am_i self-location primitive + self-first relational routing (root fix)
  • tmux-python/libtmux-mcp#94 — [bug] caller's socket advertised but never targeted; TMUX env race vs. the threadpool
  • tmux-python/libtmux-mcp#95 — [perf] list_* unbounded, unscoped, unprojected, excluded from the response limiter
  • tmux-python/libtmux-mcp#96 — [feat] invocation context (in tmux? server running?) + explicit tmux-server auto-start
  • tmux-python/libtmux#704 — upstream: no self-location API (TMUX_PANE); unreachable socket_path derivation

(← this issue: #94)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/libtmux_mcp/_utils.py at _get_server(), _get_caller_identity(), and _effective_socket_path(), then inspect server_tools.py and _build_instructions() in server.py. Reproduce with a non-default tmux socket and run the existing test suite before adding coverage for socket targeting, caller detection, instruction coherence, and the environment snapshot race. Done means the queried socket and advertised socket agree and caller detection remains correct.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.