use crate::tool::{Tool, ToolBox, ToolError};
use async_trait::async_trait;
use log::trace;
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock};
use rmcp::service::{RunningService, ServiceError};
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::{ConfigureCommandExt, StreamableHttpClientTransport, TokioChildProcess};
use rmcp::{RoleClient, ServiceExt};
use serde_json::Value;
use std::collections::HashMap;
use tokio::process::Command;
pub struct McpToolBox {
client: RunningService<RoleClient, ()>,
tools: Vec<Tool>,
}
fn connect_error(err: impl std::fmt::Display) -> ToolError {
ToolError::Configuration(format!("cannot connect to the MCP server: {err}"))
}
impl McpToolBox {
pub async fn new(
cmd: &str,
args: impl IntoIterator<Item = impl AsRef<str>>,
envs: Option<HashMap<String, String>>,
) -> Result<Self, ToolError> {
trace!("McpToolBox::new for cmd: {cmd}");
let args: Vec<String> = args.into_iter().map(|a| a.as_ref().to_string()).collect();
let command = Command::new(cmd).configure(|command| {
command.args(&args);
if let Some(envs) = envs {
command.envs(envs);
}
});
let transport = TokioChildProcess::new(command).map_err(connect_error)?;
let client = ().serve(transport).await.map_err(connect_error)?;
trace!("McpToolBox::new client initialized");
Self::from_client(client).await
}
pub async fn new_http(url: &str) -> Result<Self, ToolError> {
trace!("McpToolBox::new_http for url: {url}");
let transport = StreamableHttpClientTransport::from_uri(url.to_string());
let client = ().serve(transport).await.map_err(connect_error)?;
trace!("McpToolBox::new_http client initialized");
Self::from_client(client).await
}
pub async fn new_http_with_bearer(url: &str, token: &str) -> Result<Self, ToolError> {
trace!("McpToolBox::new_http_with_bearer for url: {url}");
let config =
StreamableHttpClientTransportConfig::with_uri(url.to_string()).auth_header(token);
let transport = StreamableHttpClientTransport::from_config(config);
let client = ().serve(transport).await.map_err(connect_error)?;
trace!("McpToolBox::new_http_with_bearer client initialized");
Self::from_client(client).await
}
async fn from_client(client: RunningService<RoleClient, ()>) -> Result<Self, ToolError> {
let tools = client
.list_all_tools()
.await
.map_err(connect_error)?
.into_iter()
.map(|mcp_tool| {
let mut tool = Tool::new(mcp_tool.name.to_string())
.with_schema(Value::Object(mcp_tool.input_schema.as_ref().clone()));
tool.description = mcp_tool.description.map(|d| d.to_string());
tool
})
.collect();
Ok(Self { client, tools })
}
}
fn result_to_text(result: &CallToolResult) -> String {
let has_text = result.content.iter().any(|block| block.as_text().is_some());
if !has_text && let Some(structured) = &result.structured_content {
return structured.to_string();
}
result
.content
.iter()
.map(|block| match block {
ContentBlock::Text(text) => text.text.clone(),
ContentBlock::Image(_) => "[image omitted]".to_string(),
ContentBlock::Audio(_) => "[audio omitted]".to_string(),
ContentBlock::Resource(_) => "[embedded resource omitted]".to_string(),
ContentBlock::ResourceLink(link) => format!("[resource link: {}]", link.uri),
_ => "[unsupported content omitted]".to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
#[async_trait]
impl ToolBox for McpToolBox {
fn tools_definitions(&self) -> Result<Vec<Tool>, ToolError> {
Ok(self.tools.clone())
}
async fn call_tool(&self, tool_name: String, arguments: Value) -> Result<String, ToolError> {
if !self.tools.iter().any(|t| t.name.as_str() == tool_name) {
return Err(ToolError::NotFound(tool_name));
}
let arguments = match arguments {
Value::Object(map) => Some(map),
Value::Null => None,
other => {
return Err(ToolError::InvalidArguments(format!(
"Arguments for tool '{tool_name}' must be a JSON object, got: {other}"
)));
}
};
let mut params = CallToolRequestParams::new(tool_name.clone());
if let Some(arguments) = arguments {
params = params.with_arguments(arguments);
}
let result = self
.client
.call_tool(params)
.await
.map_err(|err| match err {
ServiceError::McpError(_)
| ServiceError::Timeout { .. }
| ServiceError::Cancelled { .. } => ToolError::Failed(err.to_string()),
err => ToolError::fatal(err),
})?;
let text = result_to_text(&result);
if result.is_error == Some(true) {
return Err(ToolError::Failed(text));
}
Ok(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result as AnyhowResult;
use serde_json::json;
async fn create_test_toolbox() -> AnyhowResult<McpToolBox> {
Ok(McpToolBox::new("uvx", ["mcp-server-time", "--local-timezone", "UTC"], None).await?)
}
#[tokio::test]
async fn test_new_and_tools_definitions() -> AnyhowResult<()> {
let mcp_tools = create_test_toolbox().await?;
let tool_defs = mcp_tools.tools_definitions()?;
assert!(tool_defs.len() >= 2);
let get_time_tool = tool_defs
.iter()
.find(|t| t.name.as_str() == "get_current_time");
assert!(
get_time_tool.is_some(),
"Expected tool 'get_current_time' not found"
);
assert_eq!(get_time_tool.unwrap().name.as_str(), "get_current_time");
assert!(get_time_tool.unwrap().description.is_some());
assert!(get_time_tool.unwrap().schema.is_some());
let convert_time_tool = tool_defs.iter().find(|t| t.name.as_str() == "convert_time");
assert!(
convert_time_tool.is_some(),
"Expected tool 'convert_time' not found"
);
assert_eq!(convert_time_tool.unwrap().name.as_str(), "convert_time");
assert!(convert_time_tool.unwrap().description.is_some());
assert!(convert_time_tool.unwrap().schema.is_some());
Ok(())
}
#[tokio::test]
async fn test_call_tool_convert_time() -> AnyhowResult<()> {
let mcp_tools = create_test_toolbox().await?;
let arguments = json!({
"source_timezone": "Europe/Warsaw",
"target_timezone": "America/New_York",
"time": "12:00"
});
let result = mcp_tools
.call_tool("convert_time".to_string(), arguments)
.await?;
assert!(!result.is_empty());
Ok(())
}
#[tokio::test]
async fn test_call_tool_invalid_tool() -> AnyhowResult<()> {
let mcp_tools = create_test_toolbox().await?;
let arguments = json!({});
let result = mcp_tools
.call_tool("non_existent_tool".to_string(), arguments)
.await;
assert!(matches!(result, Err(ToolError::NotFound(_))));
Ok(())
}
#[tokio::test]
async fn test_call_tool_server_error_reaches_model() -> AnyhowResult<()> {
let mcp_tools = create_test_toolbox().await?;
let arguments = json!({ "timezone": "Not/AZone" });
let result = mcp_tools
.call_tool("get_current_time".to_string(), arguments)
.await;
match result {
Err(ToolError::Failed(message)) => assert!(!message.is_empty()),
other => panic!("Expected ToolError::Failed, got {other:?}"),
}
Ok(())
}
#[test]
fn test_result_to_text() -> AnyhowResult<()> {
let mixed: CallToolResult = serde_json::from_value(json!({
"content": [
{ "type": "text", "text": "first" },
{ "type": "image", "data": "AAAA", "mimeType": "image/png" },
{ "type": "text", "text": "second" }
]
}))?;
assert_eq!(result_to_text(&mixed), "first\n[image omitted]\nsecond");
let structured: CallToolResult = serde_json::from_value(json!({
"content": [],
"structuredContent": { "sum": 3 }
}))?;
assert_eq!(result_to_text(&structured), r#"{"sum":3}"#);
Ok(())
}
#[tokio::test]
async fn test_call_tool_rejects_non_object_arguments() -> AnyhowResult<()> {
let mcp_tools = create_test_toolbox().await?;
let result = mcp_tools
.call_tool("get_current_time".to_string(), json!("Europe/Warsaw"))
.await;
assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
Ok(())
}
}