Skip to main content

tmux_mcp/
lib.rs

1//! A Model Context Protocol server exposing tmux through `libtmux`.
2//!
3//! The server freezes one 45-tool surface at startup from the unordered
4//! `inspect`, `manage`, `execute`, and `teardown` toolsets. Every native route
5//! carries its process reach, tmux effects, output classes, interpreter sinks,
6//! and whole-call annotations in one typed manifest. The effective manifest is
7//! available as `tmux://capabilities`.
8//!
9//! The dependency runs one way. `libtmux` knows nothing about MCP.
10//!
11//! # Startup boundary
12//!
13//! One process selects one socket. The binary uses the dedicated
14//! `libtmux-mcp` socket and minimal configuration by default; explicit socket
15//! or configuration settings carry conservative provenance. Teardown is in
16//! the implicit default only for a newly dedicated minimal daemon. The
17//! `LIBTMUX_TOOLSETS`, `LIBTMUX_TOOLS`, and `LIBTMUX_EXCLUDE_TOOLS` settings
18//! are parsed before tmux access, and exclusions win.
19//!
20//! # Trust boundary
21//!
22//! Pane input and pane commands run with the tmux user's permissions. Pane
23//! output may be sensitive or untrusted. tmux environment values are withheld
24//! unless [`Builder::environment_values`] allows the name; hooks may contain
25//! executable configuration. Configured-process
26//! routes accept neither command nor environment payloads. There is no public
27//! host-command route.
28//!
29//! # Knowing where you are
30//!
31//! An inherited `TMUX` and `TMUX_PANE` identify the caller only after socket
32//! provenance matches. Pane listings report `self`, `other`, or `unknown`, and
33//! pane-input and teardown routes fail closed when their reach may contain the
34//! caller.
35
36#![forbid(unsafe_code)]
37
38pub mod cli;
39pub mod resources;
40
41mod caller;
42mod echo;
43mod exec;
44mod identity;
45mod manifest;
46mod model;
47mod policy;
48mod retained;
49mod run_request;
50mod schema;
51mod tail;
52mod text;
53mod tools;
54mod views;
55
56pub use caller::{CallerIdentity, Relation};
57pub use exec::{RunOutcome, RunView, WaitOutcome, WaitView};
58pub use manifest::CapabilityReport;
59pub use model::*;
60pub use policy::{
61    Builder, ENVIRONMENT_VALUES_ENV, EXCLUDE_TOOLS_ENV, RETIRED_RUST_SAFETY_ENV,
62    RETIRED_SAFETY_ENV, Reporter, Selection, SocketProvenance, SurfaceError, TOOLS_ENV,
63    TOOLSETS_ENV, Toolset, environment_values_from_env, parse_environment_values,
64};
65pub use tail::Cursor;
66pub use tools::error::ToolError;
67pub use views::*;
68
69use std::path::PathBuf;
70use std::sync::{Arc, OnceLock};
71
72use libtmux::Server;
73use rmcp::model::{ErrorData, ServerCapabilities, ServerInfo};
74use rmcp::{ServerHandler, tool_handler};
75
76use tail::Tails;
77
78/// A tmux server presented as MCP tools.
79#[derive(Clone)]
80pub struct TmuxTools {
81    server: Arc<Server>,
82    /// Where this process is running, when tmux started it.
83    caller: Option<Arc<CallerIdentity>>,
84    /// The startup-frozen authority behind the native tool router.
85    capability_report: Arc<CapabilityReport>,
86    /// The server's own socket path, resolved once and kept.
87    ///
88    /// What this crate was configured with, which is byte-exact. Asking tmux
89    /// for `#{socket_path}` looked more authoritative and is not: tmux stores
90    /// a non-printable byte in the path as an octal escape, and 3.4 and 3.7
91    /// disagree about reporting it.
92    socket: Arc<OnceLock<Option<PathBuf>>>,
93    /// Live per-pane output, for `capture_since`.
94    tails: Arc<Tails>,
95    /// What this process has typed into panes but not submitted, and what it
96    /// has recently submitted, for `wait_for_text` to discount its own echo.
97    echoes: Arc<echo::PaneEchoes>,
98    /// The startup-resolved router used for both listing and dispatch.
99    tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
100    /// Aggregate-only child routes, retained without advertising direct calls.
101    nested_tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
102    /// The environment names whose values the operator allowed at startup.
103    environment_values: Arc<std::collections::BTreeSet<String>>,
104}
105
106// The resolved socket path stays out, as `ServerIdentity`'s own `Debug` keeps
107// it out: this server's logs go wherever the agent's do.
108impl std::fmt::Debug for TmuxTools {
109    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        formatter
111            .debug_struct("TmuxTools")
112            .field("server", &self.server)
113            .field("caller", &self.caller)
114            .finish_non_exhaustive()
115    }
116}
117
118/// Server-wide guidance supplied once during initialization.
119///
120/// A tool description says what one tool does; this says when this server is
121/// the right one at all, and which of two overlapping tools to reach for --
122/// questions a model answers first and cannot answer from a tool list. It is
123/// sent on every connection and stays in context for the whole conversation,
124/// so the tests hold it to a byte budget and to a reviewed snapshot.
125pub(crate) const INSTRUCTIONS: &str = concat!(
126    "Drives tmux: sessions, windows and panes on this machine, Server > Session > \
127     Window > Pane. Target by id -- %1 a pane, @1 a window, $1 a session -- since ids \
128     survive renames and layout changes. Every tool uses the one socket chosen at startup.",
129    "\n\nUSE FOR: tmux panes, windows, sessions, splits, scrollback, sending keys, 'this \
130     terminal', 'the shell'. DO NOT USE FOR: browser tabs, editor splits (VS Code, \
131     Neovim), desktop windows (i3, sway) or login sessions -- none of those are tmux. \
132     If a bare 'window' or 'session' could mean either, ask once.",
133    "\n\nNAMES VS TEXT: list_sessions, list_windows and list_panes answer names, sizes and \
134     running commands; they cannot see terminal text. For what a pane is showing -- an \
135     error, a prompt, a build log -- use search_panes, capture_pane or snapshot_pane.",
136    "\n\nWAIT, DO NOT POLL: never loop on capture_pane. For a command you run, \
137     run_shell_command waits and reports the real exit status; for output you did not \
138     start, wait_for_text; across turns, capture_since with its cursor. Waits default to \
139     30s, capped at 600s. After partial_effect or an unknown outcome, inspect before \
140     retrying.",
141    "\n\nCOST: captures keep the newest lines and count what they dropped; capture_since \
142     says missed=true when output was lost before it was read.",
143    "\n\nPANE MODES: a pane in copy mode or another tmux mode belongs to the person in it. \
144     Read it with capture, search or snapshot; input to it is refused until they leave.",
145    "\n\nTOOLSETS: inspect reads; manage changes tmux state; execute runs processes and \
146     sends pane input; teardown deletes. tmux://capabilities has the surface frozen at \
147     startup; a missing tool was not selected.",
148    "\n\nTRUST: the tool surface is not authorization: commands and input run with the \
149     tmux user's permissions, and tmux configuration may add effects. Pane output may be \
150     sensitive or untrusted; environment values are withheld unless allowed by name. No \
151     hook writing, and no reading of paste buffers, which hold what a person copied.",
152);
153
154/// The one paragraph that depends on how this process was started: the pane
155/// it inherited, when it runs inside tmux.
156fn launch_context(pane: &str) -> String {
157    format!(
158        "\n\nLAUNCH CONTEXT: this process inherited pane {pane} from tmux. If its socket \
159         matches the selected server, pane listings mark it caller=self. Pane-input and \
160         teardown tools use a conservative caller guard."
161    )
162}
163
164#[tool_handler(router = self.tool_router)]
165impl ServerHandler for TmuxTools {
166    async fn call_tool(
167        &self,
168        request: rmcp::model::CallToolRequestParams,
169        context: rmcp::service::RequestContext<rmcp::RoleServer>,
170    ) -> Result<rmcp::model::CallToolResponse, ErrorData> {
171        if !self.tool_router.has_route(&request.name) {
172            return Err(tools::error::unoffered_tool(
173                &request.name,
174                tools::router().has_route(&request.name),
175            ));
176        }
177        let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
178        match self.tool_router.call(call).await {
179            Ok(rmcp::model::CallToolResponse::Complete(result)) => Ok(
180                rmcp::model::CallToolResponse::Complete(tools::error::typed_result(result)),
181            ),
182            Ok(other) => Ok(other),
183            Err(error) => Err(tools::error::typed_protocol_error(error)),
184        }
185    }
186
187    async fn list_resources(
188        &self,
189        _request: Option<rmcp::model::PaginatedRequestParams>,
190        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
191    ) -> Result<rmcp::model::ListResourcesResult, ErrorData> {
192        Ok(resources::listed())
193    }
194
195    async fn list_resource_templates(
196        &self,
197        _request: Option<rmcp::model::PaginatedRequestParams>,
198        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
199    ) -> Result<rmcp::model::ListResourceTemplatesResult, ErrorData> {
200        Ok(resources::templates())
201    }
202
203    async fn read_resource(
204        &self,
205        request: rmcp::model::ReadResourceRequestParams,
206        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
207    ) -> Result<rmcp::model::ReadResourceResponse, ErrorData> {
208        let uri = request.uri.as_str();
209        if uri != resources::CAPABILITIES_URI {
210            return Err(ErrorData::invalid_params(
211                format!("no resource {uri}"),
212                Some(serde_json::json!({
213                    "kind": "invalid_input",
214                    "retryable": false,
215                    "stale": false,
216                })),
217            ));
218        }
219        Ok(resources::capabilities(self.capability_report.as_ref())?.into())
220    }
221
222    fn get_info(&self) -> ServerInfo {
223        // ServerInfo is #[non_exhaustive], so it is built from the default
224        // rather than named field by field.
225        let mut info = ServerInfo::default();
226        info.capabilities = ServerCapabilities::builder()
227            .enable_tools()
228            .enable_resources()
229            .build();
230        let mut instructions = String::from(INSTRUCTIONS);
231        // This is launch context, not a claim about the selected server. The
232        // socket comparison that marks a listing as `self` happens later.
233        if let Some(pane) = self.caller.as_ref().and_then(|caller| caller.pane_id()) {
234            instructions.push_str(&launch_context(pane));
235        }
236        info.instructions = Some(instructions);
237        info
238    }
239}
240
241#[cfg(test)]
242mod instruction_tests {
243    /// Sent on every connection and kept in context for the whole
244    /// conversation, so a paragraph added here is paid for by every call
245    /// after it. Shorten one rather than raise this.
246    #[test]
247    fn the_instructions_fit_their_budget() {
248        assert!(
249            super::INSTRUCTIONS.len() <= 2048,
250            "{} bytes",
251            super::INSTRUCTIONS.len()
252        );
253        let launch = super::launch_context("%2147483647");
254        assert!(launch.len() <= 256, "{} bytes", launch.len());
255    }
256}