Skip to content

Commit 09993dc

Browse files
authored
feat(fastmcp): align tool surface, prompts, and middleware (#15)
Broad follow-up to the initial MCP surface, realigning the server around FastMCP 3.2 primitives and closing gaps that blocked real agent workflows. Adds four prompt recipes, a full middleware stack (timing, error-handling, audit, safety, readonly-retry, tail-preserving response limiting), a discovery tool, buffer and read-only hook tools, and channel-based wait primitives — all with Pydantic output models, NumPy-docstring-derived parameter descriptions, bounded outputs with tail-preserving truncation, and CI coverage across the supported tmux matrix. **New tools** - Discovery: `list_servers` — enumerates live tmux sockets with an honest scope (default dir + `LIBTMUX_SOCKET_PATH` + `extra_socket_paths`) rather than a filesystem crawl. - Waits: `wait_for_text`, `wait_for_content_change`, `wait_for_channel`, `signal_channel`. All bounded, `Context.report_progress` / `Context.warning` aware, and propagate `asyncio.CancelledError` cleanly. `capture_pane` calls run through `asyncio.to_thread` so FastMCP's event loop stays responsive under long waits. - Buffers: `load_buffer`, `paste_buffer`, `show_buffer`, `delete_buffer`. Agent-namespaced as `libtmux_mcp_<uuid>_<name>` to prevent collisions; leaked buffers are GC'd on graceful lifespan shutdown. - Hooks (read-only): `show_hook`, `show_hooks` — the latter merges the `-g` global-window tree into `scope='server'`. **Prompt recipes** - Four FastMCP prompts: `run_and_wait`, `diagnose_failing_pane`, `build_dev_workspace`, `interrupt_gracefully`. `run_and_wait` UUID-scopes its wait-for channel and uses `repr()` for send-keys payloads. `build_dev_workspace` uses the real tool parameter names (`session_name=`, `pane_id=`, `direction=`), drops stray Enter presses, and no longer waits for shell prompts after launching screen-grabbing programs (`vim`, `watch`, `tail -f`); a new `log_command` parameter replaces the Linux-only `/var/log/syslog` default. - Set `LIBTMUX_MCP_PROMPTS_AS_TOOLS=1` to expose prompts as tools for tools-only MCP clients. **Middleware stack** - `TimingMiddleware`, `ErrorHandlingMiddleware`. - `AuditMiddleware`: per-call structured logging with digest-redacted argument summaries. Moved outside `SafetyMiddleware` so tier denials are audited too. - `SafetyMiddleware`: tier-gated tool visibility (readonly / mutating / destructive). - `ReadonlyRetryMiddleware`: transparent retry of readonly tools on transient `libtmux.exc.LibTmuxException`; mutating tools never retry. Retry warnings log under `libtmux_mcp.retry`. - `TailPreservingResponseLimitingMiddleware`: trims oversized output from the head so the active prompt survives. **Bounded outputs** - `capture_pane`, `snapshot_pane`, `show_buffer` accept `max_lines` (default 500) with tail-preserving truncation and typed `content_truncated` / `content_truncated_lines` signals. Pass `max_lines=None` to opt out. **Lifespan + safety** - FastMCP lifespan with a `tmux` presence probe that fails fast with `RuntimeError` if the binary is missing on `PATH`. - `search_panes` neutralizes tmux format-string injection in the regex fast path; also always takes the fast path when `regex=False`, and returns panes in numeric `pane_id` order. - macOS `TMUX_TMPDIR` self-kill guard: resolves the server socket via `tmux display-message #{socket_path}` before env-based reconstruction, with a basename-match fallback that closes the launchd divergence gap. - Caller-identity scoping: self-protection is scoped to the caller's tmux socket rather than global. **Refactors** - `tools/pane_tools.py` split into a `pane_tools/` subpackage (`io`, `meta`, `layout`, `lifecycle`, `copy_mode`, `pipe`, `search`, `wait`) with a thin `__init__` re-export. - `handle_tool_errors_async` decorator for Context-using tools. - `ANNOTATIONS_SHELL` open-world shell-tool annotations hoisted into `_utils` with the five consumers documented inline. **Docs** - New pages: `tools/buffers.md`, `tools/hooks.md`, `tools/waits.md`, `tools/index.md`; `panes.md` documents the `SearchPanesResult` migration; `safety.md` covers the macOS `TMUX_TMPDIR` caveat, the audit log, `pipe_pane`, and `set_environment`. - New topic pages: `topics/completion.md`, `topics/logging.md`, `topics/pagination.md`. - Reusable Sphinx widget framework under `docs/_ext/widgets/` powers a `{mcp-install}` picker with nested client / install-method tabs and per-client tool hints. Rendering goes through the Jinja `highlight` filter so copybutton parity matches the rest of the site; non-HTML Sphinx builders degrade gracefully. - Prompts and Resources promoted to top-level peers of Tools, surfaced as landing-page grid cards with FastMCP autodoc wiring. ### Breaking changes - `search_panes` now returns `libtmux_mcp.models.SearchPanesResult` instead of `list[PaneContentMatch]`. Matches moved to `.matches`; new `truncated`, `truncated_panes`, `total_panes_matched`, `offset`, `limit` fields enable pagination. Migration: `for m in search_panes(...)` → `for m in search_panes(...).matches`. - Minimum `fastmcp>=3.2.4` (was `>=3.1.0`). Required for `ReadonlyRetryMiddleware` and per-parameter input-schema descriptions.
2 parents 1d4dd37 + 79c3097 commit 09993dc

77 files changed

Lines changed: 10068 additions & 1645 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -254,14 +254,31 @@ type
254254

255255
### Doctests
256256

257-
**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests.
258-
259-
**CRITICAL RULES:**
260-
- Doctests MUST actually execute - never comment out function calls or similar
261-
- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run)
262-
- If you cannot create a working doctest, **STOP and ask for help**
263-
264-
**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. Use the fixtures properly - tmux is required to run tests anyway.
257+
This repo is an **MCP server**, not a general-purpose library. Most tools
258+
require a live tmux server to do anything meaningful, so a blanket
259+
doctest mandate doesn't fit the shape of the code. Scope doctests to
260+
functions where they actually work offline.
261+
262+
**Where doctests SHOULD be used:**
263+
- Pure helper functions (parsers, formatters, digest / redaction
264+
logic, small utilities) that can run with no external state.
265+
- Examples in module-level docstrings that illustrate a concept without
266+
hitting tmux, the filesystem, or the network.
267+
268+
**Where doctests are exempt:**
269+
- Any tool function that calls `_get_server`, touches a `Session`,
270+
`Window`, or `Pane`, or otherwise requires tmux to be running. Use a
271+
unit test with fixtures instead.
272+
- Functions that do I/O, spawn subprocesses, or read environment.
273+
274+
**CRITICAL RULES for doctests that exist:**
275+
- They MUST actually execute — never comment out function calls or
276+
similar.
277+
- They MUST NOT be converted to `.. code-block::` as a workaround
278+
(code-blocks don't run).
279+
- `# doctest: +SKIP` is discouraged. If a function can't run offline,
280+
write a unit test instead of a skipped doctest — a skipped test is
281+
just noise.
265282

266283
**When output varies, use ellipsis:**
267284
```python

CHANGES

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,103 @@
66
_Notes on upcoming releases will be added here_
77
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
88

9+
_FastMCP alignment: new tools, prompts, and middleware (#15)_
10+
11+
### Breaking changes
12+
13+
- {tooliconl}`search-panes` now returns
14+
{class}`~libtmux_mcp.models.SearchPanesResult` instead of
15+
`list[`{class}`~libtmux_mcp.models.PaneContentMatch``]`. Matches
16+
moved to `.matches`; new `truncated`, `truncated_panes`,
17+
`total_panes_matched`, `offset`, `limit` fields enable pagination.
18+
Migrate `for m in search_panes(...)` →
19+
`for m in search_panes(...).matches`.
20+
- Minimum `fastmcp>=3.2.4` (was `>=3.1.0`). Required for
21+
{class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` and
22+
per-parameter input-schema descriptions.
23+
24+
### What's new
25+
26+
**New tools**
27+
28+
- Discovery: {tooliconl}`list-servers`.
29+
- Waits: {tooliconl}`wait-for-text`,
30+
{tooliconl}`wait-for-content-change`, {tooliconl}`wait-for-channel`,
31+
{tooliconl}`signal-channel`. All bounded, emit
32+
`ctx.report_progress` / `ctx.warning`, and propagate
33+
{exc}`asyncio.CancelledError` cleanly.
34+
- Buffers: {tooliconl}`load-buffer`, {tooliconl}`paste-buffer`,
35+
{tooliconl}`show-buffer`, {tooliconl}`delete-buffer`.
36+
Agent-namespaced as `libtmux_mcp_<uuid>_<name>` to prevent
37+
collisions; leaked buffers GC'd on graceful shutdown.
38+
- Hooks (read-only): {tooliconl}`show-hook`, {tooliconl}`show-hooks`.
39+
- Panes / windows: {tooliconl}`snapshot-pane`, {tooliconl}`pipe-pane`,
40+
{tooliconl}`display-message`, {tooliconl}`paste-text`,
41+
{tooliconl}`select-pane`, {tooliconl}`swap-pane`,
42+
{tooliconl}`select-window`, {tooliconl}`move-window`,
43+
{tooliconl}`enter-copy-mode`, {tooliconl}`exit-copy-mode`.
44+
45+
**Prompt recipes**
46+
47+
- Four prompts: `run_and_wait`, `diagnose_failing_pane`,
48+
`build_dev_workspace`, `interrupt_gracefully`. Set
49+
`LIBTMUX_MCP_PROMPTS_AS_TOOLS=1` to expose them as tools for
50+
tools-only MCP clients.
51+
52+
**Middleware stack**
53+
54+
- `TimingMiddleware`, `ErrorHandlingMiddleware`,
55+
{class}`~libtmux_mcp.middleware.AuditMiddleware` (digest-redacted
56+
argument summaries),
57+
{class}`~libtmux_mcp.middleware.SafetyMiddleware` (tier-gated tool
58+
visibility),
59+
{class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware`
60+
(transparent retry of readonly tools on transient
61+
{exc}`libtmux.exc.LibTmuxException`; mutating tools never retry),
62+
{class}`~libtmux_mcp.middleware.TailPreservingResponseLimitingMiddleware`
63+
(trims oversized output from the head so the active prompt
64+
survives).
65+
66+
**Bounded outputs**
67+
68+
- {tooliconl}`capture-pane`, {tooliconl}`snapshot-pane`,
69+
{tooliconl}`show-buffer` accept `max_lines` (default 500) with
70+
tail-preserving truncation and typed `content_truncated` /
71+
`content_truncated_lines` signals. Pass `max_lines=None` to opt
72+
out.
73+
74+
**Other**
75+
76+
- Tool input schemas carry per-parameter `description` fields
77+
auto-extracted from NumPy-style docstrings.
78+
- Lifespan startup probe fails fast with {exc}`RuntimeError` if
79+
`tmux` is missing on `PATH`.
80+
- {attr}`~libtmux_mcp.models.SessionInfo.active_pane_id` surfaces the
81+
pane id returned by {tooliconl}`create-session`.
82+
83+
### Fixes
84+
85+
- {tooliconl}`search-panes` neutralizes tmux format-string injection
86+
in the regex fast path.
87+
- macOS `TMUX_TMPDIR` self-kill guard: resolves the server socket via
88+
`tmux display-message #{socket_path}` before env-based
89+
reconstruction; basename-match fallback closes the launchd
90+
divergence gap.
91+
- `build_dev_workspace` prompt uses the real tool parameter names
92+
(`session_name=`, `pane_id=`, `direction=`) and no longer waits for
93+
shell prompts after launching screen-grabbing programs (`vim`,
94+
`watch`, `tail -f`). New `log_command` parameter replaces the
95+
Linux-only `/var/log/syslog` default.
96+
- `ReadonlyRetryMiddleware` retry warnings log under
97+
`libtmux_mcp.retry`.
98+
99+
### Documentation
100+
101+
- New per-tool pages: `buffers.md`, `hooks.md`, `waits.md`,
102+
`tools/index.md`. `panes.md` documents the `SearchPanesResult`
103+
migration. `safety.md` covers the macOS `TMUX_TMPDIR` caveat, the
104+
audit log, `pipe_pane`, and `set_environment`.
105+
9106
## libtmux-mcp 0.1.0a1 (2026-04-13)
10107

11108
### What's new

conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
if t.TYPE_CHECKING:
2525
import pathlib
2626

27-
pytest_plugins = ["pytester"]
27+
pytest_plugins = ["pytester", "sphinx.testing.fixtures"]
2828

2929

3030
@pytest.fixture(autouse=True)

docs/_ext/widgets/__init__.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Reusable widget framework for Sphinx docs.
2+
3+
Each widget is a ``BaseWidget`` subclass in a sibling module (e.g.
4+
``mcp_install.py``) plus a ``<docs>/_widgets/<name>/widget.{html,js,css}``
5+
asset directory. Widgets autodiscover at ``setup()`` time — adding a new one
6+
requires no registry edits. Usage from Markdown/RST:
7+
8+
.. code-block:: markdown
9+
10+
```{mcp-install}
11+
:variant: compact
12+
```
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import functools
18+
import typing as t
19+
20+
from ._assets import install_widget_assets
21+
from ._base import (
22+
BaseWidget,
23+
depart_widget_container,
24+
visit_widget_container,
25+
widget_container,
26+
)
27+
from ._directive import make_widget_directive
28+
from ._discovery import discover
29+
30+
if t.TYPE_CHECKING:
31+
from sphinx.application import Sphinx
32+
33+
__version__ = "0.1.0"
34+
35+
__all__ = [
36+
"BaseWidget",
37+
"__version__",
38+
"setup",
39+
"widget_container",
40+
]
41+
42+
43+
def setup(app: Sphinx) -> dict[str, t.Any]:
44+
"""Register every discovered widget and wire the asset pipeline."""
45+
widgets = discover()
46+
47+
app.add_node(
48+
widget_container,
49+
html=(visit_widget_container, depart_widget_container),
50+
)
51+
52+
for name, widget_cls in widgets.items():
53+
app.add_directive(name, make_widget_directive(widget_cls))
54+
55+
app.connect(
56+
"builder-inited",
57+
functools.partial(install_widget_assets, widgets=widgets),
58+
)
59+
60+
return {
61+
"version": __version__,
62+
"parallel_read_safe": True,
63+
"parallel_write_safe": True,
64+
}

docs/_ext/widgets/_assets.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Copy widget assets into ``_static/widgets/<name>/`` and register them."""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
import typing as t
7+
8+
from sphinx.util import logging
9+
from sphinx.util.fileutil import copy_asset_file
10+
11+
from ._base import BaseWidget
12+
13+
if t.TYPE_CHECKING:
14+
from sphinx.application import Sphinx
15+
16+
logger = logging.getLogger(__name__)
17+
18+
STATIC_SUBDIR = "widgets"
19+
20+
21+
def install_widget_assets(
22+
app: Sphinx,
23+
widgets: dict[str, type[BaseWidget]],
24+
) -> None:
25+
"""Copy each widget's ``widget.{css,js}`` into ``_static/widgets/<name>/``.
26+
27+
Assets are then registered via ``app.add_css_file`` / ``app.add_js_file`` so
28+
every page includes them (same pattern as ``sphinx-copybutton``). This is
29+
intentionally simpler than per-page inclusion — the files are small and the
30+
docs are not bandwidth-constrained.
31+
"""
32+
if app.builder.format != "html":
33+
return
34+
35+
srcdir = pathlib.Path(app.srcdir)
36+
outdir_static = pathlib.Path(app.outdir) / "_static" / STATIC_SUBDIR
37+
38+
for name, widget_cls in widgets.items():
39+
asset_dir = widget_cls.assets_dir(srcdir)
40+
dest = outdir_static / name
41+
42+
for filename, register in (
43+
("widget.css", app.add_css_file),
44+
("widget.js", app.add_js_file),
45+
):
46+
source = asset_dir / filename
47+
if not source.is_file():
48+
continue
49+
dest.mkdir(parents=True, exist_ok=True)
50+
copy_asset_file(str(source), str(dest))
51+
register(f"{STATIC_SUBDIR}/{name}/{filename}")

0 commit comments

Comments
 (0)