Skip to content

MCP: freeze the tool surface, bound its work, and guard pane input - #10

Open
tony wants to merge 65 commits into
masterfrom
libtmux-mcp-sync
Open

MCP: freeze the tool surface, bound its work, and guard pane input#10
tony wants to merge 65 commits into
masterfrom
libtmux-mcp-sync

Conversation

@tony

@tony tony commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

libtmux-mcp exposed a surface whose shape depended on runtime configuration: safety tiers gated which tools appeared, LIBTMUX_WATCH added dynamic resource notifications, and prompts, completions, per-call server discovery and workspace tools added routes alongside the typed ones. This branch replaces all of that with one native catalog that drives registration, schemas, trust metadata, selection, and the static tmux://capabilities resource, then bounds and guards what the surviving tools are allowed to do.

The capability surface

One Catalog is now the single source for 45 tools. It drives runtime registration, the documented surface, and the tests, so the three cannot drift. Unordered toolsets (inspect, manage, execute, teardown) with named include and exclude lists replace safety tiers; the tool count is pinned by CapabilityRegistryTest and MainTest so adding a tool without deciding its toolset fails the gate.

Prompts, completions, watches, dynamic resources, per-call server discovery, and the workspace tools are removed. Copy-mode entry and exit are removed from MCP and stay library-only: Pane.copyMode and Pane.exitMode remain for Java callers, but the MCP surface reads pane text through capture_pane history, snapshot_pane, search_panes, or capture_since rather than taking ownership of an attached client's modal interface. libtmux-mcp/AGENTS.md writes that boundary down so the next tool addition is decided against a rule rather than a precedent.

The server now defaults to the dedicated libtmux-mcp socket, accepts separate socket-name and absolute socket-path selectors, and enables teardown by default only for a newly created minimal daemon.

Bounded work

Every unbounded path got a ceiling. Search stops after 200 panes, 20,000 lines, 1,000,000 bytes of matching input, or five seconds. Read batches validate each nested call and cap the complete JSON-RPC response — line framing included — at 1,000,000 bytes without dropping a row that already executed. Request IDs over 512 KiB now fail before dispatch instead of consuming that response budget.

Pattern matching moved to re2j, so a caller-supplied regular expression cannot exhaust the time ceiling through backtracking. The time limit and the engine choice enforce the same invariant from two sides.

capture_since and call_read_tools_batch now advertise observe-only tmux effects, and capability rows disclose both output risk dimensions separately: pane text, environment and configured-command values, and names, titles, paths or current commands advertise both secret and untrusted-content risk, while strictly structural results stay false for both.

Outbound sends

The pinned SDK's stdio transport rejects concurrent emission into its outbound sink, so SerializedTransportProvider gives each session a send queue bounded at 256 messages and 16 MB. That queue had a defect of its own, fixed here: it subscribed to the delegate while holding its lock. The SDK resolves a send as Mono.zip(inboundReady, outboundReady).then(Mono.defer(...)), which completes on the subscribing thread once both readiness sinks are done — the normal state after startup. Completion therefore re-entered the drain inline, so a caller's completion callback ran under the transport lock and the queue recursed one stack frame per message. Sends are now promoted on a drain flag, and both the delegate subscription and the caller's sink resolve with the lock released. The existing tests could not see it because their transport double always deferred completion; the new one completes synchronously, the way the real transport does.

Pane input

MCP pane input now refuses effective recipients in a human-owned mode. send_keys and each send_keys_batch operation resolve pane-level synchronize-panes overrides before dispatch, so a single call cannot fan out into a pane a person is driving. paste_text stays target-only, dead configured recipients fail closed, and run_shell_command checks for a singular cohort both before setup and again before input, because its completion, output and status are all singular.

Underneath that, caller identity is now proven rather than trusted. Canonical caller identity is parsed and compared by physical endpoint first, and disabled, malformed or inconsistent input authority is refused. Terminal session, window, pane and zoom are decoded from one snapshot, with control clients excluded before terminal-only context is parsed, so an active pane no longer implies a proven client. Canonical window indexes bind pane and terminal-client placements, so linked topology or a relocated client invalidates the final guard instead of slipping past it. Dispatch reserves a full-generation cohort so no other writer can overlap delivery or paste staging, and rechecks exact pane context immediately before send and paste. For teardown, confirm_self no longer bypasses incomplete or stale inherited caller identity. run_shell_command holds one non-queueing generation lease across both of its checks and keeps ownership uncertain — rather than releasing it — until it sees a valid status or an authenticated disappearance.

run_shell_command framing

Completion detection no longer relies on mutable pane-shell state. Markers and signalling run in an isolated outer subshell reached through an absolute selected tmux executable and the server's resolved -S socket, so an ordinary output-command alias or function, a pane-local PATH or socket variable, an inherited errexit, or a readonly nonce name cannot lose completion or close the pane. The frame leaves no status variable behind.

Two assumptions remain, and are stated in the changelog rather than hidden: the parent shell has not replaced the exact client word or trap/eval/exit with functions, and marker commands honor trusted server hooks.

Pointing an agent at this build

scripts/mcp_swap.py is replaced by tools/mcp-swap, a private Java utility in the build. Keeping a Python switcher alongside a Java one would have let the safety and recovery contracts drift, which is the failure the tool exists to prevent.

It covers eight agent clients across JSON, JSONC and TOML. OpenCode edits preserve JSONC comments and trailing commas, Pi reports its adapter prerequisite, and antigravity selects canonical agy. Multi-client use and revert preflight and stage one transaction, preserve config symlinks, reverse proven writes on failure, and keep --dry-run fully observational. Recovery records are persistent and versioned, binding each backup to the exact swapped config, path topology, and server route; drift fails closed without deleting recovery. Decoding is strict UTF-8 in both directions, so Java's permissive decoder cannot silently replace invalid bytes and let a later swap persist the corruption.

$ ./gradlew :tools:mcp-swap:installDist
$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use --dry-run

The root check task gained a buildFile.exists() filter, because tools is materialized as a project by tools:mcp-swap but has no build file and therefore no check task.

Core library and test fixtures

Pane.sendKeys ends tmux option parsing before caller keys, so values such as -X, -R and -N reach pane programs through the core API and through both MCP routes. Pane.breakOut preserves a literal # in a requested window name: break-pane -n receives the raw name, and only the tmux 3.7 rename fallback applies tmux format literalization — the 3.7 behavior that fallback exists for is measured in docs/spikes/09-break-pane-3.7.md.

NamedServerFixture in libtmux-junit5 lets a test own an explicitly named server safely. It binds teardown to the reported process, socket path, and inode, then fails closed if any part of that identity changed before cleanup — path plus PID is not enough, because a socket can be unlinked and recreated at the same path by a different server in between.

The suite's tmux quarantine now derives from libtmuxSocketRoot rather than hardcoding the default, so overriding that property moves a run's bare-client sockets along with its named ones instead of splitting them across two roots. The length check on that root moved ahead of the first path built under it.

examples/ gains ServeTmuxOverMcp, which serves a tmux server over a caller-supplied transport and lists what an agent would be offered. It runs under ExamplesRunTest with the other four. It reports 41 rather than the catalog's 45, which is the teardown rule made visible: a server the process did not start does not hand an agent the four tools that end it.

Breaking changes

Retired configuration fails with migration guidance rather than being ignored:

  • LIBTMUX_SAFETY and --safety — use toolsets with include and exclude lists.
  • LIBTMUX_WATCH and --watch — use bounded wait and capture tools instead of dynamic resource notifications.

docs/guide/mcp.md maps every earlier public tool, resource URI, prompt workflow, and completion path to its current typed route, its composed workflow, or an explicit no-replacement boundary.

Verification

$ ./gradlew check --rerun-tasks

BUILD SUCCESSFUL, 76 actionable tasks and 76 executed — nothing reported UP-TO-DATE, so every gate actually ran. That is the full suite with 0 failures, including tools/mcp-swap, libtmux-mcp, integration-tests, and the docs-tests that compile and run every documentation snippet. Run against tmux 3.7d on JDK 26.

That is one local tmux on one JDK, and CONTRIBUTING.md says plainly that a green check has not predicted the matrix. CI covers the rest and passes: check on JDK 21 and 25, the full tmux matrix across 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7, 3.7a, 3.7b and 3.7c, and CodeQL.

Commit messages were normalized to WRITING.md in a message-only rewrite — every subject at 50 characters or fewer and in Scope(type[detail]) form, every body line at 72 or fewer. The rewrite was verified tree-identical to the reviewed history before it was pushed.

Notes for review

This is larger than one sitting of review. The through-line is the MCP surface — the core Pane fixes are bugs the guarded-input work surfaced, NamedServerFixture is what the socket-identity tests needed, and tools/mcp-swap is how the surface gets exercised in a real agent — but if you would rather review the switcher separately, it is self-contained under tools/mcp-swap and touches nothing else except settings.gradle.kts, the version catalog, and the root check filter.

tony added 30 commits September 5, 2026 04:23
why: The aggregate capped its duplicated tool result at one MiB before
JSON-RPC added the response id and line framing.

what:
- Apply the 1,000,000-byte cap to the complete outgoing response
- Retain every executed row and mark removed nested payloads
- Cover the real stdio envelope, id, and newline
why: Cursor capture observes retained pane output; it does not change
tmux state under the capability contract.

what:
- Publish capture_since as observe-only
- Derive the read aggregate's observe-only union
why: The SDK accepts request lines large enough for an ID to exceed the
complete batch-response budget before a tool runs.

what:
- Cap serialized stdio request IDs at 512 KiB before SDK dispatch
- Write a bounded id-null invalid-request response under the output lock
- Cover the accepted boundary and rejected inspect call over real stdio
why: Copy mode is attached-client modal state. MCP can read pane text
through bounded capture without taking ownership of a person's mode.

what:
- Remove copy-mode enter and exit from the MCP catalog and handlers
- Keep manifest tests and documentation aligned at 45 tools
- Preserve the core Pane copy-mode API for Java callers
- Add module-local guidance for future MCP changes
why: The public manifest contains 45 tools and keeps modal copy-mode
ownership in the core Java library.

what:
- Correct the fixed MCP tool count
- Document the capture-based route and retained Pane APIs
why: Interactive shell state can suppress completion markers, abort the
pane, collide with command status bookkeeping, or reroute a relative
client/socket.

what:
- Emit framed status and completion from an outer subshell exit trap
- Pin an absolute executable and the live server's absolute socket
- Preserve inherited command state inside a separate inner subshell
- Document and cover the explicit shell/server trust boundaries
why: Pane input can fan out through synchronized panes. Modal, dead, or
changed pane state makes delivery unsafe or ambiguous.

what:
- Resolve strict effective recipient cohorts from one targeted listing
- Refuse modal, dead, plural, or changed command targets before input
- Preserve resolved batch membership across policy and dispatch failures
why: Tool callers need the same modal, synchronized, and observational
boundaries in schemas, tool descriptions, and user guidance.

what:
- Require resolved pane ids on every batch result row
- Explain effective cohorts, target-only paste, and two run preflights
- Preserve README structure, examples, links, and generated inventory
why: MCP callers need the modal input and completion framing boundaries
in the Unreleased ledger.

what:
- Record effective-cohort refusal and target-only paste
- Record isolated framing, exact routing, and trusted-shell limits
why: Deep module build paths can exhaust AF_UNIX before tmux appends its
uid directory and named socket.

what:
- Derive a short quarantine from worktree and task identity
- Recreate each task namespace before launching its test process
- Cover the path contract and named sockets with real tmux
why: Concurrent Gradle invocations can share a stable task digest, and
deleting an old namespace can unlink a live tmux socket.

what:
- Include the execution owner in each quarantine identity
- Refuse prior files and sockets before pruning empty directories
- Document concurrent and stale-state handling
tmux leaves named socket inodes after the server exits. The next run
therefore trips the quarantine fail-closed stale-entry guard.

Capture the reported process, path, and inode before teardown. Delete
only that socket after the process dies, and prune only empty fixture
directories.
The forced branch check found formatting drift in the MCP input changes.

Run the repository formatter on the affected MCP production and test
files. Every file keeps the same non-whitespace byte stream.
why: Named tmux teardown can leave a dead Unix socket that poisons the
next Gradle invocation.

what:
- Authenticate the reported PID, path, and inode before teardown
- Fail closed on replaced entries and prune only empty directories
- Reuse the shared cleanup in launcher and integration tests
why: Caller text beginning with a tmux flag is parsed as control input
instead of keys.

what:
- End send-keys option parsing before caller-supplied keys
- Cover the core API and MCP single and batch routes
why: break-pane does not expand tmux formats, so escaping a hash changes
the caller's requested name.

what:
- Pass the requested name raw to break-pane
- Keep format escaping on the tmux 3.7 rename fallback
- Cover current, oldest, and fallback tmux lanes
why: Rich tmux results can return secrets and untrusted instructions,
but the registry advertised only one risk dimension for many tools.

what:
- Define exact risks for all forty-five public tools
- Mark content-bearing results sensitive and untrusted
- Keep ID, status, boolean, and numeric results structural
why: Several changed guide sentences exceeded the repository prose width
convention.

what:
- Reflow only the affected MCP guide sentences
why: Public fixture, Pane, and capability behavior changed after review.

what:
- Record named-server ownership and literal input fixes
- Disclose the corrected MCP output-risk contract
why:
- Scheduling could expire the deadline before Bash installed its trap.

what:
- Wait until the trap and initial child are live before cleanup.
- Delay setup past the deadline so barrier removal reliably fails.
why: A stale fixture could send kill-server to a replacement endpoint.

what:
- Reauthenticate a live endpoint before ending its server.
- Refuse endpoint commands when ownership cannot be proven.
- Cover bounded cleanup without harming a replacement server.
why: Explicit MCP endpoints need PID and inode safe cleanup.

what:
- Add an exact-path ownership overload with containment checks.
- Reuse the fixture for launch ownership and assert no residue.
- Exercise two consecutive explicit-path lifecycles.
why: Three branch-authored guide lines exceeded 80 columns.

what:
- Reflow only whitespace while preserving every word and link.
why: A replacement could bind between identity and kill requests.

what:
- Fence the captured PID and kill in one tmux invocation.
- Reject the stale branch without touching its server.
- Keep replacement cleanup on the test thread and cover the race.
why: OpenCode and Pi were absent, and a late preflight failure could
leave earlier client configs partially swapped.

what:
- add format-preserving OpenCode and Pi adapters plus the
  antigravity-to-agy alias
- render and validate every selected config before the first write
- test isolated per-client and combined swap/revert behavior and
  document the eight-client surface
why: Document the expanded development swapper surface and its safety
boundary.

what:
- record the eight clients, JSONC fidelity, Pi prerequisite, alias, and
  all-config preflight
tony added 28 commits September 5, 2026 18:56
why: Revert now depends on an authenticated backup and state pair, but
the usage guide still described a backup-only workflow.

what: Document exact ownership checks, fail-closed retention, and state
participation in the all-client transaction.
why: The Unreleased swap entry covers transactions and symlinks but
omits the persistent ownership proof that now guards revert.

what: Record versioned recovery state and fail-closed retention on
drift.
why: Repeat use copied prior state instead of moving the owned file, so
rollback changed its inode and reported an incomplete recovery.

what: Move prior state into its recovery slot before replacement, and
prove exact identity restoration across state and config failures.
why: A repeat use can publish a new config immediately before a human
replacement makes rollback unprovable. Cleaning the previous state inode
then destroys part of the authenticated recovery unit.

what:
- Preserve prior state recovery when config rollback is blocked.
- Cover the post-publication human replacement deterministically.
why: A pane can enter a human-owned mode after paste setup, and empty
text was rejected before its safety guard. Either path violated the
target-only paste boundary.

what:
- Stage one private buffer, then revalidate the target before one paste.
- Treat empty text without Enter as a guarded, buffer-free no-op.
- Clean a staged buffer when the guard or paste dispatch fails.
why: A completed swap or revert could report success while private
recovery stages remained after cleanup failed.

what:
- Return a transaction failure when post-commit stage cleanup is
  incomplete
- Cover use and revert cleanup failures and residue-free success
why: Concurrent swaps and late path replacements could bypass preflight,
remove an unexpected inode, or mutate through a replaced lock.

what:
- Hold and authenticate the persistent swap lock for mutations
- Reject config, backup, and state aliases to the lock
- Preserve unexpected inodes at every use and revert replace boundary
why: Pane input accepted disabled panes and incomplete or inconsistent
inherited caller claims.

what:
- Parse canonical caller identity and compare its physical endpoint
  first
- Snapshot daemon generation and caller topology with pane input state
- Refuse disabled, malformed, or inconsistent input authority
why: confirm_self could bypass incomplete or stale inherited caller
identity and end an unproven target.

what:
- Reject uncertain caller claims before considering confirmation
- Recheck pane, session, daemon, and socket in one tmux response
- Preserve exact-self and foreign-daemon teardown behavior
why: Terminal attention trusted an active pane without proving the
client's session and window, while control clients parsed irrelevant
fields.

what:
- Decode terminal session, window, pane, and zoom from one snapshot
- Exclude control clients before parsing terminal-only context
- Reject unknown, malformed, and inconsistent client placements
why: Pane input had no process-wide owner across its final safety check,
so another writer could overlap delivery or paste staging.

what:
- Reserve full-generation configured cohorts across each dispatch
- Recheck exact pane context immediately before send and paste
- Unify physical socket aliases and keep empty paste buffer-free
why: Timeout, cancellation, or ambiguous delivery released ownership
while the dispatched shell frame could still be running.

what:
- Hold one nonqueueing generation lease across both run checks
- Retain uncertainty until valid status or authenticated disappearance
- Reject concurrent input and malformed completion without queuing
why: Pane input omitted window indexes and reduced terminal clients to
attended pane IDs, so linked topology or client relocation could change
without invalidating the final guard.

what:
- Parse canonical window indexes for pane and terminal-client placements
- Reject incomplete linked-window rectangles and inconsistent clients
- Bind full canonical client placement records into input transitions
why: The repository-native swap utility needs one deterministic model of
every supported client before its config transaction can replace the
Python script.

what:
- Add a non-published Gradle application module
- Model all eight client config routes in canonical order
- Parse repeated and comma-separated selectors with the agy alias
- Prove every client ordering normalizes to the same selection
why: A native swapper must update JSON, JSONC, and TOML without turning
developer convenience into destructive config reformatting.

what:
- Render the standard and OpenCode server entry shapes
- Splice JSONC while retaining comments and trailing commas
- Replace only the selected TOML table and keep unrelated bytes
- Round-trip every client and quoted server name
why: Client config rewrites need one fail-closed transaction; otherwise
a late path change or partial failure can mix server routes and destroy
a person's config or recovery copy.

what:
- Hold a private authenticated interprocess lock
- Bind logical routes to physical files across every client
- Stage create-new config, backup, and recovery replacements
- Roll back exact identities in reverse and retain uncertain recovery
why: A byte-identical backup replacement or late route change could be
accepted across invocations or erase the only exact recovery source.

what:
- Link first backups to the authenticated original inode
- Bind backup identity into checksummed recovery state
- Recheck unselected clients and lock ownership at every boundary
- Retain recovery artifacts when exact rollback becomes uncertain
why: The internal swap transaction had no Java entry point, leaving
developers dependent on Python to select launchers and operate configs.

what:
- Add native detect, status, use, revert, and doctor commands
- Support dist, Gradle, and explicit executable launch modes
- Preflight dry runs and build the server before transactional writes
- Exercise all eight client formats through use and byte-exact revert
why: Replacing a server entry could discard LIBTMUX_TOOLSETS or leave
the retired safety setting active in nested TOML configuration.

what:
- Preserve existing JSON, JSONC, and TOML environment values
- Remove LIBTMUX_SAFETY only with an explicit toolset replacement
- Accept repeatable KEY=VALUE overrides from the native command
- Keep comments and unrelated server entries during environment updates
why: Dry runs accepted unsafe lock routes, and coercive recovery reads
could treat malformed fields as valid state.

what:
- Validate lock files and private directories without creating them
- Require canonical integer and boolean recovery fields
- Bracket file reads with mode and link-count checks
- Retain recovery after checksummed type tampering
why: Valid MCP entries may omit an empty argument list, but status and
replacement rejected those JSON and TOML configurations.

what:
- Treat an absent args field as an empty list
- Keep rejecting present argument fields with the wrong type
- Cover both standard JSON and TOML client shapes
why: Path strings and file identities do not detect a parent directory
replacement that moves every protected file back into place.

what:
- Bind each live route to its nearest existing directory inode
- Persist the directory binding in recovery state
- Reject replacements during commit and across a later revert
- Cover both races with inode-preserving directory swaps
why: Java's permissive decoder can replace invalid config bytes and let
a subsequent swap persist silent corruption.

what:
- Decode JSON, JSONC, and TOML with strict UTF-8 error reporting
- Refuse malformed bytes during both reads and updates
- Cover invalid bytes in parseable strings and comments
why: The private Java utility must cover every supported client and
Claude scope without relying on the retired Python implementation.

what:
- Add exact preflight, cross-port locking, and layered recovery
- Harden eight-client transactions, formats, routes, and cleanup
why: Maintaining two config switchers would let safety and recovery
contracts drift.

what:
- Remove the Python implementation and tests after native parity
- Document native builds, scopes, preflight, and recovery
why: The pinned SDK resolves a stdio send on the subscribing thread once
its readiness sinks are complete, so completion re-entered the drain: a
caller's callback ran while the send lock was held, and the queue
recursed one stack frame per message.

what:
- Promote queued sends on a drain flag instead of recursively
- Subscribe and resolve caller sinks outside the send lock
- Cover a synchronously completing delegate, which the paused test
  double could not reach
why: The swap entry still opened with mcp_swap.py, the script this
branch deletes, and was the only stale reference to that path left in
the repository.

what:
- Name tools/mcp-swap and the task that builds it
- Record the strict UTF-8 decoding the entry omitted
why: The tmux quarantine hardcoded the default socket root, so
overriding libtmuxSocketRoot split a run's bare-client sockets from its
named ones across two roots.

what:
- Derive the quarantine directory from the configured root
- Check that root's length before building a path under it
- Drop two comment stubs left by the move into doFirst
why: Nothing in examples/ reached libtmux-mcp, so the one question an
embedder asks first, what an agent will actually be offered, had no
runnable answer.

what:
- Serve a tmux server over a caller-supplied transport and list tools
- Record that teardown is withheld from a server the example did not
  start, which is why the count is 41 and not the catalog's 45
- Run it under ExamplesRunTest with the other four
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant