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 { limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) } #[cfg(test)] mod tests { use super::*; #[test] fn cap_limit_default() { assert_eq!(cap_limit(None), DEFAULT_LIMIT); } #[test] fn cap_limit_normal_value() { assert_eq!(cap_limit(Some(100)), 100); } #[test] fn cap_limit_exceeds_max() { assert_eq!(cap_limit(Some(500)), MAX_LIMIT); assert_eq!(cap_limit(Some(201)), MAX_LIMIT); } #[test] fn cap_limit_zero_clamped_to_one() { assert_eq!(cap_limit(Some(0)), 1); } #[test] fn cap_limit_negative_clamped_to_one() { assert_eq!(cap_limit(Some(-10)), 1); } #[test] fn cap_limit_boundary_values() { assert_eq!(cap_limit(Some(1)), 1); assert_eq!(cap_limit(Some(MAX_LIMIT)), MAX_LIMIT); } #[test] fn list_findings_params_deserialize() { let json = serde_json::json!({ "repo_id": "abc", "severity": "high", "status": "open", "scan_type": "sast", "limit": 25 }); let params: ListFindingsParams = serde_json::from_value(json).unwrap(); assert_eq!(params.repo_id.as_deref(), Some("abc")); assert_eq!(params.severity.as_deref(), Some("high")); assert_eq!(params.status.as_deref(), Some("open")); assert_eq!(params.scan_type.as_deref(), Some("sast")); assert_eq!(params.limit, Some(25)); } #[test] fn list_findings_params_all_optional() { let json = serde_json::json!({}); let params: ListFindingsParams = serde_json::from_value(json).unwrap(); assert!(params.repo_id.is_none()); assert!(params.severity.is_none()); assert!(params.status.is_none()); assert!(params.scan_type.is_none()); assert!(params.limit.is_none()); } #[test] fn get_finding_params_deserialize() { let json = serde_json::json!({ "id": "507f1f77bcf86cd799439011" }); let params: GetFindingParams = serde_json::from_value(json).unwrap(); assert_eq!(params.id, "507f1f77bcf86cd799439011"); } #[test] fn findings_summary_params_deserialize() { let json = serde_json::json!({ "repo_id": "r1" }); let params: FindingsSummaryParams = serde_json::from_value(json).unwrap(); assert_eq!(params.repo_id.as_deref(), Some("r1")); let json2 = serde_json::json!({}); let params2: FindingsSummaryParams = serde_json::from_value(json2).unwrap(); assert!(params2.repo_id.is_none()); } } #[derive(Debug, Deserialize, JsonSchema)] pub struct ListFindingsParams { /// Filter by repository ID pub repo_id: Option, /// Filter by severity: info, low, medium, high, critical pub severity: Option, /// Filter by status: open, triaged, false_positive, resolved, ignored pub status: Option, /// Filter by scan type: sast, sbom, cve, gdpr, oauth, secret_detection, lint, code_review pub scan_type: Option, /// Maximum number of results (default 50, max 200) pub limit: Option, } pub async fn list_findings( db: &Database, params: ListFindingsParams, ) -> Result { 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 { 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, } #[derive(serde::Serialize)] struct SeverityCount { severity: String, count: u64, } pub async fn findings_summary( db: &Database, params: FindingsSummaryParams, ) -> Result { 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)])) }