Skip to content

The list_* family is unbounded, unscoped, unprojected — and explicitly excluded from the response limiter #95

Description

@tony

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, no offset, 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:

No size controls:

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_TOOLS is 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:

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#L83limit=500, offset=0, max_matched_lines_per_pane=50, returning a SearchPanesResult envelope with truncated / 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#L352list_servers costs a display-message + len(server.sessions) round-trip per discovered socket: O(sockets) in subprocess spawns, not just tokens.
  • env_tools.py#L23show_environment returns the entire tmux environment dict with no filter/limit/projection. Also a secret-disclosure surface.
  • io.py#L333run_command's max_lines defaults to None (unbounded), inconsistent with the other five capture-family tools which default to CAPTURE_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 defaultCAPTURE_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_panesWith no session_id/window_id this 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

  1. list_panes() with no args on a multi-session server returns only the caller's session (post default-scoping), and list_panes(scope="server") returns all.
  2. list_panes(limit=5) on a 20-pane server returns 5 rows and an envelope with truncated=True / total_matched=20.
  3. list_panes(compact=True) omits the 8 geometry fields.
  4. A response-limiter test asserting list_panes is subject to the cap (fails today).
  5. A schema-size regression test pinning tools/list bytes, so growth is visible in CI.

Related

  • Companion feature: whoami — removes the reason agents call unscoped list_panes in 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.

(← this issue: #95)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions