use rmcp::{
ErrorData as McpError, handler::server::wrapper::Parameters, model::CallToolResult, tool,
tool_router,
};
use rsigma_eval::{CorrelationConfig, CorrelationEngine, Engine, EvaluationResult, JsonEvent};
use rsigma_runtime::{EnrichersFile, EnrichmentPipeline, build_enrichers, load_enrichers_file};
use serde_json::{Value, json};
use crate::input::resolve_path;
use super::RsigmaMcp;
use super::shared::{invalid, json_result, parse_match_detail, to_value};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct EvaluateInput {
#[serde(default)]
pub yaml: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub events: Option<Vec<Value>>,
#[serde(default)]
pub events_path: Option<String>,
#[serde(default)]
pub pipelines: Vec<String>,
#[serde(default)]
pub match_detail: Option<String>,
#[serde(default)]
pub timestamp_fields: Vec<String>,
#[serde(default)]
pub enrichers: Option<String>,
#[serde(default)]
pub enrichers_path: Option<String>,
}
#[tool_router(router = evaluate_events_router, vis = "pub(crate)")]
impl RsigmaMcp {
#[tool(
description = "Evaluate JSON events against Sigma rules and return matches. Detection-only rules use the stateless engine; collections with correlations use the stateful correlation engine. Rules via inline `yaml` or `path`; events via an inline `events` JSON array or an NDJSON `events_path`. Optional `pipelines` and `match_detail` (off/summary/full)."
)]
async fn evaluate_events(
&self,
Parameters(input): Parameters<EvaluateInput>,
) -> Result<CallToolResult, McpError> {
Ok(json_result(&self.run_evaluate_events(input).await?))
}
pub(crate) async fn run_evaluate_events(
&self,
input: EvaluateInput,
) -> Result<Value, McpError> {
let collection = self.load_collection(input.yaml.as_deref(), input.path.as_deref())?;
let pipelines = self.load_pipelines(&input.pipelines)?;
let events = self.load_events(input.events, input.events_path.as_deref())?;
let detail = parse_match_detail(input.match_detail.as_deref())?;
let enrichment =
self.build_enrichment(input.enrichers.as_deref(), input.enrichers_path.as_deref())?;
let mut results: Vec<Value> = Vec::new();
let mut detection_matches = 0usize;
let mut correlation_matches = 0usize;
let json_events: Vec<JsonEvent> = events.iter().map(JsonEvent::borrow).collect();
let refs: Vec<&JsonEvent> = json_events.iter().collect();
let batches: Vec<Vec<EvaluationResult>> = if collection.correlations.is_empty() {
let mut engine = Engine::new();
engine.set_match_detail(detail);
for p in &pipelines {
engine.add_pipeline(p.clone());
}
engine
.add_collection(&collection)
.map_err(|e| invalid(format!("rule compile error: {e}")))?;
engine.evaluate_batch(&refs)
} else {
let mut config = CorrelationConfig::default();
if !input.timestamp_fields.is_empty() {
let mut fields = input.timestamp_fields.clone();
fields.extend(config.timestamp_fields);
config.timestamp_fields = fields;
}
let mut engine = CorrelationEngine::new(config);
engine.set_match_detail(detail);
for p in &pipelines {
engine.add_pipeline(p.clone());
}
engine
.add_collection(&collection)
.map_err(|e| invalid(format!("rule compile error: {e}")))?;
engine
.process_batch(&refs)
.into_iter()
.map(|pr| pr.to_vec())
.collect()
};
for (idx, mut per_event) in batches.into_iter().enumerate() {
if let Some(pipeline) = &enrichment {
pipeline.run(&mut per_event).await;
}
for r in &per_event {
if r.is_correlation() {
correlation_matches += 1;
} else {
detection_matches += 1;
}
results.push(json!({ "event_index": idx, "result": to_value(r) }));
}
}
Ok(json!({
"ok": true,
"summary": {
"events_evaluated": events.len(),
"detection_matches": detection_matches,
"correlation_matches": correlation_matches,
"enriched": enrichment.is_some(),
},
"results": results,
}))
}
fn build_enrichment(
&self,
enrichers: Option<&str>,
enrichers_path: Option<&str>,
) -> Result<Option<EnrichmentPipeline>, McpError> {
let file: Option<EnrichersFile> = match (enrichers, enrichers_path) {
(Some(_), Some(_)) => {
return Err(invalid(
"provide either `enrichers` or `enrichers_path`, not both",
));
}
(None, None) => None,
(Some(text), None) => Some(
yaml_serde::from_str(text)
.map_err(|e| invalid(format!("invalid enrichers config: {e}")))?,
),
(None, Some(p)) => {
let path = resolve_path(p, self.root());
Some(load_enrichers_file(&path).map_err(|e| invalid(e.to_string()))?)
}
};
match file {
None => Ok(None),
Some(file) => build_enrichers(file)
.map(Some)
.map_err(|e| invalid(format!("enrichers config error: {e}"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{GOLDEN_RULE, VALID_RULE, handler};
#[tokio::test]
async fn evaluate_events_detects_match() {
let v = handler()
.run_evaluate_events(EvaluateInput {
yaml: Some(VALID_RULE.to_string()),
path: None,
events: Some(vec![json!({ "CommandLine": "cmd /c whoami" })]),
events_path: None,
pipelines: vec![],
match_detail: Some("summary".to_string()),
timestamp_fields: vec![],
enrichers: None,
enrichers_path: None,
})
.await
.unwrap();
assert_eq!(v["ok"], true);
assert_eq!(v["summary"]["detection_matches"], 1);
assert_eq!(v["results"][0]["event_index"], 0);
}
#[tokio::test]
async fn evaluate_events_requires_events() {
let err = handler()
.run_evaluate_events(EvaluateInput {
yaml: Some(VALID_RULE.to_string()),
path: None,
events: None,
events_path: None,
pipelines: vec![],
match_detail: None,
timestamp_fields: vec![],
enrichers: None,
enrichers_path: None,
})
.await
.unwrap_err();
assert!(format!("{err:?}").contains("events"));
}
#[tokio::test]
async fn evaluate_events_with_template_enricher() {
let enrichers = r#"
enrichers:
- id: runbook
kind: detection
type: template
inject_field: runbook_url
template: "https://wiki/${detection.rule.id}"
"#;
let v = handler()
.run_evaluate_events(EvaluateInput {
yaml: Some(VALID_RULE.to_string()),
path: None,
events: Some(vec![json!({ "CommandLine": "cmd /c whoami" })]),
events_path: None,
pipelines: vec![],
match_detail: None,
timestamp_fields: vec![],
enrichers: Some(enrichers.to_string()),
enrichers_path: None,
})
.await
.unwrap();
assert_eq!(v["summary"]["enriched"], true);
let enrichments = &v["results"][0]["result"]["enrichments"];
assert_eq!(
enrichments["runbook_url"],
"https://wiki/8b1d8c97-5b3a-4d77-9b48-7c5f7c8b1a2a"
);
}
#[tokio::test]
async fn evaluate_events_invalid_enricher_config_errors() {
let enrichers = r#"
enrichers:
- id: bad
kind: detection
type: template
inject_field: out
template: "https://wiki/${correlation.rule.id}"
"#;
let err = handler()
.run_evaluate_events(EvaluateInput {
yaml: Some(VALID_RULE.to_string()),
path: None,
events: Some(vec![json!({ "CommandLine": "cmd /c whoami" })]),
events_path: None,
pipelines: vec![],
match_detail: None,
timestamp_fields: vec![],
enrichers: Some(enrichers.to_string()),
enrichers_path: None,
})
.await
.unwrap_err();
assert!(format!("{err:?}").contains("namespace"));
}
#[tokio::test]
async fn golden_evaluate_events() {
let v = handler()
.run_evaluate_events(EvaluateInput {
yaml: Some(GOLDEN_RULE.to_string()),
path: None,
events: Some(vec![
json!({ "CommandLine": "cmd /c whoami /priv" }),
json!({ "CommandLine": "ipconfig /all" }),
]),
events_path: None,
pipelines: vec![],
match_detail: Some("summary".to_string()),
timestamp_fields: vec![],
enrichers: None,
enrichers_path: None,
})
.await
.unwrap();
insta::with_settings!({sort_maps => true}, {
insta::assert_json_snapshot!("evaluate_events", v);
});
}
}