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`
Nobody has claimed this yet.
- 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:
- 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
- #19 —
is_callerfalse 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 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.
- tmux-python/libtmux-mcp#92 — [bug] documented self-location recipe is unexecutable (
filters={"is_caller": true}→[]) - tmux-python/libtmux-mcp#93 — [feat]
where_am_iself-location primitive + self-first relational routing (root fix) - tmux-python/libtmux-mcp#94 — [bug] caller's socket advertised but never targeted;
TMUXenv 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); unreachablesocket_pathderivation
(← this issue: #94)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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