feat: add MCP server for exposing compliance data to LLMs (#5)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m4s
CI / Security Audit (push) Successful in 1m42s
CI / Tests (push) Successful in 4m38s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 1s
CI / Deploy MCP (push) Failing after 2s
CI / Detect Changes (push) Successful in 7s
CI / Deploy Docs (push) Successful in 2s

New `compliance-mcp` crate providing a Model Context Protocol server
with 7 tools: list/get/summarize findings, list SBOM packages, SBOM
vulnerability report, list DAST findings, and DAST scan summary.
Supports stdio (local dev) and Streamable HTTP (deployment via MCP_PORT).
Includes Dockerfile, CI clippy check, and Coolify deploy job.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-03-09 08:21:04 +00:00
parent d13cef94cb
commit 32e5fc21e7
28 changed files with 1847 additions and 224 deletions

View File

@@ -0,0 +1,154 @@
use mongodb::bson::doc;
use rmcp::{model::*, ErrorData as McpError};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::database::Database;
const MAX_LIMIT: i64 = 200;
const DEFAULT_LIMIT: i64 = 50;
fn cap_limit(limit: Option<i64>) -> i64 {
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListDastFindingsParams {
/// Filter by DAST target ID
pub target_id: Option<String>,
/// Filter by scan run ID
pub scan_run_id: Option<String>,
/// Filter by severity: info, low, medium, high, critical
pub severity: Option<String>,
/// Only show confirmed exploitable findings
pub exploitable: Option<bool>,
/// Filter by vulnerability type (e.g. sql_injection, xss, ssrf)
pub vuln_type: Option<String>,
/// Maximum number of results (default 50, max 200)
pub limit: Option<i64>,
}
pub async fn list_dast_findings(
db: &Database,
params: ListDastFindingsParams,
) -> Result<CallToolResult, McpError> {
let mut filter = doc! {};
if let Some(ref target_id) = params.target_id {
filter.insert("target_id", target_id);
}
if let Some(ref scan_run_id) = params.scan_run_id {
filter.insert("scan_run_id", scan_run_id);
}
if let Some(ref severity) = params.severity {
filter.insert("severity", severity);
}
if let Some(exploitable) = params.exploitable {
filter.insert("exploitable", exploitable);
}
if let Some(ref vuln_type) = params.vuln_type {
filter.insert("vuln_type", vuln_type);
}
let limit = cap_limit(params.limit);
let mut cursor = db
.dast_findings()
.find(filter)
.sort(doc! { "created_at": -1 })
.limit(limit)
.await
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
let mut results = Vec::new();
while cursor
.advance()
.await
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
{
let finding = cursor
.deserialize_current()
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
results.push(finding);
}
let json = serde_json::to_string_pretty(&results)
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DastScanSummaryParams {
/// Filter by DAST target ID
pub target_id: Option<String>,
}
pub async fn dast_scan_summary(
db: &Database,
params: DastScanSummaryParams,
) -> Result<CallToolResult, McpError> {
let mut filter = doc! {};
if let Some(ref target_id) = params.target_id {
filter.insert("target_id", target_id);
}
// Get recent scan runs
let mut cursor = db
.dast_scan_runs()
.find(filter.clone())
.sort(doc! { "started_at": -1 })
.limit(10)
.await
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
let mut scan_runs = Vec::new();
while cursor
.advance()
.await
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
{
let run = cursor
.deserialize_current()
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
scan_runs.push(serde_json::json!({
"id": run.id.map(|id| id.to_hex()),
"target_id": run.target_id,
"status": run.status,
"findings_count": run.findings_count,
"exploitable_count": run.exploitable_count,
"endpoints_discovered": run.endpoints_discovered,
"started_at": run.started_at.to_rfc3339(),
"completed_at": run.completed_at.map(|t| t.to_rfc3339()),
}));
}
// Count findings by severity
let mut findings_filter = doc! {};
if let Some(ref target_id) = params.target_id {
findings_filter.insert("target_id", target_id);
}
let total_findings = db
.dast_findings()
.count_documents(findings_filter.clone())
.await
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
let mut exploitable_filter = findings_filter.clone();
exploitable_filter.insert("exploitable", true);
let exploitable_count = db
.dast_findings()
.count_documents(exploitable_filter)
.await
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
let summary = serde_json::json!({
"total_findings": total_findings,
"exploitable_findings": exploitable_count,
"recent_scan_runs": scan_runs,
});
let json = serde_json::to_string_pretty(&summary)
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}