tmux-mcp 0.1.0-alpha.16

Model Context Protocol server exposing tmux through libtmux (alpha)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
mod contract;
mod control;
pub(crate) mod error;
mod inspect;
mod observe;
mod pane_input;

use std::collections::BTreeSet;
use std::path::Path;
use std::time::Duration;

use libtmux::{CaptureOptions, TmuxText};
use rmcp::handler::server::wrapper::Json;
use rmcp::model::ErrorData;

use crate::caller::Relation;
use crate::{
    Capture, Marks, PaneView, Panes, SessionView, Sessions, TmuxTools, WindowView, Windows,
};

use error::{ToolError, bad_input, object_gone, tmux_error};

/// Render tmux bytes for a protocol that requires valid UTF-8.
///
/// tmux permits names and titles that are not UTF-8. JSON cannot carry those
/// bytes, so they are replaced rather than dropping the whole response.
fn lossy(value: &TmuxText) -> String {
    value.to_string_lossy().into_owned()
}

/// The same, for a field tmux may genuinely not report.
fn lossy_optional(value: Option<&TmuxText>) -> Option<String> {
    value.map(lossy)
}

/// Which tmux object an option belongs to.
///
/// Boxed because a `Session`, `Window` and `Pane` each carry their own
/// snapshot, and the enum is a short-lived dispatch rather than something
/// worth sizing to its largest arm.
enum OptionScope {
    /// The server's own options.
    Server,
    /// The session options a new session inherits.
    GlobalSession,
    /// The window options a new window inherits.
    GlobalWindow,
    /// One session's options.
    Session(Box<libtmux::Session>),
    /// One window's options.
    Window(Box<libtmux::Window>),
    /// One pane's options.
    Pane(Box<libtmux::Pane>),
}

pub(super) fn router() -> rmcp::handler::server::router::tool::ToolRouter<TmuxTools> {
    TmuxTools::inspect_router()
        + TmuxTools::control_router()
        + TmuxTools::contract_router()
        + TmuxTools::observe_router()
}

impl TmuxTools {
    /// Describe sessions, shared by the tool and the `tmux://` resource so the
    /// two cannot drift into different accounts of the same session.
    ///
    /// `foreign_attached` overrides `Session::is_attached` when it is
    /// `Some`: see [`Self::foreign_attached_sessions`].
    pub(super) fn render_sessions(
        sessions: &[libtmux::Session],
        foreign_attached: Option<&BTreeSet<String>>,
    ) -> Sessions {
        Sessions {
            sessions: sessions
                .iter()
                .map(|session| {
                    let id = session.id().to_string();
                    let attached = foreign_attached
                        .map_or_else(|| session.is_attached(), |set| set.contains(&id));
                    SessionView {
                        id,
                        name: lossy(session.name()),
                        windows: session.window_count(),
                        attached,
                    }
                })
                .collect(),
        }
    }

    /// The sessions with a client attached that this process did not open
    /// for its own observation.
    ///
    /// While `wait_for_text`, `stream_output`, or any other control
    /// connection this server opens is live, tmux counts it as an attached
    /// client the same as a human's terminal: `Session::is_attached` alone
    /// cannot tell the two apart. `Server::owns_control_client` resolves
    /// each client this reads from `list-clients` by pid, so a session
    /// reads as attached only when something else is there too.
    ///
    /// `None` when the listing itself could not be read: an empty server
    /// reports `no current target` for a server-wide listing, and every
    /// caller here falls back to `Session::is_attached` rather than
    /// answering `false` for a session that may well be attached.
    pub(super) async fn foreign_attached_sessions(&self) -> Option<BTreeSet<String>> {
        let result = self
            .server
            .cmd(
                libtmux::Command::new("list-clients")
                    .arg("-F")
                    .arg("#{session_id} #{client_pid}"),
            )
            .await
            .ok()?;
        if !result.success() {
            return None;
        }

        let mut sessions = BTreeSet::new();
        for line in result.stdout_lossy().lines() {
            let mut fields = line.split(' ');
            let (Some(session), Some(pid)) = (fields.next(), fields.next()) else {
                continue;
            };
            let Ok(pid) = pid.parse::<u32>() else {
                continue;
            };
            if !self.server.owns_control_client(pid) {
                sessions.insert(session.to_owned());
            }
        }
        Some(sessions)
    }

    /// Read what the last command in a pane printed.
    ///
    /// tmux records where a prompt and its output begin from the OSC 133
    /// sequences a shell emits. Where those marks exist this is exact; where
    /// they do not, the whole screen comes back with `marks` saying why, so a
    /// caller reads a field rather than guessing from a suspiciously long
    /// answer.
    pub(super) async fn capture_last_command(
        &self,
        pane: &str,
    ) -> Result<Json<Capture>, ToolError> {
        let target = self.find_pane(pane).await?;
        let supported = self.server.capabilities().await.is_ok_and(|capabilities| {
            capabilities
                .tmux_version()
                .has_behavior(&libtmux::since::CAPTURE_LINE_FLAGS)
        });

        let (rendered, marks) = if supported {
            let lines = target
                .capture_lines(CaptureOptions::history())
                .await
                .map_err(|e| tmux_error(&e))?;

            // The last run begins at the last line marked as output, and ends
            // where the next prompt begins -- which for the last command is
            // the end of what tmux holds.
            match lines.iter().rposition(|line| line.starts_output) {
                Some(from) => {
                    // Searched past the output's own line: a shell that emits
                    // both marks before printing anything puts them on one
                    // line, and that prompt cannot delimit its own output.
                    let to = lines[from + 1..]
                        .iter()
                        .position(|line| line.starts_prompt)
                        .map_or(lines.len(), |offset| from + 1 + offset);
                    (
                        lines[from..to]
                            .iter()
                            .map(|line| line.text.to_string_lossy().into_owned())
                            .collect::<Vec<_>>(),
                        Marks::Present,
                    )
                }
                // Falling back to the history would answer a request for one
                // command's output with everything the pane ever printed,
                // which is the most expensive answer available. The visible
                // screen is the bounded approximation.
                None => (
                    target
                        .capture_with(CaptureOptions::visible())
                        .await
                        .map_err(|e| tmux_error(&e))?
                        .iter()
                        .map(|line| line.to_string_lossy().into_owned())
                        .collect(),
                    Marks::Absent,
                ),
            }
        } else {
            let lines = target
                .capture_with(CaptureOptions::visible())
                .await
                .map_err(|e| tmux_error(&e))?;
            (
                lines
                    .iter()
                    .map(|line| line.to_string_lossy().into_owned())
                    .collect(),
                Marks::Unsupported,
            )
        };

        Ok(Json(Capture {
            pane: target.id().to_string(),
            lines: rendered.len(),
            text: rendered.join("\n"),
            marks,
        }))
    }

    /// Render one window as the protocol sees it.
    pub(super) fn one_window(window: &libtmux::Window) -> WindowView {
        WindowView {
            id: window.id().to_string(),
            session_id: window.session_id().to_string(),
            index: window.index(),
            name: lossy(window.name()),
            panes: window.pane_count(),
            active: window.is_active(),
            linked: window.is_linked(),
        }
    }

    /// Render windows as the protocol sees them.
    pub(super) fn render_windows(windows: &[libtmux::Window]) -> Windows {
        let windows: Vec<_> = windows.iter().map(Self::one_window).collect();

        Windows { windows }
    }

    /// Resolve the object an option belongs to.
    async fn option_scope(
        &self,
        scope: Option<&str>,
        target: Option<&str>,
    ) -> Result<OptionScope, ToolError> {
        let needs = |what: &str| bad_input(format!("scope {what} needs a target id"));

        match scope {
            Some("server") => Ok(OptionScope::Server),
            None | Some("global-session") => Ok(OptionScope::GlobalSession),
            Some("global-window") => Ok(OptionScope::GlobalWindow),
            Some("session") => Ok(OptionScope::Session(Box::new(
                self.find_session(target.ok_or_else(|| needs("session"))?)
                    .await?,
            ))),
            Some("window") => Ok(OptionScope::Window(Box::new(
                self.find_window(target.ok_or_else(|| needs("window"))?)
                    .await?,
            ))),
            Some("pane") => Ok(OptionScope::Pane(Box::new(
                self.find_pane(target.ok_or_else(|| needs("pane"))?).await?,
            ))),
            Some(unknown) => Err(bad_input(format!(
                "scope must be server, global-session, global-window, session, window, \
                     or pane, not {unknown}"
            ))),
        }
    }

    /// How long a blocking tool may hold the caller's turn.
    ///
    /// An MCP call blocks the agent that made it, so an unbounded wait costs a
    /// whole turn with nothing to show. The ceiling is generous enough for a
    /// slow build and short enough that a wedged wait is an annoyance.
    pub(super) fn budget(seconds: Option<u64>) -> Duration {
        Duration::from_secs(seconds.unwrap_or(30).clamp(1, 600))
    }

    /// Render panes as the protocol sees them, saying which one is our own.
    pub(super) fn render_panes(&self, panes: &[libtmux::Pane]) -> Panes {
        let socket = self.socket();
        let panes: Vec<_> = panes
            .iter()
            .map(|pane| self.pane_view(pane, socket))
            .collect();

        Panes { panes }
    }

    /// Describe one pane, including where it stands relative to this process.
    pub(super) fn pane_view(&self, pane: &libtmux::Pane, socket: Option<&Path>) -> PaneView {
        let id = pane.id().to_string();
        PaneView {
            caller: self
                .caller
                .as_ref()
                .map_or(Relation::Unknown, |caller| caller.relation_to(&id, socket)),
            id,
            window_id: pane.window_id().to_string(),
            command: lossy_optional(pane.current_command()),
            path: lossy_optional(pane.current_path()),
            active: pane.is_active(),
        }
    }

    /// The socket path this process connects through.
    ///
    /// Taken from this crate's configuration rather than from
    /// `#{socket_path}`, for the reasons `pane_input_endpoint` records: tmux
    /// stores a non-printable byte in the path as an octal escape and
    /// releases disagree about it, and reading the answer back as lossy UTF-8
    /// replaced any non-UTF-8 byte regardless of version. Both produced a
    /// path that matched nothing, which for a caller comparison means failing
    /// to recognize the caller's own pane.
    ///
    /// Resolved once. Two calls racing compute the same answer, so the loser
    /// discarding its own is harmless.
    pub(super) fn socket(&self) -> Option<&Path> {
        self.socket
            .get_or_init(|| Some(self.server.socket_path().to_path_buf()))
            .as_deref()
    }

    /// The pane protected as the inherited caller on this server.
    ///
    /// A returned pane has been resolved in the caller's claimed session on
    /// the selected daemon. Malformed or stale context refuses the operation.
    pub(super) async fn protected_pane(&self) -> Result<Option<&str>, ToolError> {
        if self.caller.is_none() {
            return Ok(None);
        }
        let generation = self.server.generation().await.map_err(|e| tmux_error(&e))?;
        let panes = self.server.panes().await.map_err(|e| tmux_error(&e))?;
        let socket = self.socket().ok_or_else(|| {
            Self::caller_context_refusal("tmux did not report its selected socket")
        })?;
        self.server
            .require_generation(generation)
            .await
            .map_err(|e| tmux_error(&e))?;
        self.caller_pane_for_snapshot(socket, generation, &panes)
    }

    pub(super) fn caller_pane_for_snapshot<'a>(
        &'a self,
        socket: &Path,
        generation: libtmux::ServerGeneration,
        panes: &[libtmux::Pane],
    ) -> Result<Option<&'a str>, ToolError> {
        let Some(caller) = self.caller.as_deref() else {
            return Ok(None);
        };
        caller
            .resolve_on(socket, generation, panes)
            .map_err(|detail| {
                Self::caller_context_refusal(&format!("inherited caller context is {detail}"))
            })
    }

    /// The tools this server offers after startup selection.
    #[must_use]
    pub fn offered(&self) -> Vec<rmcp::model::Tool> {
        self.tool_router.list_all()
    }

    /// The pane this process runs in, when tmux named a complete identity.
    ///
    /// Reported without checking it against the server, because this is for
    /// saying what the environment claimed rather than for deciding anything.
    #[must_use]
    pub fn caller_pane(&self) -> Option<&str> {
        self.caller.as_ref().and_then(|caller| caller.pane_id())
    }

    /// Whether the inherited caller context is set but malformed, which makes
    /// pane-input and teardown tools refuse every call.
    #[must_use]
    pub fn caller_is_malformed(&self) -> bool {
        self.caller
            .as_ref()
            .is_some_and(|caller| caller.is_malformed())
    }

    /// Classify a refusal that protects the pane this process talks through.
    pub(super) fn self_protection(message: String) -> ToolError {
        ErrorData::invalid_params(
            message,
            // Its own kind, because this is the server declining rather than
            // tmux: an agent that reads `refused` might reasonably try a
            // different argument, and no argument gets past this one.
            Some(serde_json::json!({
                "kind": "self_protection",
                "retryable": false,
                "stale": false,
            })),
        )
        .into()
    }

    fn caller_context_refusal(detail: &str) -> ToolError {
        Self::self_protection(format!(
            "refusing this operation because {detail}; restart the MCP outside tmux or with a complete current TMUX and TMUX_PANE context"
        ))
    }

    /// Refuse a command that may destroy the pane this process talks through.
    pub(super) fn self_harm(what: &str, own: &str) -> ToolError {
        Self::self_protection(format!(
            "refusing to kill this {what}: pane {own} matches this MCP server's inherited \
             caller context, so killing it may end this conversation. Run the command in \
             a terminal if that is what you meant."
        ))
    }

    /// Resolve a window id, reporting an unknown one as invalid input.
    ///
    /// The id is parsed before tmux sees it, which buys three things over
    /// scanning a full listing. tmux matches the id server-side and returns
    /// the one row. Text that is not an id at all is reported as bad input
    /// rather than as a window that went away, because an agent told "look
    /// again" will look again and `not-a-window` will still not be a window.
    /// And `@01` resolves, where a string comparison against the canonical
    /// `@1` called it missing.
    pub(super) async fn find_window(&self, id: &str) -> Result<libtmux::Window, ToolError> {
        let window: libtmux::WindowId = id.parse().map_err(|error: libtmux::IdParseError| {
            let sigil = error.expected_sigil();
            bad_input(format!(
                "{id} is not a window id: expected {sigil} followed by digits, as in {sigil}1"
            ))
        })?;

        self.server
            .window_by_id(&window)
            .await
            .map_err(|e| tmux_error(&e))?
            .ok_or_else(|| object_gone("window", id))
    }

    /// Resolve a pane id, reporting an unknown one as invalid input.
    ///
    /// Shares the reasoning on [`Self::find_window`].
    pub(super) async fn find_pane(&self, id: &str) -> Result<libtmux::Pane, ToolError> {
        let pane: libtmux::PaneId = id.parse().map_err(|error: libtmux::IdParseError| {
            let sigil = error.expected_sigil();
            bad_input(format!(
                "{id} is not a pane id: expected {sigil} followed by digits, as in {sigil}1"
            ))
        })?;

        self.server
            .pane_by_id(&pane)
            .await
            .map_err(|e| tmux_error(&e))?
            .ok_or_else(|| object_gone("pane", id))
    }

    /// Resolve a session by `$`-prefixed id or by name.
    ///
    /// An id is looked up as one, because the server's instructions tell an
    /// agent to prefer ids. Text that starts with `$` and is neither an id
    /// nor a session's name is invalid input, not a session that went away.
    pub(super) async fn find_session(&self, target: &str) -> Result<libtmux::Session, ToolError> {
        if target.starts_with('$')
            && let Ok(id) = target.parse::<libtmux::SessionId>()
        {
            return self
                .server
                .session_by_id(&id)
                .await
                .map_err(|e| tmux_error(&e))?
                .ok_or_else(|| object_gone("session", target));
        }
        let named = self
            .server
            .sessions()
            .await
            .map_err(|e| tmux_error(&e))?
            .into_iter()
            .find(|session| session.name() == target.as_bytes());
        match named {
            Some(session) => Ok(session),
            None if target.starts_with('$') => Err(bad_input(format!(
                "{target} is not a session id or name: an id is $ followed by digits, as in $1"
            ))),
            None => Err(object_gone("session", target)),
        }
    }
}