use super::{
ToolCapability, ToolResult, ToolResultDisplay, ToolRuntime,
args::{
AstGrepArgs, BrowserArgs, CodeSearchArgs, DiagnosticsArgs, FindArgs, GrepArgs,
HashEditArgs, ListFilesArgs, ReadArgs, ReferencesArgs, RepoMapArgs, SubagentsArgs,
ViewImageArgs, WebSearchArgs,
},
};
use crate::{mcp::ContentBlock, output::ToolDispatchContext, output::redact_sensitive_text};
use serde_json::{Value, json};
use std::path::PathBuf;
pub(crate) const MAX_MCP_TOOL_TEXT_BYTES: usize = 65_536;
pub(crate) const MAX_MCP_STRUCTURED_JSON_BYTES: usize = 16_384;
const MCP_TOOL_PREFIX: &str = "mcp__";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TouchedPathKind {
Read,
ViewImage,
HashEdit,
Write,
Grep,
Find,
ListFiles,
RepoMap,
AstGrep,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TouchedPath {
pub(crate) canonical: PathBuf,
pub(crate) kind: TouchedPathKind,
pub(crate) success: bool,
pub(crate) inside_root: bool,
pub(crate) is_dir: bool,
pub(crate) self_authored_agents_md: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct ToolDispatchOutcome {
pub(crate) result: ToolResult,
pub(crate) touched_paths: Vec<TouchedPath>,
}
impl ToolDispatchOutcome {
fn result_only(result: ToolResult) -> Self {
Self {
result,
touched_paths: Vec::new(),
}
}
}
impl ToolRuntime {
pub fn dispatch(&self, name: &str, arguments: Value) -> ToolResult {
self.dispatch_with_context(name, arguments, ToolDispatchContext::new(None, None))
}
pub(crate) fn dispatch_with_context(
&self,
name: &str,
arguments: Value,
context: ToolDispatchContext,
) -> ToolResult {
self.dispatch_with_context_outcome(name, arguments, context)
.result
}
pub(crate) fn dispatch_with_context_outcome(
&self,
name: &str,
arguments: Value,
context: ToolDispatchContext,
) -> ToolDispatchOutcome {
let explicit_path = explicit_path_for_dispatch(name, &arguments);
match self.dispatch_inner(name, arguments, context) {
Ok(result) => {
let touched_paths =
self.touched_paths_for_dispatch(name, explicit_path.as_deref(), &result);
ToolDispatchOutcome {
result,
touched_paths,
}
}
Err(error) => ToolDispatchOutcome::result_only(ToolResult {
tool_name: name.to_string(),
success: false,
content: error.to_string(),
metadata: json!({}),
display: ToolResultDisplay::default(),
}),
}
}
fn dispatch_inner(
&self,
name: &str,
arguments: Value,
context: ToolDispatchContext,
) -> anyhow::Result<ToolResult> {
context.cancellation.check()?;
if name.starts_with(MCP_TOOL_PREFIX) {
if self.is_tool_disabled(name) {
anyhow::bail!("tool disabled for this session: {name}");
}
return self.dispatch_mcp(name, arguments, &context);
}
let tool = ToolCapability::from_dispatch_name(name)
.ok_or_else(|| anyhow::anyhow!("unknown tool '{name}'"))?;
if self.is_tool_disabled(tool.canonical_name()) {
anyhow::bail!("tool disabled for this session: {name}");
}
match tool {
ToolCapability::Read => self.read(
serde_json::from_value::<ReadArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::ViewImage => self.view_image(
serde_json::from_value::<ViewImageArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::Bash => {
self.bash(serde_json::from_value(arguments)?, &context.cancellation)
}
ToolCapability::Browser => self.browser(
serde_json::from_value::<BrowserArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::HashEdit => Ok(self.hash_edit(
serde_json::from_value::<HashEditArgs>(arguments)?.validate()?,
&context.cancellation,
)),
ToolCapability::Write => {
self.write_file(serde_json::from_value(arguments)?, &context.cancellation)
}
ToolCapability::Grep => self.grep(
serde_json::from_value::<GrepArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::Find => {
self.find(serde_json::from_value::<FindArgs>(arguments)?.validate()?)
}
ToolCapability::ListFiles => {
self.list_files(serde_json::from_value::<ListFilesArgs>(arguments)?.validate()?)
}
ToolCapability::RepoMap => self.repo_map(
serde_json::from_value::<RepoMapArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::Subagents => {
let tool_args: SubagentsArgs = serde_json::from_value(arguments)?;
let runtime_args = crate::subagents::SubagentsArgs::try_from(tool_args)?;
let arguments = serde_json::to_value(runtime_args)?;
self.subagents
.as_ref()
.map(|runner| runner(arguments, context))
.ok_or_else(|| anyhow::anyhow!("subagents runtime is not configured"))
}
ToolCapability::WebSearch => self.web_search(
serde_json::from_value::<WebSearchArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::CodeSearch => self.code_search(
serde_json::from_value::<CodeSearchArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::AstGrep => self.ast_grep(
serde_json::from_value::<AstGrepArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::Diagnostics => self.diagnostics(
serde_json::from_value::<DiagnosticsArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::References => self.references(
serde_json::from_value::<ReferencesArgs>(arguments)?.validate()?,
&context.cancellation,
),
}
}
fn touched_paths_for_dispatch(
&self,
name: &str,
explicit_path: Option<&str>,
result: &ToolResult,
) -> Vec<TouchedPath> {
let Some(tool) = ToolCapability::from_dispatch_name(name) else {
return Vec::new();
};
match tool {
ToolCapability::Read => self.touched_read_paths(result),
ToolCapability::ViewImage if result.success => result
.metadata
.get("path")
.and_then(Value::as_str)
.and_then(|path| {
self.touched_existing_path(path, TouchedPathKind::ViewImage, false)
})
.into_iter()
.collect(),
ToolCapability::HashEdit if result.success => result
.metadata
.get("path")
.and_then(Value::as_str)
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::HashEdit, true))
.into_iter()
.collect(),
ToolCapability::Write if result.success => result
.metadata
.get("path")
.and_then(Value::as_str)
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::Write, true))
.into_iter()
.collect(),
ToolCapability::Grep if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::Grep, false))
.into_iter()
.collect(),
ToolCapability::Find if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::Find, false))
.into_iter()
.collect(),
ToolCapability::ListFiles if result.success => explicit_path
.and_then(|path| {
self.touched_existing_path(path, TouchedPathKind::ListFiles, false)
})
.into_iter()
.collect(),
ToolCapability::RepoMap if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::RepoMap, false))
.into_iter()
.collect(),
ToolCapability::AstGrep if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::AstGrep, false))
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn touched_read_paths(&self, result: &ToolResult) -> Vec<TouchedPath> {
if let Some(results) = result.metadata.get("results").and_then(Value::as_array) {
return results
.iter()
.filter(|item| item.get("success").and_then(Value::as_bool) == Some(true))
.filter_map(|item| item.get("path").and_then(Value::as_str))
.filter_map(|path| self.touched_existing_path(path, TouchedPathKind::Read, false))
.collect();
}
if result.success {
return result
.metadata
.get("path")
.and_then(Value::as_str)
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::Read, false))
.into_iter()
.collect();
}
Vec::new()
}
fn touched_existing_path(
&self,
path: &str,
kind: TouchedPathKind,
self_authored_on_agents: bool,
) -> Option<TouchedPath> {
let path_buf = PathBuf::from(path);
let resolved = if path_buf.is_absolute() {
path_buf
} else {
self.cwd.join(path_buf)
};
let canonical = resolved.canonicalize().ok()?;
let inside_root = canonical.starts_with(&self.cwd_canonical);
let is_dir = canonical.is_dir();
let self_authored_agents_md = self_authored_on_agents
&& canonical
.file_name()
.is_some_and(|name| name == "AGENTS.md");
Some(TouchedPath {
canonical,
kind,
success: true,
inside_root,
is_dir,
self_authored_agents_md,
})
}
fn dispatch_mcp(
&self,
name: &str,
arguments: Value,
context: &ToolDispatchContext,
) -> anyhow::Result<ToolResult> {
context.cancellation.check()?;
let manager = self
.mcp
.as_ref()
.ok_or_else(|| anyhow::anyhow!("MCP tool runtime is not configured"))?
.lock()
.map_err(|_| anyhow::anyhow!("MCP tool runtime lock poisoned"))?;
let result = manager.call_tool_cancellable(name, Some(arguments), &context.cancellation);
Ok(match result {
Ok(result) => mcp_call_result_to_tool_result(name, result),
Err(error) => ToolResult {
tool_name: name.to_string(),
success: false,
content: bounded_text(&error.to_string(), MAX_MCP_TOOL_TEXT_BYTES),
metadata: json!({"mcp": true}),
display: ToolResultDisplay::default(),
},
})
}
}
fn explicit_path_for_dispatch(name: &str, arguments: &Value) -> Option<String> {
let tool = ToolCapability::from_dispatch_name(name)?;
match tool {
ToolCapability::Grep
| ToolCapability::Find
| ToolCapability::ListFiles
| ToolCapability::RepoMap
| ToolCapability::AstGrep => explicit_argument_path(arguments).map(str::to_owned),
_ => None,
}
}
fn explicit_argument_path(arguments: &Value) -> Option<&str> {
arguments
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
}
fn mcp_call_result_to_tool_result(name: &str, result: crate::mcp::CallToolResult) -> ToolResult {
let content = sanitize_mcp_result_content(&result);
ToolResult {
tool_name: name.to_string(),
success: !result.is_error.unwrap_or(false),
content,
metadata: json!({"mcp": true, "is_error": result.is_error.unwrap_or(false)}),
display: ToolResultDisplay::default(),
}
}
fn sanitize_mcp_result_content(result: &crate::mcp::CallToolResult) -> String {
let mut output = String::new();
for block in &result.content {
if !output.is_empty() {
output.push('\n');
}
match block {
ContentBlock::Text { text } => {
output.push_str(&bounded_text(
&redact_sensitive_text(text),
MAX_MCP_TOOL_TEXT_BYTES,
));
}
ContentBlock::Image { mime_type, .. } => {
output.push_str(&format!("[mcp content block redacted: image {mime_type}]"))
}
ContentBlock::Audio { mime_type, .. } => {
output.push_str(&format!("[mcp content block redacted: audio {mime_type}]"))
}
ContentBlock::Resource { resource } => {
let mime = resource.mime_type.as_deref().unwrap_or("unknown");
output.push_str(&format!("[mcp content block redacted: resource {mime}]"));
}
}
}
if let Some(structured) = &result.structured_content {
if !output.is_empty() {
output.push('\n');
}
let structured = redact_sensitive_text(&structured.to_string());
output.push_str("structuredContent: ");
output.push_str(&bounded_text(&structured, MAX_MCP_STRUCTURED_JSON_BYTES));
}
bounded_text(&output, MAX_MCP_TOOL_TEXT_BYTES)
}
fn bounded_text(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let mut end = max_bytes;
while !text.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n[truncated: MCP output exceeded {max_bytes} bytes]",
&text[..end]
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::protocol::{CallToolResult, ContentBlock};
use std::{
collections::HashSet,
sync::{Arc, Mutex},
};
#[test]
fn hash_edit_dispatch_reaches_implementation() {
let temp = tempfile::TempDir::new().unwrap();
let file = temp.path().join("a.txt");
std::fs::write(&file, "old\n").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let tag = runtime.hashline_snapshots.lock().unwrap().record(
file.canonicalize().unwrap(),
"old\n",
[1],
);
let result = runtime.dispatch(
"hash_edit",
json!({"input": format!("[a.txt#{tag}]\nSWAP 1.=1:\n+new")}),
);
assert!(result.success, "{}", result.content);
assert_eq!(result.tool_name, "hash_edit");
assert_eq!(std::fs::read_to_string(file).unwrap(), "new\n");
}
#[test]
fn successful_hash_edit_result_tracks_hash_edit_touched_path_kind() {
let temp = tempfile::TempDir::new().unwrap();
let file = temp.path().join("a.txt");
std::fs::write(&file, "old\n").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let result = ToolResult {
tool_name: "hash_edit".to_string(),
success: true,
content: "applied".to_string(),
metadata: json!({"path":"a.txt"}),
display: ToolResultDisplay::default(),
};
let touched = runtime.touched_paths_for_dispatch("hash_edit", None, &result);
assert_eq!(touched.len(), 1);
assert_eq!(touched[0].canonical, file.canonicalize().unwrap());
assert_eq!(touched[0].kind, TouchedPathKind::HashEdit);
}
#[test]
fn touched_path_outcome_tracks_successful_read_paths_from_partial_multi_read() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
std::fs::create_dir_all(root.join("src")).unwrap();
let file = root.join("src/lib.rs");
std::fs::write(&file, "fn main() {}\n").unwrap();
let runtime = ToolRuntime::new(root).unwrap();
let outcome = runtime.dispatch_with_context_outcome(
"read",
json!({"paths":["src/lib.rs", "src/missing.rs"]}),
ToolDispatchContext::new(None, None),
);
assert!(
!outcome.result.success,
"aggregate multi-read should fail with one missing file"
);
assert_eq!(outcome.touched_paths.len(), 1);
let touched = &outcome.touched_paths[0];
assert_eq!(touched.canonical, file.canonicalize().unwrap());
assert_eq!(touched.kind, TouchedPathKind::Read);
assert!(touched.success);
assert!(touched.inside_root);
assert!(!touched.is_dir);
assert!(!touched.self_authored_agents_md);
}
#[test]
fn touched_path_outcome_marks_allowed_absolute_outside_cwd_without_blocking_tool() {
let root = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let file = outside.path().join("outside.txt");
std::fs::write(&file, "outside\n").unwrap();
let runtime = ToolRuntime::new(root.path()).unwrap();
let outcome = runtime.dispatch_with_context_outcome(
"read",
json!({"path": file}),
ToolDispatchContext::new(None, None),
);
assert!(outcome.result.success, "{}", outcome.result.content);
assert_eq!(outcome.touched_paths.len(), 1);
assert_eq!(
outcome.touched_paths[0].canonical,
file.canonicalize().unwrap()
);
assert!(!outcome.touched_paths[0].inside_root);
}
#[test]
fn touched_path_outcome_only_tracks_explicit_search_paths() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("src")).unwrap();
std::fs::write(temp.path().join("src/needle.txt"), "needle\n").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let implicit_grep = runtime.dispatch_with_context_outcome(
"grep",
json!({"pattern":"needle"}),
ToolDispatchContext::new(None, None),
);
assert!(
implicit_grep.result.success,
"{}",
implicit_grep.result.content
);
assert!(implicit_grep.touched_paths.is_empty());
let explicit_grep = runtime.dispatch_with_context_outcome(
"grep",
json!({"pattern":"needle", "path":"src"}),
ToolDispatchContext::new(None, None),
);
assert!(
explicit_grep.result.success,
"{}",
explicit_grep.result.content
);
assert_eq!(explicit_grep.touched_paths.len(), 1);
assert_eq!(
explicit_grep.touched_paths[0].canonical,
temp.path().join("src").canonicalize().unwrap()
);
assert!(explicit_grep.touched_paths[0].is_dir);
let implicit_find = runtime.dispatch_with_context_outcome(
"find",
json!({"query":"needle"}),
ToolDispatchContext::new(None, None),
);
assert!(
implicit_find.result.success,
"{}",
implicit_find.result.content
);
assert!(implicit_find.touched_paths.is_empty());
let explicit_repo_map = runtime.dispatch_with_context_outcome(
"repo_map",
json!({"path":"src"}),
ToolDispatchContext::new(None, None),
);
assert!(
explicit_repo_map.result.success,
"{}",
explicit_repo_map.result.content
);
assert_eq!(explicit_repo_map.touched_paths.len(), 1);
assert_eq!(
explicit_repo_map.touched_paths[0].canonical,
temp.path().join("src").canonicalize().unwrap()
);
assert_eq!(
explicit_repo_map.touched_paths[0].kind,
TouchedPathKind::RepoMap
);
assert!(explicit_repo_map.touched_paths[0].is_dir);
let implicit_repo_map = runtime.dispatch_with_context_outcome(
"repo_map",
json!({}),
ToolDispatchContext::new(None, None),
);
assert!(
implicit_repo_map.result.success,
"{}",
implicit_repo_map.result.content
);
assert!(implicit_repo_map.touched_paths.is_empty());
}
#[test]
fn touched_path_outcome_marks_self_authored_agents_md_for_write() {
let temp = tempfile::TempDir::new().unwrap();
let agents = temp.path().join("AGENTS.md");
let runtime = ToolRuntime::new(temp.path()).unwrap();
let write = runtime.dispatch_with_context_outcome(
"write",
json!({"path":"AGENTS.md", "content":"old\n"}),
ToolDispatchContext::new(None, None),
);
assert!(write.result.success, "{}", write.result.content);
assert_eq!(write.touched_paths.len(), 1);
assert_eq!(
write.touched_paths[0].canonical,
agents.canonicalize().unwrap()
);
assert_eq!(write.touched_paths[0].kind, TouchedPathKind::Write);
assert!(write.touched_paths[0].self_authored_agents_md);
}
#[test]
fn touched_path_outcome_skips_successful_scheme_reads() {
let temp = tempfile::TempDir::new().unwrap();
let sessions = temp.path().join(".magi-code/sessions");
std::fs::create_dir_all(&sessions).unwrap();
std::fs::write(sessions.join("safe_ID-123.jsonl"), "{}\n").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let outcome = runtime.dispatch_with_context_outcome(
"read",
json!({"path":"session://safe_ID-123"}),
ToolDispatchContext::new(None, None),
);
assert!(outcome.result.success, "{}", outcome.result.content);
assert!(outcome.touched_paths.is_empty());
}
#[test]
fn excluded_tools_emit_no_touched_paths() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let outcome = runtime.dispatch_with_context_outcome(
"bash",
json!({"command":"printf ok"}),
ToolDispatchContext::new(None, None),
);
assert!(outcome.result.success, "{}", outcome.result.content);
assert!(outcome.touched_paths.is_empty());
}
#[test]
fn mcp_and_unknown_dispatch_errors_emit_no_touched_paths() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let mcp = runtime.dispatch_with_context_outcome(
"mcp__mock__echo",
json!({"path":"AGENTS.md"}),
ToolDispatchContext::new(None, None),
);
assert!(!mcp.result.success);
assert!(mcp.touched_paths.is_empty());
let unknown = runtime.dispatch_with_context_outcome(
"not_a_tool",
json!({"path":"AGENTS.md"}),
ToolDispatchContext::new(None, None),
);
assert!(!unknown.result.success);
assert!(unknown.touched_paths.is_empty());
}
#[test]
fn legacy_grep_and_find_dispatch_aliases_still_execute() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(temp.path().join("needle.txt"), "needle\n").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let grep = runtime.dispatch("ffgrep", json!({"pattern":"needle", "path":"."}));
assert!(grep.success, "{}", grep.content);
assert_eq!(grep.tool_name, "grep");
assert!(grep.content.contains("needle.txt"));
let find = runtime.dispatch("fffind", json!({"query":"needle", "kind":"files"}));
assert!(find.success, "{}", find.content);
assert_eq!(find.tool_name, "find");
assert!(find.content.contains("needle.txt"));
}
#[test]
fn disabled_builtin_and_alias_are_rejected_before_execution() {
let temp = tempfile::TempDir::new().unwrap();
let disabled = Arc::new(Mutex::new(HashSet::from(["bash".to_string()])));
let runtime = ToolRuntime::new(temp.path())
.unwrap()
.with_disabled_tools(disabled);
let result = runtime.dispatch("shell", json!({"command":"printf nope"}));
assert!(!result.success);
assert_eq!(result.content, "tool disabled for this session: shell");
}
#[test]
fn disabled_mcp_tool_is_rejected_before_mcp_runtime_lookup() {
let temp = tempfile::TempDir::new().unwrap();
let disabled = Arc::new(Mutex::new(HashSet::from(["mcp__mock__echo".to_string()])));
let runtime = ToolRuntime::new(temp.path())
.unwrap()
.with_disabled_tools(disabled);
let result = runtime.dispatch("mcp__mock__echo", json!({"text":"hi"}));
assert!(!result.success);
assert_eq!(
result.content,
"tool disabled for this session: mcp__mock__echo"
);
}
#[test]
fn mcp_result_sanitizer_bounds_text_and_structured_content() {
let result = CallToolResult {
content: vec![ContentBlock::Text {
text: "x".repeat(MAX_MCP_TOOL_TEXT_BYTES + 100),
}],
structured_content: Some(
json!({"value":"y".repeat(MAX_MCP_STRUCTURED_JSON_BYTES + 100)}),
),
is_error: Some(false),
};
let tool_result = mcp_call_result_to_tool_result("mcp__mock__echo", result);
assert!(tool_result.success);
assert!(tool_result.content.len() <= MAX_MCP_TOOL_TEXT_BYTES + 64);
assert!(
tool_result
.content
.contains("[truncated: MCP output exceeded")
);
}
#[test]
fn mcp_result_sanitizer_redacts_text_and_structured_content() {
let text_secret = "sk-fakeMcpTextSecret123";
let structured_secret = "plain-mcp-structured-secret";
let result = CallToolResult {
content: vec![ContentBlock::Text {
text: format!("token from server: {text_secret}"),
}],
structured_content: Some(json!({"api_key": structured_secret, "count": 7})),
is_error: Some(false),
};
let tool_result = mcp_call_result_to_tool_result("mcp__mock__echo", result);
assert!(tool_result.success);
assert!(tool_result.content.matches("<redacted>").count() >= 2);
assert!(tool_result.content.contains(r#""count":7"#));
assert!(
!tool_result.content.contains(text_secret),
"{}",
tool_result.content
);
assert!(
!tool_result.content.contains(structured_secret),
"{}",
tool_result.content
);
assert!(
!tool_result.content.contains("sk-"),
"{}",
tool_result.content
);
}
#[test]
fn mcp_result_sanitizer_redacts_non_text_blocks_and_preserves_error_flag() {
let result = CallToolResult {
content: vec![
ContentBlock::Image {
data: "base64".to_string(),
mime_type: "image/png".to_string(),
},
ContentBlock::Audio {
data: "base64".to_string(),
mime_type: "audio/wav".to_string(),
},
],
structured_content: None,
is_error: Some(true),
};
let tool_result = mcp_call_result_to_tool_result("mcp__mock__echo", result);
assert!(!tool_result.success);
assert!(tool_result.content.contains("image image/png"));
assert!(tool_result.content.contains("audio audio/wav"));
assert!(!tool_result.content.contains("base64"));
}
}