feat: add MCP servers dashboard page with CRUD and token management
Some checks failed
CI / Deploy MCP (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 3m58s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Clippy (pull_request) Successful in 4m1s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
Some checks failed
CI / Deploy MCP (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 3m58s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 3s
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Clippy (pull_request) Successful in 4m1s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
New page at /mcp-servers to register, view, and manage MCP server instances. Shows endpoint config, enabled tools, and access tokens with reveal/regenerate controls. Includes McpServerConfig model in compliance-core, MongoDB collection accessor, server functions for list/add/delete/regenerate-token, sidebar nav entry, and full CSS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -615,6 +615,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
|
"bson",
|
||||||
"chrono",
|
"chrono",
|
||||||
"compliance-core",
|
"compliance-core",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
@@ -638,6 +639,7 @@ dependencies = [
|
|||||||
"tower-sessions",
|
"tower-sessions",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
"uuid",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
212
assets/main.css
212
assets/main.css
@@ -533,3 +533,215 @@ tr:hover {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
opacity: 0.6;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
67
compliance-core/src/models/mcp.rs
Normal file
67
compliance-core/src/models/mcp.rs
Normal file
@@ -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<bson::oid::ObjectId>,
|
||||||
|
/// 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<u16>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Optional description / notes
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// MongoDB URI this server connects to
|
||||||
|
pub mongodb_uri: Option<String>,
|
||||||
|
/// Database name
|
||||||
|
pub mongodb_database: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod embedding;
|
|||||||
pub mod finding;
|
pub mod finding;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
|
pub mod mcp;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
@@ -23,6 +24,7 @@ pub use graph::{
|
|||||||
CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, ImpactAnalysis,
|
CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, ImpactAnalysis,
|
||||||
};
|
};
|
||||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||||
|
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||||
pub use repository::{ScanTrigger, TrackedRepository};
|
pub use repository::{ScanTrigger, TrackedRepository};
|
||||||
pub use sbom::{SbomEntry, VulnRef};
|
pub use sbom::{SbomEntry, VulnRef};
|
||||||
pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType};
|
pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType};
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ server = [
|
|||||||
"dep:url",
|
"dep:url",
|
||||||
"dep:sha2",
|
"dep:sha2",
|
||||||
"dep:base64",
|
"dep:base64",
|
||||||
|
"dep:uuid",
|
||||||
|
"dep:bson",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -67,3 +69,5 @@ rand = { version = "0.9", optional = true }
|
|||||||
url = { version = "2", optional = true }
|
url = { version = "2", optional = true }
|
||||||
sha2 = { workspace = true, optional = true }
|
sha2 = { workspace = true, optional = true }
|
||||||
base64 = { version = "0.22", optional = true }
|
base64 = { version = "0.22", optional = true }
|
||||||
|
uuid = { workspace = true, optional = true }
|
||||||
|
bson = { version = "2", features = ["chrono-0_4"], optional = true }
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ pub enum Route {
|
|||||||
DastFindingsPage {},
|
DastFindingsPage {},
|
||||||
#[route("/dast/findings/:id")]
|
#[route("/dast/findings/:id")]
|
||||||
DastFindingDetailPage { id: String },
|
DastFindingDetailPage { id: String },
|
||||||
|
#[route("/mcp-servers")]
|
||||||
|
McpServersPage {},
|
||||||
#[route("/settings")]
|
#[route("/settings")]
|
||||||
SettingsPage {},
|
SettingsPage {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ pub fn Sidebar() -> Element {
|
|||||||
route: Route::DastOverviewPage {},
|
route: Route::DastOverviewPage {},
|
||||||
icon: rsx! { Icon { icon: BsBug, width: 18, height: 18 } },
|
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 {
|
NavItem {
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
route: Route::SettingsPage {},
|
route: Route::SettingsPage {},
|
||||||
|
|||||||
@@ -42,4 +42,8 @@ impl Database {
|
|||||||
pub fn tracker_issues(&self) -> Collection<TrackerIssue> {
|
pub fn tracker_issues(&self) -> Collection<TrackerIssue> {
|
||||||
self.inner.collection("tracker_issues")
|
self.inner.collection("tracker_issues")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mcp_servers(&self) -> Collection<McpServerConfig> {
|
||||||
|
self.inner.collection("mcp_servers")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
compliance-dashboard/src/infrastructure/mcp.rs
Normal file
160
compliance-dashboard/src/infrastructure/mcp.rs
Normal file
@@ -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<McpServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_mcp_servers() -> Result<McpServersResponse, ServerFnError> {
|
||||||
|
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<u16> = 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<String, ServerFnError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod dast;
|
|||||||
pub mod findings;
|
pub mod findings;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
|
pub mod mcp;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod scans;
|
pub mod scans;
|
||||||
|
|||||||
328
compliance-dashboard/src/pages/mcp_servers.rs
Normal file
328
compliance-dashboard/src/pages/mcp_servers.rs
Normal file
@@ -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::<Toasts>();
|
||||||
|
|
||||||
|
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<Option<String>> = use_signal(|| None);
|
||||||
|
// Track which server is pending delete confirmation
|
||||||
|
let mut confirm_delete: Signal<Option<(String, String)>> = 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..." } } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ pub mod graph_explorer;
|
|||||||
pub mod graph_index;
|
pub mod graph_index;
|
||||||
pub mod impact_analysis;
|
pub mod impact_analysis;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
|
pub mod mcp_servers;
|
||||||
pub mod overview;
|
pub mod overview;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
@@ -27,6 +28,7 @@ pub use graph_explorer::GraphExplorerPage;
|
|||||||
pub use graph_index::GraphIndexPage;
|
pub use graph_index::GraphIndexPage;
|
||||||
pub use impact_analysis::ImpactAnalysisPage;
|
pub use impact_analysis::ImpactAnalysisPage;
|
||||||
pub use issues::IssuesPage;
|
pub use issues::IssuesPage;
|
||||||
|
pub use mcp_servers::McpServersPage;
|
||||||
pub use overview::OverviewPage;
|
pub use overview::OverviewPage;
|
||||||
pub use repositories::RepositoriesPage;
|
pub use repositories::RepositoriesPage;
|
||||||
pub use sbom::SbomPage;
|
pub use sbom::SbomPage;
|
||||||
|
|||||||
Reference in New Issue
Block a user