Repository navigation
The caller's tmux socket is advertised in the instructions but never targeted: an MCP inside tmux -L work queries default #94
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:
- advertise
(socket work)in its instructions, - query
defaulton every single tool call, - compute
is_caller: falseon every pane (the check demands a confirmed realpath socket match, and the caller's pane isn't ondefault), - and therefore make the instructions' own advertised recovery — "filter
list_panesforis_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_instructionsreadsos.environ["TMUX_PANE"]server.py#L201— hand-parses$TMUXto recover the socket name for the prose line
Targeted (the real behaviour):
_utils.py#L502—_get_server()consults onlyLIBTMUX_SOCKET/LIBTMUX_SOCKET_PATH_utils.py#L523— with neither set, a bareServer()→socket_nameandsocket_pathbothNone→ tmux resolvesdefault
The consequence:
_utils.py#L186—_compute_is_callerrequires_caller_is_strictly_on_server, a realpath socket match. Caller is onwork, panes are ondefault→Falsefor 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
- Start a non-default tmux server and a shell in it:
tmux -L work new-session -d -s repro
- Attach, and from inside a pane on that server launch an MCP client whose libtmux-mcp server inherits the environment (
$TMUXwill point at theworksocket;LIBTMUX_SOCKETunset). - Inspect the server instructions delivered at
initialize.- Observed:
Agent context: this MCP runs inside tmux pane %N (socket work).
- Observed:
- Call
list_panes().- Observed: panes from the
defaultsocket (or[]if no default server exists) — not theworkserver the agent is actually sitting in. - Observed:
is_callerisfalseon every returned row. - Expected: panes from
work, with exactly one rowis_caller: true.
- Observed: panes from the
- 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:
libtmux/server.py#L172(constructor)libtmux/server.py#L346(Server.cmd)libtmux/neo.py#L725(fetch_objsre-derives the flags independently)
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
- With
LIBTMUX_SOCKETunset and$TMUXpointing at a non-default socket, assert_get_server()targets that socket (notdefault). - On a non-default caller socket, assert
list_panes()returns exactly one row withis_caller is True. - Assert the instructions' advertised socket equals the socket
_get_server()actually resolves (a coherence test — this is the invariant that is broken today). - Race regression: with
_get_caller_identitysnapshotted, assert it still returns the caller's identity whileos.environ["TMUX"]is transiently deleted.
Related
- is_caller annotation gives false positives across tmux sockets #19 —
is_callerfalse positives across sockets. This is the mirror-image defect: is_caller annotation gives false positives across tmux sockets #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 onServer(socket_path=caller.socket_path), is immune to this bug by construction. - Upstream libtmux: unreachable
socket_pathderivation inServer.__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.
- The documented self-location recipe is unexecutable: filters={"is_caller": true} silently returns [] #92 — [bug] documented self-location recipe is unexecutable (
filters={"is_caller": true}→[]) - Add a self-location primitive (where_am_i): agents must dump every pane on the server to learn which pane they are in #93 — [feat]
where_am_iself-location primitive + self-first relational routing (root fix) - The caller's tmux socket is advertised in the instructions but never targeted: an MCP inside
tmux -L workqueriesdefault#94 — [bug] caller's socket advertised but never targeted;TMUXenv race vs. the threadpool - The list_* family is unbounded, unscoped, unprojected — and explicitly excluded from the response limiter #95 — [perf]
list_*unbounded, unscoped, unprojected, excluded from the response limiter - Surface invocation context (in tmux? server running? which socket?) and make tmux-server auto-start explicit #96 — [feat] invocation context (in tmux? server running?) + explicit tmux-server auto-start
- No self-location API (TMUX_PANE), and the socket_path derivation in Server.__init__ is unreachable dead code libtmux#704 — upstream: no self-location API (
TMUX_PANE); unreachablesocket_pathderivation
(← this issue: #94)
Activity