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
default on every single tool call,
- compute
is_caller: false on every pane (the check demands a confirmed realpath socket match, and the caller's pane isn't on default),
- 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 default → False 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
- 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 (
$TMUX will point at the work socket; LIBTMUX_SOCKET unset).
- Inspect the server instructions delivered at
initialize.
- Observed:
Agent context: this MCP runs inside tmux pane %N (socket work).
- 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.
- 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
- With
LIBTMUX_SOCKET unset and $TMUX pointing at a non-default socket, assert _get_server() targets that socket (not default).
- On a non-default caller socket, assert
list_panes() returns exactly one row with is_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_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)
Summary
_build_instructions()parses$TMUXat 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 fromLIBTMUX_SOCKET/LIBTMUX_SOCKET_PATH— never from$TMUX. With neither env var set it constructs a bareServer(), which targets tmux'sdefaultsocket.So an MCP server launched inside
tmux -L workwill:(socket work)in its instructions,defaulton every single tool call,is_caller: falseon every pane (the check demands a confirmed realpath socket match, and the caller's pane isn't ondefault),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 lineTargeted (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 resolvesdefaultThe 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_pathauto-derivation inServer.__init__is unreachable dead code — its conditions are mutually contradictory, soServer(socket_name=...)and bareServer()always leavesocket_path is None:libtmux/server.py#L198This is exactly why libtmux-mcp had to grow
_effective_socket_path()(_utils.py#L191), whose fallback shells out totmux display-message -p '#{socket_path}'.Reproduction
$TMUXwill point at theworksocket;LIBTMUX_SOCKETunset).initialize.Agent context: this MCP runs inside tmux pane %N (socket work).list_panes().defaultsocket (or[]if no default server exists) — not theworkserver the agent is actually sitting in.is_callerisfalseon every returned row.work, with exactly one rowis_caller: true.tmux -L work kill-server.Suggested fix
1. Make
_get_server()follow the caller's socket. WhenLIBTMUX_SOCKET/LIBTMUX_SOCKET_PATHare unset and$TMUXis present, default to the caller's socket path:Using the socket path (
-S) rather than the name (-L) sidesteps both libtmux's unreachablesocket_pathderivation and the macOS/launchdTMUX_TMPDIRdivergence.Server(socket_path=...)is supported and re-injects-Son 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=1initially — 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 todefault, the instructions must not claim(socket work). Say(querying socket default; set LIBTMUX_SOCKET to target your own).3. Fix
ServerInfoso the effective socket is knowable.get_server_infoechoesserver.socket_name/server.socket_path, which are bothNonefor a bareServer()— even against a live server. Populate them from_effective_socket_path()so the agent can at least discover which socket it just queried.server_tools.py#L210Bonus defect found while investigating:
os.environ["TMUX"]racelibtmux's
new_sessiondeletesos.environ["TMUX"]process-globally (restoring it in afinally) so a nestednew-sessiondoesn't inherit the parent server:libtmux/server.py#L2280fastmcp runs sync tools in a threadpool (
run_in_thread=Trueby default, never overridden here):fastmcp/tools/function_tool.py#L163,#L457And
_get_caller_identity()re-readsos.environon every call rather than snapshotting at startup:_utils.py#L123So a
list_panesorkill_serverrunning concurrently with acreate_sessioncan observeTMUXunset — degradingis_callertoNoneand, more seriously, stripping the self-kill guard's primary socket signal.Fix: snapshot
CallerIdentityonce 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
LIBTMUX_SOCKETunset and$TMUXpointing at a non-default socket, assert_get_server()targets that socket (notdefault).list_panes()returns exactly one row withis_caller is True._get_server()actually resolves (a coherence test — this is the invariant that is broken today)._get_caller_identitysnapshotted, assert it still returns the caller's identity whileos.environ["TMUX"]is transiently deleted.Related
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.filters={"is_caller": true}silently returns[].whoami— which, if built onServer(socket_path=caller.socket_path), is immune to this bug by construction.socket_pathderivation inServer.__init__.Environment
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.filters={"is_caller": true}→[])where_am_iself-location primitive + self-first relational routing (root fix)tmux -L workqueriesdefault#94 — [bug] caller's socket advertised but never targeted;TMUXenv race vs. the threadpoollist_*unbounded, unscoped, unprojected, excluded from the response limiterTMUX_PANE); unreachablesocket_pathderivation(← this issue: #94)