use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use colored::Colorize;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tracing::{debug, info, warn};
use crate::cli::server::resolve_server;
use crate::client::McpClient;
use crate::protocol::Implementation;
use crate::scanner::context::ServerContext;
use crate::scanner::rules::{
OAuthAbuseDetector, SchemaPoisoningDetector, ToolInjectionDetector, ToolShadowingDetector,
UnicodeHiddenDetector,
};
use crate::scanner::Finding;
use crate::scanner::{ScanProfile, ScanResults};
use crate::transport::{connect_with_type, TransportConfig, TransportType};
#[derive(Debug, Clone)]
pub struct ResultsDiff {
pub new_findings: Vec<Finding>,
pub fixed_findings: Vec<Finding>,
pub unchanged_findings: Vec<Finding>,
}
impl ResultsDiff {
pub fn compute(previous: &ScanResults, current: &ScanResults) -> Self {
let previous_fingerprints: HashSet<String> =
previous.findings.iter().map(Self::fingerprint).collect();
let current_fingerprints: HashSet<String> =
current.findings.iter().map(Self::fingerprint).collect();
let new_findings: Vec<Finding> = current
.findings
.iter()
.filter(|f| !previous_fingerprints.contains(&Self::fingerprint(f)))
.cloned()
.collect();
let fixed_findings: Vec<Finding> = previous
.findings
.iter()
.filter(|f| !current_fingerprints.contains(&Self::fingerprint(f)))
.cloned()
.collect();
let unchanged_findings: Vec<Finding> = current
.findings
.iter()
.filter(|f| previous_fingerprints.contains(&Self::fingerprint(f)))
.cloned()
.collect();
Self {
new_findings,
fixed_findings,
unchanged_findings,
}
}
fn fingerprint(finding: &Finding) -> String {
format!(
"{}:{}:{}:{}",
finding.rule_id, finding.location.component, finding.location.identifier, finding.title
)
}
pub fn has_changes(&self) -> bool {
!self.new_findings.is_empty() || !self.fixed_findings.is_empty()
}
pub fn display(&self) {
if !self.has_changes() {
println!("{}", "No changes detected since last scan.".dimmed());
println!(
" Total issues: {}",
self.unchanged_findings.len().to_string().yellow()
);
return;
}
if !self.new_findings.is_empty() {
println!();
println!(
"{} {} {}",
"â–²".red().bold(),
self.new_findings.len().to_string().red().bold(),
"NEW ISSUE(S) DETECTED:".red().bold()
);
for finding in &self.new_findings {
println!(
" {} {} [{}] {}",
"+".red(),
finding.severity.colored_display(),
finding.rule_id.dimmed(),
finding.title
);
if !finding.location.identifier.is_empty() {
println!(
" └─ {}: {}",
finding.location.component.dimmed(),
finding.location.identifier.yellow()
);
}
}
}
if !self.fixed_findings.is_empty() {
println!();
println!(
"{} {} {}",
"â–¼".green().bold(),
self.fixed_findings.len().to_string().green().bold(),
"ISSUE(S) FIXED:".green().bold()
);
for finding in &self.fixed_findings {
println!(
" {} {} [{}] {}",
"-".green(),
finding.severity.as_str().dimmed(),
finding.rule_id.dimmed(),
finding.title.strikethrough()
);
}
}
println!();
println!(
"{}",
format!(
"Summary: {} new, {} fixed, {} unchanged",
self.new_findings.len(),
self.fixed_findings.len(),
self.unchanged_findings.len()
)
.bright_black()
);
}
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
server: &str,
args: &[String],
watch_paths: Vec<PathBuf>,
profile: ScanProfile,
debounce_ms: u64,
clear_screen: bool,
) -> Result<()> {
info!("Starting watch mode for MCP server: {}", server);
let spec = resolve_server(server, None)?;
let server_name = spec.name;
let resolved_cmd = spec.command;
let resolved_env = spec.env;
let mut resolved_args = spec.args;
resolved_args.extend(args.iter().cloned());
for (key, value) in &resolved_env {
std::env::set_var(key, value);
}
println!("{}", "Starting watch mode...".cyan().bold());
println!(" Server: {}", server_name.yellow());
println!(
" Command: {} {}",
resolved_cmd.dimmed(),
resolved_args.join(" ").dimmed()
);
println!(" Profile: {:?}", profile);
println!(
" Watching: {}",
watch_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
.yellow()
);
println!(" Debounce: {}ms", debounce_ms);
println!(" Differential display: {}", "enabled".green());
println!();
println!("{}", "Press Ctrl+C to stop watching".bright_black());
println!("{}", "─".repeat(60));
println!();
let mut previous_results =
run_scan_with_results(server, args, profile, clear_screen, None).await?;
let (tx, rx) = channel();
let config = Config::default()
.with_poll_interval(Duration::from_millis(debounce_ms))
.with_compare_contents(false);
let mut watcher: RecommendedWatcher =
Watcher::new(tx, config).map_err(|e| anyhow::anyhow!("Failed to create watcher: {}", e))?;
for path in &watch_paths {
if path.exists() {
watcher
.watch(path, RecursiveMode::Recursive)
.map_err(|e| anyhow::anyhow!("Failed to watch {}: {}", path.display(), e))?;
debug!("Watching: {}", path.display());
} else {
warn!("Path does not exist, skipping: {}", path.display());
}
}
let server_path = PathBuf::from(server);
if server_path.exists() && !watch_paths.iter().any(|p| p == &server_path) {
watcher
.watch(&server_path, RecursiveMode::NonRecursive)
.map_err(|e| anyhow::anyhow!("Failed to watch server file: {}", e))?;
}
if let Some(parent) = server_path.parent() {
if parent.exists() && !watch_paths.iter().any(|p| p == parent) {
let _ = watcher.watch(parent, RecursiveMode::NonRecursive);
}
}
let mut last_event_time = std::time::Instant::now();
let debounce_duration = Duration::from_millis(debounce_ms);
loop {
match rx.recv() {
Ok(result) => match result {
Ok(event) => {
if should_trigger_scan(&event) {
let now = std::time::Instant::now();
if now.duration_since(last_event_time) >= debounce_duration {
last_event_time = now;
println!();
println!(
"{} {}",
"File changed:".cyan(),
event
.paths
.first()
.map(|p| p.display().to_string())
.unwrap_or_default()
.yellow()
);
match run_scan_with_results(
server,
args,
profile,
clear_screen,
Some(&previous_results),
)
.await
{
Ok(new_results) => {
previous_results = new_results;
}
Err(e) => {
eprintln!("{}", format!("Scan error: {}", e).red());
}
}
} else {
debug!("Debouncing event");
}
}
}
Err(e) => {
warn!("Watch error: {}", e);
}
},
Err(e) => {
eprintln!("{}", format!("Channel error: {}", e).red());
break;
}
}
}
Ok(())
}
fn should_trigger_scan(event: &Event) -> bool {
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
event.paths.iter().any(|p| {
let path_str = p.to_string_lossy();
if path_str.contains(".git") {
return false;
}
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
!name.starts_with('.')
&& !name.ends_with('~')
&& !name.ends_with(".swp")
&& !name.ends_with(".tmp")
} else {
true
}
})
}
_ => false,
}
}
async fn run_scan_with_results(
server: &str,
args: &[String],
profile: ScanProfile,
clear_screen: bool,
previous_results: Option<&ScanResults>,
) -> Result<ScanResults> {
if clear_screen {
print!("\x1B[2J\x1B[1;1H");
}
println!("{}", "Running security scan...".cyan());
println!("{}", "─".repeat(60));
let spec = resolve_server(server, None)?;
let server_name = spec.name;
let command = spec.command;
let env = spec.env;
let mut resolved_args = spec.args;
resolved_args.extend(args.iter().cloned());
let results = run_resolved_scan(&server_name, &command, &resolved_args, &env, profile).await?;
if let Some(prev) = previous_results {
let diff = ResultsDiff::compute(prev, &results);
diff.display();
println!();
println!("{}", "─".repeat(60));
println!(
"Current state: {} critical, {} high, {} medium, {} low, {} info",
results.summary.critical.to_string().red().bold(),
results.summary.high.to_string().red(),
results.summary.medium.to_string().yellow(),
results.summary.low.to_string().blue(),
results.summary.info.to_string().dimmed()
);
} else {
results.print_text();
}
println!();
println!(
"{}",
format!(
"Scan completed at {}",
chrono::Local::now().format("%H:%M:%S")
)
.bright_black()
);
println!("{}", "Waiting for file changes...".bright_black());
Ok(results)
}
async fn run_resolved_scan(
name: &str,
command: &str,
args: &[String],
env: &HashMap<String, String>,
profile: ScanProfile,
) -> Result<ScanResults> {
let start = Instant::now();
let mut results = ScanResults::new(name, profile);
let transport_type = if command.starts_with("http://") || command.starts_with("https://") {
TransportType::StreamableHttp
} else {
TransportType::Stdio
};
let transport_config = TransportConfig {
timeout_secs: 30,
..Default::default()
};
tracing::info!("Connecting to server: {} via {:?}", name, transport_type);
let transport_box = connect_with_type(command, args, env, transport_config, transport_type)
.await
.context("Failed to connect to server")?;
let client_info = Implementation::new("mcplint-scanner", env!("CARGO_PKG_VERSION"));
let mut client = McpClient::new(transport_box, client_info);
client.mark_connected();
let init_result = client.initialize().await?;
let mut ctx = ServerContext::new(
&init_result.server_info.name,
&init_result.server_info.version,
&init_result.protocol_version,
init_result.capabilities.clone(),
)
.with_transport(transport_type.to_string())
.with_target(name);
if init_result.capabilities.has_tools() {
if let Ok(tools) = client.list_tools().await {
ctx = ctx.with_tools(tools);
}
}
if init_result.capabilities.has_resources() {
if let Ok(resources) = client.list_resources().await {
ctx = ctx.with_resources(resources);
}
}
if init_result.capabilities.has_prompts() {
if let Ok(prompts) = client.list_prompts().await {
ctx = ctx.with_prompts(prompts);
}
}
let mut checks = 0;
if !ctx.tools.is_empty() {
let detector = ToolInjectionDetector::new();
for finding in detector.check_tools(&ctx.tools) {
results.add_finding(finding);
}
checks += 1;
let detector = ToolShadowingDetector::new();
for finding in detector.check_tools(&ctx.tools, Some(name)) {
results.add_finding(finding);
}
checks += 1;
let detector = SchemaPoisoningDetector::new();
for finding in detector.check_tools(&ctx.tools) {
results.add_finding(finding);
}
checks += 1;
let detector = UnicodeHiddenDetector::new();
for finding in detector.check_tools(&ctx.tools) {
results.add_finding(finding);
}
checks += 1;
let detector = OAuthAbuseDetector::new();
for finding in detector.check_tools(&ctx.tools) {
results.add_finding(finding);
}
checks += 1;
}
let _ = client.close().await;
results.total_checks = checks;
results.duration_ms = start.elapsed().as_millis() as u64;
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::{FindingLocation, Severity};
use notify::event::{CreateKind, ModifyKind, RemoveKind};
fn make_finding(
rule_id: &str,
severity: Severity,
title: &str,
component: &str,
identifier: &str,
) -> Finding {
Finding::new(rule_id, severity, title, "Test description").with_location(FindingLocation {
component: component.to_string(),
identifier: identifier.to_string(),
context: None,
})
}
fn make_empty_results() -> ScanResults {
ScanResults::new("test-server", ScanProfile::Standard)
}
#[test]
fn should_trigger_on_create() {
let event = Event {
kind: EventKind::Create(CreateKind::File),
paths: vec![PathBuf::from("test.rs")],
attrs: Default::default(),
};
assert!(should_trigger_scan(&event));
}
#[test]
fn should_trigger_on_modify() {
let event = Event {
kind: EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)),
paths: vec![PathBuf::from("server.js")],
attrs: Default::default(),
};
assert!(should_trigger_scan(&event));
}
#[test]
fn should_trigger_on_remove() {
let event = Event {
kind: EventKind::Remove(RemoveKind::File),
paths: vec![PathBuf::from("old_file.py")],
attrs: Default::default(),
};
assert!(should_trigger_scan(&event));
}
#[test]
fn should_not_trigger_on_hidden_files() {
let event = Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![PathBuf::from(".hidden")],
attrs: Default::default(),
};
assert!(!should_trigger_scan(&event));
}
#[test]
fn should_not_trigger_on_swap_files() {
let event = Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![PathBuf::from("file.swp")],
attrs: Default::default(),
};
assert!(!should_trigger_scan(&event));
}
#[test]
fn should_not_trigger_on_backup_files() {
let event = Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![PathBuf::from("file.js~")],
attrs: Default::default(),
};
assert!(!should_trigger_scan(&event));
}
#[test]
fn should_not_trigger_on_git_files() {
let event = Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: vec![PathBuf::from("project/.git/index")],
attrs: Default::default(),
};
assert!(!should_trigger_scan(&event));
}
#[test]
fn diff_empty_results_has_no_changes() {
let prev = make_empty_results();
let curr = make_empty_results();
let diff = ResultsDiff::compute(&prev, &curr);
assert!(!diff.has_changes());
assert!(diff.new_findings.is_empty());
assert!(diff.fixed_findings.is_empty());
assert!(diff.unchanged_findings.is_empty());
}
#[test]
fn diff_detects_new_findings() {
let prev = make_empty_results();
let mut curr = make_empty_results();
curr.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"New Issue",
"tool",
"test_tool",
));
let diff = ResultsDiff::compute(&prev, &curr);
assert!(diff.has_changes());
assert_eq!(diff.new_findings.len(), 1);
assert!(diff.fixed_findings.is_empty());
assert!(diff.unchanged_findings.is_empty());
}
#[test]
fn diff_detects_fixed_findings() {
let mut prev = make_empty_results();
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Fixed Issue",
"tool",
"test_tool",
));
let curr = make_empty_results();
let diff = ResultsDiff::compute(&prev, &curr);
assert!(diff.has_changes());
assert!(diff.new_findings.is_empty());
assert_eq!(diff.fixed_findings.len(), 1);
assert!(diff.unchanged_findings.is_empty());
}
#[test]
fn diff_detects_unchanged_findings() {
let mut prev = make_empty_results();
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Unchanged Issue",
"tool",
"test_tool",
));
let mut curr = make_empty_results();
curr.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Unchanged Issue",
"tool",
"test_tool",
));
let diff = ResultsDiff::compute(&prev, &curr);
assert!(!diff.has_changes());
assert!(diff.new_findings.is_empty());
assert!(diff.fixed_findings.is_empty());
assert_eq!(diff.unchanged_findings.len(), 1);
}
#[test]
fn diff_handles_mixed_changes() {
let mut prev = make_empty_results();
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Fixed Issue",
"tool",
"tool1",
));
prev.add_finding(make_finding(
"MCP-INJ-002",
Severity::Medium,
"Unchanged Issue",
"tool",
"tool2",
));
let mut curr = make_empty_results();
curr.add_finding(make_finding(
"MCP-INJ-002",
Severity::Medium,
"Unchanged Issue",
"tool",
"tool2",
));
curr.add_finding(make_finding(
"MCP-INJ-003",
Severity::Critical,
"New Issue",
"tool",
"tool3",
));
let diff = ResultsDiff::compute(&prev, &curr);
assert!(diff.has_changes());
assert_eq!(diff.new_findings.len(), 1);
assert_eq!(diff.fixed_findings.len(), 1);
assert_eq!(diff.unchanged_findings.len(), 1);
assert_eq!(diff.new_findings[0].rule_id, "MCP-INJ-003");
assert_eq!(diff.fixed_findings[0].rule_id, "MCP-INJ-001");
assert_eq!(diff.unchanged_findings[0].rule_id, "MCP-INJ-002");
}
#[test]
fn diff_ignores_finding_id_differences() {
let mut prev = make_empty_results();
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Issue",
"tool",
"test_tool",
));
let mut curr = make_empty_results();
curr.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Issue",
"tool",
"test_tool",
));
let diff = ResultsDiff::compute(&prev, &curr);
assert!(!diff.has_changes());
assert_eq!(diff.unchanged_findings.len(), 1);
}
#[test]
fn diff_treats_different_locations_as_different_findings() {
let mut prev = make_empty_results();
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Issue",
"tool",
"tool_a",
));
let mut curr = make_empty_results();
curr.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Issue",
"tool",
"tool_b",
));
let diff = ResultsDiff::compute(&prev, &curr);
assert!(diff.has_changes());
assert_eq!(diff.new_findings.len(), 1);
assert_eq!(diff.fixed_findings.len(), 1);
}
#[test]
fn diff_treats_different_titles_as_different_findings() {
let mut prev = make_empty_results();
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Issue A",
"tool",
"test_tool",
));
let mut curr = make_empty_results();
curr.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Issue B",
"tool",
"test_tool",
));
let diff = ResultsDiff::compute(&prev, &curr);
assert!(diff.has_changes());
assert_eq!(diff.new_findings.len(), 1);
assert_eq!(diff.fixed_findings.len(), 1);
}
#[test]
fn fingerprint_is_consistent() {
let finding = make_finding(
"MCP-INJ-001",
Severity::High,
"Test Issue",
"tool",
"my_tool",
);
let fp1 = ResultsDiff::fingerprint(&finding);
let fp2 = ResultsDiff::fingerprint(&finding);
assert_eq!(fp1, fp2);
assert_eq!(fp1, "MCP-INJ-001:tool:my_tool:Test Issue");
}
#[test]
fn diff_multiple_findings_same_rule() {
let mut prev = make_empty_results();
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Injection",
"tool",
"tool_a",
));
prev.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Injection",
"tool",
"tool_b",
));
let mut curr = make_empty_results();
curr.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Injection",
"tool",
"tool_b",
));
curr.add_finding(make_finding(
"MCP-INJ-001",
Severity::High,
"Injection",
"tool",
"tool_c",
));
let diff = ResultsDiff::compute(&prev, &curr);
assert!(diff.has_changes());
assert_eq!(diff.new_findings.len(), 1); assert_eq!(diff.fixed_findings.len(), 1); assert_eq!(diff.unchanged_findings.len(), 1); }
}