use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::fmt;
use std::future::Future;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use libtmux::Server;
use rmcp::model::ErrorData;
use serde::{Deserialize, Serialize};
use crate::tail::Tails;
use crate::{CallerIdentity, TmuxTools, schema, tools};
pub const RETIRED_RUST_SAFETY_ENV: &str = "TMUX_MCP_SAFETY";
pub const TOOLSETS_ENV: &str = "LIBTMUX_TOOLSETS";
pub const TOOLS_ENV: &str = "LIBTMUX_TOOLS";
pub const EXCLUDE_TOOLS_ENV: &str = "LIBTMUX_EXCLUDE_TOOLS";
pub const RETIRED_SAFETY_ENV: &str = "LIBTMUX_SAFETY";
pub const ENVIRONMENT_VALUES_ENV: &str = "LIBTMUX_ENVIRONMENT_VALUES";
const MAX_ENVIRONMENT_VALUES: usize = 32;
pub fn parse_environment_values(value: Option<&str>) -> Result<BTreeSet<String>, SurfaceError> {
let names = parse_optional_names(value, ENVIRONMENT_VALUES_ENV)?;
if let Some(name) = names.iter().find(|name| name.contains(['=', '\0'])) {
return Err(SurfaceError::new(format!(
"{ENVIRONMENT_VALUES_ENV} names {name:?}, which is not a variable name"
)));
}
if names.len() > MAX_ENVIRONMENT_VALUES {
return Err(SurfaceError::new(format!(
"{ENVIRONMENT_VALUES_ENV} allows at most {MAX_ENVIRONMENT_VALUES} names"
)));
}
Ok(names)
}
pub fn environment_values_from_env() -> Result<BTreeSet<String>, SurfaceError> {
parse_environment_values(unicode_env(ENVIRONMENT_VALUES_ENV)?.as_deref())
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Toolset {
Inspect,
Manage,
Execute,
Teardown,
}
impl Toolset {
const ALL: [Self; 4] = [Self::Inspect, Self::Manage, Self::Execute, Self::Teardown];
fn parse(name: &str) -> Option<Self> {
match name {
"inspect" => Some(Self::Inspect),
"manage" => Some(Self::Manage),
"execute" => Some(Self::Execute),
"teardown" => Some(Self::Teardown),
_ => None,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Inspect => "inspect",
Self::Manage => "manage",
Self::Execute => "execute",
Self::Teardown => "teardown",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Selection {
toolsets: Vec<Toolset>,
include: BTreeSet<String>,
exclude: BTreeSet<String>,
}
impl Selection {
pub fn parse(
toolsets: Option<&str>,
include: Option<&str>,
exclude: Option<&str>,
) -> Result<Self, SurfaceError> {
Self::parse_for_socket(toolsets, include, exclude, false)
}
pub fn parse_for_socket(
toolsets: Option<&str>,
include: Option<&str>,
exclude: Option<&str>,
default_teardown: bool,
) -> Result<Self, SurfaceError> {
let toolsets = match toolsets {
None if default_teardown => Toolset::ALL.to_vec(),
None => vec![Toolset::Inspect, Toolset::Manage, Toolset::Execute],
Some("") => Vec::new(),
Some(value) => parse_names(value, "LIBTMUX_TOOLSETS")?
.into_iter()
.map(|name| {
Toolset::parse(&name).ok_or_else(|| {
SurfaceError::new(format!(
"unknown toolset {name:?}; expected inspect, manage, execute, or teardown"
))
})
})
.collect::<Result<BTreeSet<_>, _>>()?
.into_iter()
.collect(),
};
Ok(Self {
toolsets,
include: parse_optional_names(include, "LIBTMUX_TOOLS")?,
exclude: parse_optional_names(exclude, "LIBTMUX_EXCLUDE_TOOLS")?,
})
}
pub fn from_env(default_teardown: bool) -> Result<Self, SurfaceError> {
for retired in [RETIRED_SAFETY_ENV, RETIRED_RUST_SAFETY_ENV] {
if std::env::var_os(retired).is_some() {
return Err(SurfaceError::new(format!(
"{retired} has been removed; use {TOOLSETS_ENV}"
)));
}
}
let toolsets = unicode_env(TOOLSETS_ENV)?;
let include = unicode_env(TOOLS_ENV)?;
let exclude = unicode_env(EXCLUDE_TOOLS_ENV)?;
Self::parse_for_socket(
toolsets.as_deref(),
include.as_deref(),
exclude.as_deref(),
default_teardown,
)
}
#[must_use]
pub fn toolsets(&self) -> &[Toolset] {
&self.toolsets
}
pub(super) fn includes(&self, name: &str) -> bool {
self.include.contains(name)
}
pub(super) fn excludes(&self, name: &str) -> bool {
self.exclude.contains(name)
}
pub(super) fn included_names(&self) -> &BTreeSet<String> {
&self.include
}
pub(super) fn excluded_names(&self) -> &BTreeSet<String> {
&self.exclude
}
}
fn unicode_env(name: &'static str) -> Result<Option<String>, SurfaceError> {
match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(value)) => Err(non_unicode(name, value)),
}
}
fn non_unicode(name: &'static str, _value: OsString) -> SurfaceError {
SurfaceError::new(format!("{name} must be valid UTF-8"))
}
fn parse_optional_names(
value: Option<&str>,
variable: &'static str,
) -> Result<BTreeSet<String>, SurfaceError> {
match value {
None | Some("") => Ok(BTreeSet::new()),
Some(value) => Ok(parse_names(value, variable)?.into_iter().collect()),
}
}
fn parse_names(value: &str, variable: &'static str) -> Result<Vec<String>, SurfaceError> {
value
.split(',')
.map(|raw| {
let name = raw.trim();
if name.is_empty() {
Err(SurfaceError::new(format!(
"{variable} contains an empty name"
)))
} else {
Ok(name.to_owned())
}
})
.collect()
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SurfaceError(String);
impl SurfaceError {
pub(crate) fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl fmt::Display for SurfaceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for SurfaceError {}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SocketProvenance {
DedicatedMinimal,
DedicatedExisting,
OperatorSelected,
OperatorSelectedAbsent,
UserConfigured,
UserConfiguredExisting,
#[default]
Unknown,
}
impl SocketProvenance {
#[must_use]
pub const fn defaults_to_teardown(self) -> bool {
matches!(self, Self::DedicatedMinimal)
}
fn report(self, server: &Server) -> crate::manifest::SocketReport {
let selector = server.socket_name().map_or_else(
|| format!("path:{}", server.socket_path().display()),
|name| format!("name:{}", name.to_string_lossy()),
);
let (selection_provenance, server_state, configuration_provenance) = match self {
Self::DedicatedMinimal => ("default-dedicated", "created", "minimal"),
Self::DedicatedExisting => ("default-dedicated", "existing", "unknown"),
Self::OperatorSelected | Self::UserConfiguredExisting => {
("operator-current", "existing", "unknown")
}
Self::OperatorSelectedAbsent => ("operator-current", "absent", "unknown"),
Self::UserConfigured => ("operator-current", "absent", "user-configured"),
Self::Unknown => ("unknown", "unknown", "unknown"),
};
crate::manifest::SocketReport {
selector,
selection_provenance,
server_state,
configuration_provenance,
namespace_boundary: "tmux-objects-only",
}
}
}
fn shell_quote(value: &OsStr) -> String {
let value = value.to_string_lossy();
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn connection_report(
socket: &crate::manifest::SocketReport,
server: &Server,
) -> crate::manifest::ConnectionReport {
let path = server.socket_path().as_os_str();
crate::manifest::ConnectionReport {
socket_selector: socket.selector.clone(),
socket_provenance: socket.selection_provenance,
resolved_socket_path: server.socket_path().to_string_lossy().into_owned(),
server_state: socket.server_state,
configuration_provenance: socket.configuration_provenance,
attach_command: format!(
"{} -N -S {} attach",
shell_quote(server.tmux_executable()),
shell_quote(path),
),
}
}
#[derive(Debug)]
pub struct Builder {
server: Server,
caller: Option<CallerIdentity>,
selection: Selection,
socket_provenance: SocketProvenance,
environment_values: BTreeSet<String>,
}
impl Builder {
#[must_use]
pub fn environment_values(mut self, names: BTreeSet<String>) -> Self {
self.environment_values = names;
self
}
#[must_use]
pub fn caller(mut self, caller: Option<CallerIdentity>) -> Self {
self.caller = caller;
self
}
#[must_use]
pub fn selection(mut self, selection: Selection) -> Self {
self.selection = selection;
self
}
#[must_use]
pub const fn socket_provenance(mut self, provenance: SocketProvenance) -> Self {
self.socket_provenance = provenance;
self
}
#[must_use]
#[allow(
clippy::expect_used,
reason = "build preserves the existing infallible constructor contract"
)]
pub fn build(self) -> TmuxTools {
self.try_build()
.expect("native tool routes and the requested surface are valid")
}
pub fn try_build(self) -> Result<TmuxTools, SurfaceError> {
let identity = Arc::new(crate::identity::InstanceIdentity::new());
let mut router = tools::router();
for route in router.map.values_mut() {
schema::strip_unknown_formats(Arc::make_mut(&mut route.attr.input_schema));
if let Some(schema) = route.attr.output_schema.as_mut() {
schema::strip_unknown_formats(Arc::make_mut(schema));
}
}
let mut resolved = crate::manifest::resolve(router, &self.selection)?;
let socket = self.socket_provenance.report(&self.server);
resolved.report.connection = connection_report(&socket, &self.server);
resolved.report.socket = socket;
let router = resolved.router;
Ok(TmuxTools {
server: Arc::new(self.server),
caller: self.caller.map(Arc::new),
capability_report: Arc::new(resolved.report),
socket: Arc::new(OnceLock::new()),
tails: Arc::new(Tails::new(identity)),
echoes: Arc::new(crate::echo::PaneEchoes::new()),
tool_router: router,
nested_tool_router: resolved.nested_router,
environment_values: Arc::new(self.environment_values),
})
}
}
#[derive(Clone, Debug)]
struct Progress {
peer: rmcp::service::Peer<rmcp::RoleServer>,
token: rmcp::model::ProgressToken,
}
#[derive(Clone, Debug, Default)]
pub struct Reporter(Option<Progress>);
impl Reporter {
#[must_use]
pub const fn none() -> Self {
Self(None)
}
}
impl<C> rmcp::handler::server::common::FromContextPart<C> for Reporter
where
C: rmcp::handler::server::common::AsRequestContext,
{
fn from_context_part(context: &mut C) -> Result<Self, ErrorData> {
let context = context.as_request_context();
Ok(Self(context.meta.get_progress_token().map(|token| {
Progress {
peer: context.peer.clone(),
token,
}
})))
}
}
impl Progress {
async fn say(&self, so_far: f64, message: impl Into<String>) {
let mut param = rmcp::model::ProgressNotificationParam::new(self.token.clone(), so_far);
param.message = Some(message.into());
let _ = self.peer.notify_progress(param).await;
}
}
pub(super) async fn reporting<T>(
reporter: Reporter,
what: &str,
work: impl Future<Output = T>,
) -> T {
let Some(progress) = reporter.0 else {
return work.await;
};
let began = tokio::time::Instant::now();
let ticker = async {
let mut every = tokio::time::interval(PROGRESS_EVERY);
every.tick().await;
loop {
every.tick().await;
let elapsed = began.elapsed().as_secs();
progress
.say(
f64::from(u32::try_from(elapsed).unwrap_or(u32::MAX)),
format!("{what}, {elapsed}s so far"),
)
.await;
}
};
tokio::select! {
outcome = work => outcome,
() = ticker => unreachable!("the ticker loops forever"),
}
}
const PROGRESS_EVERY: Duration = Duration::from_secs(5);
impl TmuxTools {
#[must_use]
pub fn new(server: Server) -> Self {
Self::builder(server).build()
}
#[must_use]
pub fn builder(server: Server) -> Builder {
Builder {
server,
caller: CallerIdentity::from_env(),
selection: Selection {
toolsets: vec![Toolset::Inspect, Toolset::Manage, Toolset::Execute],
include: BTreeSet::new(),
exclude: BTreeSet::new(),
},
socket_provenance: SocketProvenance::Unknown,
environment_values: BTreeSet::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::{Selection, Toolset};
use crate::manifest::{OutputClass, ProcessReach, TmuxEffect};
use std::collections::BTreeSet;
#[test]
fn toolset_selection_distinguishes_empty_from_empty_tokens() {
let empty = Selection::parse(Some(""), None, None).expect("empty surface");
assert!(empty.toolsets().is_empty());
let inspect = Selection::parse(Some("inspect"), None, None).expect("one toolset");
assert_eq!(inspect.toolsets(), &[Toolset::Inspect]);
for malformed in [",inspect", "inspect,", "inspect,,manage"] {
let error = Selection::parse(Some(malformed), None, None).expect_err("empty token");
assert!(error.to_string().contains("empty"), "{malformed}: {error}");
}
}
#[test]
fn registered_routes_are_the_capability_manifest() {
let selection =
Selection::parse_for_socket(None, None, None, true).expect("dedicated minimal surface");
let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
.expect("complete manifest");
let listed = resolved.router.list_all();
let reported: Vec<_> = resolved
.report
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect();
let names: Vec<_> = listed.iter().map(|tool| tool.name.as_ref()).collect();
assert_eq!(names, reported);
assert!(listed.iter().all(|tool| {
let description = tool.description.as_deref().expect("description");
resolved
.report
.tools
.iter()
.any(|row| row.name == tool.name && description.ends_with(row.controlled_opener()))
}));
}
#[test]
fn every_tools_first_sentence_is_distinct() {
let selection =
Selection::parse_for_socket(None, None, None, true).expect("dedicated minimal surface");
let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
.expect("complete manifest");
let listed = resolved.router.list_all();
let mut by_first_sentence: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
for tool in &listed {
let description = tool.description.as_deref().expect("description");
let first_sentence = description.split(". ").next().unwrap_or(description);
by_first_sentence
.entry(first_sentence)
.or_default()
.push(tool.name.as_ref());
}
let collisions: Vec<_> = by_first_sentence
.into_iter()
.filter(|(_, names)| names.len() > 1)
.collect();
assert!(
collisions.is_empty(),
"tools sharing a first sentence, indistinguishable by a caller that reads only that \
far: {collisions:?}",
);
}
#[test]
fn every_tools_own_description_ends_its_sentence() {
let unfinished: Vec<_> = crate::tools::router()
.list_all()
.into_iter()
.filter(|tool| {
!tool
.description
.as_deref()
.unwrap_or_default()
.trim_end()
.ends_with(['.', '!', '?'])
})
.map(|tool| tool.name.into_owned())
.collect();
assert!(unfinished.is_empty(), "no closing period: {unfinished:?}");
}
#[test]
fn annotations_follow_each_tools_capability_row() {
let selection = Selection::parse(Some("inspect,manage,execute,teardown"), None, None)
.expect("selection");
let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
.expect("complete manifest");
let mut distinct = BTreeSet::new();
let mut destructive_tools = BTreeSet::new();
for tool in resolved.router.list_all() {
let row = &resolved
.report
.tools
.iter()
.find(|row| row.name == tool.name)
.expect("report row")
.capability;
let hints = tool.annotations.as_ref().expect("annotations");
let read_only = hints.read_only_hint.expect("readOnlyHint");
let destructive = hints.destructive_hint.expect("destructiveHint");
let changes_tmux = row.toolset != Toolset::Inspect
|| row.process_reach != ProcessReach::None
|| row
.tmux_effects
.iter()
.any(|effect| *effect != TmuxEffect::Observe);
let can_destroy = row.tmux_effects.contains(&TmuxEffect::Delete)
|| matches!(
row.process_reach,
ProcessReach::PaneInput | ProcessReach::PaneCommand
);
assert_eq!(read_only, !changes_tmux, "{} readOnlyHint", tool.name);
assert_eq!(destructive, can_destroy, "{} destructiveHint", tool.name);
if destructive {
destructive_tools.insert(tool.name.to_string());
}
distinct.insert((
read_only,
destructive,
hints.idempotent_hint.expect("idempotentHint"),
hints.open_world_hint.expect("openWorldHint"),
));
}
assert!(distinct.len() > 3, "hints barely vary: {distinct:?}");
assert!(destructive_tools.contains("set_history_limit"));
}
#[test]
fn configured_value_reads_disclose_configured_command_output() {
let selection = Selection::parse(Some("inspect"), None, None).expect("selection");
let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
.expect("complete manifest");
for name in ["get_tmux_variables", "show_option"] {
let tool = resolved
.report
.tools
.iter()
.find(|tool| tool.name == name)
.expect("tool row");
assert_eq!(
tool.capability.output_classes,
[OutputClass::TmuxMetadata, OutputClass::ConfiguredCommand]
.into_iter()
.collect(),
"{name}",
);
assert!(
tool.controlled_opener()
.starts_with("Read configured tmux commands;")
);
}
}
#[test]
fn synchronize_panes_is_the_only_declared_input_amplifier() {
let selection = Selection::parse(Some("inspect,manage,execute,teardown"), None, None)
.expect("selection");
let resolved = crate::manifest::resolve(crate::tools::router(), &selection)
.expect("complete manifest");
let report = serde_json::to_value(resolved.report).expect("report serializes");
let tools = report["tools"].as_array().expect("tool rows");
assert!(
tools
.iter()
.all(|tool| tool["amplifiesFutureInput"].is_boolean()),
"every manifest row carries the amplification fact"
);
let amplified: Vec<_> = tools
.iter()
.filter(|tool| tool["amplifiesFutureInput"] == true)
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect();
assert_eq!(amplified, ["set_synchronize_panes"]);
let synchronize = resolved
.router
.list_all()
.into_iter()
.find(|tool| tool.name == "set_synchronize_panes")
.expect("synchronize route");
let description = synchronize.description.as_deref().expect("description");
for claim in ["window default", "Individual pane overrides", "can amplify"] {
assert!(description.contains(claim), "missing {claim:?}");
}
}
#[test]
fn unknown_provenance_defaults_without_teardown() {
let selection = Selection::parse(None, None, None).expect("conservative default");
assert_eq!(
selection.toolsets(),
&[Toolset::Inspect, Toolset::Manage, Toolset::Execute]
);
}
#[test]
fn spawn_routes_accept_no_command_or_environment_payload() {
let router = crate::tools::router();
for name in [
"create_session",
"create_window",
"split_window",
"respawn_pane",
] {
let schema = &router.get(name).expect("spawn route").input_schema;
let keys: BTreeSet<_> = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("object schema")
.keys()
.map(String::as_str)
.collect();
assert!(!keys.contains("command"), "{name}");
assert!(!keys.contains("environment"), "{name}");
assert!(!keys.contains("env"), "{name}");
}
}
#[test]
fn exclusion_removes_aggregate_nested_authority() {
let selection =
Selection::parse(Some("inspect"), None, Some("capture_pane")).expect("selection");
let resolved =
crate::manifest::resolve(crate::tools::router(), &selection).expect("resolved surface");
let batch = resolved
.report
.tools
.iter()
.find(|tool| tool.name == "call_read_tools_batch")
.expect("batch route");
assert!(!batch.capability.nested_authority.contains("capture_pane"));
}
#[test]
fn read_batch_covers_every_non_self_bounded_inspect_route() {
let selection = Selection::parse(Some("inspect"), None, None).expect("selection");
let resolved =
crate::manifest::resolve(crate::tools::router(), &selection).expect("resolved surface");
let expected: BTreeSet<_> = resolved
.report
.tools
.iter()
.filter(|tool| {
tool.capability.toolset == Toolset::Inspect
&& tool.name != "call_read_tools_batch"
&& tool.name != "wait_for_text"
})
.map(|tool| tool.name.clone())
.collect();
let batch = resolved
.report
.tools
.iter()
.find(|tool| tool.name == "call_read_tools_batch")
.expect("batch route");
assert_eq!(batch.capability.nested_authority, expected);
let description = resolved
.router
.list_all()
.into_iter()
.find(|tool| tool.name == "call_read_tools_batch")
.and_then(|tool| tool.description.map(std::borrow::Cow::into_owned))
.expect("batch description");
assert!(
description.contains("inner tools do not receive separate client approval"),
"{description}"
);
}
}