feat(m7.3): MCP tenant-scoped bearer tokens
CI / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (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 / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (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
LLM clients (Claude Desktop, Cursor, ChatGPT) can't run a Keycloak
OIDC flow, so the MCP server can't use JWTs for auth. This PR
introduces opaque static bearer tokens minted per-tenant via new
agent endpoints, validated by the MCP server, and used to route
incoming MCP requests to the caller's per-tenant database.
Until now, the MCP server connected to a single shared MongoDB DB
with no auth and no tenant awareness — every tool (list_findings,
list_sbom_packages, etc.) returned data across all tenants. After
M7.2 made the agent per-tenant, MCP was the lone cross-tenant data
leak. This closes it.
Design summary
- Token format: `mcpt_<43 url-safe random chars>` (48 chars total).
Opaque, never embeds tenant_id, never stored in plaintext.
- Storage: cross-tenant `<prefix>__admin.mcp_tokens` collection,
keyed by SHA-256 hash. Each row carries the tenant_id, name,
created_by, created_at, last_used_at, revoked flag.
- Agent endpoints (tenant-scoped via TenantCtx):
POST /api/v1/mcp-tokens → mint (returns raw token ONCE)
GET /api/v1/mcp-tokens → list (metadata + 12-char prefix,
never the hash)
DELETE /api/v1/mcp-tokens/id → soft revoke
- MCP middleware: extract `Authorization: Bearer mcpt_...`, sniff
the prefix, SHA-256 → lookup in admin DB → reject if missing or
revoked. Updates last_used_at fire-and-forget so it never blocks.
Sets `tokio::task_local!` TENANT_ID for the inner service call;
the rmcp tool handlers read it and resolve the per-tenant DB.
- task_local is scoped via TENANT_ID.scope(...) around next.run(req)
so the rmcp tool handlers downstream see the tenant_id without
modifying their (macro-generated) signatures.
Files
- compliance-core/src/models/mcp_token.rs (new) — McpToken +
McpTokenView (public projection without the hash).
- compliance-agent/src/database.rs — DatabasePool::admin_db() +
admin_db_name(): cross-tenant access for token storage.
- compliance-agent/src/api/handlers/mcp_tokens.rs (new) — three
endpoints. Token generation: 32 random bytes → URL-safe base64,
no padding. SHA-256 hex stored.
- compliance-mcp/src/database.rs — replaced single Database with
DatabasePool. Tenant-scoped Database constructed per request.
Same sanitization + 63-byte cap + hash fallback as the agent.
- compliance-mcp/src/auth.rs (new) — bearer middleware + task_local.
Includes a SHA-256 round-trip test against a known vector.
- compliance-mcp/src/main.rs — HTTP transport: bearer middleware
layered on /mcp (not /health, so orca's container probe still
works). stdio transport: falls back to STDIO_TENANT_ID env (defaults
to "dev") so local development still works; logged loudly as
not-for-production.
- compliance-mcp/src/server.rs — each of the 12 tool handlers
resolves the per-tenant DB via task_local before calling its tool
fn. Tool fns themselves are unchanged.
Token UX
- Generated by the dashboard (or curl + KC JWT) — user sees raw
token exactly once, copies it into their LLM client config.
- Dashboard UI for management is a follow-up; can use curl in the
meantime:
curl -X POST https://comp-dev.../api/v1/mcp-tokens \
-H "Authorization: Bearer $KC_JWT" \
-H "Content-Type: application/json" \
-d '{"name":"Claude Desktop"}'
Test plan
- cargo fmt --all clean
- cargo clippy --workspace --exclude compliance-dashboard
-- -D warnings clean
- cargo test -p compliance-core --lib — 7 pass
- cargo test -p compliance-agent --lib — 230 pass (+2 new for
token generation + sha256 stability)
- cargo test -p compliance-agent --test tenant_isolation — 6 pass
- cargo test -p compliance-mcp — 34 pass (+1 new sha256 vector)
What's deferred
- Dashboard UI for managing tokens (page + create modal + list/
revoke). Trivial once the API is live.
- Token expiry + per-tool scope (today every token grants access
to all 12 tools for its tenant).
- Lifting DatabasePool into compliance-core (duplicated for now
in compliance-mcp to keep this PR focused; lift if a third
consumer appears).
Production
- The `<prefix>__admin` DB needs to NOT collide with a tenant
DB. Sanitized tenant_id never starts with `_admin` for any
current tenant_id shape (UUIDs); flagged in the database.rs
docstring so tenant provisioning can reject `_admin*` ids
proactively.
- orca-infra MCP service block already has MONGODB_URI /
MONGODB_DATABASE — no new env needed. No KC creds since MCP
doesn't use Keycloak for its own auth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
compliance-core = { workspace = true, features = ["mongodb"] }
|
||||
compliance-core = { workspace = true, features = ["mongodb", "axum"] }
|
||||
rmcp = { version = "0.16", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -19,3 +19,6 @@ bson = { version = "2", features = ["chrono-0_4"] }
|
||||
schemars = "1.0"
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Bearer-token authentication for incoming MCP HTTP requests.
|
||||
//!
|
||||
//! LLM clients (Claude Desktop / Cursor / ChatGPT / etc.) can't run
|
||||
//! Keycloak OIDC, so the MCP server uses opaque static tokens minted
|
||||
//! per-tenant via the agent's `POST /api/v1/mcp-tokens` endpoint.
|
||||
//!
|
||||
//! Flow per request:
|
||||
//! 1. Extract `Authorization: Bearer <token>`. Missing → 401.
|
||||
//! 2. SHA-256 hash the token.
|
||||
//! 3. Look up the hash in `<prefix>__admin.mcp_tokens`. Missing or
|
||||
//! revoked → 401.
|
||||
//! 4. Fire-and-forget update of `last_used_at` so the dashboard can
|
||||
//! show staleness without blocking the handler.
|
||||
//! 5. Stash the tenant_id in [`TENANT_ID`] (a `tokio::task_local`) so
|
||||
//! the MCP tool handlers can read it without modifying rmcp's
|
||||
//! handler signatures.
|
||||
//!
|
||||
//! The `task_local` is scoped around the inner service call via
|
||||
//! [`bearer_auth`], so every handler invoked downstream sees the
|
||||
//! tenant_id without us having to thread it through the macro-
|
||||
//! generated tool router.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use mongodb::bson::doc;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::database::DatabasePool;
|
||||
|
||||
tokio::task_local! {
|
||||
/// Tenant id resolved from the bearer for this request. Set by
|
||||
/// [`bearer_auth`] before the inner service runs; read by the
|
||||
/// MCP tool handlers via [`current_tenant_id`].
|
||||
pub static TENANT_ID: String;
|
||||
}
|
||||
|
||||
/// Mongo collection name in `<prefix>__admin`.
|
||||
const COLLECTION: &str = "mcp_tokens";
|
||||
|
||||
/// Returns the tenant_id set by the auth middleware. `None` outside a
|
||||
/// request scope (e.g. unit tests that bypass the middleware).
|
||||
pub fn current_tenant_id() -> Option<String> {
|
||||
TENANT_ID.try_with(|s| s.clone()).ok()
|
||||
}
|
||||
|
||||
/// Axum middleware: validate bearer → set [`TENANT_ID`] → call inner.
|
||||
pub async fn bearer_auth(
|
||||
State(pool): State<DatabasePool>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let Some(token) = extract_bearer(&request) else {
|
||||
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
|
||||
};
|
||||
if !token.starts_with("mcpt_") {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid token format").into_response();
|
||||
}
|
||||
let token_hash = sha256_hex(&token);
|
||||
|
||||
let col = pool.admin_db().collection::<TokenLookup>(COLLECTION);
|
||||
let found = match col
|
||||
.find_one(doc! { "token_hash": &token_hash, "revoked": false })
|
||||
.await
|
||||
{
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid or revoked token").into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("MCP token lookup failed: {e}");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Token lookup error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Fire-and-forget last_used_at update — never block the handler.
|
||||
let col2 = pool.admin_db().collection::<TokenLookup>(COLLECTION);
|
||||
let hash_for_update = token_hash.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = col2
|
||||
.update_one(
|
||||
doc! { "token_hash": &hash_for_update },
|
||||
doc! { "$set": { "last_used_at": mongodb::bson::DateTime::now() } },
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let tenant_id = found.tenant_id;
|
||||
let inner = next.run(request);
|
||||
TENANT_ID.scope(tenant_id, inner).await
|
||||
}
|
||||
|
||||
/// Bare-bones projection — we don't need the whole `McpToken` here,
|
||||
/// just enough to route and confirm validity.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenLookup {
|
||||
tenant_id: String,
|
||||
}
|
||||
|
||||
fn extract_bearer(req: &Request<Body>) -> Option<String> {
|
||||
req.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(s.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sha256_known_value() {
|
||||
// python -c 'import hashlib; print(hashlib.sha256(b"mcpt_known").hexdigest())'
|
||||
assert_eq!(
|
||||
sha256_hex("mcpt_known"),
|
||||
"27cf6cf678a44244106863c1c031be8e57b84c2b3019d742f755f8e7afa75dfd"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,127 @@
|
||||
use mongodb::{Client, Collection};
|
||||
//! Per-tenant Mongo broker for the MCP server.
|
||||
//!
|
||||
//! Mirror of the agent's `compliance_agent::database::DatabasePool` —
|
||||
//! duplicated here rather than lifted into `compliance-core` to keep
|
||||
//! this PR focused. If a third consumer ever needs it, lift then.
|
||||
//!
|
||||
//! Bearer tokens (validated by the auth middleware) carry a tenant_id
|
||||
//! and the handler resolves the per-tenant database via
|
||||
//! [`DatabasePool::for_tenant_id`]. The admin database
|
||||
//! (`<db_prefix>__admin`) holds the cross-tenant `mcp_tokens`
|
||||
//! collection that the middleware queries on every request.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use mongodb::{bson::doc, Client, Collection};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use compliance_core::models::*;
|
||||
|
||||
/// 63-byte Mongo db-name cap; same invariant as the agent's pool.
|
||||
const MAX_DB_NAME_LEN: usize = 63;
|
||||
/// 16-byte SHA-256 truncation, hex-encoded → 32 chars.
|
||||
const HASH_HEX_LEN: usize = 32;
|
||||
const MAX_PREFIX_LEN: usize = MAX_DB_NAME_LEN - 1 - HASH_HEX_LEN;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DatabasePool {
|
||||
client: Client,
|
||||
db_prefix: String,
|
||||
/// Tenants we've handed out a [`Database`] for. The MCP server
|
||||
/// doesn't ensure indexes (the agent owns that side of the
|
||||
/// schema), so the marker exists only to satisfy the parallel
|
||||
/// shape — current code never reads it.
|
||||
#[allow(dead_code)]
|
||||
seen: Arc<DashMap<String, ()>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
#[error("db_prefix '{prefix}' is {len} chars; max is {max} so the hash-fallback DB name fits Mongo's 63-byte cap")]
|
||||
PrefixTooLong {
|
||||
prefix: String,
|
||||
len: usize,
|
||||
max: usize,
|
||||
},
|
||||
#[error(transparent)]
|
||||
Mongo(#[from] mongodb::error::Error),
|
||||
}
|
||||
|
||||
impl DatabasePool {
|
||||
pub async fn connect(uri: &str, db_prefix: &str) -> Result<Self, DbError> {
|
||||
if db_prefix.len() > MAX_PREFIX_LEN {
|
||||
return Err(DbError::PrefixTooLong {
|
||||
prefix: db_prefix.to_string(),
|
||||
len: db_prefix.len(),
|
||||
max: MAX_PREFIX_LEN,
|
||||
});
|
||||
}
|
||||
let client = Client::with_uri_str(uri).await?;
|
||||
client
|
||||
.database("admin")
|
||||
.run_command(doc! { "ping": 1 })
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"MCP MongoDB cluster reachable; per-tenant pool ready (db prefix '{db_prefix}')"
|
||||
);
|
||||
Ok(Self {
|
||||
client,
|
||||
db_prefix: db_prefix.to_string(),
|
||||
seen: Arc::new(DashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read-only handle to the tenant's database. No indexes are
|
||||
/// ensured here — the agent owns writes, MCP only reads.
|
||||
pub fn for_tenant_id(&self, tenant_id: &str) -> Database {
|
||||
let db_name = self.tenant_db_name(tenant_id);
|
||||
self.seen.insert(tenant_id.to_string(), ());
|
||||
Database::new(self.client.database(&db_name))
|
||||
}
|
||||
|
||||
/// Cross-tenant admin DB — holds the `mcp_tokens` collection that
|
||||
/// the auth middleware queries to map bearer → tenant_id.
|
||||
pub fn admin_db(&self) -> mongodb::Database {
|
||||
self.client.database(&format!("{}__admin", self.db_prefix))
|
||||
}
|
||||
|
||||
pub fn tenant_db_name(&self, tenant_id: &str) -> String {
|
||||
let sanitized = sanitize_tenant_id(tenant_id);
|
||||
let natural = format!("{}_{}", self.db_prefix, sanitized);
|
||||
if natural.len() <= MAX_DB_NAME_LEN {
|
||||
natural
|
||||
} else {
|
||||
let mut h = Sha256::new();
|
||||
h.update(tenant_id.as_bytes());
|
||||
let digest = h.finalize();
|
||||
let suffix = hex::encode(&digest[..HASH_HEX_LEN / 2]);
|
||||
format!("{}_{}", self.db_prefix, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_tenant_id(tenant_id: &str) -> String {
|
||||
tenant_id
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | '.' | '"' | '$' | ' ' | '\0' => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Typed accessors for the MCP-readable collections in a tenant DB.
|
||||
/// Matches the agent's `Database` shape but only exposes what the MCP
|
||||
/// tool handlers actually need.
|
||||
#[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(crate) fn new(inner: mongodb::Database) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub fn findings(&self) -> Collection<Finding> {
|
||||
|
||||
+35
-10
@@ -1,10 +1,11 @@
|
||||
mod auth;
|
||||
mod database;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use database::Database;
|
||||
use database::DatabasePool;
|
||||
use rmcp::transport::{
|
||||
streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig,
|
||||
StreamableHttpService,
|
||||
@@ -24,36 +25,60 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let mongo_uri =
|
||||
std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".to_string());
|
||||
let db_name =
|
||||
// MONGODB_DATABASE is reused as the per-tenant DB-name prefix —
|
||||
// same convention as the agent so `<prefix>__admin.mcp_tokens`
|
||||
// and `<prefix>_<tenant_id>` line up across services.
|
||||
let db_prefix =
|
||||
std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "compliance_scanner".to_string());
|
||||
|
||||
let db = Database::connect(&mongo_uri, &db_name).await?;
|
||||
let pool = DatabasePool::connect(&mongo_uri, &db_prefix).await?;
|
||||
|
||||
// If MCP_PORT is set, run as Streamable HTTP server; otherwise use stdio.
|
||||
// HTTP transport: bind a small axum router with bearer-auth in
|
||||
// front of the rmcp service. `/health` stays public for orca's
|
||||
// container probe.
|
||||
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 pool_for_factory = pool.clone();
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok(ComplianceMcpServer::new(db_clone.clone())),
|
||||
move || Ok(ComplianceMcpServer::new(pool_for_factory.clone())),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route("/health", axum::routing::get(|| async { "ok" }))
|
||||
.nest_service("/mcp", service);
|
||||
.nest_service(
|
||||
"/mcp",
|
||||
axum::Router::new().fallback_service(service).layer(
|
||||
axum::middleware::from_fn_with_state(pool.clone(), auth::bearer_auth),
|
||||
),
|
||||
);
|
||||
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 {
|
||||
// stdio transport — used when run as a local MCP server next
|
||||
// to the LLM client. There's no HTTP layer to do bearer auth,
|
||||
// so we synthesize a tenant_id from STDIO_TENANT_ID for local
|
||||
// development. NEVER use this in production.
|
||||
tracing::info!("Starting MCP server on stdio");
|
||||
let server = ComplianceMcpServer::new(db);
|
||||
let synth_tenant = std::env::var("STDIO_TENANT_ID").unwrap_or_else(|_| "dev".to_string());
|
||||
tracing::warn!(
|
||||
tenant_id = %synth_tenant,
|
||||
"stdio transport — using synthetic tenant id; DO NOT use in production"
|
||||
);
|
||||
let server = ComplianceMcpServer::new(pool);
|
||||
let transport = rmcp::transport::stdio();
|
||||
use rmcp::ServiceExt;
|
||||
let handle = server.serve(transport).await?;
|
||||
handle.waiting().await?;
|
||||
auth::TENANT_ID
|
||||
.scope(synth_tenant, async {
|
||||
let handle = server.serve(transport).await?;
|
||||
handle.waiting().await?;
|
||||
Ok::<_, Box<dyn std::error::Error>>(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,20 +2,37 @@ use rmcp::{
|
||||
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
||||
};
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::auth::current_tenant_id;
|
||||
use crate::database::{Database, DatabasePool};
|
||||
use crate::tools::{dast, findings, pentest, sbom};
|
||||
|
||||
pub struct ComplianceMcpServer {
|
||||
db: Database,
|
||||
pool: DatabasePool,
|
||||
#[allow(dead_code)]
|
||||
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl ComplianceMcpServer {
|
||||
/// Resolve the per-tenant `Database` from the bearer-set
|
||||
/// `task_local`. Every tool handler calls this; missing context
|
||||
/// surfaces as `internal_error` because it means the auth
|
||||
/// middleware was misconfigured (handler ran without scope).
|
||||
fn tenant_db(&self) -> Result<Database, rmcp::ErrorData> {
|
||||
let tenant_id = current_tenant_id().ok_or_else(|| {
|
||||
rmcp::ErrorData::internal_error(
|
||||
"no tenant context — bearer middleware not in chain".to_string(),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Ok(self.pool.for_tenant_id(&tenant_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl ComplianceMcpServer {
|
||||
pub fn new(db: Database) -> Self {
|
||||
pub fn new(pool: DatabasePool) -> Self {
|
||||
Self {
|
||||
db,
|
||||
pool,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
@@ -29,7 +46,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::ListFindingsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::list_findings(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
findings::list_findings(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a single finding by its ID")]
|
||||
@@ -37,7 +55,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::GetFindingParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::get_finding(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
findings::get_finding(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a summary of findings counts grouped by severity and status")]
|
||||
@@ -45,7 +64,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::FindingsSummaryParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::findings_summary(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
findings::findings_summary(&db, params).await
|
||||
}
|
||||
|
||||
// ── SBOM ──────────────────────────────────────────────
|
||||
@@ -57,7 +77,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<sbom::ListSbomPackagesParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
sbom::list_sbom_packages(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
sbom::list_sbom_packages(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -67,7 +88,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<sbom::SbomVulnReportParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
sbom::sbom_vuln_report(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
sbom::sbom_vuln_report(&db, params).await
|
||||
}
|
||||
|
||||
// ── DAST ──────────────────────────────────────────────
|
||||
@@ -79,7 +101,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<dast::ListDastFindingsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
dast::list_dast_findings(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
dast::list_dast_findings(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a summary of recent DAST scan runs and finding counts")]
|
||||
@@ -87,7 +110,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<dast::DastScanSummaryParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
dast::dast_scan_summary(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
dast::dast_scan_summary(&db, params).await
|
||||
}
|
||||
|
||||
// ── Pentest ─────────────────────────────────────────────
|
||||
@@ -99,7 +123,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<pentest::ListPentestSessionsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
pentest::list_pentest_sessions(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
pentest::list_pentest_sessions(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a single AI pentest session by its ID")]
|
||||
@@ -107,7 +132,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<pentest::GetPentestSessionParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
pentest::get_pentest_session(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
pentest::get_pentest_session(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -117,7 +143,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<pentest::GetAttackChainParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
pentest::get_attack_chain(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
pentest::get_attack_chain(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get chat messages from a pentest session")]
|
||||
@@ -125,7 +152,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<pentest::GetPentestMessagesParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
pentest::get_pentest_messages(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
pentest::get_pentest_messages(&db, params).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -135,7 +163,8 @@ impl ComplianceMcpServer {
|
||||
&self,
|
||||
Parameters(params): Parameters<pentest::PentestStatsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
pentest::pentest_stats(&self.db, params).await
|
||||
let db = self.tenant_db()?;
|
||||
pentest::pentest_stats(&db, params).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +178,7 @@ impl ServerHandler for ComplianceMcpServer {
|
||||
.build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
"Compliance Scanner MCP server. Query security findings, SBOM data, DAST results, and AI pentest sessions."
|
||||
"Compliance Scanner MCP server. Query security findings, SBOM data, DAST results, and AI pentest sessions for your tenant."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user