1use rmcp::model::{CallToolResult, ContentBlock, ErrorCode, ErrorData, IntoContents};
2
3#[derive(Debug)]
18pub struct ToolError(ErrorData);
19
20impl From<ErrorData> for ToolError {
21 fn from(value: ErrorData) -> Self {
22 Self(value)
23 }
24}
25
26impl std::fmt::Display for ToolError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 f.write_str(&self.0.message)
31 }
32}
33
34impl std::error::Error for ToolError {}
35
36impl ToolError {
37 #[must_use]
45 pub fn into_error_data(self) -> ErrorData {
46 self.0
47 }
48}
49
50impl IntoContents for ToolError {
51 fn into_contents(self) -> Vec<ContentBlock> {
52 let body = serde_json::json!({
53 "message": self.0.message,
54 "data": self.0.data,
55 });
56 vec![ContentBlock::text(body.to_string())]
57 }
58}
59
60pub(super) fn tmux_error(error: &libtmux::Error) -> ToolError {
81 use libtmux::ErrorKind;
82
83 let kind = error.kind();
84 let retryable = error.is_transient();
85 let detail = serde_json::json!({
86 "kind": match kind {
87 ErrorKind::PartialEffect => "partial_effect",
88 ErrorKind::ObjectGone => "object_gone",
89 ErrorKind::Refused => "refused",
90 ErrorKind::ServerGone => "server_gone",
91 ErrorKind::Timeout => "timeout",
92 ErrorKind::Unreachable => "unreachable",
93 ErrorKind::UnsupportedVersion => "unsupported_version",
94 ErrorKind::InvalidInput => "invalid_input",
95 ErrorKind::Transport => "transport",
96 ErrorKind::Decode => "decode",
97 _ => "other",
100 },
101 "retryable": retryable,
102 "stale": error.is_object_gone(),
103 });
104 let message = error.to_string();
105
106 match (kind, retryable) {
107 (ErrorKind::PartialEffect, _) | (ErrorKind::Refused, true) => {
108 ErrorData::internal_error(message, Some(detail))
109 }
110 (ErrorKind::ObjectGone | ErrorKind::InvalidInput, _) | (ErrorKind::Refused, false) => {
111 ErrorData::invalid_params(message, Some(detail))
112 }
113 _ => ErrorData::internal_error(message, Some(detail)),
114 }
115 .into()
116}
117
118fn partial_effect(message: impl Into<String>) -> ToolError {
119 ErrorData::internal_error(
120 message.into(),
121 Some(serde_json::json!({
122 "kind": "partial_effect",
123 "retryable": false,
124 "stale": false,
125 })),
126 )
127 .into()
128}
129
130pub(super) struct EffectBoundary {
131 operation: &'static str,
132 effect_seen: bool,
133}
134
135impl EffectBoundary {
136 pub(super) const fn new(operation: &'static str) -> Self {
137 Self {
138 operation,
139 effect_seen: false,
140 }
141 }
142
143 pub(super) fn mark(&mut self) {
144 self.effect_seen = true;
145 }
146
147 pub(super) fn error(&self, error: libtmux::Error) -> ToolError {
148 let error = if self.effect_seen {
149 error.after_effect(self.operation)
150 } else {
151 error
152 };
153 tmux_error(&error)
154 }
155
156 pub(super) fn tmux<T>(&self, result: Result<T, libtmux::Error>) -> Result<T, ToolError> {
157 result.map_err(|error| self.error(error))
158 }
159
160 pub(super) fn local(&self, message: impl Into<String>) -> ToolError {
161 debug_assert!(self.effect_seen);
162 partial_effect(message)
163 }
164}
165
166fn stale_detail() -> serde_json::Value {
171 serde_json::json!({
172 "kind": "object_gone",
173 "retryable": false,
176 "stale": true,
177 })
178}
179
180pub(super) fn object_gone(what: &str, id: &str) -> ToolError {
187 ErrorData::invalid_params(format!("no {what} {id}"), Some(stale_detail())).into()
188}
189
190pub(super) fn vanished(message: &str) -> ToolError {
196 ErrorData::internal_error(message.to_owned(), Some(stale_detail())).into()
197}
198
199pub(super) fn bad_input(message: impl Into<String>) -> ToolError {
204 ErrorData::invalid_params(
205 message.into(),
206 Some(serde_json::json!({
207 "kind": "invalid_input",
208 "retryable": false,
209 "stale": false,
210 })),
211 )
212 .into()
213}
214
215pub(crate) fn typed_result(mut result: CallToolResult) -> CallToolResult {
222 if result.is_error != Some(true) {
223 return result;
224 }
225 let [block] = result.content.as_slice() else {
226 return result;
227 };
228 let Some(text) = block.as_text() else {
229 return result;
230 };
231 let typed = serde_json::from_str::<serde_json::Value>(&text.text)
232 .is_ok_and(|body| body.get("data").is_some_and(serde_json::Value::is_object));
233 if !typed {
234 result.content = bad_input(text.text.clone()).into_contents();
235 }
236 result
237}
238
239pub(crate) fn typed_protocol_error(mut error: ErrorData) -> ErrorData {
241 if error.data.is_none() {
242 let kind = if error.code == ErrorCode::INVALID_PARAMS {
243 "invalid_input"
244 } else {
245 "internal"
246 };
247 error.data = Some(serde_json::json!({
248 "kind": kind,
249 "retryable": false,
250 "stale": false,
251 }));
252 }
253 error
254}
255
256pub(crate) fn unoffered_tool(tool: &str, exists: bool) -> ErrorData {
261 let message = if exists {
262 format!(
263 "tool {tool} is not offered: this server's startup selection left it out; the \
264 operator can add it with LIBTMUX_TOOLSETS or LIBTMUX_TOOLS"
265 )
266 } else {
267 format!("no tool {tool}")
268 };
269 bad_input(message).into_error_data()
270}
271
272#[cfg(test)]
273mod tests {
274 use std::time::Duration;
275
276 use libtmux::test::TestServer;
277 use libtmux::{Command, CommandChain, DispatchLimits, ErrorKind, Server};
278 use rmcp::model::ErrorCode;
279
280 use super::{EffectBoundary, tmux_error};
281
282 #[test]
283 fn an_effect_boundary_changes_only_later_failures() {
284 let mut boundary = EffectBoundary::new("send_keys");
285 let first = boundary
286 .error(libtmux::Error::RuntimeNested)
287 .into_error_data();
288 assert_eq!(first.code, ErrorCode::INVALID_PARAMS);
289 assert_eq!(
290 first.data.expect("the first error carries detail")["kind"],
291 "invalid_input",
292 );
293
294 boundary.mark();
295 let later = boundary
296 .error(libtmux::Error::RuntimeNested)
297 .into_error_data();
298 assert_eq!(later.code, ErrorCode::INTERNAL_ERROR);
299 let detail = later.data.expect("the later error carries detail");
300 assert_eq!(detail["kind"], "partial_effect", "{detail}");
301 assert_eq!(detail["retryable"], false, "{detail}");
302 assert_eq!(detail["stale"], false, "{detail}");
303
304 let local = boundary
305 .local("the selected object vanished")
306 .into_error_data();
307 assert_eq!(local.code, ErrorCode::INTERNAL_ERROR);
308 let detail = local.data.expect("the local error carries detail");
309 assert_eq!(detail["kind"], "partial_effect", "{detail}");
310 assert_eq!(detail["retryable"], false, "{detail}");
311 assert_eq!(detail["stale"], false, "{detail}");
312 }
313
314 #[tokio::test]
315 async fn a_transient_refusal_is_a_server_error() {
316 let limits = DispatchLimits::default()
317 .max_in_flight(1)
318 .acquire_timeout(Some(Duration::from_millis(100)));
319 let guard = TestServer::builder()
320 .dispatch_limits(limits)
321 .start()
322 .await
323 .expect("tmux starts");
324 let limited = guard.server().clone();
325 let coordinator = Server::builder()
326 .socket_path(guard.socket_path())
327 .config_file(guard.server().config_file().expect("the fixture config"))
328 .tmux_executable(guard.server().tmux_executable())
329 .build()
330 .expect("a coordination handle");
331
332 let holding = {
333 let server = limited.clone();
334 tokio::spawn(async move {
335 server
336 .chain(
337 CommandChain::new(
338 Command::new("wait-for").arg("-S").arg("retry-refused-held"),
339 )
340 .then(Command::new("wait-for").arg("retry-refused-release")),
341 )
342 .await
343 })
344 };
345 let held = coordinator
346 .wait_for_channel("retry-refused-held", Duration::from_secs(2))
347 .await
348 .expect("the holding dispatch starts");
349
350 let error = limited
351 .cmd(Command::new("list-sessions"))
352 .await
353 .expect_err("the only dispatch permit is occupied");
354 coordinator
355 .signal_channel("retry-refused-release")
356 .await
357 .expect("the holding dispatch is released");
358 holding
359 .await
360 .expect("the holding task finishes")
361 .expect("the holding dispatch succeeds");
362 coordinator.shutdown().await.expect("the coordinator stops");
363 guard.shutdown().await.expect("tmux fixture shuts down");
364
365 assert_eq!(held, libtmux::ChannelWait::Signalled);
366 assert_eq!(error.kind(), ErrorKind::Refused);
367 let projected = tmux_error(&error).into_error_data();
368 assert_eq!(projected.code, ErrorCode::INTERNAL_ERROR);
369 let detail = projected.data.expect("the refusal carries detail");
370 assert_eq!(detail["kind"], "refused", "{detail}");
371 assert_eq!(detail["retryable"], true, "{detail}");
372 assert_eq!(detail["stale"], false, "{detail}");
373 }
374}