use rmcp::{
ErrorData as McpError, handler::server::wrapper::Parameters, model::CallToolResult, tool,
tool_router,
};
use rsigma_eval::{CorrelationConfig, CorrelationEngine, Engine, Pipeline};
use serde_json::{Value, json};
use super::RsigmaMcp;
use super::shared::json_result;
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ValidateInput {
#[serde(default)]
pub yaml: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub pipelines: Vec<String>,
#[serde(default)]
pub resolve_sources: bool,
}
#[tool_router(router = validate_rules_router, vis = "pub(crate)")]
impl RsigmaMcp {
#[tool(
description = "Validate that Sigma rules parse and compile cleanly: parse, build the detection engine, and check correlation references. Optional `pipelines` and `resolve_sources` (resolve dynamic sources). Accepts inline `yaml` or a file/directory `path`."
)]
async fn validate_rules(
&self,
Parameters(input): Parameters<ValidateInput>,
) -> Result<CallToolResult, McpError> {
Ok(json_result(&self.run_validate_rules(input).await?))
}
pub(crate) async fn run_validate_rules(&self, input: ValidateInput) -> Result<Value, McpError> {
let collection = self.load_collection(input.yaml.as_deref(), input.path.as_deref())?;
let mut pipelines = self.load_pipelines(&input.pipelines)?;
let mut source_errors: Vec<String> = Vec::new();
if input.resolve_sources {
pipelines = resolve_pipeline_sources(pipelines, &mut source_errors).await;
}
let mut engine = Engine::new();
for p in &pipelines {
engine.add_pipeline(p.clone());
}
let compile_errors: Vec<Value> = engine
.add_rules(&collection.rules)
.into_iter()
.map(|(idx, e)| {
let rule = &collection.rules[idx];
let id = rule.id.as_deref().unwrap_or(rule.title.as_str());
json!({ "rule": id, "error": e.to_string() })
})
.collect();
let mut correlation_error: Option<String> = None;
if !collection.correlations.is_empty() {
let mut corr = CorrelationEngine::new(CorrelationConfig::default());
for p in &pipelines {
corr.add_pipeline(p.clone());
}
if let Err(e) = corr.add_collection(&collection) {
correlation_error = Some(e.to_string());
}
}
let ok = collection.errors.is_empty()
&& compile_errors.is_empty()
&& correlation_error.is_none()
&& source_errors.is_empty();
Ok(json!({
"ok": ok,
"summary": {
"detection_rules": collection.rules.len(),
"correlation_rules": collection.correlations.len(),
"filter_rules": collection.filters.len(),
"parse_errors": collection.errors.len(),
"compile_errors": compile_errors.len(),
},
"parse_errors": collection.errors,
"compile_errors": compile_errors,
"correlation_error": correlation_error,
"source_errors": source_errors,
}))
}
}
async fn resolve_pipeline_sources(
pipelines: Vec<Pipeline>,
source_errors: &mut Vec<String>,
) -> Vec<Pipeline> {
let resolver = rsigma_runtime::DefaultSourceResolver::new();
let mut resolved_pipelines = Vec::with_capacity(pipelines.len());
for pipeline in pipelines {
if pipeline.is_dynamic() {
match rsigma_runtime::sources::resolve_all(&resolver, &pipeline.sources).await {
Ok(data) => {
let expanded = rsigma_runtime::sources::template::TemplateExpander::expand(
&pipeline, &data,
);
resolved_pipelines.push(expanded);
}
Err(e) => {
source_errors.push(format!("pipeline '{}': {e}", pipeline.name));
resolved_pipelines.push(pipeline);
}
}
} else {
resolved_pipelines.push(pipeline);
}
}
resolved_pipelines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{VALID_RULE, handler};
#[tokio::test]
async fn validate_rules_ok_and_compile_error() {
let ok = handler()
.run_validate_rules(ValidateInput {
yaml: Some(VALID_RULE.to_string()),
path: None,
pipelines: vec![],
resolve_sources: false,
})
.await
.unwrap();
assert_eq!(ok["ok"], true);
let bad_yaml = "title: T\nlogsource:\n category: test\ndetection:\n sel:\n a: b\n condition: missing_ref\n";
let bad = handler()
.run_validate_rules(ValidateInput {
yaml: Some(bad_yaml.to_string()),
path: None,
pipelines: vec![],
resolve_sources: false,
})
.await
.unwrap();
assert_eq!(bad["ok"], false);
assert!(!bad["compile_errors"].as_array().unwrap().is_empty());
}
}