List sessions through MCP
tmux-mcp 0.1.0-alpha.16 · Source
This program connects an rmcp client to an embedded tmux-mcp server.
It discovers the tools, calls list_sessions,
and reads the session names from structured content.
It creates a private tmux server with two sessions running cat, then stops
that server before exiting. The socket lives in a temporary directory under
/tmp/libtmux-rs-dev/; the program does not select a server from your shell’s
TMUX variable or load your tmux configuration.
Run the example¶
Use Git, Rust 1.97.1, and tmux 3.2a or newer on Linux or macOS. Both libtmux crates below use the source revision documented by the MCP reference. The example was run on Linux with tmux 3.2a and 3.7c.
Create a directory for the program:
$ mkdir inspect-mcp-sessions$ cd inspect-mcp-sessions$ mkdir srcSave this as Cargo.toml:
[package]name = "inspect-mcp-sessions"version = "0.1.0"edition = "2024"publish = false
[dependencies]libtmux = { git = "https://github.com/libtmux/libtmux-rs", rev = "a6fc2a65674177b92b17fa380757155d2ba150fd" }tmux-mcp = { git = "https://github.com/libtmux/libtmux-rs", rev = "a6fc2a65674177b92b17fa380757155d2ba150fd" }rmcp = { version = "=3.1.2", features = ["client", "server", "transport-io"] }serde_json = "=1.0.151"tempfile = "=3.27.0"tokio = { version = "=1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }Save this as src/main.rs:
use std::error::Error;use std::io::ErrorKind;use std::path::Path;use std::time::Duration;
use libtmux::{NewSessionOptions, Server};use rmcp::model::CallToolRequestParams;use rmcp::service::QuitReason;use rmcp::{Peer, RoleClient, ServiceExt as _};use tmux_mcp::{Selection, TmuxTools};use tokio::time::timeout;
type ExampleError = Box<dyn Error + Send + Sync>;
fn check(condition: bool, message: &str) -> Result<(), ExampleError> { if condition { Ok(()) } else { Err(message.into()) }}
async fn inspect(client: &Peer<RoleClient>) -> Result<(), ExampleError> { let offered = client.list_all_tools().await?; check( offered.iter().any(|tool| tool.name == "list_sessions"), "list_sessions was not offered", )?; check( !offered.iter().any(|tool| tool.name == "send_keys"), "send_keys was offered by the inspection server", )?;
let result = client .call_tool(CallToolRequestParams::new("list_sessions").with_arguments(Default::default())) .await?; if result.is_error == Some(true) { return Err(format!("list_sessions failed: {}", serde_json::to_string(&result)?).into()); } let data = result .structured_content .ok_or("list_sessions returned no structured content")?; let sessions = data["sessions"] .as_array() .ok_or("structured content has no sessions array")?; let mut names = Vec::new(); for session in sessions { names.push(session["name"].as_str().ok_or("session has no name")?); check( session["id"].as_str().is_some_and(|id| id.starts_with('$')), "session has no tmux ID", )?; } names.sort_unstable(); check(names == ["build", "editor"], "unexpected session list")?; for name in names { println!("session: {name}"); } Ok(())}
fn record_close( label: &str, result: Result<Option<QuitReason>, tokio::task::JoinError>, failures: &mut Vec<String>,) { match result { Ok(Some(QuitReason::Cancelled | QuitReason::Closed)) => {} Ok(Some(reason)) => failures.push(format!("{label}: {reason:?}")), Ok(None) => failures.push(format!("{label}: cleanup deadline exceeded")), Err(error) => failures.push(format!("{label}: {error}")), }}
async fn exchange(server: &Server) -> Result<(), ExampleError> { let tools = TmuxTools::builder(server.clone()) .caller(None) .selection(Selection::parse(Some("inspect"), None, None)?) .build(); let (client_io, server_io) = tokio::io::duplex(1 << 20); let startup = async { tokio::try_join!( async { ().serve(client_io).await.map_err(ExampleError::from) }, async { tools.serve(server_io).await.map_err(ExampleError::from) }, ) }; let (mut client, mut service) = timeout(Duration::from_secs(5), startup).await??; let outcome = timeout(Duration::from_secs(10), inspect(client.peer())).await;
let mut failures = Vec::new(); match outcome { Ok(Ok(())) => {} Ok(Err(error)) => failures.push(format!("MCP request: {error}")), Err(error) => failures.push(format!("MCP request deadline: {error}")), } record_close( "MCP client cleanup", client.close_with_timeout(Duration::from_secs(5)).await, &mut failures, ); record_close( "MCP server cleanup", service.close_with_timeout(Duration::from_secs(5)).await, &mut failures, ); if failures.is_empty() { Ok(()) } else { Err(failures.join("; ").into()) }}
async fn demonstrate(server: &Server) -> Result<(), ExampleError> { for name in ["build", "editor"] { server .new_session(NewSessionOptions::new(name).command("cat")) .await?; } exchange(server).await}
async fn wait_until_stopped(socket: &Path) -> Result<(), ExampleError> { loop { match tokio::net::UnixStream::connect(socket).await { Ok(_) => tokio::time::sleep(Duration::from_millis(20)).await, Err(error) if matches!( error.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused ) => { return Ok(()); } Err(error) => return Err(error.into()), } }}
#[tokio::main(flavor = "current_thread")]async fn main() -> Result<(), ExampleError> { let root = std::path::Path::new("/tmp/libtmux-rs-dev"); std::fs::create_dir_all(root)?; let directory = tempfile::Builder::new() .prefix("mcp-sessions-") .tempdir_in(root)?; std::fs::write( directory.path().join("owner"), std::process::id().to_string(), )?; let socket = directory.path().join("tmux.sock"); let server = Server::builder() .socket_path(&socket) .config_file("/dev/null") .default_timeout(Duration::from_secs(5)) .build()?; eprintln!("owned socket: {}", socket.display()); let outcome = demonstrate(&server).await;
// Stop the owned daemon before closing its command executor. let killed = server.kill().await; let stopped = timeout(Duration::from_secs(5), wait_until_stopped(&socket)).await; let closed = timeout(Duration::from_secs(5), server.shutdown()).await; let cleanup_failed = killed.is_err() || !matches!(&stopped, Ok(Ok(()))) || !matches!(&closed, Ok(Ok(()))); let mut failures = Vec::new(); if let Err(error) = outcome { failures.push(format!("example: {error}")); } if let Err(error) = killed { failures.push(format!("daemon cleanup: {error}")); } match stopped { Ok(Ok(())) => {} Ok(Err(error)) => failures.push(format!("daemon cleanup verification: {error}")), Err(error) => failures.push(format!("daemon cleanup verification deadline: {error}")), } match closed { Ok(Ok(())) => {} Ok(Err(error)) => failures.push(format!("executor cleanup: {error}")), Err(error) => failures.push(format!("executor cleanup deadline: {error}")), } if cleanup_failed { let retained = directory.keep(); failures.push(format!("inspect retained directory {}", retained.display())); } else if let Err(error) = directory.close() { failures.push(format!("directory cleanup: {error}")); } if failures.is_empty() { Ok(()) } else { Err(failures.join("; ").into()) }}Resolve the dependencies, then run the program:
$ cargo +1.97.1 generate-lockfile$ cargo +1.97.1 run --locked --quietKeep Cargo.lock with the program to retain the resolved transitive versions.
The program prints the private socket path to stderr. Its stdout is:
session: buildsession: editorRead the result¶
The two protocol endpoints exchange JSON-RPC over an in-memory byte stream.
They perform the MCP handshake and tool discovery before the call. The
server offers inspect; the program checks that discovery contains
list_sessions and excludes send_keys.
A completed protocol request can still report a tool failure. The program
checks isError before reading structuredContent, then verifies the
returned names and tmux IDs. It reads metadata; it does not capture pane
output or send input through MCP.
The embedded server receives its selection in code. The client connection guide covers a separate MCP process launched from a client’s configuration, and Tool selection explains the available groups.
Shutdown¶
Handshake and request waits have deadlines. The program closes both MCP endpoints after a successful or failed request, then stops its owned tmux daemon, waits for its Unix socket to close, and shuts down the libtmux command executor. It reports operation and cleanup failures together.
If daemon or executor cleanup fails, or the socket still accepts connections at the deadline, it retains the temporary directory and prints its path for inspection. The program exits with an error.