use rmcp::model::{CallToolResult, ContentBlock, ErrorCode, ErrorData, IntoContents};
#[derive(Debug)]
pub struct ToolError(ErrorData);
impl From<ErrorData> for ToolError {
fn from(value: ErrorData) -> Self {
Self(value)
}
}
impl std::fmt::Display for ToolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0.message)
}
}
impl std::error::Error for ToolError {}
impl ToolError {
#[must_use]
pub fn into_error_data(self) -> ErrorData {
self.0
}
}
impl IntoContents for ToolError {
fn into_contents(self) -> Vec<ContentBlock> {
let body = serde_json::json!({
"message": self.0.message,
"data": self.0.data,
});
vec![ContentBlock::text(body.to_string())]
}
}
pub(super) fn tmux_error(error: &libtmux::Error) -> ToolError {
use libtmux::ErrorKind;
let kind = error.kind();
let retryable = error.is_transient();
let detail = serde_json::json!({
"kind": match kind {
ErrorKind::PartialEffect => "partial_effect",
ErrorKind::ObjectGone => "object_gone",
ErrorKind::Refused => "refused",
ErrorKind::ServerGone => "server_gone",
ErrorKind::Timeout => "timeout",
ErrorKind::Unreachable => "unreachable",
ErrorKind::UnsupportedVersion => "unsupported_version",
ErrorKind::InvalidInput => "invalid_input",
ErrorKind::Transport => "transport",
ErrorKind::Decode => "decode",
_ => "other",
},
"retryable": retryable,
"stale": error.is_object_gone(),
});
let message = error.to_string();
match (kind, retryable) {
(ErrorKind::PartialEffect, _) | (ErrorKind::Refused, true) => {
ErrorData::internal_error(message, Some(detail))
}
(ErrorKind::ObjectGone | ErrorKind::InvalidInput, _) | (ErrorKind::Refused, false) => {
ErrorData::invalid_params(message, Some(detail))
}
_ => ErrorData::internal_error(message, Some(detail)),
}
.into()
}
fn partial_effect(message: impl Into<String>) -> ToolError {
ErrorData::internal_error(
message.into(),
Some(serde_json::json!({
"kind": "partial_effect",
"retryable": false,
"stale": false,
})),
)
.into()
}
pub(super) struct EffectBoundary {
operation: &'static str,
effect_seen: bool,
}
impl EffectBoundary {
pub(super) const fn new(operation: &'static str) -> Self {
Self {
operation,
effect_seen: false,
}
}
pub(super) fn mark(&mut self) {
self.effect_seen = true;
}
pub(super) fn error(&self, error: libtmux::Error) -> ToolError {
let error = if self.effect_seen {
error.after_effect(self.operation)
} else {
error
};
tmux_error(&error)
}
pub(super) fn tmux<T>(&self, result: Result<T, libtmux::Error>) -> Result<T, ToolError> {
result.map_err(|error| self.error(error))
}
pub(super) fn local(&self, message: impl Into<String>) -> ToolError {
debug_assert!(self.effect_seen);
partial_effect(message)
}
}
fn stale_detail() -> serde_json::Value {
serde_json::json!({
"kind": "object_gone",
"retryable": false,
"stale": true,
})
}
pub(super) fn object_gone(what: &str, id: &str) -> ToolError {
ErrorData::invalid_params(format!("no {what} {id}"), Some(stale_detail())).into()
}
pub(super) fn vanished(message: &str) -> ToolError {
ErrorData::internal_error(message.to_owned(), Some(stale_detail())).into()
}
pub(super) fn bad_input(message: impl Into<String>) -> ToolError {
ErrorData::invalid_params(
message.into(),
Some(serde_json::json!({
"kind": "invalid_input",
"retryable": false,
"stale": false,
})),
)
.into()
}
pub(crate) fn typed_result(mut result: CallToolResult) -> CallToolResult {
if result.is_error != Some(true) {
return result;
}
let [block] = result.content.as_slice() else {
return result;
};
let Some(text) = block.as_text() else {
return result;
};
let typed = serde_json::from_str::<serde_json::Value>(&text.text)
.is_ok_and(|body| body.get("data").is_some_and(serde_json::Value::is_object));
if !typed {
result.content = bad_input(text.text.clone()).into_contents();
}
result
}
pub(crate) fn typed_protocol_error(mut error: ErrorData) -> ErrorData {
if error.data.is_none() {
let kind = if error.code == ErrorCode::INVALID_PARAMS {
"invalid_input"
} else {
"internal"
};
error.data = Some(serde_json::json!({
"kind": kind,
"retryable": false,
"stale": false,
}));
}
error
}
pub(crate) fn unoffered_tool(tool: &str, exists: bool) -> ErrorData {
let message = if exists {
format!(
"tool {tool} is not offered: this server's startup selection left it out; the \
operator can add it with LIBTMUX_TOOLSETS or LIBTMUX_TOOLS"
)
} else {
format!("no tool {tool}")
};
bad_input(message).into_error_data()
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use libtmux::test::TestServer;
use libtmux::{Command, CommandChain, DispatchLimits, ErrorKind, Server};
use rmcp::model::ErrorCode;
use super::{EffectBoundary, tmux_error};
#[test]
fn an_effect_boundary_changes_only_later_failures() {
let mut boundary = EffectBoundary::new("send_keys");
let first = boundary
.error(libtmux::Error::RuntimeNested)
.into_error_data();
assert_eq!(first.code, ErrorCode::INVALID_PARAMS);
assert_eq!(
first.data.expect("the first error carries detail")["kind"],
"invalid_input",
);
boundary.mark();
let later = boundary
.error(libtmux::Error::RuntimeNested)
.into_error_data();
assert_eq!(later.code, ErrorCode::INTERNAL_ERROR);
let detail = later.data.expect("the later error carries detail");
assert_eq!(detail["kind"], "partial_effect", "{detail}");
assert_eq!(detail["retryable"], false, "{detail}");
assert_eq!(detail["stale"], false, "{detail}");
let local = boundary
.local("the selected object vanished")
.into_error_data();
assert_eq!(local.code, ErrorCode::INTERNAL_ERROR);
let detail = local.data.expect("the local error carries detail");
assert_eq!(detail["kind"], "partial_effect", "{detail}");
assert_eq!(detail["retryable"], false, "{detail}");
assert_eq!(detail["stale"], false, "{detail}");
}
#[tokio::test]
async fn a_transient_refusal_is_a_server_error() {
let limits = DispatchLimits::default()
.max_in_flight(1)
.acquire_timeout(Some(Duration::from_millis(100)));
let guard = TestServer::builder()
.dispatch_limits(limits)
.start()
.await
.expect("tmux starts");
let limited = guard.server().clone();
let coordinator = Server::builder()
.socket_path(guard.socket_path())
.config_file(guard.server().config_file().expect("the fixture config"))
.tmux_executable(guard.server().tmux_executable())
.build()
.expect("a coordination handle");
let holding = {
let server = limited.clone();
tokio::spawn(async move {
server
.chain(
CommandChain::new(
Command::new("wait-for").arg("-S").arg("retry-refused-held"),
)
.then(Command::new("wait-for").arg("retry-refused-release")),
)
.await
})
};
let held = coordinator
.wait_for_channel("retry-refused-held", Duration::from_secs(2))
.await
.expect("the holding dispatch starts");
let error = limited
.cmd(Command::new("list-sessions"))
.await
.expect_err("the only dispatch permit is occupied");
coordinator
.signal_channel("retry-refused-release")
.await
.expect("the holding dispatch is released");
holding
.await
.expect("the holding task finishes")
.expect("the holding dispatch succeeds");
coordinator.shutdown().await.expect("the coordinator stops");
guard.shutdown().await.expect("tmux fixture shuts down");
assert_eq!(held, libtmux::ChannelWait::Signalled);
assert_eq!(error.kind(), ErrorKind::Refused);
let projected = tmux_error(&error).into_error_data();
assert_eq!(projected.code, ErrorCode::INTERNAL_ERROR);
let detail = projected.data.expect("the refusal carries detail");
assert_eq!(detail["kind"], "refused", "{detail}");
assert_eq!(detail["retryable"], true, "{detail}");
assert_eq!(detail["stale"], false, "{detail}");
}
}