MCP: freeze the tool surface, bound its work, and guard pane input - #10
Open
tony wants to merge 65 commits into
Open
MCP: freeze the tool surface, bound its work, and guard pane input#10tony wants to merge 65 commits into
tony wants to merge 65 commits into
Conversation
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
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
tony
force-pushed
the
libtmux-mcp-sync
branch
from
September 6, 2026 11:19
50c8537 to
f492e20
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
libtmux-mcpexposed a surface whose shape depended on runtime configuration: safety tiers gated which tools appeared,LIBTMUX_WATCHadded 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 statictmux://capabilitiesresource, then bounds and guards what the surviving tools are allowed to do.The capability surface
One
Catalogis 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 byCapabilityRegistryTestandMainTestso 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.copyModeandPane.exitModeremain for Java callers, but the MCP surface reads pane text throughcapture_panehistory,snapshot_pane,search_panes, orcapture_sincerather than taking ownership of an attached client's modal interface.libtmux-mcp/AGENTS.mdwrites 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-mcpsocket, 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_sinceandcall_read_tools_batchnow 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
SerializedTransportProvidergives 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 asMono.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_keysand eachsend_keys_batchoperation resolve pane-levelsynchronize-panesoverrides before dispatch, so a single call cannot fan out into a pane a person is driving.paste_textstays target-only, dead configured recipients fail closed, andrun_shell_commandchecks 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_selfno longer bypasses incomplete or stale inherited caller identity.run_shell_commandholds 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_commandframingCompletion 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
-Ssocket, so an ordinary output-command alias or function, a pane-localPATHor socket variable, an inheritederrexit, 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/exitwith functions, and marker commands honor trusted server hooks.Pointing an agent at this build
scripts/mcp_swap.pyis replaced bytools/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
antigravityselects canonicalagy. Multi-client use and revert preflight and stage one transaction, preserve config symlinks, reverse proven writes on failure, and keep--dry-runfully 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-runThe root
checktask gained abuildFile.exists()filter, becausetoolsis materialized as a project bytools:mcp-swapbut has no build file and therefore nochecktask.Core library and test fixtures
Pane.sendKeysends tmux option parsing before caller keys, so values such as-X,-Rand-Nreach pane programs through the core API and through both MCP routes.Pane.breakOutpreserves a literal#in a requested window name:break-pane -nreceives 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 indocs/spikes/09-break-pane-3.7.md.NamedServerFixtureinlibtmux-junit5lets 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
libtmuxSocketRootrather 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/gainsServeTmuxOverMcp, which serves a tmux server over a caller-supplied transport and lists what an agent would be offered. It runs underExamplesRunTestwith 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_SAFETYand--safety— use toolsets with include and exclude lists.LIBTMUX_WATCHand--watch— use bounded wait and capture tools instead of dynamic resource notifications.docs/guide/mcp.mdmaps 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-tasksBUILD SUCCESSFUL, 76 actionable tasks and 76 executed — nothing reportedUP-TO-DATE, so every gate actually ran. That is the full suite with 0 failures, includingtools/mcp-swap,libtmux-mcp,integration-tests, and thedocs-teststhat compile and run every documentation snippet. Run against tmux 3.7d on JDK 26.That is one local tmux on one JDK, and
CONTRIBUTING.mdsays plainly that a greencheckhas not predicted the matrix. CI covers the rest and passes:checkon 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.mdin a message-only rewrite — every subject at 50 characters or fewer and inScope(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
Panefixes are bugs the guarded-input work surfaced,NamedServerFixtureis what the socket-identity tests needed, andtools/mcp-swapis how the surface gets exercised in a real agent — but if you would rather review the switcher separately, it is self-contained undertools/mcp-swapand touches nothing else exceptsettings.gradle.kts, the version catalog, and the rootcheckfilter.