feat: add MCP server for exposing compliance data to LLMs
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (pull_request) Successful in 3m51s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Clippy (push) Successful in 3m51s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (pull_request) Successful in 3m51s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Clippy (push) Successful in 3m51s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
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>
This commit is contained in:
21
compliance-mcp/Cargo.toml
Normal file
21
compliance-mcp/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "compliance-mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
compliance-core = { workspace = true, features = ["mongodb"] }
|
||||
rmcp = { version = "0.16", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
mongodb = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = "0.15"
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
bson = { version = "2", features = ["chrono-0_4"] }
|
||||
schemars = "1.0"
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
34
compliance-mcp/src/database.rs
Normal file
34
compliance-mcp/src/database.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use mongodb::{Client, Collection};
|
||||
|
||||
use compliance_core::models::*;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Database {
|
||||
inner: mongodb::Database,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn connect(uri: &str, db_name: &str) -> Result<Self, mongodb::error::Error> {
|
||||
let client = Client::with_uri_str(uri).await?;
|
||||
let db = client.database(db_name);
|
||||
db.run_command(mongodb::bson::doc! { "ping": 1 }).await?;
|
||||
tracing::info!("MCP server connected to MongoDB '{db_name}'");
|
||||
Ok(Self { inner: db })
|
||||
}
|
||||
|
||||
pub fn findings(&self) -> Collection<Finding> {
|
||||
self.inner.collection("findings")
|
||||
}
|
||||
|
||||
pub fn sbom_entries(&self) -> Collection<SbomEntry> {
|
||||
self.inner.collection("sbom_entries")
|
||||
}
|
||||
|
||||
pub fn dast_findings(&self) -> Collection<DastFinding> {
|
||||
self.inner.collection("dast_findings")
|
||||
}
|
||||
|
||||
pub fn dast_scan_runs(&self) -> Collection<DastScanRun> {
|
||||
self.inner.collection("dast_scan_runs")
|
||||
}
|
||||
}
|
||||
58
compliance-mcp/src/main.rs
Normal file
58
compliance-mcp/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
mod database;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use database::Database;
|
||||
use rmcp::transport::{
|
||||
streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig,
|
||||
StreamableHttpService,
|
||||
};
|
||||
use server::ComplianceMcpServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("compliance_mcp=info".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
let mongo_uri =
|
||||
std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".to_string());
|
||||
let db_name =
|
||||
std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "compliance_scanner".to_string());
|
||||
|
||||
let db = Database::connect(&mongo_uri, &db_name).await?;
|
||||
|
||||
// If MCP_PORT is set, run as Streamable HTTP server; otherwise use stdio.
|
||||
if let Ok(port_str) = std::env::var("MCP_PORT") {
|
||||
let port: u16 = port_str.parse()?;
|
||||
tracing::info!("Starting MCP server on HTTP port {port}");
|
||||
|
||||
let db_clone = db.clone();
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok(ComplianceMcpServer::new(db_clone.clone())),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
let router = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
|
||||
tracing::info!("MCP HTTP server listening on 0.0.0.0:{port}");
|
||||
axum::serve(listener, router).await?;
|
||||
} else {
|
||||
tracing::info!("Starting MCP server on stdio");
|
||||
let server = ComplianceMcpServer::new(db);
|
||||
let transport = rmcp::transport::stdio();
|
||||
use rmcp::ServiceExt;
|
||||
let handle = server.serve(transport).await?;
|
||||
handle.waiting().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
109
compliance-mcp/src/server.rs
Normal file
109
compliance-mcp/src/server.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use rmcp::{
|
||||
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
||||
};
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::tools::{dast, findings, sbom};
|
||||
|
||||
pub struct ComplianceMcpServer {
|
||||
db: Database,
|
||||
#[allow(dead_code)]
|
||||
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl ComplianceMcpServer {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self {
|
||||
db,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Findings ──────────────────────────────────────────
|
||||
|
||||
#[tool(
|
||||
description = "List security findings with optional filters for repo, severity, status, and scan type"
|
||||
)]
|
||||
async fn list_findings(
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::ListFindingsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::list_findings(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a single finding by its ID")]
|
||||
async fn get_finding(
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::GetFindingParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::get_finding(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a summary of findings counts grouped by severity and status")]
|
||||
async fn findings_summary(
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::FindingsSummaryParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::findings_summary(&self.db, params).await
|
||||
}
|
||||
|
||||
// ── SBOM ──────────────────────────────────────────────
|
||||
|
||||
#[tool(
|
||||
description = "List SBOM packages with optional filters for repo, vulnerabilities, package manager, and license"
|
||||
)]
|
||||
async fn list_sbom_packages(
|
||||
&self,
|
||||
Parameters(params): Parameters<sbom::ListSbomPackagesParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
sbom::list_sbom_packages(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Generate a vulnerability report for a repository showing all packages with known CVEs"
|
||||
)]
|
||||
async fn sbom_vuln_report(
|
||||
&self,
|
||||
Parameters(params): Parameters<sbom::SbomVulnReportParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
sbom::sbom_vuln_report(&self.db, params).await
|
||||
}
|
||||
|
||||
// ── DAST ──────────────────────────────────────────────
|
||||
|
||||
#[tool(
|
||||
description = "List DAST findings with optional filters for target, scan run, severity, exploitability, and vulnerability type"
|
||||
)]
|
||||
async fn list_dast_findings(
|
||||
&self,
|
||||
Parameters(params): Parameters<dast::ListDastFindingsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
dast::list_dast_findings(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a summary of recent DAST scan runs and finding counts")]
|
||||
async fn dast_scan_summary(
|
||||
&self,
|
||||
Parameters(params): Parameters<dast::DastScanSummaryParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
dast::dast_scan_summary(&self.db, params).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for ComplianceMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::V_2024_11_05,
|
||||
capabilities: ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
"Compliance Scanner MCP server. Query security findings, SBOM data, and DAST results."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
154
compliance-mcp/src/tools/dast.rs
Normal file
154
compliance-mcp/src/tools/dast.rs
Normal 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)]))
|
||||
}
|
||||
163
compliance-mcp/src/tools/findings.rs
Normal file
163
compliance-mcp/src/tools/findings.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
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 ListFindingsParams {
|
||||
/// Filter by repository ID
|
||||
pub repo_id: Option<String>,
|
||||
/// Filter by severity: info, low, medium, high, critical
|
||||
pub severity: Option<String>,
|
||||
/// Filter by status: open, triaged, false_positive, resolved, ignored
|
||||
pub status: Option<String>,
|
||||
/// Filter by scan type: sast, sbom, cve, gdpr, oauth
|
||||
pub scan_type: Option<String>,
|
||||
/// Maximum number of results (default 50, max 200)
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_findings(
|
||||
db: &Database,
|
||||
params: ListFindingsParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut filter = doc! {};
|
||||
if let Some(ref repo_id) = params.repo_id {
|
||||
filter.insert("repo_id", repo_id);
|
||||
}
|
||||
if let Some(ref severity) = params.severity {
|
||||
filter.insert("severity", severity);
|
||||
}
|
||||
if let Some(ref status) = params.status {
|
||||
filter.insert("status", status);
|
||||
}
|
||||
if let Some(ref scan_type) = params.scan_type {
|
||||
filter.insert("scan_type", scan_type);
|
||||
}
|
||||
|
||||
let limit = cap_limit(params.limit);
|
||||
|
||||
let mut cursor = db
|
||||
.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 GetFindingParams {
|
||||
/// Finding ID (MongoDB ObjectId hex string)
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub async fn get_finding(
|
||||
db: &Database,
|
||||
params: GetFindingParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let oid = bson::oid::ObjectId::parse_str(¶ms.id)
|
||||
.map_err(|e| McpError::invalid_params(format!("invalid ObjectId: {e}"), None))?;
|
||||
|
||||
let finding = db
|
||||
.findings()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?
|
||||
.ok_or_else(|| McpError::invalid_params("finding not found", None))?;
|
||||
|
||||
let json = serde_json::to_string_pretty(&finding)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct FindingsSummaryParams {
|
||||
/// Filter by repository ID
|
||||
pub repo_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SeverityCount {
|
||||
severity: String,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
pub async fn findings_summary(
|
||||
db: &Database,
|
||||
params: FindingsSummaryParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut base_filter = doc! {};
|
||||
if let Some(ref repo_id) = params.repo_id {
|
||||
base_filter.insert("repo_id", repo_id);
|
||||
}
|
||||
|
||||
let severities = ["critical", "high", "medium", "low", "info"];
|
||||
let mut counts = Vec::new();
|
||||
|
||||
for sev in &severities {
|
||||
let mut filter = base_filter.clone();
|
||||
filter.insert("severity", sev);
|
||||
let count = db
|
||||
.findings()
|
||||
.count_documents(filter)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
counts.push(SeverityCount {
|
||||
severity: sev.to_string(),
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
let total: u64 = counts.iter().map(|c| c.count).sum();
|
||||
|
||||
let mut status_counts = Vec::new();
|
||||
for status in &["open", "triaged", "false_positive", "resolved", "ignored"] {
|
||||
let mut filter = base_filter.clone();
|
||||
filter.insert("status", status);
|
||||
let count = db
|
||||
.findings()
|
||||
.count_documents(filter)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
status_counts.push(serde_json::json!({ "status": status, "count": count }));
|
||||
}
|
||||
|
||||
let summary = serde_json::json!({
|
||||
"total": total,
|
||||
"by_severity": counts,
|
||||
"by_status": status_counts,
|
||||
});
|
||||
|
||||
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)]))
|
||||
}
|
||||
3
compliance-mcp/src/tools/mod.rs
Normal file
3
compliance-mcp/src/tools/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod dast;
|
||||
pub mod findings;
|
||||
pub mod sbom;
|
||||
129
compliance-mcp/src/tools/sbom.rs
Normal file
129
compliance-mcp/src/tools/sbom.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
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 ListSbomPackagesParams {
|
||||
/// Filter by repository ID
|
||||
pub repo_id: Option<String>,
|
||||
/// Only show packages with known vulnerabilities
|
||||
pub has_vulns: Option<bool>,
|
||||
/// Filter by package manager (e.g. npm, cargo, pip)
|
||||
pub package_manager: Option<String>,
|
||||
/// Filter by license (e.g. MIT, Apache-2.0)
|
||||
pub license: Option<String>,
|
||||
/// Maximum number of results (default 50, max 200)
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_sbom_packages(
|
||||
db: &Database,
|
||||
params: ListSbomPackagesParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut filter = doc! {};
|
||||
if let Some(ref repo_id) = params.repo_id {
|
||||
filter.insert("repo_id", repo_id);
|
||||
}
|
||||
if let Some(ref pm) = params.package_manager {
|
||||
filter.insert("package_manager", pm);
|
||||
}
|
||||
if let Some(ref license) = params.license {
|
||||
filter.insert("license", license);
|
||||
}
|
||||
if params.has_vulns == Some(true) {
|
||||
filter.insert("known_vulnerabilities.0", doc! { "$exists": true });
|
||||
}
|
||||
|
||||
let limit = cap_limit(params.limit);
|
||||
|
||||
let mut cursor = db
|
||||
.sbom_entries()
|
||||
.find(filter)
|
||||
.sort(doc! { "name": 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 entry = cursor
|
||||
.deserialize_current()
|
||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
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 SbomVulnReportParams {
|
||||
/// Repository ID to generate vulnerability report for
|
||||
pub repo_id: String,
|
||||
}
|
||||
|
||||
pub async fn sbom_vuln_report(
|
||||
db: &Database,
|
||||
params: SbomVulnReportParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let filter = doc! {
|
||||
"repo_id": ¶ms.repo_id,
|
||||
"known_vulnerabilities.0": { "$exists": true },
|
||||
};
|
||||
|
||||
let mut cursor = db
|
||||
.sbom_entries()
|
||||
.find(filter)
|
||||
.sort(doc! { "name": 1 })
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let mut vulnerable_packages = Vec::new();
|
||||
let mut total_vulns = 0u64;
|
||||
|
||||
while cursor
|
||||
.advance()
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
|
||||
{
|
||||
let entry = cursor
|
||||
.deserialize_current()
|
||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
|
||||
total_vulns += entry.known_vulnerabilities.len() as u64;
|
||||
vulnerable_packages.push(serde_json::json!({
|
||||
"name": entry.name,
|
||||
"version": entry.version,
|
||||
"package_manager": entry.package_manager,
|
||||
"license": entry.license,
|
||||
"vulnerabilities": entry.known_vulnerabilities,
|
||||
}));
|
||||
}
|
||||
|
||||
let report = serde_json::json!({
|
||||
"repo_id": params.repo_id,
|
||||
"vulnerable_packages_count": vulnerable_packages.len(),
|
||||
"total_vulnerabilities": total_vulns,
|
||||
"packages": vulnerable_packages,
|
||||
});
|
||||
|
||||
let json = serde_json::to_string_pretty(&report)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
Reference in New Issue
Block a user