use super::{
ToolCapability, ToolResult, ToolResultDisplay, ToolRuntime,
args::{
AstGrepArgs, FindArgs, GrepArgs, HashEditArgs, ListFilesArgs, ReadArgs, SubagentsArgs,
ViewImageArgs,
},
exa::WebArgs,
};
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,
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)]
pub(super) struct FilesystemOutcome {
pub(super) result: ToolResult,
pub(super) paths: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct ToolDispatchOutcome {
pub(crate) result: ToolResult,
pub(crate) touched_paths: Vec<TouchedPath>,
pub(crate) changed_paths: Vec<PathBuf>,
}
impl ToolDispatchOutcome {
fn result_only(result: ToolResult) -> Self {
Self {
result,
touched_paths: Vec::new(),
changed_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 {
match self.dispatch_inner(name, arguments, context) {
Ok(outcome) => outcome,
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<ToolDispatchOutcome> {
context.cancellation.check()?;
let explicit_path = explicit_path_for_dispatch(name, &arguments);
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)
.map(ToolDispatchOutcome::result_only);
}
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}");
}
let result = 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::HashEdit => {
let outcome = self.hash_edit_outcome(
serde_json::from_value::<HashEditArgs>(arguments)?.validate()?,
&context.cancellation,
);
return Ok(self.filesystem_dispatch_outcome(outcome, TouchedPathKind::HashEdit));
}
ToolCapability::Write => {
let outcome = self.write_file_outcome(
serde_json::from_value(arguments)?,
&context.cancellation,
)?;
return Ok(self.filesystem_dispatch_outcome(outcome, TouchedPathKind::Write));
}
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::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::Web => self.web(
serde_json::from_value::<WebArgs>(arguments)?,
&context.cancellation,
),
ToolCapability::AstGrep => self.ast_grep(
serde_json::from_value::<AstGrepArgs>(arguments)?.validate()?,
&context.cancellation,
),
}?;
let touched_paths =
self.touched_paths_for_dispatch(name, explicit_path.as_deref(), &result);
Ok(ToolDispatchOutcome {
result,
touched_paths,
changed_paths: Vec::new(),
})
}
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::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::AstGrep if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::AstGrep, false))
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn filesystem_dispatch_outcome(
&self,
outcome: FilesystemOutcome,
kind: TouchedPathKind,
) -> ToolDispatchOutcome {
let touched_paths = outcome
.paths
.iter()
.filter_map(|path| self.touched_existing_path(path, kind, true))
.collect();
ToolDispatchOutcome {
result: outcome.result,
touched_paths,
changed_paths: outcome.paths,
}
}
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: impl AsRef<std::path::Path>,
kind: TouchedPathKind,
self_authored_on_agents: bool,
) -> Option<TouchedPath> {
let path_buf = path.as_ref().to_path_buf();
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 resolved_call = {
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"))?;
manager.resolve_tool_call(name)
};
let result = resolved_call.and_then(|resolved_call| {
resolved_call.call_tool_cancellable(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::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},
};
#[cfg(unix)]
fn mock_mcp_config(mode: &str, timeout: u64) -> crate::config::McpServerConfig {
let mut env = std::collections::BTreeMap::new();
env.insert("MCP_MOCK_MODE".to_string(), mode.to_string());
crate::config::McpServerConfig::Stdio(crate::config::McpStdioServerConfig {
command: "sh".to_string(),
args: vec![format!(
"{}/tests/fixtures/mcp/mock_stdio_server.sh",
env!("CARGO_MANIFEST_DIR")
)],
env,
enabled: true,
timeout: Some(timeout),
})
}
#[cfg(unix)]
#[test]
fn runtime_clones_dispatch_other_servers_and_read_definitions_during_blocked_call() {
let temp = tempfile::TempDir::new().unwrap();
let accepted_file = temp.path().join("accepted");
let release_file = temp.path().join("release");
let mut blocked = mock_mcp_config("blocked_tool_call", 5);
let crate::config::McpServerConfig::Stdio(blocked_stdio) = &mut blocked else {
unreachable!();
};
blocked_stdio.env.insert(
"MCP_MOCK_TOOL_ACCEPTED_FILE".to_string(),
accepted_file.to_string_lossy().into_owned(),
);
blocked_stdio.env.insert(
"MCP_MOCK_TOOL_RELEASE_FILE".to_string(),
release_file.to_string_lossy().into_owned(),
);
let mut mcp_settings = crate::config::McpServersSettings::new();
mcp_settings.insert("blocked".to_string(), blocked);
mcp_settings.insert("fast".to_string(), mock_mcp_config("normal", 5));
let manager = Arc::new(Mutex::new(
crate::mcp::manager::McpManager::from_settings_strict(&mcp_settings, None).unwrap(),
));
let runtime = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
crate::config::McPaths::from_root(temp.path().join("mc-home")),
crate::config::Settings::default(),
Some(Arc::clone(&manager)),
)
.unwrap();
let blocked_runtime = runtime.clone();
let blocked_thread = std::thread::spawn(move || {
blocked_runtime.dispatch("mcp__blocked__echo", serde_json::json!({"text":"blocked"}))
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while !accepted_file.exists() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
accepted_file.exists(),
"blocked server did not accept tool call"
);
let fast_result = runtime
.clone()
.dispatch("mcp__fast__echo", serde_json::json!({"text":"fast"}));
assert!(fast_result.success, "{}", fast_result.content);
assert_eq!(fast_result.content, "fast");
let definitions = runtime.dynamic_provider_tool_definitions();
assert!(definitions.iter().any(|definition| {
definition.get("name").and_then(Value::as_str) == Some("mcp__blocked__echo")
}));
assert!(matches!(
manager.lock().unwrap().statuses().get("blocked"),
Some(crate::mcp::manager::McpServerStatus::Connected { .. })
));
assert!(!release_file.exists());
std::fs::write(&release_file, b"release").unwrap();
let blocked_result = blocked_thread.join().unwrap();
assert!(blocked_result.success, "{}", blocked_result.content);
assert_eq!(blocked_result.content, "blocked");
manager.lock().unwrap().shutdown();
}
#[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 outcome = runtime.dispatch_with_context_outcome(
"hash_edit",
json!({"input": format!("[a.txt#{tag}]\nSWAP 1.=1:\n+new")}),
ToolDispatchContext::new(None, None),
);
assert!(outcome.result.success, "{}", outcome.result.content);
assert_eq!(outcome.result.tool_name, "hash_edit");
assert_eq!(outcome.touched_paths.len(), 1);
assert_eq!(
outcome.touched_paths[0].canonical,
file.canonicalize().unwrap()
);
assert_eq!(outcome.touched_paths[0].kind, TouchedPathKind::HashEdit);
assert_eq!(std::fs::read_to_string(file).unwrap(), "new\n");
}
#[test]
fn filesystem_dispatch_uses_local_paths_not_result_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
for name in ["a.txt", "source.txt", "deleted.txt"] {
std::fs::write(temp.path().join(name), "old\n").unwrap();
assert!(runtime.dispatch("read", json!({"paths": [name]})).success);
}
let root = temp.path().canonicalize().unwrap();
let tag =
runtime
.hashline_snapshots
.lock()
.unwrap()
.record(root.join("a.txt"), "old\n", [1]);
let mut outcome = runtime.hash_edit_outcome(
HashEditArgs { input: format!("[a.txt#{tag}]\nSWAP 1.=1:\n+new\n[source.txt#{tag}]\nMV moved.txt\n[deleted.txt#{tag}]\nREM") },
&crate::cancellation::AgentCancellation::default(),
);
assert!(outcome.result.success, "{}", outcome.result.content);
assert_eq!(
outcome.paths,
vec![
root.join("a.txt"),
root.join("source.txt"),
root.join("moved.txt"),
root.join("deleted.txt")
]
);
assert!(!root.join("source.txt").exists());
assert!(!root.join("deleted.txt").exists());
outcome.result.metadata = json!({"path": "wrong.txt", "files": []});
let dispatched = runtime.filesystem_dispatch_outcome(outcome, TouchedPathKind::HashEdit);
assert_eq!(
dispatched
.touched_paths
.iter()
.map(|path| &path.canonical)
.collect::<Vec<_>>(),
vec![&root.join("a.txt"), &root.join("moved.txt")]
);
assert_eq!(
dispatched.changed_paths,
vec![
root.join("a.txt"),
root.join("source.txt"),
root.join("moved.txt"),
root.join("deleted.txt")
]
);
}
#[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,
"partial multi-read should succeed when at least one resource succeeds"
);
assert_eq!(outcome.result.metadata["succeeded"], 1);
assert_eq!(outcome.result.metadata["failed"], 1);
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_is_empty_when_all_multi_read_resources_fail() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let outcome = runtime.dispatch_with_context_outcome(
"read",
json!({"paths":["missing-a.rs", "missing-b.rs"]}),
ToolDispatchContext::new(None, None),
);
assert!(!outcome.result.success);
assert_eq!(outcome.result.metadata["succeeded"], 0);
assert_eq!(outcome.result.metadata["failed"], 2);
assert!(outcome.touched_paths.is_empty());
}
#[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!({"patterns":["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!({"patterns":["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());
}
#[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 removed_research_tools_are_unknown() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
for name in ["browser", "web_extract", "web_search", "code_search"] {
let result = runtime.dispatch(name, json!({}));
assert!(!result.success);
assert!(result.content.contains("unknown tool"));
}
}
#[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!({"patterns":["needle"], "path":"."}));
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"));
}
}