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#L83 — limit=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#L352 — list_servers costs a display-message + len(server.sessions) round-trip per discovered socket: O(sockets) in subprocess spawns, not just tokens.
env_tools.py#L23 — show_environment returns the entire tmux environment dict with no filter/limit/projection. Also a secret-disclosure surface.
io.py#L333 — run_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 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 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
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.
list_panes(limit=5) on a 20-pane server returns 5 rows and an envelope with truncated=True / total_matched=20.
list_panes(compact=True) omits the 8 geometry fields.
- A response-limiter test asserting
list_panes is subject to the cap (fails today).
- 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)
Summary
list_panes,list_windows,list_sessionsandlist_serversare the discovery anchors of this server. All four:limit, nooffset, no field projection, no compact mode,list[...], a type that literally cannot express "there were more rows",They are the only read family in the codebase with no bounded-output contract — in a project where
search_panes,capture_pane,snapshot_paneandcapture_sinceall document truncation carefully. Andlist_panesis the tool the server instructions point agents at for the most common question they ask ("which pane am I in?").Measured cost
PaneInfois 21 fields/row. fastmcp emits every structured result twice — once as a JSONcontentblock, once asstructuredContent— so every field costs double (fastmcp/tools/base.py#L365). Measured end-to-end:list_panesrows≈240 tokens per pane. On a busy dev box a single unscoped
list_panescost ~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
PaneInfofields are geometry (pane_left/top/right/bottom,pane_at_left/right/top/bottom) that onlyfind_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_nameNo 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 modelfilterscuts tokens but not tmux work:_apply_filtersmaterializes the wholeQueryList(a fulllist-panes -a) and filters in Python afterward._utils.py#L827Excluded 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_bufferfastmcp's middleware applies to all tools when
tools=None. By passing an explicit list, libtmux-mcp opted thelist_*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 resultAnd 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_panesis 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. (Thoughsearch_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/listmeasures 169,272 bytes across 57 tools — 47% of it auto-derivedoutputSchema(78,980 B), 30%inputSchema, 13% descriptions. fastmcp derives anoutput_schemafrom 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_windowsto 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'ssession_idfor free. Require an explicitscope="server"(orall_sessions=True) to opt into the server-wide fan-out.2. Field projection / compact mode. Add
compact: bool = True(orfields: list[str] | None). A compactPaneInfoof{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_positionalready exists to serve that need.3. Bounded-output contract. Give the four
list_*toolslimit/offset, and wrap their barelist[...]returns in an envelope modelled on the existingSearchPanesResult(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_TOOLSwithlist_panes,list_windows,list_sessions,list_servers,show_environment,run_command. Today those have no cap at any layer. Also consider loweringDEFAULT_RESPONSE_LIMIT_BYTESfrom 1 MB to something token-shaped (e.g. 128 KB).5. Tighten
search_panesdefaults.SEARCH_DEFAULT_LIMIT500 → ~25,SEARCH_DEFAULT_MAX_LINES_PER_PANE50 → ~10. The pagination machinery is already built and correct; only the defaults are unsafe.6. Fix
run_command'smax_linesdefault →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:
8. Shed
outputSchemaon the prose-shaped tools. Passoutput_schema=Noneoncapture_pane,capture_since,snapshot_pane,search_panes,show_buffer,display_message— the escape hatch isfastmcp/tools/base.py#L357. Returning aToolResultexplicitly (#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=Falseis not a size lever here — measured 0-byte delta, because fastmcp already runscompress_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.list_panesis subject to the cap (fails today).tools/listbytes, so growth is visible in CI.Related
whoami— removes the reason agents call unscopedlist_panesin the first place.filters={"is_caller": true}silently returns[]— so even the advertised token-saving filter doesn't work.Environment
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.filters={"is_caller": true}→[])where_am_iself-location primitive + self-first relational routing (root fix)tmux -L workqueriesdefault#94 — [bug] caller's socket advertised but never targeted;TMUXenv race vs. the threadpoollist_*unbounded, unscoped, unprojected, excluded from the response limiterTMUX_PANE); unreachablesocket_pathderivation(← this issue: #95)