use crate::protocol::{ToolInfo, ToolResult};
use crate::schema::Schema;
use rustlavel_core::{Json, Result};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
pub type ToolFuture = Pin<Box<dyn Future<Output = Result<Json>> + Send>>;
pub trait ToolHandler: Send + Sync + 'static {
fn call(&self, arguments: Json) -> ToolFuture;
}
impl<F, Fut> ToolHandler for F
where
F: Fn(Json) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Json>> + Send + 'static,
{
fn call(&self, arguments: Json) -> ToolFuture {
Box::pin(self(arguments))
}
}
#[derive(Clone)]
pub struct Tool {
pub name: String,
pub description: String,
pub schema: Schema,
handler: Arc<dyn ToolHandler>,
}
impl Tool {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
schema: Schema,
handler: impl ToolHandler,
) -> Self {
Tool {
name: name.into(),
description: description.into(),
schema,
handler: Arc::new(handler),
}
}
pub fn info(&self) -> ToolInfo {
ToolInfo::describe(&self.name, &self.description, &self.schema)
}
pub async fn call(&self, arguments: Json) -> ToolResult {
rustlavel_http::panic::install_hook();
let handler = Arc::clone(&self.handler);
let outcome =
rustlavel_http::panic::catch(async move { handler.call(arguments).await }).await;
match outcome {
Ok(Ok(value)) => ToolResult::from_value(value),
Ok(Err(error)) => ToolResult::error(format!("`{}` failed: {error}", self.name)),
Err(message) => {
let at = rustlavel_http::panic::take_location()
.map(|l| format!(" at {}:{}", l.file, l.line))
.unwrap_or_default();
rustlavel_core::error!("panic in MCP tool `{}`{at}: {message}", self.name);
ToolResult::error(format!("`{}` panicked: {message}", self.name))
}
}
}
}
impl std::fmt::Debug for Tool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Tool").field("name", &self.name).field("schema", &self.schema).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustlavel_core::Error;
fn echo_tool() -> Tool {
Tool::new(
"echo",
"Repeat a message back",
Schema::object().string("message", "What to repeat"),
|args: Json| async move {
Ok(Json::from(args.get("message").and_then(Json::as_str).unwrap_or("").to_string()))
},
)
}
#[tokio::test]
async fn a_tool_returns_its_value_as_text_content() {
let result = echo_tool().call(Json::object([("message", Json::from("hi"))])).await;
assert!(!result.is_error);
assert_eq!(result.text_content(), "hi");
}
#[tokio::test]
async fn a_handler_error_becomes_an_error_result_not_a_failure() {
let tool = Tool::new("boom", "Always fails", Schema::object(), |_: Json| async {
Err(Error::msg("the database is asleep"))
});
let result = tool.call(Json::Null).await;
assert!(result.is_error);
assert!(result.text_content().contains("the database is asleep"));
}
#[tokio::test]
async fn a_panicking_handler_is_caught_and_reported() {
let tool = Tool::new("panics", "Always panics", Schema::object(), |_: Json| async {
panic!("index out of bounds");
});
let result = tool.call(Json::Null).await;
assert!(result.is_error);
assert!(result.text_content().contains("index out of bounds"));
}
#[tokio::test]
async fn a_panic_after_an_await_point_is_caught_too() {
let tool = Tool::new("late", "Panics later", Schema::object(), |_: Json| async {
tokio::task::yield_now().await;
panic!("after yielding");
});
assert!(tool.call(Json::Null).await.is_error);
}
#[test]
fn a_tool_describes_itself_for_tools_list() {
let info = echo_tool().info();
assert_eq!(info.name, "echo");
assert_eq!(info.input_schema.get("required").unwrap().to_string(), r#"["message"]"#);
}
}