Repository navigation
The list_* family is unbounded, unscoped, unprojected — and explicitly excluded from the response limiter #95
Description
Summary
list_panes, list_windows, list_sessions and list_servers are the discovery anchors of this server. All four:
- default to the entire tmux server (no scoping to the caller's session/window),
- have no
limit, nooffset, no field projection, no compact mode, - return a bare
list[...], a type that literally cannot express "there were more rows", - and are deliberately excluded from the response-limiting middleware.
They are the only read family in the codebase with no bounded-output contract — in a project where search_panes, capture_pane, snapshot_pane and capture_since all document truncation carefully. And list_panes is the tool the server instructions point agents at for the most common question they ask ("which pane am I in?").
Measured cost
PaneInfo is 21 fields/row. fastmcp emits every structured result twice — once as a JSON content block, once as structuredContent — so every field costs double (fastmcp/tools/base.py#L365). Measured end-to-end:
list_panes rows |
content | structuredContent | total | ≈ tokens |
|---|---|---|---|---|
| 10 | 4,381 B | 4,812 B | 9,193 B | ~2.5K |
| 50 | 21,981 B | 24,092 B | 46,073 B | ~12.8K |
| 100 | 43,981 B | 48,192 B | 92,173 B | ~25.6K |
≈240 tokens per pane. On a busy dev box a single unscoped list_panes cost ~11K tokens of an agent's context — the overwhelming majority of it panes in unrelated sessions the agent had no interest in.
8 of the 21 PaneInfo fields are geometry (pane_left/top/right/bottom, pane_at_left/right/top/bottom) that only find_pane_by_position — an existing dedicated tool that computes corners server-side — actually needs.
Evidence
Unscoped by default:
window_tools.py#L100—list_panes→panes = server.panes(all sessions, all windows)session_tools.py#L74—list_windows→windows = server.windowsserver_tools.py#L65—list_sessionshas no scoping param at all beyondsocket_name
No size controls:
window_tools.py#L51—list_panes(...) -> list[PaneInfo]; the only knob isfilters, defaultNonemodels.py#L53—PaneInfo, 21 fieldsmodels.py#L450—PaneSnapshot, 28 fields, the widest model
filters cuts tokens but not tmux work: _apply_filters materializes the whole QueryList (a full list-panes -a) and filters in Python afterward.
Excluded from the response limiter — on an unverified assumption:
server.py#L245— the comment: "structured responses from list/get tools stay under the cap naturally"server.py#L246—_RESPONSE_LIMITED_TOOLSis a 5-tool allowlist:capture_pane,capture_since,search_panes,snapshot_pane,show_buffer
fastmcp's middleware applies to all tools when tools=None. By passing an explicit list, libtmux-mcp opted the list_* family out of even the 1 MB backstop:
fastmcp/server/middleware/response_limiting.py#L114—if self.tools is not None and context.message.name not in self.tools: return result
And the cap itself is not token-shaped: DEFAULT_RESPONSE_LIMIT_BYTES = 1_000_000 (middleware.py#L666) ≈ 280K tokens — larger than most model context windows. It is a transport backstop, not a context budget.
Contrast — the house style done right. search_panes is the only paginated tool, with a real bounded-output contract:
search.py#L83—limit=500,offset=0,max_matched_lines_per_pane=50, returning aSearchPanesResultenvelope withtruncated/total_panes_matched
…which proves the omission in the list_* family is an oversight, not a design choice. (Though search_panes' defaults are far too generous: 500 panes × 50 lines = up to 25,000 lines in one response — enough to blow past the 1 MB cap it is subject to.)
Other offenders:
server_tools.py#L352—list_serverscosts adisplay-message+len(server.sessions)round-trip per discovered socket: O(sockets) in subprocess spawns, not just tokens.env_tools.py#L23—show_environmentreturns the entire tmux environment dict with no filter/limit/projection. Also a secret-disclosure surface.io.py#L333—run_command'smax_linesdefaults toNone(unbounded), inconsistent with the other five capture-family tools which default toCAPTURE_DEFAULT_MAX_LINES = 500. It is not in the limiter allowlist either.
Schema-side cost, too. tools/list measures 169,272 bytes across 57 tools — 47% of it auto-derived outputSchema (78,980 B), 30% inputSchema, 13% descriptions. fastmcp derives an output_schema from every return annotation unless one is passed explicitly (fastmcp/tools/function_parsing.py#L344); libtmux-mcp never passes one, so all 57 tools carry it.
Suggested fix
Ordered by leverage. Note that every field removed pays off 2× because of the content/structuredContent duplication.
1. Default-scope list_panes / list_windows to the caller's own session. This is the biggest single win and it turns the common case from "all N panes on the box" into "the ~3 panes I'm working in". _get_caller_identity() already yields the caller's session_id for free. Require an explicit scope="server" (or all_sessions=True) to opt into the server-wide fan-out.
2. Field projection / compact mode. Add compact: bool = True (or fields: list[str] | None). A compact PaneInfo of {pane_id, window_id, session_id, pane_current_command, pane_active, is_caller} is 6 of 21 fields — a ~70% token cut. Drop the 8 geometry fields from the default projection entirely; find_pane_by_position already exists to serve that need.
3. Bounded-output contract. Give the four list_* tools limit / offset, and wrap their bare list[...] returns in an envelope modelled on the existing SearchPanesResult (truncated / total_matched / offset / limit). A bare list cannot signal "there were more rows" — the envelope is a prerequisite for any cap to be honest.
4. Close the middleware allowlist gap. Either pass tools=None (fastmcp's default = apply to all) or extend _RESPONSE_LIMITED_TOOLS with list_panes, list_windows, list_sessions, list_servers, show_environment, run_command. Today those have no cap at any layer. Also consider lowering DEFAULT_RESPONSE_LIMIT_BYTES from 1 MB to something token-shaped (e.g. 128 KB).
5. Tighten search_panes defaults. SEARCH_DEFAULT_LIMIT 500 → ~25, SEARCH_DEFAULT_MAX_LINES_PER_PANE 50 → ~10. The pagination machinery is already built and correct; only the defaults are unsafe.
6. Fix run_command's max_lines default → CAPTURE_DEFAULT_MAX_LINES, so all six capture-family tools share one bounded-output contract.
7. Per-tool token-cost notes in descriptions. Agents budget well when told, and cannot infer this from a schema:
list_panes— With nosession_id/window_idthis returns EVERY pane on the entire server (all sessions). Cost ≈240 tokens/pane; a 50-pane server ≈13K tokens. Scope it.
8. Shed outputSchema on the prose-shaped tools. Pass output_schema=None on capture_pane, capture_since, snapshot_pane, search_panes, show_buffer, display_message — the escape hatch is fastmcp/tools/base.py#L357. Returning a ToolResult explicitly (#L318) is a full passthrough and kills the 2× duplication for the big-payload tools.
Negative result, recorded so it isn't re-litigated: dereference_schemas=False is not a size lever here — measured 0-byte delta, because fastmcp already runs compress_schema(prune_titles=True) and the schemas contain no $defs/$ref.
Tests
list_panes()with no args on a multi-session server returns only the caller's session (post default-scoping), andlist_panes(scope="server")returns all.list_panes(limit=5)on a 20-pane server returns 5 rows and an envelope withtruncated=True/total_matched=20.list_panes(compact=True)omits the 8 geometry fields.- A response-limiter test asserting
list_panesis subject to the cap (fails today). - A schema-size regression test pinning
tools/listbytes, so growth is visible in CI.
Related
- Companion feature:
whoami— removes the reason agents call unscopedlist_panesin the first place. - Companion bug:
filters={"is_caller": true}silently returns[]— so even the advertised token-saving filter doesn't work.
Environment
- libtmux-mcp: 0.1.0a17
- libtmux: 0.61.0
- fastmcp: 3.4.3 / mcp: 1.28.1
- tmux: 3.7b
- Python: 3.14.0
Cross-references — filed together from a single audit of v0.1.0a17. The self-location gap is the root cause; the rest are what it exposed.
- The documented self-location recipe is unexecutable: filters={"is_caller": true} silently returns [] #92 — [bug] documented self-location recipe is unexecutable (
filters={"is_caller": true}→[]) - Add a self-location primitive (where_am_i): agents must dump every pane on the server to learn which pane they are in #93 — [feat]
where_am_iself-location primitive + self-first relational routing (root fix) - The caller's tmux socket is advertised in the instructions but never targeted: an MCP inside
tmux -L workqueriesdefault#94 — [bug] caller's socket advertised but never targeted;TMUXenv race vs. the threadpool - The list_* family is unbounded, unscoped, unprojected — and explicitly excluded from the response limiter #95 — [perf]
list_*unbounded, unscoped, unprojected, excluded from the response limiter - Surface invocation context (in tmux? server running? which socket?) and make tmux-server auto-start explicit #96 — [feat] invocation context (in tmux? server running?) + explicit tmux-server auto-start
- No self-location API (TMUX_PANE), and the socket_path derivation in Server.__init__ is unreachable dead code libtmux#704 — upstream: no self-location API (
TMUX_PANE); unreachablesocket_pathderivation
(← this issue: #95)
Activity