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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions