use serde_json::{Value, json};
use shuvarie_llm::{Tool, ToolContext, ToolExecutionError, ToolOutput};
use shuvarie_mcp::{McpToolInfo, SharedMcpManager};
use crate::permissions::Access;
use crate::truncate::truncate_output;
const ARG_DETAIL_CHARS: usize = 512;
pub(crate) struct McpTool {
manager: SharedMcpManager,
server: String,
tool: String,
composite: String,
info: McpToolInfo,
max_output_chars: usize,
access: Access,
}
impl McpTool {
pub(crate) fn new(
manager: SharedMcpManager,
server: String,
info: McpToolInfo,
max_output_chars: usize,
access: Access,
) -> Self {
let composite = shuvarie_mcp::composite_tool_name(&server, &info.name);
Self {
manager,
server,
tool: info.name.clone(),
composite,
info,
max_output_chars,
access,
}
}
}
impl Tool for McpTool {
const NAME: &'static str = "mcp_tool";
type Args = Value;
type Output = ToolOutput;
type Error = ToolExecutionError;
fn description(&self) -> String {
self.info.description.clone().unwrap_or_else(|| {
format!(
"Call the `{tool}` tool on the `{server}` MCP server.",
tool = self.tool,
server = self.server,
)
})
}
fn parameters(&self) -> Value {
match &self.info.input_schema {
Some(schema) if schema.is_object() => schema.clone(),
_ => json!({"type": "object", "properties": {}}),
}
}
async fn call(
&self,
_ctx: &mut ToolContext,
args: Value,
) -> Result<ToolOutput, ToolExecutionError> {
let composite = self.composite.clone();
let max_output_chars = self.max_output_chars;
let result: Result<ToolOutput, String> = async move {
let arguments = args
.as_object()
.ok_or_else(|| "arguments must be a JSON object".to_string())?
.clone();
let detail = format!(
"Server `{server}`, tool `{tool}`. Arguments: {arguments}",
server = self.server,
tool = self.tool,
arguments = summarize(&serde_json::to_string(&arguments).unwrap_or_default()),
);
self.access.authorize_tool(&composite, &detail).await?;
let output = {
let mut manager = self.manager.lock().await;
manager
.call_tool(&self.server, &self.tool, Some(arguments), None)
.await?
};
if output.is_error {
return Err(format!(
"MCP tool `{composite}` reported failure: {}",
summarize(&output.text)
));
}
let text = match truncate_output(
&output.text,
max_output_chars,
"increase max_output_chars",
) {
Some(truncated) => truncated,
None => output.text,
};
Ok(ToolOutput::text(text))
}
.await;
result.map_err(ToolExecutionError::other)
}
}
fn summarize(text: &str) -> String {
let mut text = text.trim().to_string();
if text.chars().count() > ARG_DETAIL_CHARS {
text = text.chars().take(ARG_DETAIL_CHARS).collect::<String>() + "…";
}
text
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::json;
use shuvarie_llm::Tool;
use shuvarie_mcp::testkit::duplex_connection;
use shuvarie_mcp::{McpManager, McpServerSpec};
use tokio::task::JoinHandle;
use crate::permissions::{AskScope, PermissionAnswer};
use crate::test_util::{access_for_config, access_with_answering_gate, new_ctx};
use super::*;
async fn bridged_echo(access: Access) -> (McpTool, JoinHandle<()>) {
let (conn, server) = duplex_connection("test").await;
let info = conn
.tools()
.iter()
.find(|tool| tool.name == "echo")
.cloned()
.expect("echo descriptor");
let mut manager = McpManager::new(BTreeMap::from([(
"test".to_string(),
McpServerSpec::Http {
url: "https://unused.invalid/mcp".to_string(),
headers: BTreeMap::new(),
},
)]));
manager.inject_connection("test", conn);
let manager: SharedMcpManager = Arc::new(tokio::sync::Mutex::new(manager));
(
McpTool::new(manager, "test".to_string(), info, 4096, access),
server,
)
}
fn allow_all_access() -> Access {
access_for_config(&shuvarie_config::PermissionsConfig {
default: Some(shuvarie_config::Verb::Allow),
..shuvarie_config::PermissionsConfig::builtin()
})
}
#[test]
fn descriptor_flows_into_schema_description_and_name() {
let info = McpToolInfo {
name: "echo".to_string(),
title: Some("Echo".to_string()),
description: Some("Echo a message".to_string()),
input_schema: Some(
json!({"type": "object", "properties": {"message": {"type": "string"}}}),
),
};
let manager: SharedMcpManager =
Arc::new(tokio::sync::Mutex::new(McpManager::new(BTreeMap::new())));
let tool = McpTool::new(manager, "test".to_string(), info, 4096, allow_all_access());
assert_eq!(tool.composite, "mcp__test__echo");
assert_eq!(tool.description(), "Echo a message");
assert_eq!(
tool.parameters(),
json!({"type": "object", "properties": {"message": {"type": "string"}}})
);
}
#[test]
fn missing_schema_and_description_fall_back() {
let info = McpToolInfo {
name: "bare".to_string(),
title: None,
description: None,
input_schema: None,
};
let manager: SharedMcpManager =
Arc::new(tokio::sync::Mutex::new(McpManager::new(BTreeMap::new())));
let tool = McpTool::new(manager, "srv".to_string(), info, 4096, allow_all_access());
assert_eq!(tool.composite, "mcp__srv__bare");
assert_eq!(
tool.description(),
"Call the `bare` tool on the `srv` MCP server."
);
assert_eq!(
tool.parameters(),
json!({"type": "object", "properties": {}})
);
let info = McpToolInfo {
name: "weird".to_string(),
title: None,
description: None,
input_schema: Some(json!("not-an-object")),
};
let manager: SharedMcpManager =
Arc::new(tokio::sync::Mutex::new(McpManager::new(BTreeMap::new())));
let tool = McpTool::new(manager, "srv".to_string(), info, 4096, allow_all_access());
assert_eq!(
tool.parameters(),
json!({"type": "object", "properties": {}})
);
}
#[cfg(unix)]
#[tokio::test]
async fn call_round_trips_text_through_the_manager() {
let (tool, server) = bridged_echo(allow_all_access()).await;
let mut ctx = new_ctx();
let out = tool
.call(&mut ctx, json!({"message": "hi"}))
.await
.expect("echo call");
assert_eq!(out.as_text().expect("text"), "echo: hi");
drop(server);
}
#[cfg(unix)]
#[tokio::test]
async fn tool_level_failure_is_a_tool_execution_error() {
let (conn, server) = duplex_connection("test").await;
let info = conn
.tools()
.iter()
.find(|tool| tool.name == "fail")
.cloned()
.expect("fail descriptor");
let mut manager = McpManager::new(BTreeMap::from([(
"test".to_string(),
McpServerSpec::Http {
url: "https://unused.invalid/mcp".to_string(),
headers: BTreeMap::new(),
},
)]));
manager.inject_connection("test", conn);
let manager: SharedMcpManager = Arc::new(tokio::sync::Mutex::new(manager));
let tool = McpTool::new(manager, "test".to_string(), info, 4096, allow_all_access());
let mut ctx = new_ctx();
let err = tool
.call(&mut ctx, json!({}))
.await
.expect_err("isError must fail the call");
let message = err.to_string();
assert!(
message.contains("mcp__test__fail") && message.contains("boom"),
"got: {message}"
);
drop(server);
}
#[cfg(unix)]
#[tokio::test]
async fn call_asks_with_composite_name_and_session_grant_covers_it() {
let (access, mut requests) =
access_with_answering_gate(&shuvarie_config::PermissionsConfig::builtin());
let (tool, server) = bridged_echo(access).await;
let mut ctx = new_ctx();
let (result, _) = tokio::join!(tool.call(&mut ctx, json!({"message": "asked"})), async {
let request = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::task::yield_now().await;
requests.recv().await
})
.await
.expect("no permission request within 5s")
.expect("permission request");
assert!(
request
.description
.contains("Allow tool `mcp__test__echo`?"),
"got: {}",
request.description
);
assert_eq!(
request.scope,
Some(AskScope::Tool("mcp__test__echo".to_string()))
);
request
.respond
.send(PermissionAnswer::AllowSession)
.expect("answer sent");
});
result.expect("allowed");
assert!(requests.try_recv().is_err(), "no new ask after the grant");
let mut ctx = new_ctx();
tool.call(&mut ctx, json!({"message": "granted"}))
.await
.expect("covered by the grant");
assert!(requests.try_recv().is_err(), "still no ask");
drop(server);
}
}