Skip to main content

tmux_mcp/tools/
error.rs

1use rmcp::model::{CallToolResult, ContentBlock, ErrorCode, ErrorData, IntoContents};
2
3/// A tool-execution failure, reported as `isError` tool content.
4///
5/// MCP separates protocol faults (unknown tool, arguments that do not match
6/// the schema -- rejected before a tool body ever runs) from failures a tool
7/// body discovers itself. Only the first belongs in the JSON-RPC `error`
8/// field; the second is data the model reads and acts on, which is what
9/// [`rmcp::model::CallToolResult::is_error`] is for. Every helper below
10/// builds one of these instead of a raw [`ErrorData`], so a tool signature
11/// that returns `Result<_, ToolError>` cannot surface a tmux or input
12/// refusal as a protocol error by construction.
13///
14/// Public, and re-exported at the crate root: a caller that invokes
15/// [`crate::TmuxTools`]'s methods directly, rather than over the wire, gets
16/// this type back and needs [`Self::into_error_data`] to read it.
17#[derive(Debug)]
18pub struct ToolError(ErrorData);
19
20impl From<ErrorData> for ToolError {
21    fn from(value: ErrorData) -> Self {
22        Self(value)
23    }
24}
25
26/// The message a model would read, so a direct caller can propagate a tool
27/// failure with `?` like any other error.
28impl 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    /// Recover the underlying [`ErrorData`].
38    ///
39    /// For the one caller that reports a nested tool's own failure inside a
40    /// batch item rather than as this tool's failure
41    /// ([`crate::TmuxTools::call_read_tools_batch`]), and for a
42    /// test that calls a tool directly and inspects the classification a
43    /// wire client would otherwise read from `isError` content.
44    #[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
60// Every error this server returns carries the same three fields on its `data`,
61// so an agent decides what to do next by reading them rather than by matching
62// on prose. A pane that has closed and a tmux that is not running both fail;
63// the first wants the listing refreshed, the second wants the agent to stop.
64//
65// * `kind` — a short name for what went wrong.
66// * `retryable` — whether repeating the same call unchanged is safe and may
67//   succeed.
68// * `stale` — whether the target is gone, so a listing taken now would say
69//   something different.
70//
71// The JSON-RPC code answers a different question: whose move it is. A caller
72// who named a dead pane gets `invalid_params`; a pane that died between two of
73// this server's own calls gets `internal_error`. Both are classified `stale`,
74// because in both cases looking again is what helps.
75
76/// Convert a tmux failure into a protocol error an agent can act on.
77///
78/// libtmux already draws the distinctions above, so they are carried through
79/// rather than flattened.
80pub(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            // ErrorKind is #[non_exhaustive]; a kind added upstream is
98            // reported rather than mistaken for one of these.
99            _ => "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
166/// The classification for a target that is not where it was said to be.
167///
168/// Shared by the two ways this server discovers that itself, so a change to
169/// what it promises cannot apply to one and not the other.
170fn stale_detail() -> serde_json::Value {
171    serde_json::json!({
172        "kind": "object_gone",
173        // Nothing will change on its own to make this id resolve; the caller
174        // has to look again and name something else.
175        "retryable": false,
176        "stale": true,
177    })
178}
179
180/// Report a target that a listing named but tmux no longer has.
181///
182/// The `find_*` helpers notice this themselves rather than learning it from
183/// libtmux, so they mint the classification directly. An agent should not have
184/// to tell the two apart: a pane that vanished between the listing and the call
185/// reads the same either way.
186pub(super) fn object_gone(what: &str, id: &str) -> ToolError {
187    ErrorData::invalid_params(format!("no {what} {id}"), Some(stale_detail())).into()
188}
189
190/// Report state that moved between two calls this server made.
191///
192/// Not the caller's mistake — the handle was good when it was taken — so the
193/// code stays an internal error. The classification is the one for a target
194/// that was already gone, because the useful response is the same: look again.
195pub(super) fn vanished(message: &str) -> ToolError {
196    ErrorData::internal_error(message.to_owned(), Some(stale_detail())).into()
197}
198
199/// Report an argument this server will not pass to tmux.
200///
201/// Nothing about the server needs to change for the next call to work, and
202/// nothing has gone stale: the caller has to send something else.
203pub(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
215/// Give a failed result rmcp answered itself the body every tool failure has.
216///
217/// rmcp reports arguments that do not deserialize as a failed result whose
218/// only content is its own message. Every tool here fails through
219/// [`ToolError`], whose content is a JSON object carrying `data`, so a failed
220/// result without one is rmcp's, and it concerned the arguments.
221pub(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
239/// Give a protocol error rmcp raised before any tool ran the three fields.
240pub(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
256/// Refuse a tool name this process does not serve.
257///
258/// The name is either a tool the startup selection left out or no tool at
259/// all, and the fix differs: only the operator can offer the first.
260pub(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}