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

21
compliance-mcp/Cargo.toml Normal file
View 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"] }

View 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")
}
}

View 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(())
}

View 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(),
),
}
}
}

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)]))
}

View 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(&params.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)]))
}

View File

@@ -0,0 +1,3 @@
pub mod dast;
pub mod findings;
pub mod sbom;

View 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": &params.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)]))
}