tmux-python / tmux-python/libtmux-mcp

Surface invocation context (in tmux? server running? which socket?) and make tmux-server auto-start explicit

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

Nobody has claimed this yet.

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

Description

Summary

libtmux-mcp can be invoked in four materially different situations, and it currently distinguishes none of them to the agent:

# Scenario What the agent is told today
a Launched inside a tmux pane A prose line in instructions — which can be silently dropped under the byte cap, and names a socket the tools don't actually query
b Launched outside tmux, a tmux server is running Nothing. The Agent context line is simply absent, with no replacement
c Launched outside tmux, no server running Nothing. Discovery returns [], indistinguishable from "empty server"
d Outside tmux, no server, and the agent must not start one No such mode exists

The only in-tmux detection happens once, at import, in _build_instructions — and it feeds nothing but a sentence. Nothing else in the process consumes it.

Separately: create_session silently spawns a long-lived tmux daemon (sourcing the user's tmux.conf) with no gate, no opt-in, and no signal in its response that it did so.

Evidence

Detection exists but is inert
  • server.py#L195_build_instructions is the sole startup reader of TMUX_PANE
  • server.py#L279 — the lifespan probe checks only that the tmux binary exists; it never probes whether a server does
  • server.py#L221no negative branch: with TMUX/TMUX_PANE unset the Agent context block is simply omitted. Verified. Agents in scenarios (b)/(d) must infer their context from an absence.
  • resources/hierarchy.py#L38 — every registered resource is a hierarchy read. Nothing exposes inside_tmux, effective socket, or server-running state.
"No server" is indistinguishable from "empty server"

Verified empirically against a dead socket: list_sessions/list_windows/list_panes[]; list_servers[]. libtmux swallows the daemon-not-up error and returns an empty QueryList:

get_server_info is the only discriminator (is_alive=False) — and nothing in the instructions tells the agent to call it first:

Worse, on the default socket it reports socket_name=None and socket_path=None even when alive — so it cannot even tell the agent which socket it just queried:

The error path is a dead-end loop

With no server running, capture_pane(pane_id="%0") fails with "Pane not found: %0" and the suggestion "Call list_panes to discover valid pane ids" — and list_panes returns [], because no daemon exists. The agent is sent in a circle.

And "no tmux server" is logged as an unexpected ERROR for set_environment, because libtmux raises a bare ValueError (not a LibTmuxException), missing the expected-error branch and landing in the catch-all with logger.exception:

A routine "no server running" thereby pollutes operator error budgets as a non-bug.

Auto-start is silent, ungated, and unannounced

I probed every plausible candidate against a dead socket (show_option, set_option, show_environment, set_environment, signal_channel, load_buffer, create_window) — none spawn a daemon. create_session is the only tool that does (is_alive False → True):

It is registered with ANNOTATIONS_CREATE (destructiveHint=False) in the plain mutating tier — so a default-profile agent will spawn a long-lived tmux daemon, sourcing the user's ~/.tmux.conf, as a routine "create":

SessionInfo carries no signal that a whole tmux server came into existence as a side effect — the agent and the audit log see an ordinary session create:

And there is no "do not start a server" mode: no start_server tool is registered, libtmux.Server.start_server() is never called anywhere in the package, and the only way to block an auto-start is LIBTMUX_SAFETY=readonly — which hides every mutating tool.

Reproduction

Scenario (c) — no server, dead-end loop:

tmux -L novel_socket kill-server 2>/dev/null; LIBTMUX_SOCKET=novel_socket <launch mcp>
  1. list_panes()[]. 2. capture_pane(pane_id="%0")"Pane not found: %0 … Call list_panes to discover valid pane ids." 3. list_panes()[]. Observed: a loop with no way out and no statement that the server is simply not running. Expected: "No tmux server is running on socket novel_socket."

Scenario (c) — silent daemon spawn: on that same dead socket, call create_session(session_name="x"). Observed: a tmux daemon is started (sourcing the user's config) and SessionInfo looks like any other create. Expected: either an explicit opt-in, or at minimum server_started: true in the response.

Suggested fix

1. Expose the context surface. This is the same tool proposed in the self-location issue — where_am_i (readonly) + a tmux://self resource — whose payload deliberately covers server/safety state as well as caller identity:

{
  "inside_tmux": bool,
  "pane_id": str | None, "window_id": str | None, "session_id": str | None,
  "caller_socket_path": str | None,
  "effective_socket_name": str | None,   # what the tools will ACTUALLY query
  "effective_socket_path": str | None,   # (can differ from caller_* — see the socket bug)
  "server_running": bool,
  "live_server_count": int,
  "safety_level": str,                   # readonly | mutating | destructive
  "suppress_history": bool,
}

One call answers "where am I / is anything running / what will my tools target / what am I allowed to do". It replaces a prose suffix that vanishes silently under the 2048-byte instructions cap. The two issues should ship together — one tool, not two. (Naming rationale, including why get_context was rejected — "context" is hopelessly overloaded in agent-land — lives in that issue.)

Liveness must come from Server.is_alive() — one list-sessions, provably no daemon spawn (libtmux/server.py#L241). Do not infer liveness from socket-file existence: start-server leaves a stale socket file behind after the sessionless daemon exits under exit-empty on (libtmux/server.py#L1448). And do not infer it from emptiness of .sessions vs .panes — they swallow different exception sets.

2. Make server auto-start opt-in. Add allow_server_start: bool = False to create_session (default from LIBTMUX_ALLOW_SERVER_START), and register a dedicated start_server tool wrapping libtmux's existing Server.start_server(). Starting a tmux daemon sources the user's config and leaves a long-lived background process — that should be an explicit, auditable action, not a side effect of a create tagged destructiveHint=False.

(Caveat for whoever implements this: start_server() alone is insufficient — under exit-empty on the sessionless daemon exits immediately and is_alive() still returns False. A real "ensure server" must create a session.)

3. Announce the spawn. Add server_started: bool to SessionInfo so both the agent and the AuditMiddleware record can see that a create_session brought a whole daemon into existence rather than joining an existing one.

4. Give the not-found errors a real recovery hint. Have the targeted resolvers (_resolve_pane/_resolve_window/_resolve_session) probe server.is_alive() before raising, so the error names the actual cause:

"No tmux server is running on socket <name>. Call list_servers to find live sockets, or start_server / create_session(allow_server_start=true) to start one."

Also fix show_environment, which returns {"variables": {}} on a dead server — indistinguishable from a genuinely empty environment.

5. Map libtmux's bare ValueError from set_environment/unset_environment into the expected-error path in _map_exception_to_tool_error (or fix it upstream — see the libtmux issue), so "no tmux server running" stops being logged as an unexpected ERROR.

6. State the negative case in the instructions. When TMUX_PANE is absent, append an explicit segment rather than silently omitting the context block:

"This MCP is NOT running inside tmux: there is no 'self'. is_caller is null on every pane. Call where_am_i / list_servers first; ask the user which session to target rather than guessing."

Tests

  1. where_am_i() outside tmux → inside_tmux=False, and server_running correctly reflects a live vs dead socket.
  2. where_am_i() on a dead socket → server_running=False, live_server_count=0; on a live one → True.
  3. create_session(allow_server_start=False) on a dead socket raises rather than spawning; assert no socket file appears.
  4. create_session(allow_server_start=True) on a dead socket returns SessionInfo(server_started=True).
  5. capture_pane on a dead server produces an error naming "no tmux server", not "Pane not found → call list_panes".
  6. set_environment on a dead server raises an expected ToolError, and is not logged via logger.exception.
  7. Instructions assertion: with TMUX_PANE unset, the not-in-tmux segment is present.

Related

  • Companion feature: the where_am_i self-location primitive + self-first routing. That issue defines the tool; this one defines the semantics it must report. Ship together.
  • Companion bug: caller socket advertised but never targeted — which is why effective_socket_* must be a distinct field from caller_socket_* in the context payload.
  • Upstream libtmux: bare ValueError from set_environment; start_server() leaves a stale socket.

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: #96)

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 with the context and instruction paths in src/libtmux_mcp/server.py, then inspect server_tools.py, models.py, and _utils.py for server probing, session creation, response fields, and error mapping. Use the issue's seven tests as the acceptance checklist, including dead and live sockets, explicit server-start control, recovery errors, expected ToolError handling, and the negative instruction segment. Coordinate with the companion where_am_i issue because the proposed context surface is shared.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.