use std::path::{Path, PathBuf};
use std::sync::Arc;
use rmcp::{
ErrorData as McpError, RoleServer, ServerHandler,
handler::server::router::tool::ToolRouter,
model::{
AnnotateAble, Implementation, ListResourcesResult, PaginatedRequestParams, RawResource,
ReadResourceRequestParams, ReadResourceResult, ResourceContents, ServerCapabilities,
ServerInfo,
},
service::RequestContext,
tool_handler,
};
use rsigma_parser::reference::{MITRE_TACTICS, MODIFIERS};
use rsigma_parser::{LintConfig, catalogue};
use serde_json::{Value, json};
use shared::to_value;
mod convert_rules;
mod evaluate_events;
mod fix_rules;
mod lint_rules;
mod list_backends;
mod list_builtin_pipelines;
mod list_fields;
mod parse_condition;
mod parse_rule;
mod resolve_pipeline;
mod shared;
mod validate_rules;
struct State {
root: Option<PathBuf>,
lint_config: LintConfig,
}
#[derive(Clone)]
pub struct RsigmaMcp {
tool_router: ToolRouter<Self>,
state: Arc<State>,
}
impl RsigmaMcp {
pub fn new(root: Option<PathBuf>, lint_config: LintConfig) -> Self {
Self {
tool_router: Self::tool_router(),
state: Arc::new(State { root, lint_config }),
}
}
fn root(&self) -> Option<&Path> {
self.state.root.as_deref()
}
fn lint_config(&self) -> &LintConfig {
&self.state.lint_config
}
fn tool_router() -> ToolRouter<Self> {
Self::parse_rule_router()
+ Self::parse_condition_router()
+ Self::lint_rules_router()
+ Self::validate_rules_router()
+ Self::evaluate_events_router()
+ Self::convert_rules_router()
+ Self::list_backends_router()
+ Self::list_fields_router()
+ Self::resolve_pipeline_router()
+ Self::list_builtin_pipelines_router()
+ Self::fix_rules_router()
}
}
impl Default for RsigmaMcp {
fn default() -> Self {
Self::new(None, LintConfig::default())
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for RsigmaMcp {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build();
info.server_info = Implementation::from_build_env();
info.server_info.name = "rsigma-mcp".to_string();
info.server_info.version = env!("CARGO_PKG_VERSION").to_string();
info.instructions = Some(
"Sigma detection-rule toolchain: parse, parse_condition, lint, validate, evaluate, \
convert, fix, list fields, and resolve pipelines. Every tool accepts inline content \
(e.g. `yaml`) or a file `path`. Resources expose the lint catalogue and modifier / \
MITRE reference data."
.to_string(),
);
info
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let resources = vec![
RawResource::new(RESOURCE_LINT_CATALOGUE, "Lint rule catalogue").no_annotation(),
RawResource::new(RESOURCE_MODIFIERS, "Sigma field modifiers").no_annotation(),
RawResource::new(RESOURCE_MITRE_TACTICS, "MITRE ATT&CK tactics").no_annotation(),
];
Ok(ListResourcesResult {
resources,
next_cursor: None,
meta: None,
})
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
let value = match request.uri.as_str() {
RESOURCE_LINT_CATALOGUE => to_value(&catalogue()),
RESOURCE_MODIFIERS => reference_pairs_json(MODIFIERS),
RESOURCE_MITRE_TACTICS => reference_pairs_json(MITRE_TACTICS),
other => {
return Err(McpError::resource_not_found(
format!("unknown resource '{other}'"),
None,
));
}
};
let text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
Ok(ReadResourceResult::new(vec![ResourceContents::text(
text,
&request.uri,
)]))
}
}
const RESOURCE_LINT_CATALOGUE: &str = "rsigma://lint/catalogue";
const RESOURCE_MODIFIERS: &str = "rsigma://reference/modifiers";
const RESOURCE_MITRE_TACTICS: &str = "rsigma://reference/mitre-tactics";
fn reference_pairs_json(pairs: &[(&str, &str)]) -> Value {
Value::Array(
pairs
.iter()
.map(|(name, description)| json!({ "name": name, "description": description }))
.collect(),
)
}
#[cfg(test)]
pub(crate) fn handler() -> RsigmaMcp {
RsigmaMcp::new(None, LintConfig::default())
}
#[cfg(test)]
pub(crate) fn src(yaml: &str) -> shared::SourceInput {
shared::SourceInput {
yaml: Some(yaml.to_string()),
path: None,
}
}
#[cfg(test)]
pub(crate) const VALID_RULE: &str = r#"
title: Whoami Execution
id: 8b1d8c97-5b3a-4d77-9b48-7c5f7c8b1a2a
status: test
description: Detects whoami
author: test
logsource:
category: process_creation
product: windows
detection:
selection:
CommandLine|contains: whoami
condition: selection
level: medium
tags:
- attack.execution
"#;
#[cfg(test)]
pub(crate) const GOLDEN_RULE: &str = r#"
title: Whoami Execution
id: 8b1d8c97-5b3a-4d77-9b48-7c5f7c8b1a2a
status: test
description: Detects whoami execution
author: rsigma
logsource:
category: process_creation
product: windows
detection:
selection:
CommandLine|contains: whoami
condition: selection
level: medium
tags:
- attack.execution
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reference_resources_round_trip() {
let modifiers = reference_pairs_json(MODIFIERS);
assert!(
modifiers
.as_array()
.unwrap()
.iter()
.any(|m| m["name"] == "contains")
);
let cat = to_value(&catalogue());
assert_eq!(cat.as_array().unwrap().len(), 75);
}
}