use std::time::Duration;
use anyhow::{Context, Result};
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use serde::Serialize;
use serde_json::Value;
use crate::{
client::CallOutcome,
corpus::Corpus,
finding::{Finding, FindingKind, ReproInfo},
mutate::{try_generate_payload, GenMode},
seed::{derive_seed, derive_seed_canonical},
};
use super::{
destructive::DestructiveDetector,
exec::McpExec,
glob,
reporter::{Reporter, RunInfo},
};
#[derive(Debug, Clone, Serialize)]
pub struct SkippedTool {
pub tool: String,
pub reason: String,
}
#[derive(Debug, Default, Serialize)]
pub struct FuzzReport {
pub findings_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub skipped: Vec<SkippedTool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blocked: Vec<String>,
}
#[derive(Debug)]
pub enum FuzzOutcome {
DryRun(Vec<String>),
Completed(FuzzReport),
}
#[derive(Debug)]
pub struct FuzzPlan {
pub iterations: u64,
pub mode: GenMode,
pub master_seed: u64,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub max_tools: Option<usize>,
pub timeout: Duration,
pub transport_name: String,
pub detector: DestructiveDetector,
}
impl FuzzPlan {
pub async fn dry_run<C: McpExec + ?Sized>(&self, client: &C) -> Result<Vec<String>> {
let (tools, _blocked) = self.select_tools(client).await?;
Ok(tools
.into_iter()
.map(|tool| tool.name.to_string())
.collect())
}
pub async fn execute<C: McpExec + ?Sized>(
self,
client: &mut C,
corpus: &Corpus,
reporter: &mut dyn Reporter,
) -> Result<FuzzReport> {
let (tools, blocked) = self.select_tools(client).await?;
let total = tools.len() as u64 * self.iterations;
reporter.on_run_start(&RunInfo {
kind: "fuzz",
total_iterations: total,
tools: tools.iter().map(|tool| tool.name.to_string()).collect(),
blocked: blocked.clone(),
master_seed: Some(self.master_seed),
});
let mut report = FuzzReport {
findings_count: 0,
skipped: Vec::new(),
blocked,
};
for tool in tools {
let tool_name = tool.name.to_string();
let input_schema = Value::Object((*tool.input_schema).clone());
for iteration in 0..self.iterations {
reporter.on_iteration_start(&tool_name, iteration);
let seed = derive_seed(self.master_seed, &tool_name, iteration);
let canonical = derive_seed_canonical(self.master_seed, &tool_name, iteration);
let mut rng = ChaCha20Rng::from_seed(canonical);
let payload = match try_generate_payload(&input_schema, &mut rng, self.mode) {
Ok(payload) => payload,
Err(reason) => {
let skip = SkippedTool {
tool: tool_name.clone(),
reason: reason.to_string(),
};
reporter.on_skipped(&skip.tool, &skip.reason);
report.skipped.push(skip);
for i in (iteration + 1)..self.iterations {
reporter.on_iteration_end(&tool_name, i);
}
break;
}
};
let outcome = client
.call_tool(&tool_name, payload.value.clone(), self.timeout)
.await;
let kind_message_details: Option<(FindingKind, &str, String)> = match outcome {
CallOutcome::Ok(_) => None,
CallOutcome::Hang(duration) => Some((
FindingKind::Hang {
ms: duration.as_millis() as u64,
},
"tool call timed out",
format!("timeout exceeded after {duration:?}"),
)),
CallOutcome::Crash(reason) => Some((
FindingKind::Crash,
"server crashed during tool call",
reason,
)),
CallOutcome::ProtocolError(message) => Some((
FindingKind::ProtocolError,
"protocol error during tool call",
message,
)),
};
if let Some((kind, message, details)) = kind_message_details {
let finding = Finding::new(
kind,
&tool_name,
message,
details,
ReproInfo {
seed,
tool_call: payload.value,
transport: self.transport_name.clone(),
composition_trail: payload.trail,
},
);
corpus
.write_finding(&finding)
.with_context(|| format!("failed to persist finding for `{tool_name}`"))?;
reporter.on_finding(&finding);
report.findings_count += 1;
client.reconnect().await.with_context(|| {
format!("failed to reconnect after fault on `{tool_name}`")
})?;
reporter.on_iteration_end(&tool_name, iteration);
break;
}
reporter.on_iteration_end(&tool_name, iteration);
}
}
reporter.on_run_end();
Ok(report)
}
async fn select_tools<C: McpExec + ?Sized>(
&self,
client: &C,
) -> Result<(Vec<rmcp::model::Tool>, Vec<String>)> {
let all_tools = client
.list_tools()
.await
.context("failed to list tools from MCP server")?;
let mut blocked = Vec::new();
let mut tools: Vec<rmcp::model::Tool> = all_tools
.into_iter()
.filter(|tool| glob::matches_filters(tool.name.as_ref(), &self.include, &self.exclude))
.filter(|tool| {
let classification = self.detector.classify(tool);
if classification.is_runnable() {
true
} else {
blocked.push(tool.name.to_string());
false
}
})
.collect();
if let Some(max_tools) = self.max_tools {
tools.truncate(max_tools);
}
Ok((tools, blocked))
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::run::exec::MockClient;
use crate::run::reporter::NoopReporter;
use crate::target::{AllowDestructiveConfig, DestructiveConfig};
use rmcp::model::Tool;
use serde_json::json;
use std::sync::Arc;
fn make_tool(name: &str, schema: Value) -> Tool {
let map = schema.as_object().cloned().unwrap_or_default();
Tool::new(name.to_string(), "test tool".to_string(), Arc::new(map))
}
fn detector() -> DestructiveDetector {
DestructiveDetector::from_config(
&DestructiveConfig::default(),
&AllowDestructiveConfig::default(),
)
.unwrap()
}
fn plan(detector: DestructiveDetector) -> FuzzPlan {
FuzzPlan {
iterations: 4,
mode: GenMode::Conform,
master_seed: 42,
include: Vec::new(),
exclude: Vec::new(),
max_tools: None,
timeout: Duration::from_secs(1),
transport_name: "mock".to_string(),
detector,
}
}
#[tokio::test]
async fn fuzz_records_protocol_error_finding_and_reconnects() {
let tool = make_tool(
"echo",
json!({"type": "object", "properties": {"msg": {"type": "string"}}}),
);
let mut client = MockClient::new().register(tool, |_args| {
CallOutcome::ProtocolError("synthetic failure".to_string())
});
let tmp = tempfile::tempdir().unwrap();
let corpus = Corpus::new(tmp.path().join("corpus"));
let mut reporter = NoopReporter;
let report = plan(detector())
.execute(&mut client, &corpus, &mut reporter)
.await
.unwrap();
assert_eq!(report.findings_count, 1);
assert_eq!(client.reconnect_count(), 1);
assert!(report.skipped.is_empty());
}
#[tokio::test]
async fn fuzz_skips_tools_with_unresolvable_refs() {
let tool = make_tool(
"broken",
json!({"$ref": "https://external.example/schema.json"}),
);
let mut client = MockClient::new().register(tool, |_args| {
CallOutcome::Ok(rmcp::model::CallToolResult::success(vec![]))
});
let tmp = tempfile::tempdir().unwrap();
let corpus = Corpus::new(tmp.path().join("corpus"));
let mut reporter = NoopReporter;
let report = plan(detector())
.execute(&mut client, &corpus, &mut reporter)
.await
.unwrap();
assert_eq!(report.findings_count, 0);
assert_eq!(report.skipped.len(), 1);
assert!(report.skipped[0].reason.contains("external"));
}
#[tokio::test]
async fn fuzz_blocks_destructive_tools_unless_allowlisted() {
let destructive_tool = make_tool(
"delete_user",
json!({"type": "object", "properties": {"id": {"type": "string"}}}),
);
let safe_tool = make_tool(
"read_user",
json!({"type": "object", "properties": {"id": {"type": "string"}}}),
);
let mut client = MockClient::new()
.register(destructive_tool, |_| {
CallOutcome::Ok(rmcp::model::CallToolResult::success(vec![]))
})
.register(safe_tool, |_| {
CallOutcome::Ok(rmcp::model::CallToolResult::success(vec![]))
});
let tmp = tempfile::tempdir().unwrap();
let corpus = Corpus::new(tmp.path().join("corpus"));
let mut reporter = NoopReporter;
let report = plan(detector())
.execute(&mut client, &corpus, &mut reporter)
.await
.unwrap();
assert_eq!(report.blocked, vec!["delete_user".to_string()]);
}
}