diff --git a/Cargo.lock b/Cargo.lock index db5f8df..fd430af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -615,6 +615,7 @@ version = "0.1.0" dependencies = [ "axum", "base64", + "bson", "chrono", "compliance-core", "dioxus", @@ -638,6 +639,7 @@ dependencies = [ "tower-sessions", "tracing", "url", + "uuid", "web-sys", ] diff --git a/assets/main.css b/assets/main.css index 68fde61..1325340 100644 --- a/assets/main.css +++ b/assets/main.css @@ -533,3 +533,215 @@ tr:hover { color: var(--text-secondary); opacity: 0.6; } + +/* ── Utility classes ────────────────────────────────────── */ + +.mb-3 { margin-bottom: 12px; } +.mb-4 { margin-bottom: 16px; } +.text-secondary { color: var(--text-secondary); } + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-danger { + background: var(--danger); + color: #fff; +} +.btn-danger:hover { + background: #dc2626; +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); +} +.btn-secondary:hover { + background: var(--bg-primary); +} + +/* ── Modal ──────────────────────────────────────────────── */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-dialog { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + max-width: 440px; + width: 90%; +} + +.modal-dialog h3 { + margin-bottom: 12px; +} + +.modal-dialog p { + margin-bottom: 8px; + font-size: 14px; + color: var(--text-secondary); +} + +.modal-warning { + color: var(--warning) !important; + font-size: 13px !important; +} + +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; +} + +/* ── MCP Servers ────────────────────────────────────────── */ + +.mcp-server-card { + padding: 20px; +} + +.mcp-server-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.mcp-server-title { + display: flex; + align-items: center; + gap: 10px; +} + +.mcp-server-title h3 { + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.mcp-server-actions { + display: flex; + gap: 6px; +} + +.mcp-status { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.mcp-status-running { + background: rgba(34, 197, 94, 0.15); + color: var(--success); +} + +.mcp-status-stopped { + background: rgba(148, 163, 184, 0.15); + color: var(--text-secondary); +} + +.mcp-status-error { + background: rgba(239, 68, 68, 0.15); + color: var(--danger); +} + +.mcp-config-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.mcp-config-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mcp-config-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.mcp-config-value { + font-size: 13px; + color: var(--text-primary); + word-break: break-all; +} + +.mcp-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 16px; +} + +.mcp-tools-section { + margin-bottom: 16px; +} + +.mcp-tools-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.mcp-tool-badge { + display: inline-block; + padding: 3px 10px; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: 6px; + font-size: 12px; + font-family: 'JetBrains Mono', monospace; + color: var(--accent); +} + +.mcp-token-section { + margin-bottom: 12px; +} + +.mcp-token-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; +} + +.mcp-token-value { + flex: 1; + padding: 6px 10px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + font-family: 'JetBrains Mono', monospace; + color: var(--text-secondary); + word-break: break-all; +} + +.mcp-meta { + padding-top: 12px; + border-top: 1px solid var(--border); + font-size: 12px; +} diff --git a/compliance-core/src/models/mcp.rs b/compliance-core/src/models/mcp.rs new file mode 100644 index 0000000..390c255 --- /dev/null +++ b/compliance-core/src/models/mcp.rs @@ -0,0 +1,67 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Transport mode for MCP server +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum McpTransport { + Stdio, + Http, +} + +impl std::fmt::Display for McpTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdio => write!(f, "stdio"), + Self::Http => write!(f, "http"), + } + } +} + +/// Status of a running MCP server +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum McpServerStatus { + Running, + Stopped, + Error, +} + +impl std::fmt::Display for McpServerStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Running => write!(f, "running"), + Self::Stopped => write!(f, "stopped"), + Self::Error => write!(f, "error"), + } + } +} + +/// Configuration for a registered MCP server instance +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerConfig { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Display name for this MCP server + pub name: String, + /// Endpoint URL (e.g. https://mcp.example.com/mcp) + pub endpoint_url: String, + /// Transport type + pub transport: McpTransport, + /// Port number (for HTTP transport) + pub port: Option, + /// Current status + pub status: McpServerStatus, + /// Bearer access token for authentication + pub access_token: String, + /// Which tools are enabled on this server + pub tools_enabled: Vec, + /// Optional description / notes + pub description: Option, + /// MongoDB URI this server connects to + pub mongodb_uri: Option, + /// Database name + pub mongodb_database: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs index 7718a57..00fea6c 100644 --- a/compliance-core/src/models/mod.rs +++ b/compliance-core/src/models/mod.rs @@ -6,6 +6,7 @@ pub mod embedding; pub mod finding; pub mod graph; pub mod issue; +pub mod mcp; pub mod repository; pub mod sbom; pub mod scan; @@ -23,6 +24,7 @@ pub use graph::{ CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, ImpactAnalysis, }; pub use issue::{IssueStatus, TrackerIssue, TrackerType}; +pub use mcp::{McpServerConfig, McpServerStatus, McpTransport}; pub use repository::{ScanTrigger, TrackedRepository}; pub use sbom::{SbomEntry, VulnRef}; pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType}; diff --git a/compliance-dashboard/Cargo.toml b/compliance-dashboard/Cargo.toml index 39356b3..46d5eb2 100644 --- a/compliance-dashboard/Cargo.toml +++ b/compliance-dashboard/Cargo.toml @@ -34,6 +34,8 @@ server = [ "dep:url", "dep:sha2", "dep:base64", + "dep:uuid", + "dep:bson", ] [dependencies] @@ -67,3 +69,5 @@ rand = { version = "0.9", optional = true } url = { version = "2", optional = true } sha2 = { workspace = true, optional = true } base64 = { version = "0.22", optional = true } +uuid = { workspace = true, optional = true } +bson = { version = "2", features = ["chrono-0_4"], optional = true } diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index 08724cf..91e382f 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -38,6 +38,8 @@ pub enum Route { DastFindingsPage {}, #[route("/dast/findings/:id")] DastFindingDetailPage { id: String }, + #[route("/mcp-servers")] + McpServersPage {}, #[route("/settings")] SettingsPage {}, } diff --git a/compliance-dashboard/src/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs index 018db83..eb2c6d6 100644 --- a/compliance-dashboard/src/components/sidebar.rs +++ b/compliance-dashboard/src/components/sidebar.rs @@ -57,6 +57,11 @@ pub fn Sidebar() -> Element { route: Route::DastOverviewPage {}, icon: rsx! { Icon { icon: BsBug, width: 18, height: 18 } }, }, + NavItem { + label: "MCP Servers", + route: Route::McpServersPage {}, + icon: rsx! { Icon { icon: BsPlug, width: 18, height: 18 } }, + }, NavItem { label: "Settings", route: Route::SettingsPage {}, diff --git a/compliance-dashboard/src/infrastructure/database.rs b/compliance-dashboard/src/infrastructure/database.rs index 8bcfa26..cc45c24 100644 --- a/compliance-dashboard/src/infrastructure/database.rs +++ b/compliance-dashboard/src/infrastructure/database.rs @@ -42,4 +42,8 @@ impl Database { pub fn tracker_issues(&self) -> Collection { self.inner.collection("tracker_issues") } + + pub fn mcp_servers(&self) -> Collection { + self.inner.collection("mcp_servers") + } } diff --git a/compliance-dashboard/src/infrastructure/mcp.rs b/compliance-dashboard/src/infrastructure/mcp.rs new file mode 100644 index 0000000..3844682 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/mcp.rs @@ -0,0 +1,160 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::McpServerConfig; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct McpServersResponse { + pub data: Vec, +} + +#[server] +pub async fn fetch_mcp_servers() -> Result { + use mongodb::bson::doc; + + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let mut cursor = state + .db + .mcp_servers() + .find(doc! {}) + .sort(doc! { "created_at": -1 }) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let mut data = Vec::new(); + while cursor + .advance() + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + { + let server = cursor + .deserialize_current() + .map_err(|e| ServerFnError::new(e.to_string()))?; + data.push(server); + } + + Ok(McpServersResponse { data }) +} + +#[server] +pub async fn add_mcp_server( + name: String, + endpoint_url: String, + transport: String, + port: String, + description: String, + mongodb_uri: String, + mongodb_database: String, +) -> Result<(), ServerFnError> { + use chrono::Utc; + use compliance_core::models::{McpServerStatus, McpTransport}; + + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let transport = match transport.as_str() { + "http" => McpTransport::Http, + _ => McpTransport::Stdio, + }; + + let port_num: Option = port.parse().ok(); + + // Generate a random access token + let token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); + + let all_tools = vec![ + "list_findings".to_string(), + "get_finding".to_string(), + "findings_summary".to_string(), + "list_sbom_packages".to_string(), + "sbom_vuln_report".to_string(), + "list_dast_findings".to_string(), + "dast_scan_summary".to_string(), + ]; + + let now = Utc::now(); + let server = McpServerConfig { + id: None, + name, + endpoint_url, + transport, + port: port_num, + status: McpServerStatus::Stopped, + access_token: token, + tools_enabled: all_tools, + description: if description.is_empty() { + None + } else { + Some(description) + }, + mongodb_uri: if mongodb_uri.is_empty() { + None + } else { + Some(mongodb_uri) + }, + mongodb_database: if mongodb_database.is_empty() { + None + } else { + Some(mongodb_database) + }, + created_at: now, + updated_at: now, + }; + + state + .db + .mcp_servers() + .insert_one(server) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(()) +} + +#[server] +pub async fn delete_mcp_server(server_id: String) -> Result<(), ServerFnError> { + use mongodb::bson::doc; + + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let oid = bson::oid::ObjectId::parse_str(&server_id) + .map_err(|e| ServerFnError::new(e.to_string()))?; + + state + .db + .mcp_servers() + .delete_one(doc! { "_id": oid }) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(()) +} + +#[server] +pub async fn regenerate_mcp_token(server_id: String) -> Result { + use chrono::Utc; + use mongodb::bson::doc; + + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let oid = bson::oid::ObjectId::parse_str(&server_id) + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let new_token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); + + state + .db + .mcp_servers() + .update_one( + doc! { "_id": oid }, + doc! { "$set": { "access_token": &new_token, "updated_at": Utc::now().to_rfc3339() } }, + ) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(new_token) +} diff --git a/compliance-dashboard/src/infrastructure/mod.rs b/compliance-dashboard/src/infrastructure/mod.rs index 2b8831d..0bb113d 100644 --- a/compliance-dashboard/src/infrastructure/mod.rs +++ b/compliance-dashboard/src/infrastructure/mod.rs @@ -6,6 +6,7 @@ pub mod dast; pub mod findings; pub mod graph; pub mod issues; +pub mod mcp; pub mod repositories; pub mod sbom; pub mod scans; diff --git a/compliance-dashboard/src/pages/mcp_servers.rs b/compliance-dashboard/src/pages/mcp_servers.rs new file mode 100644 index 0000000..16fc69a --- /dev/null +++ b/compliance-dashboard/src/pages/mcp_servers.rs @@ -0,0 +1,328 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; +use crate::components::toast::{ToastType, Toasts}; +use crate::infrastructure::mcp::{ + add_mcp_server, delete_mcp_server, fetch_mcp_servers, regenerate_mcp_token, +}; + +#[component] +pub fn McpServersPage() -> Element { + let mut servers = use_resource(|| async { fetch_mcp_servers().await.ok() }); + let mut toasts = use_context::(); + + let mut show_form = use_signal(|| false); + let mut new_name = use_signal(String::new); + let mut new_endpoint = use_signal(String::new); + let mut new_transport = use_signal(|| "http".to_string()); + let mut new_port = use_signal(|| "8090".to_string()); + let mut new_description = use_signal(String::new); + let mut new_mongo_uri = use_signal(String::new); + let mut new_mongo_db = use_signal(String::new); + + // Track which server's token is visible + let mut visible_token: Signal> = use_signal(|| None); + // Track which server is pending delete confirmation + let mut confirm_delete: Signal> = use_signal(|| None); + + rsx! { + PageHeader { + title: "MCP Servers", + description: "Manage Model Context Protocol servers for LLM integrations", + } + + div { class: "mb-4", + button { + class: "btn btn-primary", + onclick: move |_| show_form.set(!show_form()), + if show_form() { "Cancel" } else { "Register Server" } + } + } + + if show_form() { + div { class: "card mb-4", + div { class: "card-header", "Register MCP Server" } + div { class: "mcp-form-grid", + div { class: "form-group", + label { "Name" } + input { + r#type: "text", + placeholder: "Production MCP", + value: "{new_name}", + oninput: move |e| new_name.set(e.value()), + } + } + div { class: "form-group", + label { "Endpoint URL" } + input { + r#type: "text", + placeholder: "https://mcp.example.com/mcp", + value: "{new_endpoint}", + oninput: move |e| new_endpoint.set(e.value()), + } + } + div { class: "form-group", + label { "Transport" } + select { + value: "{new_transport}", + oninput: move |e| new_transport.set(e.value()), + option { value: "http", "HTTP (Streamable)" } + option { value: "stdio", "Stdio" } + } + } + div { class: "form-group", + label { "Port" } + input { + r#type: "text", + placeholder: "8090", + value: "{new_port}", + oninput: move |e| new_port.set(e.value()), + } + } + div { class: "form-group", + label { "MongoDB URI" } + input { + r#type: "text", + placeholder: "mongodb://localhost:27017", + value: "{new_mongo_uri}", + oninput: move |e| new_mongo_uri.set(e.value()), + } + } + div { class: "form-group", + label { "Database Name" } + input { + r#type: "text", + placeholder: "compliance_scanner", + value: "{new_mongo_db}", + oninput: move |e| new_mongo_db.set(e.value()), + } + } + } + div { class: "form-group", + label { "Description" } + input { + r#type: "text", + placeholder: "Optional notes about this server", + value: "{new_description}", + oninput: move |e| new_description.set(e.value()), + } + } + button { + class: "btn btn-primary", + onclick: move |_| { + let name = new_name(); + let endpoint = new_endpoint(); + let transport = new_transport(); + let port = new_port(); + let desc = new_description(); + let mongo_uri = new_mongo_uri(); + let mongo_db = new_mongo_db(); + spawn(async move { + match add_mcp_server(name, endpoint, transport, port, desc, mongo_uri, mongo_db).await { + Ok(_) => { + toasts.push(ToastType::Success, "MCP server registered"); + servers.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + }); + show_form.set(false); + new_name.set(String::new()); + new_endpoint.set(String::new()); + new_transport.set("http".to_string()); + new_port.set("8090".to_string()); + new_description.set(String::new()); + new_mongo_uri.set(String::new()); + new_mongo_db.set(String::new()); + }, + "Register" + } + } + } + + // Delete confirmation modal + if let Some((ref del_id, ref del_name)) = *confirm_delete.read() { + div { class: "modal-overlay", + onclick: move |_| confirm_delete.set(None), + div { class: "modal-dialog", + onclick: move |e| e.stop_propagation(), + h3 { "Delete MCP Server" } + p { "Are you sure you want to remove " strong { "{del_name}" } "?" } + p { class: "text-secondary", "Connected LLM clients will lose access." } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: { + let id = del_id.clone(); + move |_| { + let id = id.clone(); + spawn(async move { + match delete_mcp_server(id).await { + Ok(_) => { + toasts.push(ToastType::Success, "Server removed"); + servers.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + }); + confirm_delete.set(None); + } + }, + "Delete" + } + } + } + } + } + + match &*servers.read() { + Some(Some(resp)) => { + if resp.data.is_empty() { + rsx! { + div { class: "card", + p { class: "text-secondary", "No MCP servers registered. Add one to get started." } + } + } + } else { + rsx! { + for server in resp.data.iter() { + { + let sid = server.id.map(|id| id.to_hex()).unwrap_or_default(); + let name = server.name.clone(); + let status_class = match server.status { + compliance_core::models::McpServerStatus::Running => "mcp-status-running", + compliance_core::models::McpServerStatus::Stopped => "mcp-status-stopped", + compliance_core::models::McpServerStatus::Error => "mcp-status-error", + }; + let is_token_visible = visible_token().as_deref() == Some(sid.as_str()); + let created_str = server.created_at.format("%Y-%m-%d %H:%M").to_string(); + + rsx! { + div { class: "card mcp-server-card mb-4", + div { class: "mcp-server-header", + div { class: "mcp-server-title", + h3 { "{server.name}" } + span { class: "mcp-status {status_class}", + "{server.status}" + } + } + div { class: "mcp-server-actions", + button { + class: "btn btn-sm btn-ghost", + title: "Delete server", + onclick: { + let id = sid.clone(); + let name = name.clone(); + move |_| { + confirm_delete.set(Some((id.clone(), name.clone()))); + } + }, + "Delete" + } + } + } + + if let Some(ref desc) = server.description { + p { class: "text-secondary mb-3", "{desc}" } + } + + div { class: "mcp-config-grid", + div { class: "mcp-config-item", + span { class: "mcp-config-label", "Endpoint" } + code { class: "mcp-config-value", "{server.endpoint_url}" } + } + div { class: "mcp-config-item", + span { class: "mcp-config-label", "Transport" } + span { class: "mcp-config-value", "{server.transport}" } + } + if let Some(port) = server.port { + div { class: "mcp-config-item", + span { class: "mcp-config-label", "Port" } + span { class: "mcp-config-value", "{port}" } + } + } + if let Some(ref db) = server.mongodb_database { + div { class: "mcp-config-item", + span { class: "mcp-config-label", "Database" } + span { class: "mcp-config-value", "{db}" } + } + } + } + + div { class: "mcp-tools-section", + span { class: "mcp-config-label", "Enabled Tools" } + div { class: "mcp-tools-list", + for tool in server.tools_enabled.iter() { + span { class: "mcp-tool-badge", "{tool}" } + } + } + } + + div { class: "mcp-token-section", + span { class: "mcp-config-label", "Access Token" } + div { class: "mcp-token-row", + code { class: "mcp-token-value", + if is_token_visible { + "{server.access_token}" + } else { + "mcp_••••••••••••••••••••••••••••" + } + } + button { + class: "btn btn-sm btn-ghost", + onclick: { + let id = sid.clone(); + move |_| { + if visible_token().as_deref() == Some(id.as_str()) { + visible_token.set(None); + } else { + visible_token.set(Some(id.clone())); + } + } + }, + if is_token_visible { "Hide" } else { "Reveal" } + } + button { + class: "btn btn-sm btn-ghost", + onclick: { + let id = sid.clone(); + move |_| { + let id = id.clone(); + spawn(async move { + match regenerate_mcp_token(id).await { + Ok(_) => { + toasts.push(ToastType::Success, "Token regenerated"); + servers.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + }); + } + }, + "Regenerate" + } + } + } + + div { class: "mcp-meta", + span { class: "text-secondary", + "Created {created_str}" + } + } + } + } + } + } + } + } + }, + Some(None) => rsx! { div { class: "card", p { "Failed to load MCP servers." } } }, + None => rsx! { div { class: "card", p { "Loading..." } } }, + } + } +} diff --git a/compliance-dashboard/src/pages/mod.rs b/compliance-dashboard/src/pages/mod.rs index 5d14ed5..623ec4a 100644 --- a/compliance-dashboard/src/pages/mod.rs +++ b/compliance-dashboard/src/pages/mod.rs @@ -10,6 +10,7 @@ pub mod graph_explorer; pub mod graph_index; pub mod impact_analysis; pub mod issues; +pub mod mcp_servers; pub mod overview; pub mod repositories; pub mod sbom; @@ -27,6 +28,7 @@ pub use graph_explorer::GraphExplorerPage; pub use graph_index::GraphIndexPage; pub use impact_analysis::ImpactAnalysisPage; pub use issues::IssuesPage; +pub use mcp_servers::McpServersPage; pub use overview::OverviewPage; pub use repositories::RepositoriesPage; pub use sbom::SbomPage;