use async_trait::async_trait;
use crate::mcp::{McpResource, McpServerManager, ResourceContents, manager_ref};
use mermaid_domain::mcp_resources::{
LIST_MCP_RESOURCES, READ_MCP_RESOURCE, list_definition, read_definition,
};
use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
use super::super::ctx::ExecContext;
use super::ToolExecutor;
const MAX_RESOURCE_OUTPUT_CHARS: usize = 100_000;
pub struct ListMcpResourcesTool;
pub struct ReadMcpResourceTool;
#[async_trait]
impl ToolExecutor for ListMcpResourcesTool {
fn name(&self) -> &'static str {
LIST_MCP_RESOURCES
}
fn is_internal(&self) -> bool {
true
}
fn schema(&self) -> ToolDefinition {
list_definition("")
}
async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
let filter = args
.get("server")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let Some(manager) = ready_manager().await else {
return not_initialized();
};
let servers = match filter {
Some(server) => vec![server.to_string()],
None => manager.resource_servers(),
};
if servers.is_empty() {
return ToolOutcome::error("No connected MCP server provides resources", None);
}
let target = filter.unwrap_or("*");
if let Some(blocked) = gate(&ctx, target, "resources/list", serde_json::json!({})).await {
return blocked;
}
let start = std::time::Instant::now();
let listing = async {
let mut entries = Vec::new();
for server in &servers {
entries.push(list_entry(manager, server).await);
}
entries
};
let entries = tokio::select! {
biased;
_ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
entries = listing => entries,
};
let count: usize = entries
.iter()
.filter_map(|e| e.get("resources").and_then(|r| r.as_array()))
.map(Vec::len)
.sum();
let text = serde_json::to_string_pretty(&entries).unwrap_or_default();
ToolOutcome::success(
mermaid_model::utils::truncate_middle(&text, MAX_RESOURCE_OUTPUT_CHARS),
format!("{count} MCP resource(s) from {} server(s)", servers.len()),
start.elapsed().as_secs_f64(),
)
.with_metadata(metadata(target, "resources/list"))
}
}
#[async_trait]
impl ToolExecutor for ReadMcpResourceTool {
fn name(&self) -> &'static str {
READ_MCP_RESOURCE
}
fn is_internal(&self) -> bool {
true
}
fn schema(&self) -> ToolDefinition {
read_definition("")
}
async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
let str_arg = |key: &str| {
args.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
};
let Some(server) = str_arg("server") else {
return ToolOutcome::error("read_mcp_resource requires 'server'", None);
};
let Some(uri) = str_arg("uri") else {
return ToolOutcome::error("read_mcp_resource requires 'uri'", None);
};
let Some(manager) = ready_manager().await else {
return not_initialized();
};
if let Some(blocked) = gate(
&ctx,
server,
"resources/read",
serde_json::json!({ "uri": uri }),
)
.await
{
return blocked;
}
let start = std::time::Instant::now();
tokio::select! {
biased;
_ = ctx.token.cancelled() => ToolOutcome::cancelled(),
result = manager.read_resource(server, uri) => match result {
Ok(contents) => {
let (text, images) = format_contents(&contents);
let mut outcome = ToolOutcome::success(
mermaid_model::utils::truncate_middle(&text, MAX_RESOURCE_OUTPUT_CHARS),
format!("{server}: read {uri}"),
start.elapsed().as_secs_f64(),
)
.with_metadata(metadata(server, "resources/read"));
if !images.is_empty() {
outcome = outcome.with_images(images);
}
outcome
},
Err(e) => ToolOutcome::error(
format!("read_mcp_resource({server}, {uri}): {e}"),
Some(start.elapsed().as_secs_f64()),
)
.with_metadata(metadata(server, "resources/read")),
},
}
}
}
async fn ready_manager() -> Option<&'static McpServerManager> {
if !manager_ref::is_ready() {
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
manager_ref::wait_ready(),
)
.await;
}
manager_ref::get().map(AsRef::as_ref)
}
fn not_initialized() -> ToolOutcome {
ToolOutcome::error("MCP servers not initialized", None)
}
async fn gate(
ctx: &ExecContext,
server: &str,
method: &str,
arguments: serde_json::Value,
) -> Option<ToolOutcome> {
let args = serde_json::json!({
"server_name": server,
"tool_name": method,
"arguments": arguments,
});
super::policy_gate::gate_external_mcp(ctx, format!("mcp {server} {method}"), &args, true).await
}
async fn list_entry(manager: &McpServerManager, server: &str) -> serde_json::Value {
match manager.list_resources(server).await {
Ok((raw, resources)) => serde_json::json!({
"server": raw,
"resources": resources_json(&resources),
}),
Err(e) => serde_json::json!({ "server": server, "error": e.to_string() }),
}
}
fn resources_json(resources: &[McpResource]) -> serde_json::Value {
serde_json::to_value(resources).unwrap_or_else(|_| serde_json::json!([]))
}
fn format_contents(contents: &[ResourceContents]) -> (String, Vec<String>) {
let mut parts = Vec::with_capacity(contents.len());
let mut images = Vec::new();
for item in contents {
let mime = item.mime_type.as_deref().unwrap_or("unknown type");
match (&item.text, &item.blob) {
(Some(text), _) => parts.push(format!("[{} ({mime})]\n{text}", item.uri)),
(None, Some(blob)) => {
let bytes = base64_decoded_len(blob);
if mime.starts_with("image/") {
images.push(blob.clone());
parts.push(format!(
"[{} ({mime}): image attached, {bytes} bytes]",
item.uri
));
} else {
parts.push(format!(
"[{} ({mime}): binary content, {bytes} bytes, not shown]",
item.uri
));
}
},
(None, None) => parts.push(format!("[{} ({mime}): empty]", item.uri)),
}
}
if parts.is_empty() {
parts.push("The resource returned no contents.".to_string());
}
(parts.join("\n\n"), images)
}
fn base64_decoded_len(blob: &str) -> usize {
let trimmed = blob.trim_end();
let padding = trimmed.bytes().rev().take_while(|b| *b == b'=').count();
(trimmed.len() / 4 * 3).saturating_sub(padding)
}
fn metadata(server: &str, method: &str) -> ToolRunMetadata {
ToolRunMetadata {
detail: ToolMetadata::Mcp {
server: server.to_string(),
tool: method.to_string(),
},
..ToolRunMetadata::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::ctx::test_exec_context;
use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
use std::path::PathBuf;
fn contents(mime: &str, text: Option<&str>, blob: Option<&str>) -> ResourceContents {
ResourceContents {
uri: "file:///r".to_string(),
mime_type: Some(mime.to_string()),
text: text.map(String::from),
blob: blob.map(String::from),
}
}
#[test]
fn text_contents_render_verbatim_under_their_uri() {
let (text, images) = format_contents(&[contents("text/plain", Some("hello"), None)]);
assert_eq!(text, "[file:///r (text/plain)]\nhello");
assert!(images.is_empty());
}
#[test]
fn binary_contents_are_summarized_not_dumped() {
let (text, images) =
format_contents(&[contents("application/pdf", None, Some("aGVsbG8="))]);
assert_eq!(
text,
"[file:///r (application/pdf): binary content, 5 bytes, not shown]"
);
assert!(
!text.contains("aGVsbG8"),
"no base64 in the model's context"
);
assert!(images.is_empty());
}
#[test]
fn image_blobs_ride_the_image_channel() {
let (text, images) = format_contents(&[contents("image/png", None, Some("iVBORw=="))]);
assert_eq!(images, ["iVBORw=="]);
assert!(text.contains("image attached, 4 bytes"), "{text}");
}
#[test]
fn base64_length_accounts_for_padding() {
assert_eq!(base64_decoded_len(""), 0);
assert_eq!(base64_decoded_len("aGVsbG8="), 5);
assert_eq!(base64_decoded_len("aGVsbA=="), 4);
assert_eq!(base64_decoded_len("aGVsbG9v"), 6);
}
#[tokio::test]
async fn read_requires_server_and_uri() {
for args in [
serde_json::json!({"uri": "file:///r"}),
serde_json::json!({"server": "s"}),
] {
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
let outcome = ReadMcpResourceTool.execute(args, ctx).await;
assert_eq!(outcome.status, ToolStatus::Error);
}
}
#[test]
fn schemas_name_their_executors() {
assert_eq!(
ListMcpResourcesTool.schema().name,
ListMcpResourcesTool.name()
);
assert_eq!(
ReadMcpResourceTool.schema().name,
ReadMcpResourceTool.name()
);
}
}