diff --git a/CHANGES b/CHANGES index 7a2add44..28c51eb6 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,59 @@ _Notes on upcoming releases will be added here_ +### Breaking changes + +**List tools return page envelopes** + +{tooliconl}`list-servers`, {tooliconl}`list-sessions`, +{tooliconl}`list-windows`, and {tooliconl}`list-panes` now return typed page +objects instead of bare lists. Read rows from `.items`; for example, +`list_sessions(...)[0]` becomes `list_sessions(...).items[0]`. The envelope +also reports `total`, `offset`, `limit`, and `truncated` for bounded discovery. +(#95) + +**Starting a tmux server now requires permission** + +{tooliconl}`create-session` no longer starts a tmux server silently when its +target is not running. MCP calls must pass `allow_server_start=true` or enable +{envvar}`LIBTMUX_ALLOW_SERVER_START`; direct Python calls must pass +`allow_server_start=True`. Starting a server launches a long-lived daemon and +loads the user's tmux configuration, so the side effect is denied by default. + +The returned {class}`~libtmux_mcp.models.SessionInfo` sets +{attr}`~libtmux_mcp.models.SessionInfo.server_started` to `true` when the call +used a startup-enabled path after a no-start attempt proved the target absent; +it is `false` when the no-start path used an existing server. Dead-server target +errors now say that no server is running, including when +{tooliconl}`set-environment` targets a stopped server. See {ref}`configuration` +for the startup policy. (#96) + +### What's new + +**Typed caller location** + +The zero-input {tooliconl}`where-am-i` tool returns a +{class}`~libtmux_mcp.models.CallerContext` for relational requests such as +"this pane", "current window", and "this session". The result distinguishes +caller identity from availability, so an outside-tmux invocation, stopped +server, stale pane, or configured-target mismatch is structured state rather +than a failed discovery call. (#93) + +**Bounded list discovery** + +All four hierarchy list tools return deterministic pages with honest totals. +{tooliconl}`list-windows` and {tooliconl}`list-panes` default to compact +discovery summaries and accept `detail="full"` for layout, geometry, process, +and working-directory metadata. Filters still run against full metadata before +projection and paging. + +Window and pane lists also accept the explicit `scope="caller_session"` option +to list the session containing the frozen caller pane. Their default remains +`scope="server"`; caller-session scope rejects explicit hierarchy selectors +and fails rather than widening the search when the caller is unavailable on +the effective tmux target. See {ref}`pagination-overview` for the page contract. +(#95) + ## libtmux-mcp 0.1.0a18 (2026-07-12) libtmux-mcp 0.1.0a18 raises the libtmux floor to 0.62.0 and tightens how the server handles lookups that cannot succeed. A readonly call carrying a stale or ambiguous id now fails on the first attempt instead of paying a second tmux round-trip and a backoff window to fail identically, and an ambiguous target — a window shared between sessions, matched by name or index — now comes back with a recovery hint that says to target it by id instead of an unexplained error. The floor bump is what makes both fixes possible: libtmux 0.62.0 folds its query errors into the {exc}`libtmux.exc.LibTmuxException` hierarchy the server keys on. diff --git a/README.md b/README.md index 6c010476..54c99c3f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Give your AI agent hands inside the terminal — create sessions, run commands, | Module | Tools | |--------|-------| -| **Server** | `list_servers`, `list_sessions`, `create_session`, `kill_server`, `get_server_info` | +| **Server** | `where_am_i`, `list_servers`, `list_sessions`, `create_session`, `kill_server`, `get_server_info` | | **Batch** | `call_readonly_tools_batch`, `call_mutating_tools_batch`, `call_destructive_tools_batch` | | **Session** | `list_windows`, `get_session_info`, `create_window`, `rename_session`, `select_window`, `kill_session` | | **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` | @@ -25,6 +25,11 @@ Give your AI agent hands inside the terminal — create sessions, run commands, | **Buffers** | `load_buffer`, `paste_buffer`, `show_buffer`, `delete_buffer` | | **Hooks** | `show_hooks`, `show_hook` | +Call `where_am_i` with no arguments when a request says "this pane", +"current window", or "this session". Its typed result distinguishes a live +caller from an outside-tmux invocation, a stopped server, a stale pane, or a +configured server that differs from the caller's server. + ## Quickstart **Requirements:** Python 3.10+, tmux on `$PATH`. diff --git a/docs/conf.py b/docs/conf.py index 3cb71806..78c21bf3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -149,13 +149,20 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any: conf["fastmcp_server_module"] = "libtmux_mcp.server:mcp" conf["fastmcp_model_module"] = "libtmux_mcp.models" conf["fastmcp_model_classes"] = ( + "ListPage", "SessionInfo", + "SessionPage", "WindowInfo", + "WindowSummary", + "WindowPage", "PaneInfo", + "PaneSummary", + "PanePage", "PaneContentMatch", "SearchPanesResult", "PaneSnapshot", "ServerInfo", + "ServerPage", "OptionResult", "OptionSetResult", "EnvironmentResult", diff --git a/docs/configuration.md b/docs/configuration.md index 2b2aa329..19e55692 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,8 @@ Runtime configuration for the libtmux-mcp server. For MCP client setup, see {ref tmux socket name (`-L`). Isolates the MCP server to a specific tmux socket. - **Type:** string -- **Default:** (none — uses the default tmux socket) +- **Default:** (none — follows the frozen caller socket inside tmux, then the + tmux default outside tmux) ```{envvar} LIBTMUX_SOCKET_PATH ``` @@ -20,7 +21,8 @@ tmux socket name (`-L`). Isolates the MCP server to a specific tmux socket. tmux socket path (`-S`). Alternative to socket name for custom socket locations. - **Type:** string -- **Default:** (none) +- **Default:** (none — falls through to the configured name, frozen caller + socket, then tmux default) ```{envvar} LIBTMUX_TMUX_BIN ``` @@ -39,6 +41,37 @@ Safety tier controlling which tools are available. See {ref}`safety`. - **Default:** `mutating` - **Values:** `readonly`, `mutating`, `destructive` +```{envvar} LIBTMUX_ALLOW_SERVER_START +``` + +Controls the MCP default for allowing {tooliconl}`create-session` to start a +tmux server when its target is not running. Starting a server launches a +long-lived background daemon and sources the user's tmux configuration. + +- **Type:** string flag +- **Default:** `0` (disabled) +- **Values:** `0`, `1` + +Unset and `0` deny the implicit daemon start; `1` allows it. Any other value +fails server startup with +`LIBTMUX_ALLOW_SERVER_START must be unset, '0', or '1'`, without echoing the +rejected value. An explicit `allow_server_start` value wins for each MCP call. +Direct Python calls always default to `False`. + +When the effective value is `false` and no server is running, +{toolref}`create-session` fails without starting one. Pass +`allow_server_start=true` for an intentional per-call start. The returned +{class}`~libtmux_mcp.models.SessionInfo` sets +{attr}`~libtmux_mcp.models.SessionInfo.server_started` to `true` when a +no-start attempt proved the target absent and the call then succeeded on the +startup-enabled path. A no-start creation accepted by an existing server +returns `false`; the field does not claim stronger attribution across other +processes. + +The server resolves {envvar}`LIBTMUX_ALLOW_SERVER_START` once during startup. +Restart the MCP server after changing it, usually by reconnecting or restarting +the MCP client. Per-call arguments take effect without a restart. + ```{envvar} LIBTMUX_SUPPRESS_HISTORY ``` @@ -71,6 +104,7 @@ Set environment variables in your MCP client config: "env": { "LIBTMUX_SOCKET": "ai_workspace", "LIBTMUX_SAFETY": "readonly", + "LIBTMUX_ALLOW_SERVER_START": "0", "LIBTMUX_SUPPRESS_HISTORY": "1" } } @@ -80,7 +114,10 @@ Set environment variables in your MCP client config: ## Socket isolation -By default, the MCP server connects to the default tmux socket. Set {envvar}`LIBTMUX_SOCKET` to isolate AI agent activity from your personal tmux sessions: +With no configured selector, a server launched inside tmux follows the frozen +caller socket; outside tmux it connects to the tmux default. Set +{envvar}`LIBTMUX_SOCKET` to isolate AI agent activity from your personal tmux +sessions: ```json "env": { "LIBTMUX_SOCKET": "ai_workspace" } @@ -88,6 +125,16 @@ By default, the MCP server connects to the default tmux socket. Set {envvar}`LIB The agent will only see sessions on the `ai_workspace` socket, not your personal sessions. -## All tools accept `socket_name` +## Target selection + +Target precedence is: explicit per-call selector, configured path, configured +name, frozen caller socket, tmux default. The configured path comes from +{envvar}`LIBTMUX_SOCKET_PATH`; the configured name comes from +{envvar}`LIBTMUX_SOCKET`. -Every tool accepts an optional `socket_name` parameter that overrides {envvar}`LIBTMUX_SOCKET` for that call. This allows agents to work across multiple tmux servers in a single session. +When both `LIBTMUX_SOCKET*` settings are unset, inside tmux, the frozen caller +socket is selected. When both are unset outside tmux, the tmux default is +selected. A targeted tool's optional `socket_name` overrides the configured and +caller-derived selectors for that call, which lets agents work across multiple +servers. {tooliconl}`list-servers` is the discovery exception: pass +`extra_socket_paths` to include sockets outside the canonical scan. diff --git a/docs/recipes.md b/docs/recipes.md index 58d0ed32..89918968 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -38,9 +38,10 @@ Run the Playwright tests against my dev server in the myapp session. ```{admonition} Agent reasoning :class: agent-thought -{toolref}`list-panes` will not help here -- it shows metadata like current -command and working directory, not terminal content. The dev server printed -its URL to the terminal minutes ago, so I need to search terminal content. +{toolref}`list-panes` will not help here -- it shows metadata like the current +command (and, with `detail="full"`, the working directory), not terminal +content. The dev server printed its URL to the terminal minutes ago, so I need +to search terminal content. ``` The agent calls {tooliconl}`search-panes` with `pattern: "Local:"` and @@ -79,9 +80,10 @@ like a blank shell. {toolref}`search-panes` searches terminal *content* -- what you would see on screen. {toolref}`list-panes` searches *metadata* like current command and -working directory. If the agent had used {toolref}`list-panes` to find a pane -running `node`, it would know a process exists but not whether it is ready or -what URL it chose. +working directory; request `detail="full"` when the compact default does not +carry the property you need. If the agent had used {toolref}`list-panes` to +find a pane running `node`, it would know a process exists but not whether it +is ready or what URL it chose. --- diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index bb65860b..29881d24 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -9,13 +9,28 @@ container — create one before creating windows or panes. **Avoid when** a session with the target name already exists — check with {tooliconl}`list-sessions` first, or the command will fail. -**Side effects:** Creates a new tmux session with one window and one pane. +**Side effects:** Creates a new tmux session with one window and one pane. If +no server is running, `allow_server_start=true` also launches a long-lived tmux +daemon and sources the user's tmux configuration. The call fails without +starting a daemon when that permission is false. **Do not pass credentials directly in `environment`.** Values persist in the new session, can be inspected with {tooliconl}`show-environment`, and reach its initial pane and future panes. Pass credential references instead; see {ref}`safety` for details. +`allow_server_start` defaults to `false` for direct Python calls. Omitted MCP +input inherits {envvar}`LIBTMUX_ALLOW_SERVER_START`, which is disabled by +default; an explicit value wins. Set it to `true` only when starting the target +tmux server is intended. The returned +{class}`~libtmux_mcp.models.SessionInfo` reports that side effect through +{attr}`~libtmux_mcp.models.SessionInfo.server_started`: `true` means a no-start +attempt proved the target absent and the call then succeeded on the +startup-enabled path. It does not claim stronger attribution across other +processes. Change the environment default only after restarting the MCP +server; per-call values apply immediately. See {ref}`configuration` for the +startup setting. + `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. Set it to `true` and {tooliconl}`create-session` copies and merges best-effort no-disk history controls into the tmux session environment. They reach the initial pane and future panes in that session. The shell can retain in-memory history, and a startup file can override these controls after the process starts. @@ -28,7 +43,8 @@ When you enable it, tmux environment arguments are added, but the spawned proces { "tool": "create_session", "arguments": { - "session_name": "dev" + "session_name": "dev", + "allow_server_start": false } } ``` @@ -42,7 +58,8 @@ Response ({class}`~libtmux_mcp.models.SessionInfo`): "window_count": 1, "session_attached": "0", "session_created": "1774521872", - "active_pane_id": "%0" + "active_pane_id": "%0", + "server_started": false } ``` diff --git a/docs/tools/server/index.md b/docs/tools/server/index.md index c5350f84..94fd84df 100644 --- a/docs/tools/server/index.md +++ b/docs/tools/server/index.md @@ -5,6 +5,10 @@ Server & process-level tools — discover sessions, control the tmux daemon, rea ::::{grid} 1 2 2 3 :gutter: 2 2 3 3 +:::{grid-item-card} {tooliconl}`where-am-i` +Resolve the frozen caller pane and effective tmux target. +::: + :::{grid-item-card} {tooliconl}`list-sessions` List all sessions on the tmux server. ::: @@ -47,6 +51,7 @@ Set an environment variable on the server. :hidden: :maxdepth: 1 +where-am-i list-sessions list-servers get-server-info diff --git a/docs/tools/server/list-servers.md b/docs/tools/server/list-servers.md index cfe681ea..7927a6b3 100644 --- a/docs/tools/server/list-servers.md +++ b/docs/tools/server/list-servers.md @@ -12,15 +12,18 @@ side project. target — pass it directly to the tool that needs it via `socket_name`. **Side effects:** None. Readonly. Stale socket files are filtered -via a kernel-fast UNIX `connect()` probe so the call stays under one -second even on machines with thousands of orphaned `tmux-/` -inodes. +via a kernel-level UNIX `connect()` probe, keeping discovery responsive +when `tmux-/` contains orphaned socket files. **Scope:** Only servers under `${TMUX_TMPDIR:-/tmp}/tmux-/` are discovered by the canonical scan. Custom `tmux -S /some/path/...` daemons that live outside that directory must be supplied via `extra_socket_paths`. +Results arrive as a {class}`~libtmux_mcp.models.ServerPage`. The default +page uses `limit=100` and `offset=0`; `total` describes every discovered live +server before paging, and `truncated` tells you whether another offset remains. + **Example:** ```json @@ -33,22 +36,28 @@ daemons that live outside that directory must be supplied via Response: ```json -[ - { - "is_alive": true, - "socket_name": "default", - "socket_path": null, - "session_count": 3, - "version": "3.6a" - }, - { - "is_alive": true, - "socket_name": "ci-runner", - "socket_path": null, - "session_count": 1, - "version": "3.6a" - } -] +{ + "items": [ + { + "is_alive": true, + "socket_name": "ci-runner", + "socket_path": null, + "session_count": 1, + "version": "3.6a" + }, + { + "is_alive": true, + "socket_name": "default", + "socket_path": null, + "session_count": 3, + "version": "3.6a" + } + ], + "total": 2, + "offset": 0, + "limit": 100, + "truncated": false +} ``` To include a custom-path daemon: @@ -57,7 +66,7 @@ To include a custom-path daemon: { "tool": "list_servers", "arguments": { - "extra_socket_paths": ["/home/user/.cache/tmux/socket"] + "extra_socket_paths": ["/path/to/tmux.sock"] } } ``` diff --git a/docs/tools/server/list-sessions.md b/docs/tools/server/list-sessions.md index 2d791776..ab7ac138 100644 --- a/docs/tools/server/list-sessions.md +++ b/docs/tools/server/list-sessions.md @@ -11,6 +11,11 @@ which session to target. **Side effects:** None. Readonly. +Results arrive as a {class}`~libtmux_mcp.models.SessionPage`. Read session rows +from `items`; `total` reports every matching row before paging. The default +page uses `limit=100` and `offset=0`. When `truncated` is true, increase +`offset` to continue from the next row. + **Example:** ```json @@ -23,15 +28,23 @@ which session to target. Response: ```json -[ - { - "session_id": "$0", - "session_name": "myproject", - "window_count": 2, - "session_attached": "0", - "session_created": "1774521871" - } -] +{ + "items": [ + { + "session_id": "$0", + "session_name": "myproject", + "window_count": 2, + "session_attached": "0", + "session_created": "1774521871", + "active_pane_id": "%0", + "server_started": false + } + ], + "total": 1, + "offset": 0, + "limit": 100, + "truncated": false +} ``` ```{fastmcp-tool-input} server_tools.list_sessions diff --git a/docs/tools/server/where-am-i.md b/docs/tools/server/where-am-i.md new file mode 100644 index 00000000..c3ebb1d3 --- /dev/null +++ b/docs/tools/server/where-am-i.md @@ -0,0 +1,58 @@ +# Locate the caller + +```{fastmcp-tool} server_tools.where_am_i +``` + +**Use when** you need to turn “this pane,” “current window,” or “this session” +into stable tmux IDs before another tool call. The targeted lookup costs extra +tmux round-trips and buys a live answer that does not trust stale session data +from the launch environment. + +**Avoid when** you need an inventory across the server. Use +{tooliconl}`list-sessions`, {toolref}`list-windows`, or {toolref}`list-panes` +instead. + +**Side effects:** None. Readonly. + +The typed result separates frozen caller identity from current availability: + +- **Live matching caller:** `inside_tmux` and `self_available` are `true`, with + `pane_id`, `window_id`, and `session_id` populated. +- **Outside tmux:** `inside_tmux` is `false`, caller IDs are null, and the + effective target still reports whether its server is running. +- **Dead target:** `server_running` and `self_available` are `false`; frozen + caller identity remains visible when it exists. +- **Stale pane:** `pane_id` retains the frozen caller ID, while + `self_available` is `false` and parent IDs are null. +- **Target mismatch:** caller and effective socket fields remain distinct, + `self_available` is `false`, and no lookup crosses to the caller's server. + +**Example:** + +```json +{ + "tool": "where_am_i", + "arguments": {} +} +``` + +Response: + +```json +{ + "inside_tmux": true, + "self_available": true, + "pane_id": "%3", + "window_id": "@2", + "session_id": "$1", + "caller_socket_path": "/path/to/tmux.sock", + "effective_socket_name": null, + "effective_socket_path": "/path/to/tmux.sock", + "server_running": true, + "safety_level": "mutating", + "suppress_history": true +} +``` + +```{fastmcp-tool-input} server_tools.where_am_i +``` diff --git a/docs/tools/session/list-windows.md b/docs/tools/session/list-windows.md index c4b99e31..53716396 100644 --- a/docs/tools/session/list-windows.md +++ b/docs/tools/session/list-windows.md @@ -10,6 +10,25 @@ session before selecting a window to work with. **Side effects:** None. Readonly. +{tooliconl}`list-windows` keeps `scope="server"` as its default. With no +session selector, that lists windows across the effective server; existing +selectors still narrow the result. Set `scope="caller_session"` to list +windows in the session containing the frozen caller pane. +That scope performs an extra targeted tmux lookup; the added work buys +fail-closed live-session accuracy instead of trusting stale session metadata. + +Caller-session scope cannot be combined with `session_name` or `session_id`. +It raises a tool error instead of widening the search when the MCP invocation +started outside tmux, the caller pane no longer resolves, or the caller socket +differs from the effective target. + +Results arrive as a {class}`~libtmux_mcp.models.WindowPage`. The default +`detail="summary"` rows keep window identity, parent session, active state, +name, index, and pane count. Set `detail="full"` when you need layout or +dimensions; that returns more metadata. Filters run against full window +metadata before rows are projected, sorted, and paged. The default page uses +`limit=100` and `offset=0`. + **Example:** ```json @@ -24,34 +43,34 @@ session before selecting a window to work with. Response: ```json -[ - { - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "c195,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24", - "active_pane_id": "%0" - }, - { - "window_id": "@1", - "window_name": "server", - "window_index": "2", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,2", - "window_active": "0", - "window_width": "80", - "window_height": "24", - "active_pane_id": "%2" - } -] +{ + "items": [ + { + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_active": "1", + "active_pane_id": "%0" + }, + { + "window_id": "@1", + "window_name": "server", + "window_index": "2", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_active": "0", + "active_pane_id": "%2" + } + ], + "total": 2, + "offset": 0, + "limit": 100, + "truncated": false +} ``` ```{fastmcp-tool-input} session_tools.list_windows diff --git a/docs/tools/window/list-panes.md b/docs/tools/window/list-panes.md index e08754e6..b53c3e39 100644 --- a/docs/tools/window/list-panes.md +++ b/docs/tools/window/list-panes.md @@ -8,6 +8,26 @@ sending keys or capturing output. **Side effects:** None. Readonly. +{tooliconl}`list-panes` keeps `scope="server"` as its default. With no +session or window selector, that lists panes across the effective server; +existing selectors still narrow the result. Set `scope="caller_session"` +to list every pane in the session containing the frozen caller pane. +That scope performs an extra targeted tmux lookup; the added work buys +fail-closed live-session accuracy instead of trusting stale session metadata. + +Caller-session scope cannot be combined with `session_name`, `session_id`, +`window_id`, or `window_index`. It raises a tool error instead of widening +the search when the MCP invocation started outside tmux, the caller pane no +longer resolves, or the caller socket differs from the effective target. + +Results arrive as a {class}`~libtmux_mcp.models.PanePage`. The default +`detail="summary"` rows carry pane and parent identity, active and caller +state, title, and current command. Set `detail="full"` when you need geometry, +the working directory, TTY, or process metadata; the larger projection is +useful for targeted inspection. Filters run against full pane metadata before +rows are projected, sorted, and paged. The default page uses `limit=100` and +`offset=0`. + **Example:** ```json @@ -22,36 +42,34 @@ sending keys or capturing output. Response: ```json -[ - { - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "15", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "build", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": false - }, - { - "pane_id": "%1", - "pane_index": "1", - "pane_width": "80", - "pane_height": "8", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12400", - "pane_title": "", - "pane_active": "0", - "window_id": "@0", - "session_id": "$0", - "is_caller": false - } -] +{ + "items": [ + { + "pane_id": "%0", + "pane_index": "0", + "pane_current_command": "zsh", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": false + }, + { + "pane_id": "%1", + "pane_index": "1", + "pane_current_command": "zsh", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": false + } + ], + "total": 2, + "offset": 0, + "limit": 100, + "truncated": false +} ``` ```{fastmcp-tool-input} window_tools.list_panes diff --git a/docs/topics/concepts.md b/docs/topics/concepts.md index 4fa68d0a..1e33f198 100644 --- a/docs/topics/concepts.md +++ b/docs/topics/concepts.md @@ -89,6 +89,12 @@ List tools ({toolref}`list-sessions`, {toolref}`list-windows`, and Supported operators: `exact`, `contains`, `startswith`, `endswith`, `regex`, `icontains`, `iexact`, `istartswith`, `iendswith`, `iregex`. +The four hierarchy discovery lists, including {tooliconl}`list-servers`, +return typed page envelopes. Rows live under `items`; `total`, `offset`, +`limit`, and `truncated` describe the page. Window and pane rows use compact +summary metadata by default and accept `detail="full"` when you need the full +projection. + ## Resources In addition to tools, the MCP server exposes `tmux://` URI resources for browsing the hierarchy: diff --git a/docs/topics/pagination.md b/docs/topics/pagination.md index 1d0f38eb..e858425f 100644 --- a/docs/topics/pagination.md +++ b/docs/topics/pagination.md @@ -21,20 +21,33 @@ current configuration, clients should expect those lists to arrive in one response unless libtmux-mcp later enables FastMCP's ``list_page_size`` setting. -### Search result paging - -One libtmux-mcp tool owns its own paging surface because a -single tmux server can carry tens of thousands of pane lines: - -- {tool}`search-panes` returns a - {class}`~libtmux_mcp.models.SearchPanesResult` wrapper with - ``matches``, ``truncated``, ``truncated_panes``, - ``total_panes_matched``, ``offset``, and ``limit``. -- Agents detect ``truncated=True`` and re-call with a higher - ``offset`` to page through the match set. - -This is application-level paging (not MCP-cursor pagination) — -the agent decides how many matches it needs and when to stop. +### Tool result paging + +Hierarchy discovery tools own a typed paging surface because tmux can hold +more rows than one useful discovery response: + +- {tool}`list-servers` returns a + {class}`~libtmux_mcp.models.ServerPage`. +- {tool}`list-sessions` returns a + {class}`~libtmux_mcp.models.SessionPage`. +- {tool}`list-windows` returns a + {class}`~libtmux_mcp.models.WindowPage`. +- {tool}`list-panes` returns a {class}`~libtmux_mcp.models.PanePage`. + +Each page has ``items``, ``total``, ``offset``, ``limit``, and ``truncated``. +The default is `limit=100` and `offset=0`. Filters run before the stable sort, +projection, and page slice, so ``total`` always describes every matching row. +When ``truncated`` is true, add the number of returned items to ``offset`` and +call again. Window and pane lists default to compact summary rows; pass +`detail="full"` only when you need the larger metadata projection. + +{tool}`search-panes` has a separate +{class}`~libtmux_mcp.models.SearchPanesResult` wrapper with ``matches``, +``truncated``, ``truncated_panes``, ``total_panes_matched``, ``offset``, and +``limit`` because it also reports per-pane content truncation. + +These are application-level pages (not MCP-cursor pagination) — the agent +decides how many rows it needs and when to stop. ### Observation cursors @@ -64,17 +77,17 @@ matches. Protocol-level cursors are for **collections the server owns end-to-end**: the tool / prompt / resource registries. -Tool-level paging and observation cursors are for **state derived -from live tmux panes**. Capturing every pane's contents and running a -regex is expensive, and the result set can change mid-scan (new panes -open, old ones close). Repeatedly reading one pane has the opposite -cost shape: the target is known, but unchanged scrollback wastes -model context. libtmux-mcp exposes each contract separately instead -of pretending live terminal state is one stable list. +Tool-level paging and observation cursors are for **state derived from live +tmux**. Hierarchy lists need deterministic, bounded discovery rows. Capturing +every pane's contents and running a regex has a different cost, and repeated +reads of one pane need an observation checkpoint rather than a collection +offset. libtmux-mcp exposes each contract separately instead of pretending +live terminal state is one stable list. ## Further reading - [MCP pagination spec](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) +- {class}`~libtmux_mcp.models.ListPage` — shared hierarchy-page fields - {class}`~libtmux_mcp.models.SearchPanesResult` — the structured wrapper for {toolref}`search-panes` - {tool}`search-panes` — the tool itself diff --git a/docs/topics/prompting.md b/docs/topics/prompting.md index 196b8288..7acef907 100644 --- a/docs/topics/prompting.md +++ b/docs/topics/prompting.md @@ -16,9 +16,10 @@ Server > Session > Window > Pane. Use pane_id (e.g. '%1') as the preferred targeting method - it is globally unique within a tmux server. Use run_command for authored shell commands, send_keys or send_keys_batch for raw TUI / persistent-shell input, capture_pane for -one-shot reads, and capture_since for repeated observation. All tools -accept an optional socket_name parameter for multi-server support -(defaults to LIBTMUX_SOCKET env var). +one-shot reads, and capture_since for repeated observation. Targeted tools +accept an optional socket_name parameter for multi-server support. Target +precedence is: explicit per-call selector, configured path, configured name, +frozen caller socket, tmux default. IMPORTANT — metadata vs content: list_windows, list_panes, and list_sessions only search metadata (names, IDs, current command). To @@ -30,7 +31,11 @@ known pane, or capture_pane for a one-shot manual inspection. The server also dynamically adds: - **Safety tier context**: Which tier is active and what tools are available -- **Caller pane awareness**: If the server runs inside tmux, it tells the agent which pane is its own (via `TMUX_PANE`). See {ref}`concepts` "Agent self-awareness" for details. +- **Caller pane awareness**: {tooliconl}`where-am-i` resolves "this pane", + "current window", and "this session" without list-and-filter discovery. Its + typed result keeps caller identity separate from whether the pane is still + available on the configured server. See {ref}`concepts` "Agent + self-awareness" for details. ## Activation and discovery @@ -40,7 +45,7 @@ The server stays out of the way when you mean a browser window, an editor split, ### Always-on tool listing (Claude Code only) -[Claude Code](https://code.claude.com/docs/en/mcp) defers loading MCP tool schemas when they exceed ~10% of your context window. By default, the three discovery anchors ({toolref}`list-panes`, {toolref}`list-windows`, {toolref}`snapshot-pane`) carry an `anthropic/alwaysLoad: true` hint so bare *"pane"* / *"window"* prompts surface this server without a `ToolSearch` hop. +[Claude Code](https://code.claude.com/docs/en/mcp) defers loading MCP tool schemas when they exceed ~10% of your context window. By default, the three discovery anchors ({toolref}`where-am-i`, {toolref}`list-windows`, {toolref}`snapshot-pane`) carry an `anthropic/alwaysLoad: true` hint so bare *"pane"* / *"window"* prompts surface this server without a `ToolSearch` hop. To force libtmux-mcp's *full* schema list to load upfront — useful if you also want {toolref}`send-keys`, {toolref}`capture-pane`, {toolref}`select-window` etc. preloaded — add `"alwaysLoad": true` at the server entry level (requires Claude Code v2.1.121+): @@ -64,6 +69,7 @@ These natural-language prompts reliably trigger the right tool sequences: | Prompt | Agent interprets as | |--------|-------------------| +| [What's running in this window?]{.prompt} | {toolref}`where-am-i` → {toolref}`list-panes` for the returned `window_id` | | [Run `pytest` in my build pane and show results]{.prompt} | {toolref}`run-command` | | [Start the dev server and wait until it's ready]{.prompt} | {toolref}`send-keys` → {toolref}`wait-for-text` (for "listening on" — third-party output the agent doesn't author) | | [Spin up the dev server in the bottom-right pane]{.prompt} | {toolref}`find-pane-by-position` (corner=bottom-right) → {toolref}`send-keys` → {toolref}`wait-for-text` (for the server's readiness banner) | @@ -144,8 +150,8 @@ This is the lever closest to the failure mode for *"current window"* / *"this se When an agent is unsure which tool to use, these rules help: -1. **Discovery first**: Call {toolref}`list-sessions` or {toolref}`list-panes` before acting on specific targets +1. **Resolve relationships first**: Call {toolref}`where-am-i` for "this pane", "current window", or "this session". Use {toolref}`list-sessions` or {toolref}`list-panes` when you need an inventory instead. 2. **Prefer IDs**: Once you have a `pane_id`, use it for all subsequent calls — it never changes during the pane's lifetime 3. **Run, wait, or observe deliberately**: For commands the agent authors, prefer {toolref}`run-command`. Use {toolref}`wait-for-channel` only for custom shell composition outside that shape. Use {toolref}`capture-since` for repeated observation, and fall back to {toolref}`wait-for-text` or {toolref}`wait-for-content-change` for output the agent doesn't author. Never call {toolref}`capture-pane` in a retry loop. -4. **Content vs. metadata**: If looking for text *in* a terminal, use {toolref}`search-panes`. If looking for pane *properties* (name, PID, path), use {toolref}`list-panes` or {toolref}`get-pane-info` +4. **Content vs. metadata**: If looking for text *in* a terminal, use {toolref}`search-panes`. If looking for pane identity, title, or current command, use {toolref}`list-panes`; request `detail="full"` for PID or path metadata, or use {toolref}`get-pane-info` for one known pane 5. **Destructive tools are opt-in**: Never kill sessions, windows, or panes unless the user explicitly asks diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md index 83a4f504..f5361966 100644 --- a/docs/topics/troubleshooting.md +++ b/docs/topics/troubleshooting.md @@ -38,7 +38,10 @@ can't find targets. $ tmux list-sessions ``` -2. Are you on the right socket? If `LIBTMUX_SOCKET` is set, the server only sees sessions on that socket: +2. Are you on the right socket? Target precedence is: explicit per-call + selector, configured path, configured name, frozen caller socket, tmux + default. If `LIBTMUX_SOCKET` is set, the server only sees sessions on that + socket unless the call supplies an explicit selector: ```console $ tmux -L ai_workspace list-sessions @@ -54,9 +57,15 @@ can't find targets. **Symptoms**: Server sees different sessions than expected, or sees nothing. -**Cause**: `LIBTMUX_SOCKET` in the MCP config isolates the server to a specific socket. Your personal sessions are on the default socket. +**Cause**: `LIBTMUX_SOCKET_PATH` or `LIBTMUX_SOCKET` in the MCP config isolates +the server to a specific socket. With both unset, a server launched inside tmux +follows its frozen caller socket; only a server launched outside tmux falls +through to the tmux default. -**Fix**: Either remove `LIBTMUX_SOCKET` from the config to use the default socket, or ensure sessions exist on the configured socket. +**Fix**: Pass `socket_name` for a one-call override, correct the configured +selector, or ensure sessions exist on the selected socket. Target precedence +is: explicit per-call selector, configured path, configured name, frozen caller +socket, tmux default. ## Pane targeting mismatch diff --git a/src/libtmux_mcp/_server_start.py b/src/libtmux_mcp/_server_start.py new file mode 100644 index 00000000..e15bcd42 --- /dev/null +++ b/src/libtmux_mcp/_server_start.py @@ -0,0 +1,139 @@ +"""Explicit tmux server-start policy helpers.""" + +from __future__ import annotations + +import threading +import typing as t + +from libtmux import Server, exc +from libtmux.common import tmux_cmd + +from libtmux_mcp._utils import ExpectedToolError, _server_not_running_error + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + from libtmux.session import Session + + +_SERVER_START_DEFAULT_TOOLS = ("create_session",) +_SESSION_CREATION_LOCKS: dict[ + tuple[str | None, str | None, str | None], threading.Lock +] = {} +_SESSION_CREATION_LOCKS_GUARD = threading.Lock() + + +class NoStartServer(Server): + """Server variant that forbids ``new-session`` from starting tmux.""" + + def cmd( + self, + cmd: str, + *args: t.Any, + target: str | int | None = None, + ) -> tmux_cmd: + """Execute commands with tmux's no-start guard on session creation.""" + if cmd == "new-session": + return super().cmd("-N", cmd, *args, target=target) + return super().cmd(cmd, *args, target=target) + + +def _resolve_allow_server_start(value: str | None) -> bool: + """Resolve the strict startup daemon-creation setting.""" + if value is None or value == "0": + return False + if value == "1": + return True + msg = "LIBTMUX_ALLOW_SERVER_START must be unset, '0', or '1'" + raise ValueError(msg) + + +def _validate_allow_server_start(value: object) -> bool: + """Require exact direct-call startup permission semantics.""" + if type(value) is not bool: + msg = "allow_server_start must be a bool" + raise ExpectedToolError(msg) + return value + + +def _creation_lock(server: Server) -> threading.Lock: + """Return the process-local creation lock for an effective target.""" + key = ( + server.socket_name, + str(server.socket_path) if server.socket_path is not None else None, + str(server.tmux_bin) if server.tmux_bin is not None else None, + ) + with _SESSION_CREATION_LOCKS_GUARD: + return _SESSION_CREATION_LOCKS.setdefault(key, threading.Lock()) + + +def _clone_no_start_server(server: Server) -> NoStartServer: + """Clone the command-affecting settings of an effective server.""" + return NoStartServer( + socket_name=server.socket_name, + socket_path=server.socket_path, + config_file=server.config_file, + colors=server.colors, + tmux_bin=server.tmux_bin, + ) + + +def _is_daemon_not_up_error(error: BaseException) -> bool: + """Match only tmux's no-start failure for an absent target.""" + if not isinstance(error, exc.LibTmuxException): + return False + message = str(error) + if not message.startswith("new-session: "): + return False + return "no server running on " in message or ( + "error connecting to " in message and "(No such file or directory)" in message + ) + + +def _create_session_with_start_policy( + server: Server, + *, + allow_server_start: bool, + session_kwargs: dict[str, t.Any], +) -> tuple[Session, bool]: + """Create a session while enforcing startup permission at execution.""" + with _creation_lock(server): + no_start_server = _clone_no_start_server(server) + try: + return no_start_server.new_session(**session_kwargs), False + except exc.LibTmuxException as error: + if not _is_daemon_not_up_error(error): + raise + if not allow_server_start: + raise _server_not_running_error() + return server.new_session(**session_kwargs), True + + +def _configure_server_start_default( + mcp: FastMCP, + enabled: bool, + *, + tool_names: tuple[str, ...] = _SERVER_START_DEFAULT_TOOLS, +) -> None: + """Publish the effective MCP default for daemon creation. + + Parameters + ---------- + mcp : FastMCP + Server receiving the public tool transform. + enabled : bool + Effective startup default to publish. + tool_names : tuple[str, ...] + Tools that inherit the default when omitted by an MCP caller. + """ + from fastmcp.server.transforms import ToolTransform + from fastmcp.tools.tool_transform import ArgTransformConfig, ToolTransformConfig + + argument = ArgTransformConfig(default=enabled) + mcp.add_transform( + ToolTransform( + { + name: ToolTransformConfig(arguments={"allow_server_start": argument}) + for name in tool_names + } + ) + ) diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 3611e20d..2f03a950 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -13,6 +13,7 @@ import os import pathlib import threading +import types import typing as t from fastmcp.exceptions import ToolError @@ -25,10 +26,23 @@ from libtmux.session import Session from libtmux.window import Window - from libtmux_mcp.models import PaneInfo, SessionInfo, WindowInfo + from libtmux_mcp.models import ListPage, PaneInfo, SessionInfo, WindowInfo logger = logging.getLogger(__name__) +_INVOCATION_ENVIRONMENT: t.Mapping[str, str] = types.MappingProxyType(dict(os.environ)) + + +def _get_invocation_environment() -> t.Mapping[str, str]: + """Return the immutable environment captured at process invocation. + + Returns + ------- + typing.Mapping[str, str] + Read-only snapshot of the environment present when this module loaded. + """ + return _INVOCATION_ENVIRONMENT + class ExpectedToolError(ToolError): """``ToolError`` for expected, agent-correctable failures. @@ -120,8 +134,9 @@ def _get_caller_identity() -> CallerIdentity | None: callers should check individual fields rather than relying on all being populated. """ - pane_id = os.environ.get("TMUX_PANE") - tmux_env = os.environ.get("TMUX") + environment = _get_invocation_environment() + pane_id = environment.get("TMUX_PANE") + tmux_env = environment.get("TMUX") if not tmux_env and not pane_id: return None @@ -131,7 +146,7 @@ def _get_caller_identity() -> CallerIdentity | None: session_id: str | None = None if tmux_env: - parts = tmux_env.split(",", 2) + parts = tmux_env.rsplit(",", 2) if parts: socket_path = parts[0] or None if len(parts) >= 2 and parts[1]: @@ -401,7 +416,7 @@ def _caller_is_strictly_on_server( #: #: Best-effort by design — safe no-op for clients that don't index the #: ``anthropic/*`` namespace. Apply only to read-tier discovery anchors -#: (``list_panes``, ``list_windows``, ``snapshot_pane``); each +#: (``where_am_i``, ``list_windows``, ``snapshot_pane``); each #: always-loaded tool consumes a fixed schema budget in clients that #: honour the hint, so widening the set has a real cost. DISCOVERY_META: dict[str, t.Any] = { @@ -480,6 +495,71 @@ def _tmux_argv(server: Server, *tmux_args: str) -> list[str]: _server_cache_lock = threading.Lock() +@dataclasses.dataclass(frozen=True) +class ServerTarget: + """Normalized selectors for one tmux server.""" + + socket_name: str | None + socket_path: str | None + tmux_bin: str | None + + +def _resolve_server_target( + socket_name: str | None = None, + socket_path: str | None = None, +) -> ServerTarget: + """Resolve explicit, configured, caller, and default server selectors. + + Parameters + ---------- + socket_name : str, optional + Explicit tmux socket name (``-L``). + socket_path : str, optional + Explicit tmux socket path (``-S``). + + Returns + ------- + ServerTarget + Immutable normalized target and configured tmux binary. + + Raises + ------ + ValueError + If both explicit socket selectors are supplied. + """ + if socket_name is not None and socket_path is not None: + msg = "socket_name and socket_path are mutually exclusive" + raise ValueError(msg) + + environment = _get_invocation_environment() + tmux_bin = environment.get("LIBTMUX_TMUX_BIN") or None + + if socket_name is not None: + return ServerTarget(socket_name, None, tmux_bin) + if socket_path is not None: + return ServerTarget(None, socket_path, tmux_bin) + + configured_path = environment.get("LIBTMUX_SOCKET_PATH") or None + if configured_path is not None: + return ServerTarget(None, configured_path, tmux_bin) + + configured_name = environment.get("LIBTMUX_SOCKET") or None + if configured_name is not None: + return ServerTarget(configured_name, None, tmux_bin) + + try: + caller_server = Server.from_env(environment) + except exc.NotInsideTmux: + return ServerTarget(None, None, tmux_bin) + + caller_socket_path = caller_server.socket_path + return ServerTarget( + None, + str(caller_socket_path) if caller_socket_path is not None else None, + tmux_bin, + ) + + def _get_server( socket_name: str | None = None, socket_path: str | None = None, @@ -489,23 +569,17 @@ def _get_server( Parameters ---------- socket_name : str, optional - tmux socket name (-L). Falls back to LIBTMUX_SOCKET env var. + Explicit tmux socket name (``-L``). socket_path : str, optional - tmux socket path (-S). Falls back to LIBTMUX_SOCKET_PATH env var. + Explicit tmux socket path (``-S``). Returns ------- Server A cached libtmux Server instance. """ - if socket_name is None: - socket_name = os.environ.get("LIBTMUX_SOCKET") - if socket_path is None: - socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") - - tmux_bin = os.environ.get("LIBTMUX_TMUX_BIN") - - cache_key = (socket_name, socket_path, tmux_bin) + target = _resolve_server_target(socket_name, socket_path) + cache_key = (target.socket_name, target.socket_path, target.tmux_bin) with _server_cache_lock: if cache_key in _server_cache: cached = _server_cache[cache_key] @@ -514,12 +588,12 @@ def _get_server( if cache_key not in _server_cache: kwargs: dict[str, t.Any] = {} - if socket_name is not None: - kwargs["socket_name"] = socket_name - if socket_path is not None: - kwargs["socket_path"] = socket_path - if tmux_bin is not None: - kwargs["tmux_bin"] = tmux_bin + if target.socket_name is not None: + kwargs["socket_name"] = target.socket_name + if target.socket_path is not None: + kwargs["socket_path"] = target.socket_path + if target.tmux_bin is not None: + kwargs["tmux_bin"] = target.tmux_bin _server_cache[cache_key] = Server(**kwargs) return _server_cache[cache_key] @@ -538,19 +612,24 @@ def _invalidate_server( socket_path : str, optional tmux socket path used in the cache key. """ - if socket_name is None: - socket_name = os.environ.get("LIBTMUX_SOCKET") - if socket_path is None: - socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") - + target = _resolve_server_target(socket_name, socket_path) + cache_key = (target.socket_name, target.socket_path, target.tmux_bin) with _server_cache_lock: - keys_to_remove = [ - key - for key in _server_cache - if key[0] == socket_name and key[1] == socket_path - ] - for key in keys_to_remove: - del _server_cache[key] + _server_cache.pop(cache_key, None) + + +def _server_not_running_error() -> ExpectedToolError: + """Build the agent-facing recovery error for a dead tmux target.""" + return ExpectedToolError( + "No tmux server is running for this target. Call list_servers to find " + "a live server, or create_session(allow_server_start=true) to start one." + ) + + +def _raise_if_server_dead(server: Server) -> None: + """Raise a targeted recovery error when ``server`` is not alive.""" + if not server.is_alive(): + raise _server_not_running_error() def _resolve_session( @@ -581,6 +660,7 @@ def _resolve_session( if session_id is not None: session = server.sessions.get(session_id=session_id, default=None) if session is None: + _raise_if_server_dead(server) raise exc.TmuxObjectDoesNotExist( obj_key="session_id", obj_id=session_id, @@ -591,6 +671,7 @@ def _resolve_session( if session_name is not None: session = server.sessions.get(session_name=session_name, default=None) if session is None: + _raise_if_server_dead(server) raise exc.TmuxObjectDoesNotExist( obj_key="session_name", obj_id=session_name, @@ -600,6 +681,7 @@ def _resolve_session( sessions = server.sessions if not sessions: + _raise_if_server_dead(server) raise exc.TmuxObjectDoesNotExist( obj_key="session", obj_id="(any)", @@ -645,6 +727,7 @@ def _resolve_window( if window_id is not None: window = server.windows.get(window_id=window_id, default=None) if window is None: + _raise_if_server_dead(server) raise exc.TmuxObjectDoesNotExist( obj_key="window_id", obj_id=window_id, @@ -659,9 +742,16 @@ def _resolve_window( session_id=session_id, ) + try: + windows = session.windows + except exc.LibTmuxException: + _raise_if_server_dead(server) + raise + if window_index is not None: - window = session.windows.get(window_index=window_index, default=None) + window = windows.get(window_index=window_index, default=None) if window is None: + _raise_if_server_dead(server) raise exc.TmuxObjectDoesNotExist( obj_key="window_index", obj_id=window_index, @@ -669,8 +759,8 @@ def _resolve_window( ) return window - windows = session.windows if not windows: + _raise_if_server_dead(server) raise exc.NoWindowsExist() return windows[0] @@ -715,6 +805,7 @@ def _resolve_pane( if pane_id is not None: pane = server.panes.get(pane_id=pane_id, default=None) if pane is None: + _raise_if_server_dead(server) raise exc.PaneNotFound(pane_id=pane_id) return pane @@ -726,19 +817,27 @@ def _resolve_pane( session_id=session_id, ) + try: + panes = window.panes + except exc.LibTmuxException: + _raise_if_server_dead(server) + raise + if pane_index is not None: - pane = window.panes.get(pane_index=pane_index, default=None) + pane = panes.get(pane_index=pane_index, default=None) if pane is None: + _raise_if_server_dead(server) raise exc.PaneNotFound(pane_id=f"index:{pane_index}") return pane - panes = window.panes if not panes: + _raise_if_server_dead(server) raise exc.PaneNotFound() return panes[0] M = t.TypeVar("M") +PageT = t.TypeVar("PageT", bound="ListPage") def _coerce_dict_arg( @@ -796,10 +895,11 @@ def _coerce_dict_arg( def _apply_filters( items: t.Any, - filters: dict[str, str] | str | None, + filters: dict[str, t.Any] | str | None, serializer: t.Callable[..., M], + synthetic_fields: frozenset[str] = frozenset(), ) -> list[M]: - """Apply QueryList filters and serialize results. + """Apply native and serialized-field filters, then return serialized results. Parameters ---------- @@ -811,6 +911,9 @@ def _apply_filters( If None or empty, all items are returned. serializer : callable Serializer function to convert each item to a model. + synthetic_fields : frozenset[str], optional + Serialized model fields that are not available to QueryList. These + fields support exact boolean matching only. Returns ------- @@ -820,7 +923,8 @@ def _apply_filters( Raises ------ ExpectedToolError - If a filter key uses an invalid lookup operator. + If a filter key uses an invalid lookup operator or a synthetic filter + does not use an exact boolean comparison. """ coerced = _coerce_dict_arg("filters", filters) if not coerced: @@ -828,27 +932,119 @@ def _apply_filters( filters = coerced valid_ops = sorted(LOOKUP_NAME_MAP.keys()) - for key in filters: - if "__" in key: - _field, op = key.rsplit("__", 1) - if op not in LOOKUP_NAME_MAP: + native_filters: dict[str, t.Any] = {} + synthetic_filters: list[tuple[str, bool]] = [] + for key, value in filters.items(): + field, op = key.rsplit("__", 1) if "__" in key else (key, "exact") + if field in synthetic_fields: + if op != "exact": msg = ( - f"Invalid filter operator '{op}' in '{key}'. " - f"Valid operators: {', '.join(valid_ops)}" + f"Synthetic filter '{field}' does not support operator '{op}'; " + "only exact boolean matching is supported" ) raise ExpectedToolError(msg) + if not isinstance(value, bool): + msg = ( + f"Synthetic filter '{field}' requires a boolean value, " + f"got {type(value).__name__}" + ) + raise ExpectedToolError(msg) + synthetic_filters.append((field, value)) + continue + if "__" in key and op not in LOOKUP_NAME_MAP: + msg = ( + f"Invalid filter operator '{op}' in '{key}'. " + f"Valid operators: {', '.join(valid_ops)}" + ) + raise ExpectedToolError(msg) + native_filters[key] = value + + filtered = items.filter(**native_filters) if native_filters else items + serialized = [serializer(item) for item in filtered] + if not synthetic_filters: + return serialized + return [ + item + for item in serialized + if all( + getattr(item, field, None) is expected + for field, expected in synthetic_filters + ) + ] + + +def _paginate( + items: list[M], + *, + limit: int, + offset: int, + page_type: type[PageT], +) -> PageT: + """Slice filtered, deterministically ordered rows into a typed page. + + Parameters + ---------- + items : list + Filtered, serialized, sorted, and projected rows. + limit : int + Maximum number of rows to return. Must be greater than zero. + offset : int + Zero-based row offset. Must be non-negative. + page_type : type[ListPage] + Concrete typed page model to construct. + + Returns + ------- + ListPage + Typed page with honest pre-pagination totals and continuation state. + + Raises + ------ + ExpectedToolError + If ``limit`` is not positive or ``offset`` is negative. - filtered = items.filter(**filters) - return [serializer(item) for item in filtered] + Examples + -------- + >>> from libtmux_mcp.models import ServerInfo, ServerPage + >>> row = ServerInfo(is_alive=True, session_count=1) + >>> page = _paginate([row], limit=1, offset=0, page_type=ServerPage) + >>> (len(page.items), page.total, page.truncated) + (1, 1, False) + """ + if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0: + msg = f"limit must be an integer greater than 0, got {limit!r}" + raise ExpectedToolError(msg) + if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: + msg = f"offset must be an integer greater than or equal to 0, got {offset!r}" + raise ExpectedToolError(msg) + + total = len(items) + page_items = items[offset : offset + limit] + return page_type.model_validate( + { + "items": page_items, + "total": total, + "offset": offset, + "limit": limit, + "truncated": offset + len(page_items) < total, + } + ) -def _serialize_session(session: Session) -> SessionInfo: +def _serialize_session( + session: Session, + *, + server_started: bool = False, +) -> SessionInfo: """Serialize a Session to a Pydantic model. Parameters ---------- session : Session The session to serialize. + server_started : bool + Whether a no-start attempt proved the target absent and the creating + call then succeeded on the startup-enabled path. Returns ------- @@ -873,6 +1069,7 @@ def _serialize_session(session: Session) -> SessionInfo: session_attached=getattr(session, "session_attached", None), session_created=getattr(session, "session_created", None), active_pane_id=active_pane_id, + server_started=server_started, ) @@ -1024,10 +1221,24 @@ def _map_exception_to_tool_error(fn_name: str, e: BaseException) -> ToolError: ) if isinstance(e, exc.LibTmuxException): return ExpectedToolError(f"tmux error: {e}") + if _is_dead_server_environment_value_error(e): + return _server_not_running_error() logger.exception("unexpected error in MCP tool %s", fn_name) return ToolError(f"Unexpected error: {type(e).__name__}: {e}") +def _is_dead_server_environment_value_error(e: BaseException) -> bool: + """Identify libtmux's narrow dead-server environment error shape.""" + if not isinstance(e, ValueError): + return False + message = str(e) + if not message.startswith("tmux set-environment stderr:"): + return False + return "no server running" in message or ( + "error connecting to" in message and "No such file or directory" in message + ) + + def handle_tool_errors( fn: t.Callable[P, R], ) -> t.Callable[P, R]: diff --git a/src/libtmux_mcp/models.py b/src/libtmux_mcp/models.py index 55e62037..ff45ed5f 100644 --- a/src/libtmux_mcp/models.py +++ b/src/libtmux_mcp/models.py @@ -27,6 +27,13 @@ class SessionInfo(BaseModel): "states where ``active_pane`` is unavailable." ), ) + server_started: bool = Field( + default=False, + description=( + "Whether a no-start attempt proved the target absent and this call " + "then succeeded on the startup-enabled path." + ), + ) class WindowInfo(BaseModel): @@ -124,6 +131,51 @@ class PaneInfo(BaseModel): ) +class WindowSummary(BaseModel): + """Compact tmux window metadata for discovery lists.""" + + model_config = ConfigDict(from_attributes=True) + + window_id: str = Field(description="Window ID (e.g. '@1')") + window_name: str | None = Field(default=None, description="Window name") + window_index: str | None = Field(default=None, description="Window index") + session_id: str | None = Field(default=None, description="Parent session ID") + session_name: str | None = Field(default=None, description="Parent session name") + pane_count: int = Field(description="Number of panes") + window_active: str | None = Field( + default=None, description="Active flag ('1' or '0')" + ) + active_pane_id: str | None = Field( + default=None, + description="Pane id (``%N``) of the window's active pane.", + ) + + +class PaneSummary(BaseModel): + """Compact tmux pane metadata for discovery lists.""" + + model_config = ConfigDict(from_attributes=True) + + pane_id: str = Field(description="Pane ID (e.g. '%1')") + pane_index: str | None = Field(default=None, description="Pane index") + pane_current_command: str | None = Field( + default=None, description="Running command" + ) + pane_title: str | None = Field(default=None, description="Pane title") + pane_active: str | None = Field( + default=None, description="Active flag ('1' or '0')" + ) + window_id: str | None = Field(default=None, description="Parent window ID") + session_id: str | None = Field(default=None, description="Parent session ID") + is_caller: bool | None = Field( + default=None, + description=( + "True for the frozen MCP caller pane, False for another pane, " + "or None outside tmux." + ), + ) + + class PaneContentMatch(BaseModel): """A pane whose captured content matched a search pattern.""" @@ -202,6 +254,83 @@ class ServerInfo(BaseModel): version: str | None = Field(default=None, description="tmux version") +class ListPage(BaseModel): + """Pagination metadata shared by deterministic tmux list results.""" + + total: int = Field(description="Matching rows before pagination.") + offset: int = Field(description="Zero-based offset used for this page.") + limit: int = Field(description="Maximum rows requested for this page.") + truncated: bool = Field( + description="True when more matching rows remain after this page." + ) + + +class SessionPage(ListPage): + """Page of tmux sessions.""" + + items: list[SessionInfo] = Field(description="Session rows in this page.") + + +class ServerPage(ListPage): + """Page of tmux servers.""" + + items: list[ServerInfo] = Field(description="Server rows in this page.") + + +class WindowPage(ListPage): + """Page of summary or full tmux window metadata.""" + + items: list[WindowSummary | WindowInfo] = Field( + description="Window rows in this page." + ) + + +class PanePage(ListPage): + """Page of summary or full tmux pane metadata.""" + + items: list[PaneSummary | PaneInfo] = Field(description="Pane rows in this page.") + + +class CallerContext(BaseModel): + """Frozen invocation identity and effective tmux server state.""" + + model_config = ConfigDict(frozen=True) + + inside_tmux: bool = Field( + description="Whether the MCP process invocation came from a tmux pane." + ) + self_available: bool = Field( + description=( + "Whether the frozen caller pane resolves on the effective live server." + ) + ) + pane_id: str | None = Field( + description="Frozen caller pane ID, or None outside tmux." + ) + window_id: str | None = Field( + description="Current caller window ID when self is available." + ) + session_id: str | None = Field( + description="Current caller session ID when self is available." + ) + caller_socket_path: str | None = Field( + description="Socket path captured from the caller's TMUX environment." + ) + effective_socket_name: str | None = Field( + description="Effective configured tmux socket name." + ) + effective_socket_path: str | None = Field( + description="Effective configured or caller tmux socket path." + ) + server_running: bool = Field( + description="Whether the effective tmux server is running." + ) + safety_level: str = Field(description="Effective MCP safety tier.") + suppress_history: bool = Field( + description="Effective semantic shell-history suppression default." + ) + + class OptionResult(BaseModel): """Result of a show_option call.""" diff --git a/src/libtmux_mcp/resources/hierarchy.py b/src/libtmux_mcp/resources/hierarchy.py index 0dcfd45e..e9924708 100644 --- a/src/libtmux_mcp/resources/hierarchy.py +++ b/src/libtmux_mcp/resources/hierarchy.py @@ -45,7 +45,9 @@ def get_sessions(socket_name: str | None = None) -> str: Parameters ---------- socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -72,7 +74,9 @@ def get_session( session_name : str The session name. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -106,7 +110,9 @@ def get_session_windows( session_name : str The session name. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -141,7 +147,9 @@ def get_window( window_index : str The window index within the session. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -177,7 +185,9 @@ def get_pane(pane_id: str, socket_name: str | None = None) -> str: pane_id : str The pane ID (e.g. '%1'). socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -205,7 +215,9 @@ def get_pane_content(pane_id: str, socket_name: str | None = None) -> str: pane_id : str The pane ID (e.g. '%1'). socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index f92a6404..90375bd7 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -22,11 +22,16 @@ _configure_history_defaults, _resolve_suppress_history, ) +from libtmux_mcp._server_start import ( + _configure_server_start_default, + _resolve_allow_server_start, +) from libtmux_mcp._utils import ( TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, VALID_SAFETY_LEVELS, + _get_caller_identity, _server_cache, ) from libtmux_mcp.middleware import ( @@ -70,7 +75,9 @@ "libtmux MCP server for tmux. " "tmux hierarchy: Server > Session > Window > Pane. " "Prefer pane_id (e.g. '%1') for targeting. " - "Targeted tmux tools accept socket_name (defaults to LIBTMUX_SOCKET); " + "Targeted tmux tools accept socket_name. Target precedence: explicit " + "per-call selector, configured path, configured name, frozen caller " + "socket, tmux default. " "list_servers discovers sockets via TMUX_TMPDIR plus extra_socket_paths." ) @@ -95,8 +102,9 @@ ) _INSTR_READ_TOOLS = ( - "Prefer snapshot_pane over capture_pane + get_pane_info; capture_since " - "for repeated observation/tailing; display_message for tmux formats." + "where_am_i resolves 'this pane', 'current window', and 'this session' " + "relative to the caller. Prefer snapshot_pane; capture_since tails; " + "display_message evaluates formats." ) _INSTR_WAIT_NOT_POLL = ( @@ -179,9 +187,7 @@ def _build_instructions( # query away). Reuse the existing safety axis instead of shipping a # separate LIBTMUX_DISCOVERABILITY knob. if safety_level == TAG_READONLY: - parts.append( - "\n\nReadonly mode: probe snapshot_pane/list_panes/search_panes if unsure." - ) + parts.append("\n\nReadonly mode: probe where_am_i/snapshot_pane if unsure.") instructions = "".join(parts) if len(instructions.encode("utf-8")) > _INSTRUCTIONS_MAX_BYTES: @@ -192,25 +198,24 @@ def _build_instructions( # the untrusted socket name and explanatory workflow before omitting the # context entirely. Never byte-slice text because UTF-8 characters may be # split across bytes. - tmux_pane = os.environ.get("TMUX_PANE") - if tmux_pane: - # Parse TMUX env: "/tmp/tmux-1000/default,48188,10" - tmux_env = os.environ.get("TMUX", "") - env_parts = tmux_env.split(",") if tmux_env else [] - socket_path = env_parts[0] if env_parts else None - socket_name = socket_path.rsplit("/", 1)[-1] if socket_path else None + caller = _get_caller_identity() + if caller is not None and caller.pane_id: + tmux_pane = caller.pane_id + socket_name = ( + caller.socket_path.rsplit("/", 1)[-1] if caller.socket_path else None + ) context_start = f"\n\nAgent context: this MCP runs inside tmux pane {tmux_pane}" context = context_start if socket_name: context += f" (socket {socket_name})" context += ( - ". Tool results mark is_caller=true; filter list_panes for it to answer " - "'which pane am I in?' (no whoami tool)." + ". where_am_i returns live window/session IDs; pane results mark " + "is_caller=true." ) pane_context = ( - f"{context_start}. Tool results mark is_caller=true; filter list_panes " - "for it to answer 'which pane am I in?' (no whoami tool)." + f"{context_start}. where_am_i returns live window/session IDs; " + "pane results mark is_caller=true." ) minimal_context = f"\n\nAgent context: tmux pane {tmux_pane}." for candidate in (context, pane_context, minimal_context): @@ -239,10 +244,14 @@ def _resolve_safety_level(value: str | None) -> str: _suppress_history = _resolve_suppress_history( os.environ.get("LIBTMUX_SUPPRESS_HISTORY") ) +_allow_server_start = _resolve_allow_server_start( + os.environ.get("LIBTMUX_ALLOW_SERVER_START") +) #: Tools covered by the tail-preserving response limiter. Only tools -#: whose output is terminal scrollback benefit from this backstop; -#: structured responses from list/get tools stay under the cap naturally. +#: whose output contains terminal text benefit from this backstop. The +#: structured ``list_*`` tools are intentionally absent: their typed page +#: envelopes bound rows without turning valid structured data into text. _RESPONSE_LIMITED_TOOLS = [ "capture_pane", "capture_since", @@ -378,6 +387,7 @@ def _register_all() -> None: register_tools(mcp) _configure_history_defaults(mcp, _suppress_history) + _configure_server_start_default(mcp, _allow_server_start) register_resources(mcp) register_prompts(mcp) _mcp_registered = True diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index d9ab55af..0b799ccf 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -10,25 +10,41 @@ import typing as t from fastmcp.exceptions import ToolError +from libtmux import exc +from libtmux.pane import Pane from libtmux_mcp._history import _prepare_spawn_environment +from libtmux_mcp._server_start import ( + _create_session_with_start_policy, + _validate_allow_server_start, +) from libtmux_mcp._utils import ( ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_RO, + DISCOVERY_META, TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, ExpectedToolError, _apply_filters, _caller_is_on_server, + _caller_is_strictly_on_server, _get_caller_identity, _get_server, _invalidate_server, + _paginate, + _resolve_server_target, _serialize_session, handle_tool_errors, ) -from libtmux_mcp.models import ServerInfo, SessionInfo +from libtmux_mcp.models import ( + CallerContext, + ServerInfo, + ServerPage, + SessionInfo, + SessionPage, +) logger = logging.getLogger(__name__) @@ -36,11 +52,76 @@ from fastmcp import FastMCP +@handle_tool_errors +def where_am_i() -> CallerContext: + """Locate this MCP invocation in tmux without listing terminal panes. + + Resolves relational requests such as 'this pane', 'current window', + and 'this session'. Caller identity remains visible when the selected + server differs, is stopped, or no longer contains the frozen pane. + + Returns + ------- + CallerContext + Frozen caller identity, effective target, and availability state. + """ + from libtmux_mcp.server import _safety_level, _suppress_history + + caller = _get_caller_identity() + target = _resolve_server_target() + server = _get_server( + socket_name=target.socket_name, + socket_path=target.socket_path, + ) + server_running = server.is_alive() + window_id: str | None = None + session_id: str | None = None + self_available = False + + if ( + server_running + and caller is not None + and caller.pane_id is not None + and _caller_is_strictly_on_server(server, caller) + ): + try: + pane = Pane.from_pane_id(server=server, pane_id=caller.pane_id) + resolved_window_id = pane.window_id + resolved_session_id = pane.session.session_id + except exc.ObjectDoesNotExist: + pass + except exc.LibTmuxException: + if not server.is_alive(): + server_running = False + else: + raise + else: + window_id = resolved_window_id + session_id = resolved_session_id + self_available = True + + return CallerContext( + inside_tmux=caller is not None, + self_available=self_available, + pane_id=caller.pane_id if caller is not None else None, + window_id=window_id, + session_id=session_id, + caller_socket_path=caller.socket_path if caller is not None else None, + effective_socket_name=target.socket_name, + effective_socket_path=target.socket_path, + server_running=server_running, + safety_level=_safety_level, + suppress_history=_suppress_history, + ) + + @handle_tool_errors def list_sessions( socket_name: str | None = None, filters: dict[str, str] | str | None = None, -) -> list[SessionInfo]: + limit: int = 100, + offset: int = 0, +) -> SessionPage: """List tmux sessions (terminal workspaces) on a tmux server. Use for tmux multiplexer sessions — 'this session', 'my workspace', @@ -51,19 +132,32 @@ def list_sessions( Parameters ---------- socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. filters : dict or str, optional Django-style filters as a dict (e.g. ``{"session_name__contains": "dev"}``) or as a JSON string. Some MCP clients require the string form. + limit : int, optional + Maximum rows to return. Defaults to 100. + offset : int, optional + Zero-based row offset. Defaults to 0. Returns ------- - list[SessionInfo] - List of session objects. + SessionPage + Page of session objects and pagination metadata. """ server = _get_server(socket_name=socket_name) sessions = server.sessions - return _apply_filters(sessions, filters, _serialize_session) + rows = _apply_filters(sessions, filters, _serialize_session) + rows.sort(key=lambda row: int(row.session_id[1:])) + return _paginate( + rows, + limit=limit, + offset=offset, + page_type=SessionPage, + ) @handle_tool_errors @@ -76,6 +170,7 @@ def create_session( environment: dict[str, str] | str | None = None, socket_name: str | None = None, *, + allow_server_start: bool = False, suppress_persistent_history: bool = False, ) -> SessionInfo: """Create a new tmux session. @@ -108,7 +203,13 @@ def create_session( overrides them. MCP audit redaction does not hide these surfaces. Pass credential references, not literal credentials. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. + allow_server_start : bool + Whether this call may start a tmux server when none is running. + Defaults to False for direct Python calls. Omitted MCP input inherits + LIBTMUX_ALLOW_SERVER_START. suppress_persistent_history : bool Whether to suppress persistent history for the spawned shell. Defaults to False for MCP and direct Python calls. This per-call option does not @@ -120,6 +221,7 @@ def create_session( SessionInfo The created session. """ + allow_server_start = _validate_allow_server_start(allow_server_start) spawn_environment = _prepare_spawn_environment( environment, suppress_persistent_history=suppress_persistent_history, @@ -138,8 +240,12 @@ def create_session( kwargs["y"] = y if spawn_environment is not None: kwargs["environment"] = spawn_environment - session = server.new_session(**kwargs) - return _serialize_session(session) + session, server_started = _create_session_with_start_policy( + server, + allow_server_start=allow_server_start, + session_kwargs=kwargs, + ) + return _serialize_session(session, server_started=server_started) @handle_tool_errors @@ -153,7 +259,9 @@ def kill_server(socket_name: str | None = None) -> str: Parameters ---------- socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -185,7 +293,9 @@ def get_server_info(socket_name: str | None = None) -> ServerInfo: Parameters ---------- socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -293,6 +403,7 @@ def _probe_server_by_path(socket_path: pathlib.Path) -> ServerInfo | None: "call_mutating_tools_batch", "call_readonly_tools_batch", "list_servers", + "where_am_i", } ) @@ -300,7 +411,9 @@ def _probe_server_by_path(socket_path: pathlib.Path) -> ServerInfo | None: @handle_tool_errors def list_servers( extra_socket_paths: list[str] | None = None, -) -> list[ServerInfo]: + limit: int = 100, + offset: int = 0, +) -> ServerPage: """Discover live tmux servers under the current user's ``$TMUX_TMPDIR``. Scans ``${TMUX_TMPDIR:-/tmp}/tmux-/`` for socket files — the @@ -325,18 +438,26 @@ def list_servers( ``connect()``) and queried for server metadata. Paths that do not exist, are not sockets, or have no listener are silently skipped. + limit : int, optional + Maximum rows to return. Defaults to 100. + offset : int, optional + Zero-based row offset. Defaults to 0. Returns ------- - list[ServerInfo] - One entry per live tmux server found. Canonical-directory - results come first, followed by successful ``extra_socket_paths`` - probes in the supplied order. Empty when nothing lives under - ``$TMUX_TMPDIR`` and no extras are supplied or reachable. + ServerPage + Deterministically ordered page of live tmux servers found. """ tmux_tmpdir = os.environ.get("TMUX_TMPDIR", "/tmp") uid_dir = pathlib.Path(tmux_tmpdir) / f"tmux-{os.geteuid()}" - results: list[ServerInfo] = [] + results_by_path: dict[str, ServerInfo] = {} + + def add_result(socket_path: pathlib.Path, info: ServerInfo) -> None: + normalized_path = os.path.normcase(os.path.realpath(socket_path)) + existing = results_by_path.get(normalized_path) + if existing is None or (not existing.is_alive and info.is_alive): + results_by_path[normalized_path] = info + if uid_dir.is_dir(): for entry in sorted(uid_dir.iterdir()): try: @@ -352,16 +473,32 @@ def list_servers( info = get_server_info(socket_name=entry.name) except ToolError: continue - results.append(info) + add_result(entry, info) for raw_path in extra_socket_paths or []: - extra = _probe_server_by_path(pathlib.Path(raw_path)) + extra_path = pathlib.Path(raw_path) + extra = _probe_server_by_path(extra_path) if extra is not None: - results.append(extra) - return results + add_result(extra_path, extra) + results = list(results_by_path.values()) + results.sort( + key=lambda result: (result.socket_path or "", result.socket_name or "") + ) + return _paginate( + results, + limit=limit, + offset=offset, + page_type=ServerPage, + ) def register(mcp: FastMCP) -> None: """Register server-level tools with the MCP instance.""" + mcp.tool( + title="Locate tmux Caller", + annotations=ANNOTATIONS_RO, + tags={TAG_READONLY}, + meta=DISCOVERY_META, + )(where_am_i) mcp.tool( title="List tmux Sessions", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} )(list_sessions) diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 7805c3ff..f6b5971d 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -4,6 +4,7 @@ import typing as t +from libtmux import exc from libtmux.constants import WindowDirection from libtmux_mcp._history import _prepare_spawn_environment @@ -19,17 +20,73 @@ ExpectedToolError, _apply_filters, _caller_is_on_server, + _caller_is_strictly_on_server, _get_caller_identity, _get_server, + _paginate, _resolve_session, _serialize_session, _serialize_window, + _server_not_running_error, handle_tool_errors, ) -from libtmux_mcp.models import SessionInfo, WindowInfo +from libtmux_mcp.models import SessionInfo, WindowInfo, WindowPage, WindowSummary if t.TYPE_CHECKING: from fastmcp import FastMCP + from libtmux.server import Server + from libtmux.session import Session + + +def _resolve_caller_session(server: Server) -> Session: + """Resolve the frozen caller pane's live session on ``server``. + + Parameters + ---------- + server : libtmux.Server + Effective server selected for the list operation. + + Returns + ------- + libtmux.Session + Live session containing the frozen caller pane. + + Raises + ------ + ExpectedToolError + If caller identity is absent, targets another socket, or no + longer resolves to a live pane. + """ + from libtmux.pane import Pane + + caller = _get_caller_identity() + if caller is None or caller.pane_id is None: + msg = ( + "scope='caller_session' requires a frozen caller pane from an MCP " + "invocation started inside tmux; use scope='server' with explicit " + "hierarchy selectors instead." + ) + raise ExpectedToolError(msg) + if not _caller_is_strictly_on_server(server, caller): + msg = ( + "scope='caller_session' is unavailable because the caller socket " + "does not match the effective tmux target; use scope='server' with " + "explicit hierarchy selectors, or target the caller's socket." + ) + raise ExpectedToolError(msg) + try: + return Pane.from_pane_id(server=server, pane_id=caller.pane_id).session + except exc.ObjectDoesNotExist: + msg = ( + "scope='caller_session' could not resolve the frozen caller pane " + "on the effective tmux target; use scope='server' with explicit " + "hierarchy selectors, or restart from a live tmux pane." + ) + raise ExpectedToolError(msg) from None + except exc.LibTmuxException: + if not server.is_alive(): + raise _server_not_running_error() from None + raise @handle_tool_errors @@ -38,7 +95,11 @@ def list_windows( session_id: str | None = None, socket_name: str | None = None, filters: dict[str, str] | str | None = None, -) -> list[WindowInfo]: + scope: t.Literal["server", "caller_session"] = "server", + detail: t.Literal["summary", "full"] = "summary", + limit: int = 100, + offset: int = 0, +) -> WindowPage: """List tmux windows (terminal tabs) in a session, or across the server. Use for tmux windows — 'current window', 'this tab' (when terminal- @@ -54,25 +115,75 @@ def list_windows( session_id : str, optional Session ID (e.g. '$1') to look up. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. filters : dict or str, optional Django-style filters as a dict (e.g. ``{"window_name__contains": "dev"}``) or as a JSON string. Some MCP clients require the string form. + scope : {"server", "caller_session"}, optional + Discovery scope. ``"server"`` preserves server-wide listing when + no session selector is supplied. ``"caller_session"`` limits the + result to the frozen caller pane's live session and cannot be + combined with ``session_name`` or ``session_id``. + detail : {"summary", "full"}, optional + Row projection. Summary rows are the compact default; full rows + include layout and dimensions. + limit : int, optional + Maximum rows to return. Defaults to 100. + offset : int, optional + Zero-based row offset. Defaults to 0. Returns ------- - list[WindowInfo] - List of serialized window objects. + WindowPage + Page of summary or full window objects and pagination metadata. """ + if scope not in ("server", "caller_session"): + msg = f"Invalid scope {scope!r}; expected 'server' or 'caller_session'." + raise ExpectedToolError(msg) + if detail not in ("summary", "full"): + msg = f"Invalid detail {detail!r}; expected 'summary' or 'full'." + raise ExpectedToolError(msg) + if scope == "caller_session" and ( + session_name is not None or session_id is not None + ): + msg = ( + "scope='caller_session' cannot be combined with explicit hierarchy " + "selectors (session_name or session_id); omit the selectors or use " + "scope='server'." + ) + raise ExpectedToolError(msg) + server = _get_server(socket_name=socket_name) - if session_name is not None or session_id is not None: + if scope == "caller_session": + windows = _resolve_caller_session(server).windows + elif session_name is not None or session_id is not None: session = _resolve_session( server, session_name=session_name, session_id=session_id ) windows = session.windows else: windows = server.windows - return _apply_filters(windows, filters, _serialize_window) + rows = _apply_filters(windows, filters, _serialize_window) + rows.sort( + key=lambda row: ( + int(row.window_id[1:]), + int(row.session_id[1:]) if row.session_id is not None else -1, + int(row.window_index) if row.window_index is not None else -1, + ) + ) + projected: list[WindowSummary | WindowInfo] + if detail == "summary": + projected = [WindowSummary.model_validate(row) for row in rows] + else: + projected = list(rows) + return _paginate( + projected, + limit=limit, + offset=offset, + page_type=WindowPage, + ) # get_session_info completes the core-tmux-hierarchy symmetry alongside @@ -141,7 +252,9 @@ def create_window( direction : str, optional Window placement direction. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. environment : dict or str, optional Per-process environment as a mapping or JSON object string. Values do not modify the tmux session environment. Each item becomes a tmux @@ -210,7 +323,9 @@ def rename_session( session_id : str, optional Session ID (e.g. '$1') to look up. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- @@ -242,7 +357,9 @@ def kill_session( session_id : str, optional Session ID (e.g. '$1') to look up. socket_name : str, optional - tmux socket name. Defaults to LIBTMUX_SOCKET env var. + tmux socket name. Target precedence is explicit per-call selector, + configured path, configured name, frozen caller socket, then tmux + default. Returns ------- diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 0a8461b3..ca2270dd 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -12,7 +12,6 @@ ANNOTATIONS_DESTRUCTIVE, ANNOTATIONS_MUTATING, ANNOTATIONS_RO, - DISCOVERY_META, TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, @@ -21,6 +20,7 @@ _caller_is_on_server, _get_caller_identity, _get_server, + _paginate, _resolve_pane, _resolve_session, _resolve_window, @@ -28,7 +28,8 @@ _serialize_window, handle_tool_errors, ) -from libtmux_mcp.models import PaneInfo, WindowInfo +from libtmux_mcp.models import PaneInfo, PanePage, PaneSummary, WindowInfo +from libtmux_mcp.tools.session_tools import _resolve_caller_session if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -39,6 +40,7 @@ "right": PaneDirection.Right, "left": PaneDirection.Left, } +_PANE_SYNTHETIC_FILTER_FIELDS = frozenset({"is_caller"}) @handle_tool_errors @@ -48,8 +50,12 @@ def list_panes( window_id: str | None = None, window_index: str | None = None, socket_name: str | None = None, - filters: dict[str, str] | str | None = None, -) -> list[PaneInfo]: + filters: dict[str, t.Any] | str | None = None, + scope: t.Literal["server", "caller_session"] = "server", + detail: t.Literal["summary", "full"] = "summary", + limit: int = 100, + offset: int = 0, +) -> PanePage: """List tmux panes (terminal multiplexer splits) in a window, session, or server. Use for terminal panes — including 'this pane', 'current pane', @@ -75,14 +81,45 @@ def list_panes( Django-style filters as a dict (e.g. ``{"pane_current_command__contains": "vim"}``) or as a JSON string. Some MCP clients require the string form. + scope : {"server", "caller_session"}, optional + Discovery scope. ``"server"`` preserves the existing hierarchy + selectors and server-wide default. ``"caller_session"`` limits + results to the frozen caller pane's live session and cannot be + combined with session or window selectors. + detail : {"summary", "full"}, optional + Row projection. Summary rows are the compact default; full rows + include geometry, process, and working-directory metadata. + limit : int, optional + Maximum rows to return. Defaults to 100. + offset : int, optional + Zero-based row offset. Defaults to 0. Returns ------- - list[PaneInfo] - List of serialized pane objects. + PanePage + Page of summary or full pane objects and pagination metadata. """ + if scope not in ("server", "caller_session"): + msg = f"Invalid scope {scope!r}; expected 'server' or 'caller_session'." + raise ExpectedToolError(msg) + if detail not in ("summary", "full"): + msg = f"Invalid detail {detail!r}; expected 'summary' or 'full'." + raise ExpectedToolError(msg) + if scope == "caller_session" and any( + selector is not None + for selector in (session_name, session_id, window_id, window_index) + ): + msg = ( + "scope='caller_session' cannot be combined with explicit hierarchy " + "selectors (session_name, session_id, window_id, or window_index); " + "omit the selectors or use scope='server'." + ) + raise ExpectedToolError(msg) + server = _get_server(socket_name=socket_name) - if window_id is not None or window_index is not None: + if scope == "caller_session": + panes = _resolve_caller_session(server).panes + elif window_id is not None or window_index is not None: window = _resolve_window( server, window_id=window_id, @@ -98,7 +135,24 @@ def list_panes( panes = session.panes else: panes = server.panes - return _apply_filters(panes, filters, _serialize_pane) + rows = _apply_filters( + panes, + filters, + _serialize_pane, + synthetic_fields=_PANE_SYNTHETIC_FILTER_FIELDS, + ) + rows.sort(key=lambda row: int(row.pane_id[1:])) + projected: list[PaneSummary | PaneInfo] + if detail == "summary": + projected = [PaneSummary.model_validate(row) for row in rows] + else: + projected = list(rows) + return _paginate( + projected, + limit=limit, + offset=offset, + page_type=PanePage, + ) # get_window_info completes the core-tmux-hierarchy symmetry of get_*_info @@ -500,7 +554,6 @@ def register(mcp: FastMCP) -> None: title="List tmux Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY}, - meta=DISCOVERY_META, )(list_panes) mcp.tool( title="Get tmux Window Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY} diff --git a/tests/conftest.py b/tests/conftest.py index 3f7dc938..b3efa972 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,18 @@ from __future__ import annotations +import os +import pathlib +import shlex +import shutil +import types import typing as t import pytest -from libtmux_mcp._utils import _server_cache +from libtmux_mcp import _utils + +_server_cache = _utils._server_cache if t.TYPE_CHECKING: from libtmux.pane import Pane @@ -36,6 +43,11 @@ def _isolate_tmux_caller_env( """ monkeypatch.delenv("TMUX", raising=False) monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.setattr( + _utils, + "_get_invocation_environment", + lambda: types.MappingProxyType(dict(os.environ)), + ) @pytest.fixture @@ -70,3 +82,28 @@ def mcp_pane(mcp_window: Window) -> Pane: active_pane = mcp_window.active_pane assert active_pane is not None return active_pane + + +@pytest.fixture +def custom_tmux_bin_without_path( + mcp_server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> str: + """Provide a working configured tmux binary while PATH has no tmux. + + Depending on ``mcp_server`` ensures the pytest plugin creates its real + daemon before PATH is narrowed for the caller-lookup regression. + """ + del mcp_server + real_tmux = shutil.which("tmux") + assert real_tmux is not None + wrapper = tmp_path / "configured-tmux" + wrapper.write_text( + f'#!/bin/sh\nexec {shlex.quote(real_tmux)} "$@"\n', + encoding="utf-8", + ) + wrapper.chmod(0o755) + monkeypatch.setenv("LIBTMUX_TMUX_BIN", str(wrapper)) + monkeypatch.setenv("PATH", str(tmp_path)) + return str(wrapper) diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 7577cbc9..6d701191 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -48,6 +48,54 @@ def test_topic_docs_do_not_overclaim_runtime_features( assert forbidden_text not in text +@pytest.mark.parametrize( + ("relative_path", "page_model"), + ( + ("tools/server/list-servers.md", "ServerPage"), + ("tools/server/list-sessions.md", "SessionPage"), + ("tools/session/list-windows.md", "WindowPage"), + ("tools/window/list-panes.md", "PanePage"), + ), +) +def test_list_tool_pages_document_typed_page_envelopes( + docs_dir: pathlib.Path, + relative_path: str, + page_model: str, +) -> None: + """List tool examples show the bounded response clients receive.""" + text = (docs_dir / relative_path).read_text(encoding="utf-8") + + assert f"{{class}}`~libtmux_mcp.models.{page_model}`" in text + for field in ("items", "total", "offset", "limit", "truncated"): + assert f'"{field}"' in text + assert "`limit=100`" in text + assert "`offset=0`" in text + + +def test_list_sessions_example_includes_creation_side_effect_field( + docs_dir: pathlib.Path, +) -> None: + """List rows show that discovery never reports starting a daemon.""" + text = (docs_dir / "tools" / "server" / "list-sessions.md").read_text( + encoding="utf-8" + ) + + assert '"server_started": false' in text + + +def test_pagination_topic_distinguishes_hierarchy_pages_from_cursors( + docs_dir: pathlib.Path, +) -> None: + """Pagination guidance names every typed list page and ordering phase.""" + text = (docs_dir / "topics" / "pagination.md").read_text(encoding="utf-8") + + for page_model in ("ServerPage", "SessionPage", "WindowPage", "PanePage"): + assert f"{{class}}`~libtmux_mcp.models.{page_model}`" in text + assert "Filters run before the stable sort" in text + assert "Window and pane lists default to compact summary rows" in text + assert "application-level pages (not MCP-cursor pagination)" in text + + def test_configuration_documents_command_history_default_and_restart( docs_dir: pathlib.Path, ) -> None: @@ -70,6 +118,100 @@ def test_configuration_documents_command_history_default_and_restart( assert "Restart the MCP server only after changing this startup setting" in text +def test_startup_docs_define_server_started_at_observable_boundary( + docs_dir: pathlib.Path, +) -> None: + """Startup reporting avoids stronger cross-process attribution claims.""" + paths = ( + docs_dir / "configuration.md", + docs_dir / "tools" / "server" / "create-session.md", + docs_dir.parent / "CHANGES", + ) + + for path in paths: + normalized = " ".join(path.read_text(encoding="utf-8").split()) + assert "no-start attempt proved the target absent" in normalized + assert "startup-enabled path" in normalized + assert "when the call created the daemon" not in normalized + + +def test_target_docs_publish_caller_aware_precedence( + docs_dir: pathlib.Path, +) -> None: + """Configuration, prompting, and recovery agree on target selection.""" + relative_paths = ( + "configuration.md", + "topics/prompting.md", + "topics/troubleshooting.md", + ) + precedence = ( + "explicit per-call selector, configured path, configured name, " + "frozen caller socket, tmux default" + ) + + for relative_path in relative_paths: + text = (docs_dir / relative_path).read_text(encoding="utf-8") + assert precedence in " ".join(text.split()) + + configuration = (docs_dir / "configuration.md").read_text(encoding="utf-8") + troubleshooting = (docs_dir / "topics/troubleshooting.md").read_text( + encoding="utf-8" + ) + normalized_configuration = " ".join(configuration.split()) + assert "inside tmux, the frozen caller socket" in normalized_configuration + assert "outside tmux, the tmux default" in normalized_configuration + assert "remove `LIBTMUX_SOCKET` from the config to use the default socket" not in ( + troubleshooting + ) + + +def test_where_am_i_tool_page_is_indexed_and_documents_typed_states( + docs_dir: pathlib.Path, +) -> None: + """Invocation discovery has a complete task page and resolvable entries.""" + page = (docs_dir / "tools" / "server" / "where-am-i.md").read_text(encoding="utf-8") + server_index = (docs_dir / "tools" / "server" / "index.md").read_text( + encoding="utf-8" + ) + prompting = (docs_dir / "topics" / "prompting.md").read_text(encoding="utf-8") + + assert "```{fastmcp-tool} server_tools.where_am_i" in page + assert "```{fastmcp-tool-input} server_tools.where_am_i" in page + for section in ("**Use when**", "**Avoid when**", "**Side effects:**"): + assert section in page + for state in ("outside tmux", "dead", "stale", "mismatch"): + assert state in page.lower() + for field in ( + "inside_tmux", + "self_available", + "pane_id", + "window_id", + "session_id", + "server_running", + ): + assert f'"{field}"' in page + assert "{tooliconl}`where-am-i`" in server_index + assert "\nwhere-am-i\n" in server_index + assert "{toolref}`where-am-i`" in prompting + + +@pytest.mark.parametrize( + "relative_path", + ("tools/session/list-windows.md", "tools/window/list-panes.md"), +) +def test_caller_session_tool_pages_name_lookup_tradeoff( + docs_dir: pathlib.Path, + relative_path: str, +) -> None: + """Caller-local discovery states the extra lookup and its benefit.""" + normalized = " ".join( + (docs_dir / relative_path).read_text(encoding="utf-8").split() + ) + + assert "extra targeted tmux lookup" in normalized + assert "fail-closed live-session accuracy" in normalized + + def test_configuration_separates_spawn_persistent_history_control( docs_dir: pathlib.Path, ) -> None: diff --git a/tests/test_list_pages.py b/tests/test_list_pages.py new file mode 100644 index 00000000..09a3ad77 --- /dev/null +++ b/tests/test_list_pages.py @@ -0,0 +1,360 @@ +"""Functional and schema contracts for bounded hierarchy list tools.""" + +from __future__ import annotations + +import asyncio +import os +import pathlib +import typing as t + +import pytest +from fastmcp import Client, FastMCP + +from libtmux_mcp._utils import ExpectedToolError +from libtmux_mcp.tools import register_tools +from libtmux_mcp.tools.server_tools import list_servers, list_sessions +from libtmux_mcp.tools.session_tools import list_windows +from libtmux_mcp.tools.window_tools import list_panes + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +# Per-tool wire-catalog ceiling with room for ordinary description and field +# growth. This catches repeated/expanded union schemas without coupling the +# test to exact bytes or to unrelated tools added elsewhere in the catalog. +_LIST_TOOL_CATALOG_BYTES_CEILING = 12_000 + + +def _numeric_id(value: str | None) -> int: + """Return the numeric part of a tmux identity for ordering assertions.""" + assert value is not None + return int(value[1:]) + + +def test_list_sessions_filters_sorts_then_pages( + mcp_server: Server, +) -> None: + """Session totals describe filtered rows before deterministic paging.""" + from libtmux_mcp import models + + page_type = getattr(models, "SessionPage", None) + assert page_type is not None, "SessionPage is not implemented" + created = [ + mcp_server.new_session(session_name=f"page-session-{suffix}") + for suffix in ("z", "a", "m") + ] + + result = list_sessions( + socket_name=mcp_server.socket_name, + filters={"session_name__startswith": "page-session-"}, + limit=2, + offset=1, + ) + + expected_ids = sorted( + (session.session_id for session in created), + key=_numeric_id, + ) + assert isinstance(result, page_type) + assert [item.session_id for item in result.items] == expected_ids[1:3] + assert result.total == 3 + assert result.offset == 1 + assert result.limit == 2 + assert result.truncated is False + + +@pytest.mark.usefixtures("mcp_session") +def test_list_servers_returns_typed_page( + mcp_server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Server discovery returns a typed page with the advertised defaults.""" + from libtmux_mcp import models + + page_type = getattr(models, "ServerPage", None) + assert page_type is not None, "ServerPage is not implemented" + monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path)) + fixture_socket = ( + pathlib.Path("/tmp") + / f"tmux-{os.geteuid()}" + / (mcp_server.socket_name or "default") + ) + assert fixture_socket.is_socket() + + result = list_servers(extra_socket_paths=[str(fixture_socket)]) + + assert isinstance(result, page_type) + assert [item.socket_path for item in result.items] == [str(fixture_socket)] + assert result.total == 1 + assert result.offset == 0 + assert result.limit == 100 + assert result.truncated is False + + +def test_list_windows_defaults_to_summary_and_accepts_full_detail( + mcp_server: Server, + mcp_session: Session, +) -> None: + """Window pages default to compact rows and expose full rows on request.""" + from libtmux_mcp import models + + page_type = getattr(models, "WindowPage", None) + summary_type = getattr(models, "WindowSummary", None) + assert page_type is not None, "WindowPage is not implemented" + assert summary_type is not None, "WindowSummary is not implemented" + mcp_session.new_window(window_name="page-window-z") + mcp_session.new_window(window_name="page-window-a") + + summary = list_windows( + session_id=mcp_session.session_id, + socket_name=mcp_server.socket_name, + limit=1, + offset=1, + ) + full = list_windows( + session_id=mcp_session.session_id, + socket_name=mcp_server.socket_name, + detail="full", + ) + + full_ids = sorted( + (window.window_id for window in mcp_session.windows), + key=_numeric_id, + ) + assert isinstance(summary, page_type) + assert summary.total == len(full_ids) + assert summary.truncated is (len(full_ids) > 2) + assert [item.window_id for item in summary.items] == full_ids[1:2] + assert all(isinstance(item, summary_type) for item in summary.items) + assert all(not hasattr(item, "window_layout") for item in summary.items) + assert [item.window_id for item in full.items] == full_ids + assert all(isinstance(item, models.WindowInfo) for item in full.items) + assert all(hasattr(item, "window_layout") for item in full.items) + + +def test_list_windows_pages_linked_rows_by_window_then_parent( + mcp_server: Server, +) -> None: + """Linked-window rows have deterministic parent ordering across pages.""" + origin_session = mcp_server.new_session(session_name="zz-linked-page-origin") + linked_window = origin_session.active_window + assert linked_window.window_id is not None + second_session = mcp_server.new_session(session_name="aa-linked-page-parent") + assert second_session.session_id is not None + link_result = mcp_server.cmd( + "link-window", + "-s", + linked_window.window_id, + "-t", + f"{second_session.session_id}:9", + ) + assert link_result.stderr == [] + + pages = [ + list_windows( + socket_name=mcp_server.socket_name, + filters={"window_id": linked_window.window_id}, + limit=1, + offset=offset, + ) + for offset in range(2) + ] + + expected_session_ids = sorted( + (origin_session.session_id, second_session.session_id), + key=_numeric_id, + ) + assert [page.total for page in pages] == [2, 2] + assert [page.items[0].session_id for page in pages] == expected_session_ids + + +def test_list_panes_filters_before_summary_projection_and_paging( + mcp_server: Server, + mcp_session: Session, +) -> None: + """Pane filters can use full metadata before compact rows are projected.""" + from libtmux_mcp import models + + page_type = getattr(models, "PanePage", None) + summary_type = getattr(models, "PaneSummary", None) + assert page_type is not None, "PanePage is not implemented" + assert summary_type is not None, "PaneSummary is not implemented" + window = mcp_session.active_window + window.split() + window.split() + command = window.panes[0].pane_current_command + assert command is not None + + summary = list_panes( + window_id=window.window_id, + socket_name=mcp_server.socket_name, + filters={"pane_current_command": command}, + limit=1, + ) + full = list_panes( + window_id=window.window_id, + socket_name=mcp_server.socket_name, + filters={"pane_current_command": command}, + detail="full", + ) + + expected_ids = sorted( + (pane.pane_id for pane in window.panes if pane.pane_current_command == command), + key=_numeric_id, + ) + assert isinstance(summary, page_type) + assert summary.total == len(expected_ids) + assert summary.truncated is (len(expected_ids) > 1) + assert [item.pane_id for item in summary.items] == expected_ids[:1] + assert all(isinstance(item, summary_type) for item in summary.items) + assert all(not hasattr(item, "pane_current_path") for item in summary.items) + assert [item.pane_id for item in full.items] == expected_ids + assert all(isinstance(item, models.PaneInfo) for item in full.items) + + +@pytest.mark.parametrize( + ("tool_name", "kwargs"), + [ + ("list_sessions", {"limit": 0}), + ("list_servers", {"limit": -1}), + ("list_windows", {"offset": -1}), + ("list_panes", {"limit": 0}), + ], + ids=["sessions-limit", "servers-limit", "windows-offset", "panes-limit"], +) +def test_list_tools_reject_invalid_direct_python_bounds( + tool_name: str, + kwargs: dict[str, int], +) -> None: + """Every list tool rejects invalid direct-Python page bounds.""" + tools: dict[str, t.Callable[..., t.Any]] = { + "list_sessions": list_sessions, + "list_servers": list_servers, + "list_windows": list_windows, + "list_panes": list_panes, + } + + with pytest.raises(ExpectedToolError, match=r"limit.*greater than 0|offset.*0"): + tools[tool_name](**kwargs) + + +@pytest.mark.parametrize( + ("parameter", "value"), + [ + ("limit", True), + ("limit", "10"), + ("offset", False), + ("offset", 0.5), + ], + ids=["limit-bool", "limit-string", "offset-bool", "offset-float"], +) +def test_list_servers_rejects_non_integer_direct_python_bounds( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + parameter: str, + value: object, +) -> None: + """The public list path preserves the shared helper's strict types.""" + monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path)) + kwargs: dict[str, t.Any] = {parameter: value} + + with pytest.raises(ExpectedToolError, match=rf"{parameter}.*integer"): + list_servers(**kwargs) + + +@pytest.mark.parametrize( + "tool", + [ + pytest.param(list_windows, id="windows"), + pytest.param(list_panes, id="panes"), + ], +) +def test_projected_list_tools_reject_invalid_direct_python_detail( + tool: t.Callable[..., t.Any], +) -> None: + """Direct callers cannot silently widen an unknown detail projection.""" + with pytest.raises(ExpectedToolError, match=r"Invalid detail.*summary.*full"): + tool(detail=t.cast("t.Any", "wide")) + + +def test_list_tool_schemas_publish_bounds_pages_and_projection_widths() -> None: + """tools/list exposes compact defaults and both typed projection widths.""" + mcp = FastMCP(name="list-page-schema-audit") + register_tools(mcp) + tools = {tool.name: tool for tool in asyncio.run(mcp.list_tools())} + + expected_item_descriptions = { + "list_sessions": {"Serialized tmux session."}, + "list_servers": {"Serialized tmux server info."}, + "list_windows": { + "Compact tmux window metadata for discovery lists.", + "Serialized tmux window.", + }, + "list_panes": { + "Compact tmux pane metadata for discovery lists.", + "Serialized tmux pane.", + }, + } + for tool_name, item_descriptions in expected_item_descriptions.items(): + tool = tools[tool_name] + assert tool.parameters["properties"]["limit"]["default"] == 100 + assert tool.parameters["properties"]["offset"]["default"] == 0 + assert tool.output_schema is not None + assert set(tool.output_schema["properties"]) == { + "items", + "total", + "offset", + "limit", + "truncated", + } + item_schema = tool.output_schema["properties"]["items"]["items"] + alternatives = item_schema.get("anyOf", [item_schema]) + assert {schema["description"] for schema in alternatives} == item_descriptions + + for tool_name in ("list_windows", "list_panes"): + detail = tools[tool_name].parameters["properties"]["detail"] + assert detail["default"] == "summary" + assert detail["enum"] == ["summary", "full"] + + +def test_list_tool_wire_catalog_entries_stay_bounded() -> None: + """Each list tool keeps a compact catalog entry with schema headroom.""" + mcp = FastMCP(name="list-page-wire-size-audit") + register_tools(mcp) + + async def _list_tools() -> dict[str, t.Any]: + async with Client(mcp) as client: + return {tool.name: tool for tool in await client.list_tools()} + + tools = asyncio.run(_list_tools()) + for tool_name in ( + "list_sessions", + "list_servers", + "list_windows", + "list_panes", + ): + payload = tools[tool_name].model_dump_json( + by_alias=True, + exclude_none=True, + ) + payload_bytes = len(payload.encode("utf-8")) + assert payload_bytes <= _LIST_TOOL_CATALOG_BYTES_CEILING, ( + f"{tool_name} MCP catalog entry is {payload_bytes} bytes; " + f"ceiling is {_LIST_TOOL_CATALOG_BYTES_CEILING}" + ) + + +def test_structured_list_pages_bypass_text_response_limiting() -> None: + """Typed list pages rely on their page bounds, not text truncation.""" + from libtmux_mcp.server import _RESPONSE_LIMITED_TOOLS + + structured_lists = { + "list_sessions", + "list_servers", + "list_windows", + "list_panes", + } + assert structured_lists.isdisjoint(_RESPONSE_LIMITED_TOOLS) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 6d29e251..76967bd0 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -910,7 +910,8 @@ async def real_call_next(_context: t.Any) -> t.Any: result = asyncio.run(middleware.on_call_tool(ctx, real_call_next)) - assert result == [] + assert result.items == [] + assert result.total == 0 assert calls["count"] == 2, ( f"retry did not fire (calls={calls['count']}). Likely cause: " f"fastmcp<3.2.4 RetryMiddleware._should_retry not walking " diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 8a565c35..bc69128f 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -358,6 +358,7 @@ def test_send_keys_batch_preserves_error_suggestions( operations: list[SendKeysOperation], expected_error_snippet: str, mcp_server: Server, + mcp_session: Session, ) -> None: """send_keys_batch preserves exception suggestions in the error string.""" assert test_id diff --git a/tests/test_resources.py b/tests/test_resources.py index 2fedfd30..a358da9a 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import json import typing as t @@ -48,6 +49,21 @@ def test_sessions_resource( assert len(data) >= 1 +def test_resource_docs_publish_caller_aware_socket_precedence( + resource_functions: dict[str, t.Any], +) -> None: + """Hierarchy resource parameters do not claim the stale env-only default.""" + precedence = ( + "explicit per-call selector, configured path, configured name, " + "frozen caller socket, then tmux default" + ) + + for function in resource_functions.values(): + docstring = inspect.getdoc(function) or "" + assert "Defaults to LIBTMUX_SOCKET env var." not in docstring + assert precedence in " ".join(docstring.split()) + + def test_session_detail_resource( resource_functions: dict[str, t.Any], mcp_session: Session ) -> None: diff --git a/tests/test_server.py b/tests/test_server.py index bcc8027a..4729d9ad 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -44,6 +44,16 @@ class BuildInstructionsFixture(t.NamedTuple): expect_socket_name="default", expect_safety_in_text="mutating", ), + BuildInstructionsFixture( + test_id="inside_tmux_comma_bearing_socket_path", + safety_level=TAG_MUTATING, + tmux_pane_env="%43", + tmux_env="/tmp/odd,dir/work,12345,0", + expect_agent_context=True, + expect_pane_id_in_text="%43", + expect_socket_name="work", + expect_safety_in_text="mutating", + ), BuildInstructionsFixture( test_id="outside_tmux_no_context", safety_level=TAG_MUTATING, @@ -104,6 +114,21 @@ class SafetyLevelFixture(t.NamedTuple): ] +class AllowServerStartSettingFixture(t.NamedTuple): + """Fixture for valid ``LIBTMUX_ALLOW_SERVER_START`` values.""" + + test_id: str + env_value: str | None + expected: bool + + +ALLOW_SERVER_START_SETTING_FIXTURES: list[AllowServerStartSettingFixture] = [ + AllowServerStartSettingFixture("unset_defaults_disabled", None, False), + AllowServerStartSettingFixture("disabled", "0", False), + AllowServerStartSettingFixture("enabled", "1", True), +] + + @pytest.mark.parametrize( BuildInstructionsFixture._fields, BUILD_INSTRUCTIONS_FIXTURES, @@ -168,6 +193,102 @@ def test_resolve_safety_level( assert _resolve_safety_level(env_value) == expected_level +@pytest.mark.parametrize( + AllowServerStartSettingFixture._fields, + ALLOW_SERVER_START_SETTING_FIXTURES, + ids=[fixture.test_id for fixture in ALLOW_SERVER_START_SETTING_FIXTURES], +) +def test_resolve_allow_server_start_setting( + test_id: str, + env_value: str | None, + expected: bool, +) -> None: + """Server startup accepts only the strict unset, ``0``, and ``1`` policy.""" + from libtmux_mcp._server_start import _resolve_allow_server_start + + assert test_id + assert _resolve_allow_server_start(env_value) is expected + + +def test_resolve_allow_server_start_rejects_invalid_without_echoing_value() -> None: + """Invalid startup policy fails with fixed, non-reflective guidance.""" + from libtmux_mcp._server_start import _resolve_allow_server_start + + rejected = "private-invalid-setting" + expected = "LIBTMUX_ALLOW_SERVER_START must be unset, '0', or '1'" + + with pytest.raises(ValueError) as excinfo: + _resolve_allow_server_start(rejected) + + assert str(excinfo.value) == expected + assert rejected not in str(excinfo.value) + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [("0", False), ("1", True)], + ids=["disabled", "enabled"], +) +def test_production_schema_publishes_server_start_default( + env_value: str, + expected: bool, +) -> None: + """The production MCP schema publishes the effective startup policy.""" + code = textwrap.dedent( + """ + import asyncio + import json + + from fastmcp import Client + + from libtmux_mcp.server import _allow_server_start, build_mcp_server + + async def main(): + async with Client(build_mcp_server()) as client: + tools = {tool.name: tool for tool in await client.list_tools()} + schema = tools["create_session"].inputSchema["properties"] + print(json.dumps({ + "effective": _allow_server_start, + "default": schema["allow_server_start"]["default"], + })) + + asyncio.run(main()) + """ + ) + env = {**os.environ, "LIBTMUX_ALLOW_SERVER_START": env_value} + + proc = subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + env=env, + text=True, + ) + + assert json.loads(proc.stdout) == { + "effective": expected, + "default": expected, + } + + +def test_invalid_allow_server_start_env_fails_startup() -> None: + """An invalid server-start setting prevents production server import.""" + rejected = "private-invalid-setting" + env = {**os.environ, "LIBTMUX_ALLOW_SERVER_START": rejected} + + proc = subprocess.run( + [sys.executable, "-c", "import libtmux_mcp.server"], + check=False, + capture_output=True, + env=env, + text=True, + ) + + assert proc.returncode != 0 + assert "LIBTMUX_ALLOW_SERVER_START must be unset, '0', or '1'" in proc.stderr + assert rejected not in proc.stderr + + def test_invalid_safety_env_hides_mutating_tools() -> None: """Invalid ``LIBTMUX_SAFETY`` values expose readonly tools only.""" code = textwrap.dedent( @@ -306,10 +427,30 @@ def test_base_instructions_document_socket_name_contract() -> None: level. """ assert "Targeted tmux tools accept" in _BASE_INSTRUCTIONS + assert ( + "explicit per-call selector, configured path, configured name, " + "frozen caller socket, tmux default" + ) in _BASE_INSTRUCTIONS + assert "defaults to LIBTMUX_SOCKET" not in _BASE_INSTRUCTIONS assert "list_servers" in _BASE_INSTRUCTIONS assert "extra_socket_paths" in _BASE_INSTRUCTIONS +def test_registered_tool_docs_do_not_claim_stale_socket_default() -> None: + """Public parameter prose reflects caller-aware target precedence.""" + import asyncio + + from fastmcp import FastMCP + + from libtmux_mcp.tools import register_tools + + mcp = FastMCP(name="socket-doc-contract") + register_tools(mcp) + + for tool in asyncio.run(mcp.list_tools()): + assert "Defaults to LIBTMUX_SOCKET env var." not in (tool.description or "") + + def test_registered_tools_accept_socket_name() -> None: """All registered tools (except list_servers) accept ``socket_name``. @@ -367,31 +508,32 @@ def test_base_instructions_document_buffer_lifecycle() -> None: assert "clipboard history" in _BASE_INSTRUCTIONS +def test_base_instructions_define_caller_relative_discovery() -> None: + """The durable prompt maps relational language to ``where_am_i``.""" + expected = ( + "where_am_i resolves 'this pane', 'current window', and " + "'this session' relative to the caller" + ) + + assert expected in _BASE_INSTRUCTIONS + + def test_build_instructions_documents_is_caller_workflow_inside_tmux( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The is_caller workflow sentence appears only when inside tmux. - - The sentence references "your pane is identified above", which is - only true when ``TMUX_PANE`` is set and the agent-context line has - been emitted. Outside tmux, the sentence would be a lie — so it - lives inside the ``if tmux_pane:`` branch of ``_build_instructions`` - and must NOT appear in ``_BASE_INSTRUCTIONS`` itself. - """ - # Outside tmux: the workflow sentence must NOT appear. + """Dynamic context augments the durable zero-input discovery rule.""" monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) outside = _build_instructions(safety_level=TAG_MUTATING) - assert "whoami tool" not in outside + assert "where_am_i" in outside assert "is_caller=true" not in outside - # Inside tmux: the workflow sentence appears. monkeypatch.setenv("TMUX_PANE", "%42") monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,12345,0") inside = _build_instructions(safety_level=TAG_MUTATING) + assert "where_am_i" in inside assert "is_caller=true" in inside - assert "whoami tool" in inside - assert "list_panes" in inside + assert "filter list_panes" not in inside def test_build_instructions_always_includes_safety() -> None: @@ -623,7 +765,8 @@ def test_readonly_hint_visible_only_on_readonly_tier( #: regression to "Evaluate Format String". _TMUX_QUALIFIED_TOOLS = frozenset( [ - # 5 server-level + # 6 server-level + "where_am_i", "list_sessions", "list_servers", "create_session", @@ -679,6 +822,7 @@ def test_readonly_hint_visible_only_on_readonly_tier( #: and add explicit boundaries. _DISCOVERY_ANCHORS = frozenset( [ + "where_am_i", "list_panes", "list_windows", "list_sessions", @@ -694,7 +838,7 @@ def test_readonly_hint_visible_only_on_readonly_tier( #: meta hint. Read-only only — best-effort hint to Claude Code that #: keeps a tiny tmux vocabulary always-visible without preloading #: every tool's schema. -_ALWAYS_LOAD_ANCHORS = frozenset(["list_panes", "list_windows", "snapshot_pane"]) +_ALWAYS_LOAD_ANCHORS = frozenset(["where_am_i", "list_windows", "snapshot_pane"]) #: Verbs-of-art whose titles stay generic — they are tmux-specific @@ -820,13 +964,11 @@ def test_discovery_anchor_descriptions_carry_tmux_and_synonyms() -> None: def test_discovery_anchors_marked_alwaysload() -> None: - """``list_panes``, ``list_windows``, ``snapshot_pane`` carry alwaysLoad. + """Self, window, and snapshot discovery carry alwaysLoad. Best-effort hint — FastMCP passes ``meta`` opaquely, so honoring - is delegated to Claude Code where the field is documented at - ``code.claude.com/docs/en/mcp`` (v2.1.121+). The test asserts only - the positive contract; over-specifying the negative space is - chrome. + is delegated to Claude Code. The exact set stays bounded because + every preloaded schema consumes permanent client context. """ import asyncio @@ -838,6 +980,13 @@ def test_discovery_anchors_marked_alwaysload() -> None: register_tools(mcp) tools = {tool.name: tool for tool in asyncio.run(mcp.list_tools())} + actual = { + name + for name, tool in tools.items() + if (getattr(tool, "meta", None) or {}).get("anthropic/alwaysLoad") is True + } + assert actual == _ALWAYS_LOAD_ANCHORS + for tool_name in _ALWAYS_LOAD_ANCHORS: tool = tools.get(tool_name) assert tool is not None, f"tool not registered: {tool_name}" @@ -847,6 +996,59 @@ def test_discovery_anchors_marked_alwaysload() -> None: ) +def test_where_am_i_registration_is_zero_input_readonly_and_typed() -> None: + """Invocation discovery publishes a typed zero-argument schema.""" + import asyncio + import inspect + + from fastmcp import FastMCP + from fastmcp.tools.function_tool import FunctionTool + + from libtmux_mcp.tools import register_tools, server_tools + + assert hasattr(server_tools, "where_am_i"), "where_am_i is not implemented" + + mcp = FastMCP(name="where-am-i-audit") + register_tools(mcp) + tools = {tool.name: tool for tool in asyncio.run(mcp.list_tools())} + tool = tools["where_am_i"] + + assert isinstance(tool, FunctionTool) + assert inspect.signature(tool.fn).parameters == {} + assert tool.parameters == { + "additionalProperties": False, + "properties": {}, + "type": "object", + } + assert tool.tags == {TAG_READONLY} + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is True + assert tool.annotations.destructiveHint is False + assert tool.annotations.idempotentHint is True + assert tool.annotations.openWorldHint is False + + schema = tool.output_schema + assert schema is not None + assert set(schema["properties"]) == { + "inside_tmux", + "self_available", + "pane_id", + "window_id", + "session_id", + "caller_socket_path", + "effective_socket_name", + "effective_socket_path", + "server_running", + "safety_level", + "suppress_history", + } + assert set(schema["required"]) == set(schema["properties"]) + assert schema["properties"]["inside_tmux"]["type"] == "boolean" + assert schema["properties"]["self_available"]["type"] == "boolean" + assert schema["properties"]["server_running"]["type"] == "boolean" + assert schema["properties"]["suppress_history"]["type"] == "boolean" + + def test_hierarchy_tool_titles_carry_tmux_qualifier() -> None: """Hierarchy-noun titles include 'tmux' for display disambiguation. diff --git a/tests/test_server_tools.py b/tests/test_server_tools.py index 525668d0..28c98fc6 100644 --- a/tests/test_server_tools.py +++ b/tests/test_server_tools.py @@ -2,8 +2,10 @@ from __future__ import annotations +import concurrent.futures import os import pathlib +import threading import typing as t import pytest @@ -17,16 +19,284 @@ ) if t.TYPE_CHECKING: + from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session + from libtmux_mcp.models import CallerContext + + +def _call_where_am_i() -> CallerContext: + """Call the public invocation-discovery function under test.""" + from libtmux_mcp.tools import server_tools + + function = getattr(server_tools, "where_am_i", None) + assert function is not None, "where_am_i is not implemented" + return t.cast("CallerContext", function()) + + +def _clear_configured_target(monkeypatch: pytest.MonkeyPatch) -> None: + """Leave the frozen caller environment as the effective target.""" + monkeypatch.delenv("LIBTMUX_SOCKET", raising=False) + monkeypatch.delenv("LIBTMUX_SOCKET_PATH", raising=False) + + +def test_where_am_i_resolves_live_nondefault_caller( + mcp_server: Server, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A live caller reports its current pane, window, and session.""" + from libtmux_mcp import models + from libtmux_mcp._utils import _effective_socket_path + from libtmux_mcp.server import _safety_level, _suppress_history + + caller_context_type = getattr(models, "CallerContext", None) + assert caller_context_type is not None, "CallerContext is not implemented" + + _clear_configured_target(monkeypatch) + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + pane_id = mcp_pane.pane_id + assert pane_id is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", pane_id) + + result = _call_where_am_i() + + assert isinstance(result, caller_context_type) + assert result.inside_tmux is True + assert result.self_available is True + assert result.pane_id == mcp_pane.pane_id + assert result.window_id == mcp_pane.window_id + assert result.session_id == mcp_pane.session.session_id + assert result.caller_socket_path == socket_path + assert result.effective_socket_name is None + assert result.effective_socket_path == socket_path + assert result.server_running is True + assert result.safety_level == _safety_level + assert result.suppress_history is _suppress_history + + +def test_where_am_i_uses_effective_server_tmux_binary( + mcp_server: Server, + mcp_pane: Pane, + custom_tmux_bin_without_path: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller lookup uses configured tmux even when PATH has no tmux.""" + from libtmux_mcp._utils import _effective_socket_path + + _clear_configured_target(monkeypatch) + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + assert mcp_pane.pane_id is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id) + + result = _call_where_am_i() + + assert custom_tmux_bin_without_path.endswith("configured-tmux") + assert result.self_available is True + assert result.pane_id == mcp_pane.pane_id + + +def test_where_am_i_reraises_unrelated_live_server_lookup_error( + mcp_server: Server, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Live operational errors flow through the standard tmux mapper.""" + from libtmux import exc + from libtmux.pane import Pane + + from libtmux_mcp._utils import ExpectedToolError, _effective_socket_path + + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + assert mcp_pane.pane_id is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id) + + def fail_lookup(cls: type[Pane], server: Server, pane_id: str) -> Pane: + del cls, server, pane_id + msg = "injected live caller lookup failure" + raise exc.LibTmuxException(msg) + + monkeypatch.setattr(Pane, "from_pane_id", classmethod(fail_lookup)) + + with pytest.raises( + ExpectedToolError, + match="tmux error: injected live caller lookup failure", + ): + _call_where_am_i() + + +def test_where_am_i_reports_dead_when_server_dies_during_lookup( + mcp_server: Server, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An operational lookup error becomes unavailable only after death.""" + from libtmux import exc + from libtmux.pane import Pane + + from libtmux_mcp._utils import _effective_socket_path + + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + assert mcp_pane.pane_id is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id) + + def kill_and_fail(cls: type[Pane], server: Server, pane_id: str) -> Pane: + del cls, pane_id + server.kill() + msg = "injected dead caller lookup failure" + raise exc.LibTmuxException(msg) + + monkeypatch.setattr(Pane, "from_pane_id", classmethod(kill_and_fail)) + + result = _call_where_am_i() + + assert result.self_available is False + assert result.server_running is False + + +def test_where_am_i_preserves_caller_on_configured_target_mismatch( + mcp_server: Server, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Configured-target mismatch keeps identity but denies self scope.""" + from libtmux.pane import Pane + + def fail_from_env( + cls: type[Pane], environment: t.Mapping[str, str] | None = None + ) -> Pane: + del cls, environment + pytest.fail("Pane.from_env must not run for a configured-target mismatch") + + monkeypatch.setattr(Pane, "from_env", classmethod(fail_from_env)) + socket_name = mcp_server.socket_name + assert socket_name is not None + pane_id = mcp_pane.pane_id + assert pane_id is not None + caller_socket = "/tmp/tmux-99999/caller-only" + monkeypatch.setenv("LIBTMUX_SOCKET", socket_name) + monkeypatch.delenv("LIBTMUX_SOCKET_PATH", raising=False) + monkeypatch.setenv("TMUX", f"{caller_socket},12345,$0") + monkeypatch.setenv("TMUX_PANE", pane_id) + + result = _call_where_am_i() + + assert result.inside_tmux is True + assert result.self_available is False + assert result.pane_id == mcp_pane.pane_id + assert result.window_id is None + assert result.session_id is None + assert result.caller_socket_path == caller_socket + assert result.effective_socket_name == socket_name + assert result.effective_socket_path is None + assert result.server_running is True + + +def test_where_am_i_returns_typed_state_for_dead_server( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dead matching server is routine absence, not a tool error.""" + dead_socket = tmp_path / "dead.sock" + monkeypatch.delenv("LIBTMUX_SOCKET", raising=False) + monkeypatch.setenv("LIBTMUX_SOCKET_PATH", str(dead_socket)) + monkeypatch.setenv("TMUX", f"{dead_socket},12345,$0") + monkeypatch.setenv("TMUX_PANE", "%404") + + result = _call_where_am_i() + + assert result.inside_tmux is True + assert result.self_available is False + assert result.pane_id == "%404" + assert result.window_id is None + assert result.session_id is None + assert result.caller_socket_path == str(dead_socket) + assert result.effective_socket_name is None + assert result.effective_socket_path == str(dead_socket) + assert result.server_running is False + + +@pytest.mark.usefixtures("mcp_session") +def test_where_am_i_returns_typed_state_for_stale_pane( + mcp_server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stale caller pane remains visible without invented parents.""" + from libtmux_mcp._utils import _effective_socket_path + + _clear_configured_target(monkeypatch) + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$0") + monkeypatch.setenv("TMUX_PANE", "%999999") + + result = _call_where_am_i() + + assert result.inside_tmux is True + assert result.self_available is False + assert result.pane_id == "%999999" + assert result.window_id is None + assert result.session_id is None + assert result.caller_socket_path == socket_path + assert result.effective_socket_path == socket_path + assert result.server_running is True + + +def test_where_am_i_returns_typed_state_outside_tmux( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An outside-tmux invocation returns false and null identity fields.""" + dead_socket = tmp_path / "outside.sock" + monkeypatch.delenv("LIBTMUX_SOCKET", raising=False) + monkeypatch.setenv("LIBTMUX_SOCKET_PATH", str(dead_socket)) + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + + result = _call_where_am_i() + + assert result.inside_tmux is False + assert result.self_available is False + assert result.pane_id is None + assert result.window_id is None + assert result.session_id is None + assert result.caller_socket_path is None + assert result.effective_socket_name is None + assert result.effective_socket_path == str(dead_socket) + assert result.server_running is False + + +def test_caller_context_is_frozen( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Invocation-discovery results cannot drift after construction.""" + from pydantic import ValidationError + + monkeypatch.setenv("LIBTMUX_SOCKET_PATH", str(tmp_path / "frozen.sock")) + result = _call_where_am_i() + + with pytest.raises(ValidationError): + result.pane_id = "%changed" + def test_list_sessions(mcp_server: Server, mcp_session: Session) -> None: - """list_sessions returns a list of SessionInfo models.""" + """list_sessions returns a typed page of SessionInfo models.""" + from libtmux_mcp.models import SessionPage + result = list_sessions(socket_name=mcp_server.socket_name) - assert isinstance(result, list) - assert len(result) >= 1 - session_ids = [s.session_id for s in result] + assert isinstance(result, SessionPage) + assert len(result.items) >= 1 + session_ids = [session.session_id for session in result.items] assert mcp_session.session_id in session_ids @@ -36,19 +306,248 @@ def test_list_sessions_empty_server(mcp_server: Server) -> None: for s in mcp_server.sessions: s.kill() result = list_sessions(socket_name=mcp_server.socket_name) - assert result == [] + assert result.items == [] + assert result.total == 0 +@pytest.mark.usefixtures("mcp_session") def test_create_session(mcp_server: Server) -> None: """create_session creates a new tmux session.""" result = create_session( session_name="mcp_test_new", socket_name=mcp_server.socket_name, + allow_server_start=True, ) assert result.session_name == "mcp_test_new" assert result.session_id is not None + assert result.server_started is False + + +def test_create_session_does_not_start_dead_server_by_default( + TestServer: type[Server], +) -> None: + """Direct Python calls deny an implicit daemon start by default. + + The standard server fixture is intentionally live, so ``TestServer`` is + used to create a cleanup-managed libtmux target without a daemon. + """ + from libtmux_mcp._utils import ExpectedToolError + + server = TestServer() + assert server.socket_name is not None + assert server.is_alive() is False + + with pytest.raises(ExpectedToolError, match="No tmux server is running"): + create_session( + session_name="mcp_no_implicit_server", + socket_name=server.socket_name, + ) + + assert server.is_alive() is False + + +@pytest.mark.parametrize( + "invalid", + ["false", 0, 1, None], + ids=["string", "zero", "one", "none"], +) +def test_create_session_rejects_non_bool_start_permission_before_target_access( + monkeypatch: pytest.MonkeyPatch, + invalid: object, +) -> None: + """Direct calls require an exact bool before inspecting a tmux target. + + Target lookup is replaced because this contract specifically guards the + validation boundary before any liveness or session-creation work. + """ + from libtmux_mcp._utils import ExpectedToolError + from libtmux_mcp.tools import server_tools + + def fail_target_lookup(*args: object, **kwargs: object) -> t.NoReturn: + pytest.fail(f"target lookup ran with args={args!r}, kwargs={kwargs!r}") + + monkeypatch.setattr(server_tools, "_get_server", fail_target_lookup) + + with pytest.raises(ExpectedToolError, match="allow_server_start must be a bool"): + create_session(allow_server_start=t.cast("t.Any", invalid)) + + +def test_create_session_denial_survives_server_death_at_new_session( + TestServer: type[Server], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A target dying at command execution cannot be restarted by denial. + + The command hook creates the otherwise unrepeatable liveness race while + retaining real libtmux and tmux behavior on both sides of the boundary. + """ + from libtmux import Server + + from libtmux_mcp._utils import ExpectedToolError + + server = TestServer() + assert server.socket_name is not None + server.new_session(session_name="mcp_start_race_anchor") + assert server.is_alive() is True + + original_cmd = Server.cmd + armed = True + + def kill_before_new_session( + command_server: Server, + cmd: str, + *args: t.Any, + target: str | int | None = None, + ) -> t.Any: + nonlocal armed + is_new_session = cmd == "new-session" or ( + cmd == "-N" and bool(args) and args[0] == "new-session" + ) + if armed and is_new_session: + armed = False + server.kill() + return original_cmd(command_server, cmd, *args, target=target) + + monkeypatch.setattr(Server, "cmd", kill_before_new_session) + + with pytest.raises(ExpectedToolError, match="No tmux server is running"): + create_session( + session_name="mcp_start_race_denied", + socket_name=server.socket_name, + allow_server_start=False, + ) + + assert server.is_alive() is False + + +def test_concurrent_create_session_attributes_only_startup_path( + TestServer: type[Server], +) -> None: + """Only the startup-enabled path reports starting a shared target.""" + server = TestServer() + assert server.socket_name is not None + assert server.is_alive() is False + ready = threading.Barrier(2) + + def create(index: int) -> bool: + ready.wait() + result = create_session( + session_name=f"mcp_concurrent_start_{index}", + socket_name=server.socket_name, + allow_server_start=True, + ) + return result.server_started + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + started = list(executor.map(create, range(2))) + assert sorted(started) == [False, True] + assert server.is_alive() is True + + +def test_create_session_explicit_start_reports_new_daemon( + TestServer: type[Server], +) -> None: + """Explicit permission starts a dead server and reports that side effect.""" + server = TestServer() + assert server.socket_name is not None + assert server.is_alive() is False + + result = create_session( + session_name="mcp_explicit_server_start", + socket_name=server.socket_name, + allow_server_start=True, + ) + + assert server.is_alive() is True + assert result.server_started is True + + +@pytest.mark.parametrize( + ("enabled", "expect_error"), + [(False, True), (True, False)], + ids=["disabled", "enabled"], +) +def test_mcp_create_session_omission_inherits_server_start_default( + TestServer: type[Server], + enabled: bool, + expect_error: bool, +) -> None: + """Omitted MCP input inherits the transformed startup policy.""" + import asyncio + + from fastmcp import Client, FastMCP + + from libtmux_mcp._server_start import _configure_server_start_default + from libtmux_mcp.tools import server_tools + + server = TestServer() + assert server.socket_name is not None + mcp = FastMCP(f"server-start-{enabled}") + server_tools.register(mcp) + _configure_server_start_default(mcp, enabled) + + async def _call() -> t.Any: + async with Client(mcp) as client: + return await client.call_tool( + "create_session", + { + "session_name": f"mcp_transformed_start_{enabled}", + "socket_name": server.socket_name, + }, + raise_on_error=False, + ) + + result = asyncio.run(_call()) + + assert result.is_error is expect_error + assert server.is_alive() is enabled + if enabled: + assert result.structured_content is not None + assert result.structured_content["server_started"] is True + else: + assert result.content + assert "No tmux server is running" in result.content[0].text + + +def test_mcp_explicit_false_overrides_enabled_server_start_default( + TestServer: type[Server], +) -> None: + """Explicit MCP denial wins over the enabled startup default.""" + import asyncio + + from fastmcp import Client, FastMCP + + from libtmux_mcp._server_start import _configure_server_start_default + from libtmux_mcp.tools import server_tools + + server = TestServer() + assert server.socket_name is not None + mcp = FastMCP("server-start-explicit-false") + server_tools.register(mcp) + _configure_server_start_default(mcp, True) + + async def _call() -> t.Any: + async with Client(mcp) as client: + return await client.call_tool( + "create_session", + { + "session_name": "mcp_explicit_start_denial", + "socket_name": server.socket_name, + "allow_server_start": False, + }, + raise_on_error=False, + ) + + result = asyncio.run(_call()) + + assert result.is_error is True + assert result.content + assert "No tmux server is running" in result.content[0].text + assert server.is_alive() is False + + +@pytest.mark.usefixtures("mcp_session") def test_create_session_returns_active_pane_id(mcp_server: Server) -> None: """create_session exposes the initial pane id of the new session. @@ -78,7 +577,7 @@ def test_create_session_returns_active_pane_id(mcp_server: Server) -> None: session_name="mcp_test_active_pane", socket_name=mcp_server.socket_name, ) - assert any(p.pane_id == result.active_pane_id for p in panes) + assert any(p.pane_id == result.active_pane_id for p in panes.items) class CreateSessionEnvStringFixture(t.NamedTuple): @@ -123,6 +622,7 @@ class CreateSessionEnvStringFixture(t.NamedTuple): CREATE_SESSION_ENV_STRING_FIXTURES, ids=[f.test_id for f in CREATE_SESSION_ENV_STRING_FIXTURES], ) +@pytest.mark.usefixtures("mcp_session") def test_create_session_environment_accepts_json_string( mcp_server: Server, test_id: str, @@ -306,11 +806,11 @@ def test_list_sessions_with_filters( socket_name=mcp_server.socket_name, filters=filters, ) - assert isinstance(result, list) if expected_count is not None: - assert len(result) == expected_count + assert len(result.items) == expected_count + assert result.total == expected_count else: - assert len(result) >= 1 + assert len(result.items) >= 1 def test_kill_server( @@ -357,11 +857,11 @@ def test_read_heavy_tools_return_pydantic_models( machine-readable ``outputSchema`` from the MCP registration, which forces agents to re-parse strings. Keep these typed. """ - from libtmux_mcp.models import ServerInfo, SessionInfo + from libtmux_mcp.models import ServerInfo, SessionInfo, SessionPage sessions = list_sessions(socket_name=mcp_server.socket_name) - assert isinstance(sessions, list) - assert all(isinstance(s, SessionInfo) for s in sessions) + assert isinstance(sessions, SessionPage) + assert all(isinstance(s, SessionInfo) for s in sessions.items) info = get_server_info(socket_name=mcp_server.socket_name) assert isinstance(info, ServerInfo) @@ -375,15 +875,19 @@ def test_list_servers_finds_live_socket(mcp_server: Server) -> None: under ``$TMUX_TMPDIR/tmux-$UID/``; the discovery tool must see it and report it alive. """ - from libtmux_mcp.models import ServerInfo + from libtmux_mcp.models import ServerInfo, ServerPage results = list_servers() - assert isinstance(results, list) - assert all(isinstance(r, ServerInfo) for r in results) - names = [r.socket_name for r in results] + assert isinstance(results, ServerPage) + assert all(isinstance(r, ServerInfo) for r in results.items) + names = [server.socket_name for server in results.items] assert mcp_server.socket_name in names # The fixture's socket must be reported alive. - found = next(r for r in results if r.socket_name == mcp_server.socket_name) + found = next( + server + for server in results.items + if server.socket_name == mcp_server.socket_name + ) assert found.is_alive is True @@ -398,7 +902,8 @@ def test_list_servers_missing_tmpdir_returns_empty( """ monkeypatch.setenv("TMUX_TMPDIR", "/nonexistent-list-servers-test") results = list_servers() - assert results == [] + assert results.items == [] + assert results.total == 0 @pytest.mark.usefixtures("mcp_session") @@ -431,16 +936,39 @@ def test_list_servers_extra_socket_paths_surfaces_custom_path( results = list_servers(extra_socket_paths=[str(fixture_socket)]) - assert isinstance(results, list) # Canonical scan saw an empty tmpdir, so everything below came from # the extra-paths probe. - socket_paths = [r.socket_path for r in results] + socket_paths = [server.socket_path for server in results.items] assert str(fixture_socket) in socket_paths - found = next(r for r in results if r.socket_path == str(fixture_socket)) + found = next( + server for server in results.items if server.socket_path == str(fixture_socket) + ) assert isinstance(found, ServerInfo) assert found.is_alive is True +@pytest.mark.usefixtures("mcp_session") +def test_list_servers_deduplicates_canonical_and_extra_socket_path( + mcp_server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Canonical and explicit aliases of one socket produce one row.""" + canonical_dir = tmp_path / f"tmux-{os.geteuid()}" + canonical_dir.mkdir() + socket_name = mcp_server.socket_name + assert socket_name is not None + fixture_socket = pathlib.Path("/tmp") / f"tmux-{os.geteuid()}" / socket_name + assert fixture_socket.is_socket() + (canonical_dir / socket_name).symlink_to(fixture_socket) + monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path)) + + result = list_servers(extra_socket_paths=[str(fixture_socket)]) + + assert result.total == 1 + assert len(result.items) == 1 + + def test_list_servers_extra_socket_paths_skips_nonexistent( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, @@ -458,4 +986,5 @@ def test_list_servers_extra_socket_paths_skips_nonexistent( results = list_servers( extra_socket_paths=[str(bogus), str(regular_file)], ) - assert results == [] + assert results.items == [] + assert results.total == 0 diff --git a/tests/test_session_tools.py b/tests/test_session_tools.py index b3364fcf..93196db1 100644 --- a/tests/test_session_tools.py +++ b/tests/test_session_tools.py @@ -7,6 +7,7 @@ import pytest from fastmcp.exceptions import ToolError +from libtmux_mcp._utils import ExpectedToolError from libtmux_mcp.tools.session_tools import ( create_window, get_session_info, @@ -15,21 +16,26 @@ rename_session, select_window, ) +from libtmux_mcp.tools.window_tools import list_panes if t.TYPE_CHECKING: + from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session def test_list_windows(mcp_server: Server, mcp_session: Session) -> None: - """list_windows returns a list of WindowInfo models.""" + """list_windows returns a typed page of summary models.""" + from libtmux_mcp.models import WindowPage, WindowSummary + result = list_windows( session_name=mcp_session.session_name, socket_name=mcp_server.socket_name, ) - assert isinstance(result, list) - assert len(result) >= 1 - assert result[0].window_id is not None + assert isinstance(result, WindowPage) + assert len(result.items) >= 1 + assert isinstance(result.items[0], WindowSummary) + assert result.items[0].window_id is not None def test_list_windows_by_id(mcp_server: Server, mcp_session: Session) -> None: @@ -38,7 +44,261 @@ def test_list_windows_by_id(mcp_server: Server, mcp_session: Session) -> None: session_id=mcp_session.session_id, socket_name=mcp_server.socket_name, ) - assert len(result) >= 1 + assert len(result.items) >= 1 + + +@pytest.mark.parametrize("explicit_scope", [False, True], ids=["default", "explicit"]) +def test_list_windows_server_scope_remains_server_wide( + mcp_server: Server, + mcp_session: Session, + explicit_scope: bool, +) -> None: + """The default and explicit server scope include every session.""" + second_session = mcp_server.new_session(session_name="list_windows_server_scope") + kwargs: dict[str, t.Any] = {"socket_name": mcp_server.socket_name} + if explicit_scope: + kwargs["scope"] = "server" + + result = list_windows(**kwargs) + + session_ids = {window.session_id for window in result.items} + assert mcp_session.session_id in session_ids + assert second_session.session_id in session_ids + + +def test_list_windows_caller_session_scope_uses_live_caller_session( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller scope resolves the pane instead of trusting stale TMUX metadata.""" + from libtmux_mcp._utils import _effective_socket_path + + caller_pane = mcp_session.active_pane + assert caller_pane is not None and caller_pane.pane_id is not None + caller_window = mcp_session.new_window(window_name="list_windows_caller") + other_session = mcp_server.new_session(session_name="list_windows_other") + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", caller_pane.pane_id) + + result = list_windows( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + assert {window.session_id for window in result.items} == {mcp_session.session_id} + assert caller_window.window_id in {window.window_id for window in result.items} + assert other_session.session_id not in { + window.session_id for window in result.items + } + + +def test_list_windows_caller_scope_uses_effective_server_tmux_binary( + mcp_server: Server, + mcp_session: Session, + mcp_pane: Pane, + custom_tmux_bin_without_path: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller-session lookup keeps the configured effective tmux binary.""" + from libtmux_mcp._utils import _effective_socket_path + + assert mcp_pane.pane_id is not None + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id) + + result = list_windows( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + assert custom_tmux_bin_without_path.endswith("configured-tmux") + assert {window.session_id for window in result.items} == {mcp_session.session_id} + + +def test_list_windows_caller_scope_reraises_unrelated_live_lookup_error( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller scope does not relabel a live operational error as stale.""" + from libtmux import exc + from libtmux.pane import Pane + + from libtmux_mcp._utils import _effective_socket_path + + caller_pane = mcp_session.active_pane + assert caller_pane is not None and caller_pane.pane_id is not None + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", caller_pane.pane_id) + + def fail_lookup(cls: type[Pane], server: Server, pane_id: str) -> Pane: + del cls, server, pane_id + msg = "injected live caller-session failure" + raise exc.LibTmuxException(msg) + + monkeypatch.setattr(Pane, "from_pane_id", classmethod(fail_lookup)) + + with pytest.raises( + ExpectedToolError, + match="tmux error: injected live caller-session failure", + ): + list_windows( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + +def test_list_windows_caller_scope_reports_server_death_during_lookup( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller scope uses dead-server recovery when lookup loses its target.""" + from libtmux import exc + from libtmux.pane import Pane + + from libtmux_mcp._utils import _effective_socket_path + + caller_pane = mcp_session.active_pane + assert caller_pane is not None and caller_pane.pane_id is not None + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", caller_pane.pane_id) + + def kill_and_fail(cls: type[Pane], server: Server, pane_id: str) -> Pane: + del cls, pane_id + server.kill() + msg = "injected dead caller-session failure" + raise exc.LibTmuxException(msg) + + monkeypatch.setattr(Pane, "from_pane_id", classmethod(kill_and_fail)) + + with pytest.raises(ExpectedToolError, match="No tmux server is running"): + list_windows( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + +@pytest.mark.parametrize( + ("selector", "value"), + [("session_name", "dev"), ("session_id", "$1")], +) +def test_list_windows_caller_session_rejects_explicit_selectors( + mcp_server: Server, + selector: str, + value: str, +) -> None: + """Caller scope cannot be mixed with hierarchy selectors.""" + kwargs: dict[str, t.Any] = { + "scope": "caller_session", + "socket_name": mcp_server.socket_name, + selector: value, + } + with pytest.raises( + ExpectedToolError, + match=r"scope='caller_session'.*cannot be combined.*scope='server'", + ): + list_windows(**kwargs) + + +def test_list_windows_caller_session_rejects_outside_tmux( + mcp_server: Server, +) -> None: + """Caller scope fails explicitly when the invocation has no caller.""" + with pytest.raises( + ExpectedToolError, + match=r"scope='caller_session'.*inside tmux.*scope='server'", + ): + list_windows( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + +def test_list_windows_caller_session_rejects_effective_socket_mismatch( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller scope never crosses from the effective target to another socket.""" + from libtmux.pane import Pane + + caller_pane = mcp_session.active_pane + assert caller_pane is not None and caller_pane.pane_id is not None + monkeypatch.setenv("TMUX", "/tmp/tmux-99999/caller-only,12345,$0") + monkeypatch.setenv("TMUX_PANE", caller_pane.pane_id) + + def fail_from_env( + cls: type[Pane], environment: t.Mapping[str, str] | None = None + ) -> Pane: + del cls, environment + pytest.fail("Pane.from_env must not run across a target mismatch") + + monkeypatch.setattr(Pane, "from_env", classmethod(fail_from_env)) + + with pytest.raises( + ExpectedToolError, + match=r"scope='caller_session'.*socket.*effective.*scope='server'", + ): + list_windows( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + +@pytest.mark.parametrize( + "list_tool", + [ + pytest.param(list_panes, id="list-panes"), + pytest.param(list_windows, id="list-windows"), + ], +) +def test_list_tools_caller_session_rejects_stale_caller_pane( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, + list_tool: t.Callable[..., t.Any], +) -> None: + """Caller scope reports recovery when the frozen pane no longer exists.""" + from libtmux_mcp._utils import _effective_socket_path + + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,{mcp_session.session_id}") + monkeypatch.setenv("TMUX_PANE", "%999999999") + + with pytest.raises( + ExpectedToolError, + match=( + r"scope='caller_session'.*could not resolve the frozen caller pane.*" + r"scope='server'.*restart from a live tmux pane" + ), + ): + list_tool( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + +def test_list_windows_rejects_invalid_scope(mcp_server: Server) -> None: + """Direct callers cannot widen a misspelled scope to the server.""" + with pytest.raises( + ExpectedToolError, + match=r"Invalid scope.*expected.*server.*caller_session", + ): + list_windows( + scope=t.cast("t.Any", "caller"), + socket_name=mcp_server.socket_name, + ) def test_get_session_info(mcp_server: Server, mcp_session: Session) -> None: @@ -221,8 +481,7 @@ def test_list_windows_with_filters( list_windows(**kwargs) else: result = list_windows(**kwargs) - assert isinstance(result, list) - assert len(result) >= expected_min_count + assert len(result.items) >= expected_min_count # Cleanup cross_win.kill() diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py index b6387bf5..01931525 100644 --- a/tests/test_spawn_tools_history.py +++ b/tests/test_spawn_tools_history.py @@ -39,7 +39,7 @@ def test_spawn_tool_signatures_preserve_positional_slots() -> None: "environment", "socket_name", ), - ("suppress_persistent_history",), + ("allow_server_start", "suppress_persistent_history"), ), create_window: ( ( @@ -676,6 +676,7 @@ def test_create_session_history_environment_reaches_future_panes( session_name=session_name, environment={"SPAWN_SCOPE_MARKER": "session", "HISTCONTROL": "ignoredups"}, socket_name=mcp_server.socket_name, + allow_server_start=True, suppress_persistent_history=True, ) session = mcp_server.sessions.get(session_name=session_name, default=None) @@ -720,6 +721,7 @@ def test_explicit_false_keeps_inherited_session_history_environment( session_name=session_name, environment={"HISTCONTROL": "ignoredups"}, socket_name=mcp_server.socket_name, + allow_server_start=True, suppress_persistent_history=True, ) session = mcp_server.sessions.get(session_name=session_name, default=None) diff --git a/tests/test_utils.py b/tests/test_utils.py index d5ee8f3d..682e8a94 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,11 @@ from __future__ import annotations +import dataclasses import os +import subprocess +import sys +import textwrap import typing as t import pytest @@ -19,6 +23,7 @@ TAG_MUTATING, TAG_READONLY, VALID_SAFETY_LEVELS, + ExpectedToolError, _apply_filters, _get_server, _invalidate_server, @@ -65,6 +70,174 @@ def test_get_server_env_var(monkeypatch: pytest.MonkeyPatch) -> None: assert server.socket_name == "env_socket" +def test_get_server_follows_caller_socket(monkeypatch: pytest.MonkeyPatch) -> None: + """_get_server targets the non-default socket that launched MCP.""" + caller_socket = "/tmp/odd,dir/caller-work" + monkeypatch.delenv("LIBTMUX_SOCKET", raising=False) + monkeypatch.delenv("LIBTMUX_SOCKET_PATH", raising=False) + monkeypatch.setenv("TMUX", f"{caller_socket},12345,$0") + + server = _get_server() + + assert server.socket_name is None + assert str(server.socket_path) == caller_socket + + +@pytest.mark.parametrize( + ("socket_name", "socket_path", "configured_name", "configured_path"), + [ + ("explicit-name", None, None, "/tmp/configured"), + (None, "/tmp/explicit", "configured-name", None), + ], + ids=["name-over-configured-path", "path-over-configured-name"], +) +def test_get_server_explicit_selector_does_not_mix_configured_selector( + monkeypatch: pytest.MonkeyPatch, + socket_name: str | None, + socket_path: str | None, + configured_name: str | None, + configured_path: str | None, +) -> None: + """An explicit selector excludes the other configured selector.""" + from libtmux_mcp import _utils + + if configured_name is not None: + monkeypatch.setenv("LIBTMUX_SOCKET", configured_name) + if configured_path is not None: + monkeypatch.setenv("LIBTMUX_SOCKET_PATH", configured_path) + + target = _utils._resolve_server_target( + socket_name=socket_name, + socket_path=socket_path, + ) + server = _get_server(socket_name=socket_name, socket_path=socket_path) + + actual_socket_path = ( + str(server.socket_path) if server.socket_path is not None else None + ) + assert target.socket_name == socket_name + assert target.socket_path == socket_path + assert server.socket_name == socket_name + assert actual_socket_path == socket_path + + +def test_get_server_configured_path_beats_configured_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """LIBTMUX_SOCKET_PATH takes precedence when both selectors are set.""" + from libtmux_mcp import _utils + + configured_path = "/tmp/configured-path" + monkeypatch.setenv("LIBTMUX_SOCKET", "configured-name") + monkeypatch.setenv("LIBTMUX_SOCKET_PATH", configured_path) + + target = _utils._resolve_server_target() + server = _get_server() + + assert target.socket_name is None + assert target.socket_path == configured_path + assert server.socket_name is None + assert str(server.socket_path) == configured_path + _invalidate_server() + assert not _server_cache + + +@pytest.mark.parametrize( + ("configured_name", "configured_path", "expected_name", "expected_path"), + [ + ("configured-name", None, "configured-name", None), + (None, "/tmp/configured-path", None, "/tmp/configured-path"), + ], + ids=["configured-name", "configured-path"], +) +def test_get_server_configured_target_beats_frozen_caller( + monkeypatch: pytest.MonkeyPatch, + configured_name: str | None, + configured_path: str | None, + expected_name: str | None, + expected_path: str | None, +) -> None: + """Configured selectors take precedence over the frozen caller socket.""" + from libtmux_mcp import _utils + + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/caller-only,12345,$0") + if configured_name is not None: + monkeypatch.setenv("LIBTMUX_SOCKET", configured_name) + if configured_path is not None: + monkeypatch.setenv("LIBTMUX_SOCKET_PATH", configured_path) + + target = _utils._resolve_server_target() + + assert target.socket_name == expected_name + assert target.socket_path == expected_path + + +def test_resolve_server_target_rejects_two_explicit_selectors() -> None: + """The normalized target rejects mutually exclusive explicit inputs.""" + from libtmux_mcp import _utils + + with pytest.raises( + ValueError, + match="socket_name and socket_path are mutually exclusive", + ): + _utils._resolve_server_target("explicit", "/tmp/explicit") + + +def test_resolve_server_target_is_immutable() -> None: + """The normalized server target is a frozen value object.""" + from libtmux_mcp import _utils + + target = _utils._resolve_server_target(socket_name="explicit") + + with pytest.raises(dataclasses.FrozenInstanceError): + target.socket_name = "changed" # type: ignore[misc] + + +def test_invocation_environment_survives_live_tmux_deletion() -> None: + """A transient live TMUX deletion cannot erase invocation identity.""" + caller_socket = "/tmp/odd,dir/caller-work" + code = textwrap.dedent( + f""" + import os + + from libtmux_mcp._utils import ( + _get_caller_identity, + _get_invocation_environment, + _get_server, + ) + + snapshot = _get_invocation_environment() + del os.environ["TMUX"] + assert snapshot["TMUX"] == {f"{caller_socket},12345,$0"!r} + try: + snapshot["TMUX"] = "changed" + except TypeError: + pass + else: + raise AssertionError("invocation environment is mutable") + assert _get_caller_identity().socket_path == {caller_socket!r} + assert str(_get_server().socket_path) == {caller_socket!r} + """ + ) + env = { + **os.environ, + "TMUX": f"{caller_socket},12345,$0", + "TMUX_PANE": "%7", + } + env.pop("LIBTMUX_SOCKET", None) + env.pop("LIBTMUX_SOCKET_PATH", None) + + result = subprocess.run( + [sys.executable, "-c", code], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def test_resolve_session_by_name(mcp_server: Server, mcp_session: Session) -> None: """_resolve_session finds session by name.""" result = _resolve_session(mcp_server, session_name=mcp_session.session_name) @@ -113,6 +286,120 @@ def test_resolve_pane_not_found(mcp_server: Server, mcp_session: Session) -> Non _resolve_pane(mcp_server, pane_id="%99999") +@pytest.mark.parametrize( + ("resolver", "kwargs"), + [ + (_resolve_session, {"session_id": "$99999"}), + (_resolve_window, {"window_id": "@99999"}), + (_resolve_pane, {"pane_id": "%99999"}), + ], + ids=["session", "window", "pane"], +) +def test_targeted_resolvers_name_dead_server_cause( + TestServer: type[Server], + resolver: t.Callable[..., object], + kwargs: dict[str, str], +) -> None: + """Missing targets on a dead daemon report liveness, not stale ids. + + The standard hierarchy fixtures require a live daemon, so ``TestServer`` + supplies a cleanup-managed dead target for this exceptional path. + """ + server = TestServer() + assert server.is_alive() is False + + with pytest.raises( + ExpectedToolError, + match=( + r"No tmux server is running.*" + r"create_session\(allow_server_start=true\)" + ), + ): + resolver(server, **kwargs) + + +def test_window_index_resolver_names_server_death_after_session_resolution( + TestServer: type[Server], +) -> None: + """A daemon death after session lookup still reports the liveness cause. + + The standard hierarchy fixtures keep their server live, so ``TestServer`` + supplies a cleanup-managed session whose daemon can be stopped mid-flow. + """ + server = TestServer() + session = server.new_session(session_name="window_index_server_death") + server.kill() + assert server.is_alive() is False + + with pytest.raises(ExpectedToolError, match="No tmux server is running"): + _resolve_window(server, session=session, window_index="99999") + + +@pytest.mark.parametrize("pane_index", [None, "99999"], ids=["default", "indexed"]) +def test_pane_resolver_names_server_death_after_window_resolution( + TestServer: type[Server], + monkeypatch: pytest.MonkeyPatch, + pane_index: str | None, +) -> None: + """A daemon death after window lookup reports the liveness cause. + + The standard hierarchy fixtures cannot stop the server at this exact seam, + so the real resolver is wrapped to kill the cleanup-managed daemon only + after it returns the target window. + """ + from libtmux_mcp import _utils + + server = TestServer() + session = server.new_session(session_name="pane_resolver_server_death") + window = session.active_window + assert window is not None + original_resolve_window = _utils._resolve_window + + def _resolve_then_kill(*args: t.Any, **kwargs: t.Any) -> Window: + resolved = original_resolve_window(*args, **kwargs) + server.kill() + return resolved + + monkeypatch.setattr(_utils, "_resolve_window", _resolve_then_kill) + + with pytest.raises(ExpectedToolError, match="No tmux server is running"): + _utils._resolve_pane( + server, + window_id=window.window_id, + pane_index=pane_index, + ) + + +def test_pane_resolver_reraises_live_server_pane_lookup_error( + TestServer: type[Server], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unrelated pane-query error on a live server is not swallowed. + + The real pane collection is replaced only to make libtmux raise at the + post-window-resolution boundary under test. + """ + from libtmux_mcp import _utils + + server = TestServer() + session = server.new_session(session_name="pane_resolver_live_error") + window = session.active_window + assert window is not None + raised = exc.LibTmuxException("pane query rejected") + + def _raise_pane_error(_window: Window) -> t.NoReturn: + raise raised + + monkeypatch.setattr(type(window), "panes", property(_raise_pane_error)) + monkeypatch.setattr(_utils, "_resolve_window", lambda *_a, **_kw: window) + + with pytest.raises(exc.LibTmuxException) as excinfo: + _utils._resolve_pane(server, pane_index="99999") + + assert excinfo.value is raised + assert server.is_alive() is True + + def test_serialize_session(mcp_session: Session) -> None: """_serialize_session produces a SessionInfo model.""" from libtmux_mcp.models import SessionInfo @@ -305,6 +592,60 @@ def test_apply_filters( assert len(result) >= 1 +def test_apply_filters_runs_native_before_synthetic_filters( + mcp_server: Server, + mcp_pane: Pane, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Synthetic filters serialize each native-filter survivor once.""" + from libtmux_mcp._utils import _effective_socket_path + + assert mcp_pane.pane_id is not None + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},1,$0") + monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id) + serialization_count = 0 + + def serialize(pane: Pane) -> t.Any: + nonlocal serialization_count + serialization_count += 1 + return _serialize_pane(pane) + + result = _apply_filters( + mcp_server.panes, + {"pane_id": mcp_pane.pane_id, "is_caller": True}, + serialize, + synthetic_fields=frozenset({"is_caller"}), + ) + + assert [pane.pane_id for pane in result] == [mcp_pane.pane_id] + assert serialization_count == 1 + + +@pytest.mark.parametrize( + ("filters", "error_match"), + [ + ({"is_caller": "true"}, "is_caller.*boolean"), + ({"is_caller__contains": True}, "is_caller.*operator 'contains'"), + ], + ids=["non-boolean", "unsupported-operator"], +) +def test_apply_filters_rejects_invalid_synthetic_filters( + mcp_server: Server, + filters: dict[str, t.Any], + error_match: str, +) -> None: + """Synthetic filters accept only exact boolean comparisons.""" + with pytest.raises(ExpectedToolError, match=error_match): + _apply_filters( + mcp_server.panes, + filters, + _serialize_pane, + synthetic_fields=frozenset({"is_caller"}), + ) + + # --------------------------------------------------------------------------- # Caller identity parsing tests # --------------------------------------------------------------------------- @@ -326,6 +667,23 @@ def test_get_caller_identity_parses_tmux_env( assert caller.pane_id == "%3" +def test_get_caller_identity_preserves_commas_in_socket_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller parsing right-splits TMUX so socket commas remain intact.""" + from libtmux_mcp._utils import _get_caller_identity + + monkeypatch.setenv("TMUX", "/tmp/odd,dir/work,12345,$7") + monkeypatch.setenv("TMUX_PANE", "%3") + + caller = _get_caller_identity() + + assert caller is not None + assert caller.socket_path == "/tmp/odd,dir/work" + assert caller.server_pid == 12345 + assert caller.session_id == "$7" + + def test_get_caller_identity_returns_none_when_unset( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -914,6 +1272,50 @@ def test_map_exception_operator_faults_stay_at_error(raised: Exception) -> None: assert mapped.log_level == logging.ERROR +def test_map_dead_server_environment_value_error_as_expected( + caplog: pytest.LogCaptureFixture, +) -> None: + """Libtmux's dead-server environment ValueError is routine absence.""" + import logging + + from libtmux_mcp._utils import ExpectedToolError, _map_exception_to_tool_error + + raised = ValueError( + "tmux set-environment stderr: " + "['error connecting to /tmp/example (No such file or directory)']" + ) + + with caplog.at_level(logging.ERROR, logger="libtmux_mcp._utils"): + mapped = _map_exception_to_tool_error("set_environment", raised) + + assert isinstance(mapped, ExpectedToolError) + assert mapped.log_level == logging.WARNING + assert "No tmux server is running" in str(mapped) + assert not [ + record for record in caplog.records if "unexpected error" in record.message + ] + + +def test_map_unrelated_value_error_as_unexpected( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unrelated programming ValueErrors remain loud unexpected failures.""" + import logging + + from libtmux_mcp._utils import ExpectedToolError, _map_exception_to_tool_error + + with caplog.at_level(logging.ERROR, logger="libtmux_mcp._utils"): + mapped = _map_exception_to_tool_error( + "set_environment", + ValueError("bad caller input"), + ) + + assert not isinstance(mapped, ExpectedToolError) + assert mapped.log_level == logging.ERROR + assert "Unexpected error: ValueError: bad caller input" in str(mapped) + assert [record for record in caplog.records if "unexpected error" in record.message] + + def test_expected_tool_error_logs_warning_through_server( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/test_window_tools.py b/tests/test_window_tools.py index ba893d51..6821a055 100644 --- a/tests/test_window_tools.py +++ b/tests/test_window_tools.py @@ -7,6 +7,7 @@ import pytest from fastmcp.exceptions import ToolError +from libtmux_mcp._utils import ExpectedToolError from libtmux_mcp.tools.window_tools import ( get_window_info, kill_window, @@ -24,15 +25,148 @@ def test_list_panes(mcp_server: Server, mcp_session: Session) -> None: - """list_panes returns a list of PaneInfo models.""" + """list_panes returns a typed page of summary models.""" + from libtmux_mcp.models import PanePage, PaneSummary + window = mcp_session.active_window result = list_panes( window_id=window.window_id, socket_name=mcp_server.socket_name, ) - assert isinstance(result, list) - assert len(result) >= 1 - assert result[0].pane_id is not None + assert isinstance(result, PanePage) + assert len(result.items) >= 1 + assert isinstance(result.items[0], PaneSummary) + assert result.items[0].pane_id is not None + + +@pytest.mark.parametrize("explicit_scope", [False, True], ids=["default", "explicit"]) +def test_list_panes_server_scope_remains_server_wide( + mcp_server: Server, + mcp_session: Session, + explicit_scope: bool, +) -> None: + """The default and explicit server scope include every session.""" + second_session = mcp_server.new_session(session_name="list_panes_server_scope") + kwargs: dict[str, t.Any] = {"socket_name": mcp_server.socket_name} + if explicit_scope: + kwargs["scope"] = "server" + + result = list_panes(**kwargs) + + session_ids = {pane.session_id for pane in result.items} + assert mcp_session.session_id in session_ids + assert second_session.session_id in session_ids + + +def test_list_panes_caller_session_scope_uses_live_caller_session( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller scope resolves the pane instead of trusting stale TMUX metadata.""" + from libtmux_mcp._utils import _effective_socket_path + + caller_pane = mcp_session.active_pane + assert caller_pane is not None and caller_pane.pane_id is not None + mcp_session.new_window(window_name="list_panes_caller_window") + other_session = mcp_server.new_session(session_name="list_panes_other_session") + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},12345,$stale") + monkeypatch.setenv("TMUX_PANE", caller_pane.pane_id) + + result = list_panes( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + assert result + assert {pane.session_id for pane in result.items} == {mcp_session.session_id} + assert other_session.session_id not in {pane.session_id for pane in result.items} + + +@pytest.mark.parametrize( + ("selector", "value"), + [ + ("session_name", "dev"), + ("session_id", "$1"), + ("window_id", "@1"), + ("window_index", "1"), + ], +) +def test_list_panes_caller_session_rejects_explicit_selectors( + mcp_server: Server, + selector: str, + value: str, +) -> None: + """Caller scope cannot be mixed with hierarchy selectors.""" + kwargs: dict[str, t.Any] = { + "scope": "caller_session", + "socket_name": mcp_server.socket_name, + selector: value, + } + with pytest.raises( + ExpectedToolError, + match=r"scope='caller_session'.*cannot be combined.*scope='server'", + ): + list_panes(**kwargs) + + +def test_list_panes_caller_session_rejects_outside_tmux( + mcp_server: Server, +) -> None: + """Caller scope fails explicitly when the invocation has no caller.""" + with pytest.raises( + ExpectedToolError, + match=r"scope='caller_session'.*inside tmux.*scope='server'", + ): + list_panes( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + +def test_list_panes_caller_session_rejects_effective_socket_mismatch( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller scope never crosses from the effective target to another socket.""" + from libtmux.pane import Pane + + caller_pane = mcp_session.active_pane + assert caller_pane is not None and caller_pane.pane_id is not None + monkeypatch.setenv("TMUX", "/tmp/tmux-99999/caller-only,12345,$0") + monkeypatch.setenv("TMUX_PANE", caller_pane.pane_id) + + def fail_from_env( + cls: type[Pane], environment: t.Mapping[str, str] | None = None + ) -> Pane: + del cls, environment + pytest.fail("Pane.from_env must not run across a target mismatch") + + monkeypatch.setattr(Pane, "from_env", classmethod(fail_from_env)) + + with pytest.raises( + ExpectedToolError, + match=r"scope='caller_session'.*socket.*effective.*scope='server'", + ): + list_panes( + scope="caller_session", + socket_name=mcp_server.socket_name, + ) + + +def test_list_panes_rejects_invalid_scope(mcp_server: Server) -> None: + """Direct callers cannot widen a misspelled scope to the server.""" + with pytest.raises( + ExpectedToolError, + match=r"Invalid scope.*expected.*server.*caller_session", + ): + list_panes( + scope=t.cast("t.Any", "caller"), + socket_name=mcp_server.socket_name, + ) def test_get_window_info(mcp_server: Server, mcp_session: Session) -> None: @@ -241,8 +375,75 @@ def test_list_panes_with_filters( list_panes(**kwargs) else: result = list_panes(**kwargs) - assert isinstance(result, list) - assert len(result) >= expected_min_count + assert len(result.items) >= expected_min_count + + +@pytest.mark.parametrize( + ("filters", "expected_is_caller"), + [ + ({"is_caller": True}, True), + ('{"is_caller": true}', True), + ({"is_caller__exact": True}, True), + ({"is_caller": False}, False), + ('{"is_caller": false}', False), + ], + ids=["true", "json-true", "exact", "false", "json-false"], +) +def test_list_panes_filters_by_caller( + mcp_server: Server, + mcp_session: Session, + monkeypatch: pytest.MonkeyPatch, + filters: dict[str, t.Any] | str, + expected_is_caller: bool, +) -> None: + """list_panes filters its serialized caller annotation.""" + from libtmux_mcp._utils import _effective_socket_path + + window = mcp_session.active_window + caller_pane = window.active_pane + assert caller_pane is not None and caller_pane.pane_id is not None + window.split() + socket_path = _effective_socket_path(mcp_server) + assert socket_path is not None + monkeypatch.setenv("TMUX", f"{socket_path},1,{mcp_session.session_id or '$0'}") + monkeypatch.setenv("TMUX_PANE", caller_pane.pane_id) + + result = list_panes( + window_id=window.window_id, + socket_name=mcp_server.socket_name, + filters=filters, + ) + + assert result.items + assert all(pane.is_caller is expected_is_caller for pane in result.items) + result_ids = {pane.pane_id for pane in result.items} + if expected_is_caller: + assert result_ids == {caller_pane.pane_id} + else: + assert caller_pane.pane_id not in result_ids + + +@pytest.mark.parametrize( + ("filters", "error_match"), + [ + ({"is_caller": "true"}, "is_caller.*boolean"), + ({"is_caller__contains": True}, "is_caller.*operator 'contains'"), + ], + ids=["non-boolean", "unsupported-operator"], +) +def test_list_panes_rejects_invalid_caller_filters( + mcp_server: Server, + mcp_session: Session, + filters: dict[str, t.Any], + error_match: str, +) -> None: + """list_panes rejects invalid caller-filter values and operators.""" + with pytest.raises(ExpectedToolError, match=error_match): + list_panes( + window_id=mcp_session.active_window.window_id, + socket_name=mcp_server.socket_name, + filters=filters, + ) # ---------------------------------------------------------------------------