Skip to main content

flymcprust_core/
tool.rs

1//! Tool definitions, outputs and errors.
2//!
3//! Tools are usually declared with the `#[tool]` macro, which implements
4//! [`Tool`] for a marker type. The trait can also be implemented by hand.
5
6use std::any::{TypeId, type_name};
7use std::borrow::Cow;
8use std::fmt;
9use std::future::Future;
10use std::pin::Pin;
11
12use schemars::JsonSchema;
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use serde_json::value::RawValue;
16use serde_json::{Map, Value};
17
18use crate::context::Ctx;
19use crate::error::McpError;
20use crate::spec::{self, CallToolResult, ContentBlock, TextContent};
21
22/// The future returned by a tool invocation.
23pub type ToolFuture = Pin<Box<dyn Future<Output = ToolResult> + Send + 'static>>;
24
25/// The outcome of a tool invocation.
26pub type ToolResult = Result<ToolOutput, ToolError>;
27
28/// Entry point of a tool: deserializes the arguments synchronously (borrowing
29/// the request buffer) and returns the future that runs the tool.
30pub type CallFn = fn(arguments: Option<&RawValue>, ctx: Ctx) -> Result<ToolFuture, ToolError>;
31
32/// A tool that can be registered with
33/// [`ServerBuilder::tool`](crate::ServerBuilder::tool).
34pub trait Tool: 'static {
35    /// Builds the tool definition. Called once, at registration.
36    fn definition() -> ToolDef;
37}
38
39/// A shared-state type a handler needs.
40#[derive(Debug, Clone, Copy)]
41pub struct StateRequirement {
42    pub(crate) type_id: TypeId,
43    pub(crate) type_name: &'static str,
44}
45
46impl StateRequirement {
47    pub(crate) fn of<T: Send + Sync + 'static>() -> Self {
48        Self {
49            type_id: TypeId::of::<T>(),
50            type_name: type_name::<T>(),
51        }
52    }
53}
54
55/// A registered tool: its protocol description plus its entry point.
56#[derive(Debug, Clone)]
57pub struct ToolDef {
58    pub(crate) tool: spec::Tool,
59    pub(crate) call: CallFn,
60    pub(crate) required_state: Vec<StateRequirement>,
61    pub(crate) scopes: Vec<String>,
62    pub(crate) ui: Option<String>,
63    pub(crate) visibility: Option<Vec<crate::apps::Visibility>>,
64}
65
66impl ToolDef {
67    /// Creates a definition from the protocol description and entry point.
68    #[must_use]
69    pub fn new(tool: spec::Tool, call: CallFn) -> Self {
70        Self {
71            tool,
72            call,
73            required_state: Vec::new(),
74            scopes: Vec::new(),
75            ui: None,
76            visibility: None,
77        }
78    }
79
80    /// Renders the tool's results with an MCP Apps interface: the `ui://`
81    /// URI of a resource registered with
82    /// [`ResourceDef::app`](crate::ResourceDef::app). The tool must still
83    /// return text `content` for hosts without MCP Apps.
84    #[must_use]
85    pub fn ui(mut self, resource_uri: impl Into<String>) -> Self {
86        self.ui = Some(resource_uri.into());
87        self
88    }
89
90    /// Who may call the tool (MCP Apps): `[Visibility::App]` hides it from
91    /// the model and leaves it to the app's interface. Default: both.
92    ///
93    /// This is a hint for hosts, **not access control**: every client can
94    /// list and call the tool, and hosts without MCP Apps show it to the
95    /// model like any other. Protect sensitive tools with OAuth scopes.
96    #[must_use]
97    pub fn visibility(
98        mut self,
99        visibility: impl IntoIterator<Item = crate::apps::Visibility>,
100    ) -> Self {
101        self.visibility = Some(visibility.into_iter().collect());
102        self
103    }
104
105    /// The interface declared with [`ui`](Self::ui).
106    #[must_use]
107    pub fn ui_resource(&self) -> Option<&str> {
108        self.ui.as_deref()
109    }
110
111    /// Declares the OAuth scopes a caller needs (all of them). Enforced when
112    /// the HTTP transport requires OAuth; the client gets `403` with an
113    /// `insufficient_scope` challenge listing these scopes.
114    #[must_use]
115    pub fn scopes<I, S>(mut self, scopes: I) -> Self
116    where
117        I: IntoIterator<Item = S>,
118        S: Into<String>,
119    {
120        self.scopes = scopes.into_iter().map(Into::into).collect();
121        self
122    }
123
124    /// The OAuth scopes declared with [`scopes`](Self::scopes).
125    #[must_use]
126    pub fn required_scopes(&self) -> &[String] {
127        &self.scopes
128    }
129
130    /// Declares that the tool needs shared state of type `T`.
131    #[must_use]
132    pub fn require_state<T: Send + Sync + 'static>(mut self) -> Self {
133        self.required_state.push(StateRequirement::of::<T>());
134        self
135    }
136
137    /// The protocol description.
138    #[must_use]
139    pub fn tool(&self) -> &spec::Tool {
140        &self.tool
141    }
142}
143
144const TIMEOUT_HELP: &str = "raise ServerBuilder::tool_timeout(..) (default 30 s) or make the \
145     handler faster; long work should stop when ctx.is_cancelled() becomes true";
146
147const PANIC_HELP: &str = "the panic message is logged just above; return an error instead of \
148     panicking (the client got a generic error)";
149
150/// What a tool produced.
151#[derive(Debug, Clone, PartialEq)]
152#[non_exhaustive]
153pub enum ToolOutput {
154    /// A final result (`resultType: "complete"`).
155    Complete(CallToolResult),
156}
157
158/// An error raised by a tool, resource or prompt handler.
159///
160/// - [`ToolError::user`] errors are shown to the client: as an `isError`
161///   tool result (so the model can correct itself), or as a `-32602` error
162///   for resources and prompts. Use them for invalid input or business-rule
163///   violations.
164/// - Any other error (`?` on a `std::error::Error`) is logged with full
165///   detail on the server, and the client receives a generic message.
166pub struct ToolError {
167    kind: ToolErrorKind,
168}
169
170enum ToolErrorKind {
171    User(Cow<'static, str>),
172    NotFound,
173    /// The handler asked the client for input (MRTR); see `Ctx::elicit`.
174    InputRequired,
175    Protocol(McpError),
176    InvalidArguments(String),
177    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
178    Timeout,
179    Panicked,
180}
181
182impl ToolError {
183    /// An error whose message is shown to the model.
184    pub fn user(message: impl Into<Cow<'static, str>>) -> Self {
185        Self {
186            kind: ToolErrorKind::User(message.into()),
187        }
188    }
189
190    /// The requested item does not exist. From a resource handler, this is
191    /// the specification's resource-not-found error (`-32602` with
192    /// `data.uri`); from a tool, an `isError` result.
193    #[must_use]
194    pub fn not_found() -> Self {
195        Self {
196            kind: ToolErrorKind::NotFound,
197        }
198    }
199
200    /// Invalid input, with a client-safe explanation (shown to the client).
201    pub fn invalid_argument(message: impl Into<String>) -> Self {
202        Self {
203            kind: ToolErrorKind::InvalidArguments(crate::error::sanitize(&message.into())),
204        }
205    }
206
207    /// A protocol-level (JSON-RPC) error instead of a tool result. Use it
208    /// for conditions the specification defines as protocol errors, such as
209    /// a missing client capability.
210    #[must_use]
211    pub fn protocol(error: McpError) -> Self {
212        Self {
213            kind: ToolErrorKind::Protocol(error),
214        }
215    }
216
217    /// The request needs client capabilities the client did not declare:
218    /// a `-32021` JSON-RPC error (HTTP 400) listing them, as the
219    /// specification requires. See [`Ctx::client_capabilities`].
220    ///
221    /// [`Ctx::client_capabilities`]: crate::Ctx::client_capabilities
222    #[must_use]
223    pub fn missing_client_capability(required: &spec::ClientCapabilities) -> Self {
224        Self::protocol(McpError::missing_client_capability(required))
225    }
226
227    /// An internal error: logged on the server, hidden from the client.
228    pub fn internal(error: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
229        Self {
230            kind: ToolErrorKind::Internal(error.into()),
231        }
232    }
233
234    /// The arguments did not match the tool's input schema. The message comes
235    /// from the deserializer and only describes the client's own input.
236    #[must_use]
237    pub fn invalid_arguments(error: &serde_json::Error) -> Self {
238        Self {
239            kind: ToolErrorKind::InvalidArguments(crate::error::sanitize(&error.to_string())),
240        }
241    }
242
243    pub(crate) fn input_required() -> Self {
244        Self {
245            kind: ToolErrorKind::InputRequired,
246        }
247    }
248
249    pub(crate) fn is_input_required(&self) -> bool {
250        matches!(self.kind, ToolErrorKind::InputRequired)
251    }
252
253    pub(crate) fn timeout() -> Self {
254        Self {
255            kind: ToolErrorKind::Timeout,
256        }
257    }
258
259    pub(crate) fn panicked() -> Self {
260        Self {
261            kind: ToolErrorKind::Panicked,
262        }
263    }
264
265    /// Splits off protocol errors, which become JSON-RPC errors rather than
266    /// tool results.
267    pub(crate) fn into_protocol(self) -> Result<McpError, Self> {
268        match self.kind {
269            ToolErrorKind::Protocol(error) => Ok(error),
270            kind => Err(Self { kind }),
271        }
272    }
273
274    /// Converts into an `isError` result, logging internal details.
275    pub(crate) fn into_call_result(self, tool: &str) -> CallToolResult {
276        let message: Cow<'static, str> = match self.kind {
277            ToolErrorKind::User(message) => message,
278            ToolErrorKind::NotFound => Cow::Borrowed("Not found."),
279            ToolErrorKind::InputRequired => {
280                tracing::error!(tool, "input required outside a multi round-trip request");
281                Cow::Borrowed("The tool failed because of an internal error.")
282            }
283            ToolErrorKind::Protocol(error) => Cow::Owned(error.message().to_owned()),
284            ToolErrorKind::InvalidArguments(details) => {
285                Cow::Owned(format!("Invalid arguments: {details}"))
286            }
287            ToolErrorKind::Internal(error) => {
288                tracing::error!(
289                    "tool `{tool}` failed: {error}\n  = help: the client saw only a generic \
290                     message; return ToolError::user(\"...\") for errors the model should see"
291                );
292                Cow::Borrowed("The tool failed because of an internal error.")
293            }
294            ToolErrorKind::Timeout => {
295                tracing::warn!("tool `{tool}` timed out\n  = help: {TIMEOUT_HELP}");
296                Cow::Borrowed("The tool did not finish within the time limit.")
297            }
298            ToolErrorKind::Panicked => {
299                tracing::error!("tool `{tool}` panicked\n  = help: {PANIC_HELP}");
300                Cow::Borrowed("The tool failed because of an internal error.")
301            }
302        };
303        let mut result = CallToolResult::new(
304            vec![ContentBlock::from(TextContent::new(message.into_owned()))],
305            "complete".to_owned(),
306        );
307        result.is_error = Some(true);
308        result
309    }
310
311    /// Converts into a JSON-RPC error for a resource or prompt handler,
312    /// logging internal details. `uri` is the resource being read, if any.
313    pub(crate) fn into_rpc_error(self, handler: &str, uri: Option<&str>) -> McpError {
314        match self.kind {
315            ToolErrorKind::User(message) => McpError::request_failed(message),
316            ToolErrorKind::NotFound => match uri {
317                Some(uri) => McpError::resource_not_found(uri),
318                None => McpError::request_failed("Not found"),
319            },
320            ToolErrorKind::Protocol(error) => error,
321            ToolErrorKind::InputRequired => {
322                tracing::error!(handler, "input required outside a multi round-trip request");
323                McpError::internal()
324            }
325            ToolErrorKind::InvalidArguments(details) => {
326                McpError::request_failed(format!("Invalid arguments: {details}"))
327            }
328            ToolErrorKind::Internal(error) => {
329                tracing::error!(
330                    "`{handler}` failed: {error}\n  = help: the client saw only a generic \
331                     message; return ToolError::user(\"...\") for errors it should see"
332                );
333                McpError::internal()
334            }
335            ToolErrorKind::Timeout => {
336                tracing::warn!("`{handler}` timed out\n  = help: {TIMEOUT_HELP}");
337                McpError::internal_with_message("The request did not finish within the time limit.")
338            }
339            ToolErrorKind::Panicked => {
340                tracing::error!("`{handler}` panicked\n  = help: {PANIC_HELP}");
341                McpError::internal()
342            }
343        }
344    }
345}
346
347impl fmt::Debug for ToolError {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        match &self.kind {
350            ToolErrorKind::User(m) => f.debug_tuple("User").field(m).finish(),
351            ToolErrorKind::NotFound => f.write_str("NotFound"),
352            ToolErrorKind::InputRequired => f.write_str("InputRequired"),
353            ToolErrorKind::Protocol(e) => f.debug_tuple("Protocol").field(e).finish(),
354            ToolErrorKind::InvalidArguments(m) => {
355                f.debug_tuple("InvalidArguments").field(m).finish()
356            }
357            ToolErrorKind::Internal(e) => f.debug_tuple("Internal").field(e).finish(),
358            ToolErrorKind::Timeout => f.write_str("Timeout"),
359            ToolErrorKind::Panicked => f.write_str("Panicked"),
360        }
361    }
362}
363
364impl fmt::Display for ToolError {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        match &self.kind {
367            ToolErrorKind::User(m) => f.write_str(m),
368            ToolErrorKind::NotFound => f.write_str("not found"),
369            ToolErrorKind::InputRequired => f.write_str("input required"),
370            ToolErrorKind::Protocol(e) => write!(f, "protocol error: {e}"),
371            ToolErrorKind::InvalidArguments(m) => write!(f, "invalid arguments: {m}"),
372            ToolErrorKind::Internal(e) => write!(f, "internal error: {e}"),
373            ToolErrorKind::Timeout => f.write_str("timed out"),
374            ToolErrorKind::Panicked => f.write_str("panicked"),
375        }
376    }
377}
378
379/// Conversion of tool errors. Implemented for [`ToolError`] and for every
380/// `std::error::Error` (treated as internal).
381pub trait IntoToolError {
382    /// Performs the conversion.
383    fn into_tool_error(self) -> ToolError;
384}
385
386impl IntoToolError for ToolError {
387    fn into_tool_error(self) -> ToolError {
388        self
389    }
390}
391
392impl<E: std::error::Error + Send + Sync + 'static> IntoToolError for E {
393    fn into_tool_error(self) -> ToolError {
394        ToolError::internal(self)
395    }
396}
397
398/// Wraps a value returned as structured content. The tool's `outputSchema`
399/// is generated from `T`, and the result carries both `structuredContent`
400/// and the serialized JSON as text, as the specification recommends.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
402pub struct Json<T>(pub T);
403
404/// Conversion of a tool's return value into a result.
405pub trait IntoToolResult {
406    /// Performs the conversion.
407    fn into_tool_result(self) -> ToolResult;
408
409    /// The `outputSchema` advertised for this return type, if any.
410    fn output_schema() -> Option<Map<String, Value>> {
411        None
412    }
413}
414
415fn text_result(text: String) -> ToolResult {
416    Ok(ToolOutput::Complete(CallToolResult::new(
417        vec![ContentBlock::from(TextContent::new(text))],
418        "complete".to_owned(),
419    )))
420}
421
422impl IntoToolResult for String {
423    fn into_tool_result(self) -> ToolResult {
424        text_result(self)
425    }
426}
427
428impl IntoToolResult for &'static str {
429    fn into_tool_result(self) -> ToolResult {
430        text_result(self.to_owned())
431    }
432}
433
434impl IntoToolResult for Cow<'static, str> {
435    fn into_tool_result(self) -> ToolResult {
436        text_result(self.into_owned())
437    }
438}
439
440macro_rules! display_results {
441    ($($t:ty),* $(,)?) => {
442        $(impl IntoToolResult for $t {
443            fn into_tool_result(self) -> ToolResult {
444                text_result(self.to_string())
445            }
446        })*
447    };
448}
449
450display_results!(
451    bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
452);
453
454impl IntoToolResult for () {
455    fn into_tool_result(self) -> ToolResult {
456        Ok(ToolOutput::Complete(CallToolResult::new(
457            Vec::new(),
458            "complete".to_owned(),
459        )))
460    }
461}
462
463impl IntoToolResult for CallToolResult {
464    fn into_tool_result(self) -> ToolResult {
465        Ok(ToolOutput::Complete(self))
466    }
467}
468
469impl IntoToolResult for ContentBlock {
470    fn into_tool_result(self) -> ToolResult {
471        Ok(ToolOutput::Complete(CallToolResult::new(
472            vec![self],
473            "complete".to_owned(),
474        )))
475    }
476}
477
478impl IntoToolResult for Vec<ContentBlock> {
479    fn into_tool_result(self) -> ToolResult {
480        Ok(ToolOutput::Complete(CallToolResult::new(
481            self,
482            "complete".to_owned(),
483        )))
484    }
485}
486
487impl<T: Serialize + JsonSchema> IntoToolResult for Json<T> {
488    fn into_tool_result(self) -> ToolResult {
489        let value = serde_json::to_value(&self.0).map_err(ToolError::internal)?;
490        let text = serde_json::to_string(&value).map_err(ToolError::internal)?;
491        let mut result = CallToolResult::new(
492            vec![ContentBlock::from(TextContent::new(text))],
493            "complete".to_owned(),
494        );
495        result.structured_content = Some(value);
496        Ok(ToolOutput::Complete(result))
497    }
498
499    fn output_schema() -> Option<Map<String, Value>> {
500        Some(schema_for::<T>())
501    }
502}
503
504impl<T: IntoToolResult, E: IntoToolError> IntoToolResult for Result<T, E> {
505    fn into_tool_result(self) -> ToolResult {
506        match self {
507            Ok(value) => value.into_tool_result(),
508            Err(error) => Err(error.into_tool_error()),
509        }
510    }
511
512    fn output_schema() -> Option<Map<String, Value>> {
513        T::output_schema()
514    }
515}
516
517/// Generates a JSON Schema (2020-12) for `T`, as used in `inputSchema` and
518/// `outputSchema`. The root `$schema` and `title` keys are removed: 2020-12
519/// is the default dialect, and the title would expose a Rust type name.
520#[must_use]
521pub fn schema_for<T: JsonSchema>() -> Map<String, Value> {
522    let generator = schemars::generate::SchemaSettings::draft2020_12().into_generator();
523    let schema = generator.into_root_schema_for::<T>();
524    let mut map = match schema.to_value() {
525        Value::Object(map) => map,
526        // `true`/`false` schemas: accept anything / nothing.
527        Value::Bool(true) => Map::new(),
528        other => {
529            let mut map = Map::new();
530            map.insert("not".to_owned(), Value::Object(Map::new()));
531            tracing::warn!(schema = %other, "unexpected schema shape");
532            map
533        }
534    };
535    map.remove("$schema");
536    map.remove("title");
537    for value in map.values_mut() {
538        strip_nonstandard_formats(value);
539    }
540    map
541}
542
543/// Numeric `format` values emitted by schemars that JSON Schema 2020-12 does
544/// not define. Validators ignore (and some warn about) them, and they cost
545/// model context; range keywords such as `minimum` carry the useful part.
546const NONSTANDARD_FORMATS: &[&str] = &[
547    "int", "int8", "int16", "int32", "int64", "int128", "uint", "uint8", "uint16", "uint32",
548    "uint64", "uint128", "float", "double",
549];
550
551fn strip_nonstandard_formats(value: &mut Value) {
552    match value {
553        Value::Object(map) => {
554            if map
555                .get("format")
556                .and_then(Value::as_str)
557                .is_some_and(|f| NONSTANDARD_FORMATS.contains(&f))
558            {
559                map.remove("format");
560            }
561            map.values_mut().for_each(strip_nonstandard_formats);
562        }
563        Value::Array(items) => items.iter_mut().for_each(strip_nonstandard_formats),
564        _ => {}
565    }
566}
567
568/// Builds a tool `inputSchema` from the JSON Schema of `T`, which must
569/// describe an object (tool arguments are always a JSON object).
570#[must_use]
571pub fn input_schema_for<T: JsonSchema>() -> spec::ToolInputSchema {
572    let mut map = schema_for::<T>();
573    // `type: "object"` is carried by the dedicated marker field.
574    map.remove("type");
575    spec::ToolInputSchema {
576        schema: None,
577        r#type: spec::ObjectType::Object,
578        extra: map,
579    }
580}
581
582/// Wraps a generated schema as a tool `outputSchema`.
583#[must_use]
584pub fn output_schema_from(schema: Map<String, Value>) -> spec::ToolOutputSchema {
585    spec::ToolOutputSchema {
586        schema: None,
587        extra: schema,
588    }
589}
590
591/// Deserializes tool arguments. A missing `arguments` member is treated as
592/// an empty object.
593///
594/// # Errors
595///
596/// Returns [`ToolError::invalid_arguments`] when the arguments do not match.
597pub fn parse_arguments<T: DeserializeOwned>(arguments: Option<&RawValue>) -> Result<T, ToolError> {
598    let raw = arguments.map_or("{}", RawValue::get);
599    serde_json::from_str(raw).map_err(|e| ToolError::invalid_arguments(&e))
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[derive(Serialize, JsonSchema)]
607    struct Point {
608        x: i32,
609        y: i32,
610    }
611
612    fn complete(r: ToolResult) -> CallToolResult {
613        match r {
614            Ok(ToolOutput::Complete(c)) => c,
615            Err(e) => panic!("unexpected error {e:?}"),
616        }
617    }
618
619    #[test]
620    fn json_output_has_structured_content_and_schema() {
621        let result = complete(Json(Point { x: 1, y: 2 }).into_tool_result());
622        assert_eq!(
623            result.structured_content,
624            Some(serde_json::json!({"x": 1, "y": 2}))
625        );
626        let schema = <Json<Point> as IntoToolResult>::output_schema().unwrap();
627        assert_eq!(schema.get("type"), Some(&Value::from("object")));
628        assert!(!schema.contains_key("title"));
629        assert!(!schema.contains_key("$schema"));
630        // `format: "int32"` is not a JSON Schema 2020-12 format.
631        assert_eq!(
632            schema["properties"]["x"],
633            serde_json::json!({ "type": "integer" })
634        );
635    }
636
637    #[test]
638    fn errors_hide_internal_details() {
639        let io = std::io::Error::other("secret path /etc/shadow");
640        let result = Err::<String, _>(io)
641            .into_tool_result()
642            .unwrap_err()
643            .into_call_result("t");
644        assert_eq!(result.is_error, Some(true));
645        let text = serde_json::to_string(&result).unwrap();
646        assert!(!text.contains("shadow"));
647
648        let user = ToolError::user("b must not be zero").into_call_result("t");
649        assert!(
650            serde_json::to_string(&user)
651                .unwrap()
652                .contains("b must not be zero")
653        );
654    }
655
656    #[test]
657    fn missing_arguments_are_an_empty_object() {
658        #[derive(serde::Deserialize)]
659        struct NoArgs {}
660        assert!(parse_arguments::<NoArgs>(None).is_ok());
661        #[derive(Debug, serde::Deserialize)]
662        #[allow(dead_code)]
663        struct NeedsA {
664            a: i32,
665        }
666        let err = parse_arguments::<NeedsA>(None).unwrap_err();
667        assert!(err.to_string().contains("missing field `a`"));
668    }
669}