feat(mcp): centralize connection access policies
This commit is contained in:
56 files changed
+6393
-617
No files matched your search
@@ -7,7 +7,7 @@ use dbx_core::{
|
||||
connection::AppState,
|
||||
db::{redis_driver::RedisCommandResult, ColumnInfo, TableInfo},
|
||||
models::connection::{ConnectionConfig, DatabaseType},
|
||||
storage::Storage,
|
||||
storage::{McpGlobalPolicy, McpGlobalPolicyState, Storage},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -42,8 +42,34 @@ impl From<&ConnectionConfig> for ConnectionSummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_mcp_allow_writes() -> Option<bool> {
|
||||
match std::env::var("DBX_MCP_ALLOW_WRITES").ok()?.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Some(true),
|
||||
"0" | "false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_mcp_policy(state: McpGlobalPolicyState) -> McpGlobalPolicy {
|
||||
effective_mcp_policy_with_legacy_allow_writes(state, legacy_mcp_allow_writes())
|
||||
}
|
||||
|
||||
fn effective_mcp_policy_with_legacy_allow_writes(
|
||||
state: McpGlobalPolicyState,
|
||||
legacy_allow_writes: Option<bool>,
|
||||
) -> McpGlobalPolicy {
|
||||
let configured = state.configured;
|
||||
let mut policy = state.policy();
|
||||
if !configured && legacy_allow_writes == Some(false) {
|
||||
policy.read_only = true;
|
||||
}
|
||||
policy
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DbxBackend: Send + Sync {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String>;
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String>;
|
||||
async fn execute_agent_tool(
|
||||
&self,
|
||||
@@ -64,7 +90,8 @@ pub trait DbxBackend: Send + Sync {
|
||||
let _ = (connection, database, sql, max_rows, timeout_secs);
|
||||
Err("SQL queries are not supported by this backend.".to_string())
|
||||
}
|
||||
async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String>;
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String>;
|
||||
async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String>;
|
||||
async fn list_tables(
|
||||
&self,
|
||||
connection: &ConnectionConfig,
|
||||
@@ -202,7 +229,10 @@ impl WebBackend {
|
||||
let mut retried = false;
|
||||
loop {
|
||||
let cookie = self.auth.lock().await.session_cookie.clone();
|
||||
let mut request = self.client.request(method.clone(), format!("{}{}", self.base_url, path));
|
||||
let mut request = self
|
||||
.client
|
||||
.request(method.clone(), format!("{}{}", self.base_url, path))
|
||||
.header("x-dbx-mcp-request", "1");
|
||||
if let Some(cookie) = cookie {
|
||||
request = request.header(reqwest::header::COOKIE, format!("dbx_session={cookie}"));
|
||||
}
|
||||
@@ -250,6 +280,10 @@ impl LocalBackend {
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for LocalBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
self.state.storage.load_mcp_global_policy().await.map(effective_mcp_policy)
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
self.state.storage.load_connections().await
|
||||
}
|
||||
@@ -286,11 +320,18 @@ impl DbxBackend for LocalBackend {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
self.state.storage.save_connections(connections).await?;
|
||||
*self.state.configs.write().await =
|
||||
connections.iter().cloned().map(|config| (config.id.clone(), config)).collect();
|
||||
Ok(())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
let config = self.state.storage.add_connection_for_mcp(config).await?;
|
||||
self.state.configs.write().await.insert(config.id.clone(), config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
|
||||
let removed = self.state.storage.remove_connection_for_mcp(connection_id).await?;
|
||||
if removed {
|
||||
self.state.configs.write().await.remove(connection_id);
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
async fn list_tables(
|
||||
@@ -571,6 +612,15 @@ impl DbxBackend for LocalBackend {
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for WebBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
self.request(reqwest::Method::GET, "/api/app-settings/mcp-policy", None)
|
||||
.await?
|
||||
.json::<McpGlobalPolicyState>()
|
||||
.await
|
||||
.map(effective_mcp_policy)
|
||||
.map_err(|error| format!("Invalid MCP policy response: {error}"))
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
self.request(reqwest::Method::GET, "/api/connection/list", None)
|
||||
.await?
|
||||
@@ -643,8 +693,24 @@ impl DbxBackend for WebBackend {
|
||||
.map_err(|error| format!("Invalid query response: {error}"))
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Err("Connection changes are unavailable in DBX Web mode.".to_string())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
self.request(reqwest::Method::POST, "/api/connection/mcp/add", Some(json!({ "config": config })))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid MCP connection response: {error}"))
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result<bool, String> {
|
||||
self.request(
|
||||
reqwest::Method::POST,
|
||||
"/api/connection/mcp/remove",
|
||||
Some(json!({ "connectionId": connection_id })),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid MCP connection response: {error}"))
|
||||
}
|
||||
|
||||
async fn list_tables(
|
||||
@@ -1324,6 +1390,18 @@ pub fn new_connection_config(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn policy_state(configured: bool, read_only: bool) -> McpGlobalPolicyState {
|
||||
McpGlobalPolicyState { configured, read_only, allow_dangerous_sql: false, allowed_connection_ids: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_only_only_restricts_an_unconfigured_policy() {
|
||||
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(false, false), Some(false)).read_only);
|
||||
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(false, false), Some(true)).read_only);
|
||||
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(true, false), Some(false)).read_only);
|
||||
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, true), Some(true)).read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_database_type_using_dbx_protocol_names() {
|
||||
assert_eq!(parse_database_type("Postgres").unwrap(), DatabaseType::Postgres);
|
||||
|
||||
+446
-122
@@ -14,8 +14,14 @@ use crate::mongo::{self, MongoCommand, MongoSafetyError};
|
||||
use dbx_core::{
|
||||
db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety},
|
||||
models::connection::DatabaseType,
|
||||
production_safety::{is_production_database, targets_production_database},
|
||||
sql_risk::{classify_sql_risk_for_database, SqlRisk},
|
||||
production_safety::{
|
||||
is_production_database, mongo_pipeline_targets_production_database, targets_production_database,
|
||||
},
|
||||
query_execution_sql::is_write_sql_for_database,
|
||||
sql_risk::{
|
||||
classify_sql_risk_for_database, is_dangerous_sql_for_database, mcp_sql_has_forbidden_database_switch, SqlRisk,
|
||||
},
|
||||
storage::McpGlobalPolicy,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -131,27 +137,44 @@ pub struct DbxMcpServer {
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct McpScope {
|
||||
pub connection_id: Option<String>,
|
||||
pub connection_ids: Vec<String>,
|
||||
pub connection_name: Option<String>,
|
||||
pub database: Option<String>,
|
||||
}
|
||||
|
||||
struct ResolvedConnection {
|
||||
connection: dbx_core::models::connection::ConnectionConfig,
|
||||
policy: McpGlobalPolicy,
|
||||
}
|
||||
|
||||
impl McpScope {
|
||||
pub fn from_env() -> Self {
|
||||
let mut connection_ids = scoped_connection_ids(std::env::var("DBX_MCP_SCOPE_CONNECTION_IDS").ok().as_deref());
|
||||
if connection_ids.is_empty() {
|
||||
if let Some(connection_id) = non_empty_env("DBX_MCP_SCOPE_CONNECTION_ID") {
|
||||
connection_ids.push(connection_id);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
connection_id: non_empty_env("DBX_MCP_SCOPE_CONNECTION_ID"),
|
||||
connection_ids,
|
||||
connection_name: non_empty_env("DBX_MCP_SCOPE_CONNECTION_NAME"),
|
||||
database: non_empty_env("DBX_MCP_SCOPE_DATABASE"),
|
||||
}
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
self.connection_id.is_some() || self.connection_name.is_some()
|
||||
self.connection_scope_enabled() || self.database.is_some()
|
||||
}
|
||||
|
||||
fn connection_scope_enabled(&self) -> bool {
|
||||
!self.connection_ids.is_empty() || self.connection_name.is_some()
|
||||
}
|
||||
|
||||
fn matches(&self, connection: &dbx_core::models::connection::ConnectionConfig) -> bool {
|
||||
self.connection_id.as_deref() == Some(connection.id.as_str())
|
||||
|| self.connection_name.as_deref() == Some(connection.name.as_str())
|
||||
if !self.connection_ids.is_empty() {
|
||||
return self.connection_ids.iter().any(|id| id == &connection.id);
|
||||
}
|
||||
self.connection_name.as_deref() == Some(connection.name.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,18 +214,21 @@ impl DbxMcpServer {
|
||||
let rows = connections.iter().map(ConnectionSummary::from).collect::<Vec<_>>();
|
||||
text(format_connections(&rows))
|
||||
}
|
||||
Err(error) => tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
Err(error) => backend_tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_list_tables", description = "List tables and views for a database connection")]
|
||||
async fn list_tables(&self, Parameters(request): Parameters<ListTablesRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self.backend.list_tables(&connection, &database, &request.schema.unwrap_or_default()).await {
|
||||
let database = match self.resolve_database(request.database, &resolved.connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
match self.backend.list_tables(&resolved.connection, &database, &request.schema.unwrap_or_default()).await {
|
||||
Ok(tables) if tables.is_empty() => text("No tables found."),
|
||||
Ok(tables) => text(
|
||||
tables
|
||||
@@ -224,14 +250,17 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool(name = "dbx_describe_table", description = "Get column definitions for a table")]
|
||||
async fn describe_table(&self, Parameters(request): Parameters<DescribeTableRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = match self.resolve_database(request.database, &resolved.connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self
|
||||
.backend
|
||||
.get_columns(&connection, &database, &request.schema.unwrap_or_default(), &request.table)
|
||||
.get_columns(&resolved.connection, &database, &request.schema.unwrap_or_default(), &request.table)
|
||||
.await
|
||||
{
|
||||
Ok(columns) if columns.is_empty() => text("No columns found."),
|
||||
@@ -245,35 +274,43 @@ impl DbxMcpServer {
|
||||
description = "Execute a SQL query on a database connection (max 100 rows returned)"
|
||||
)]
|
||||
async fn execute_query(&self, Parameters(request): Parameters<ExecuteQueryRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
if connection.db_type == dbx_core::models::connection::DatabaseType::Redis {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_REQUIRED",
|
||||
"Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command.",
|
||||
);
|
||||
}
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
let command = match validate_mongo_command(&connection, &database, &request.sql) {
|
||||
let command = match validate_mongo_command(connection, &resolved.policy, &database, &request.sql) {
|
||||
Ok(command) => command,
|
||||
Err(error) => return error,
|
||||
};
|
||||
return match self.backend.execute_mongo_command(&connection, &database, &command).await {
|
||||
return match self.backend.execute_mongo_command(connection, &database, &command).await {
|
||||
Ok(result) => text(format_query_result(&result, 100)),
|
||||
Err(error) => tool_error("QUERY_ERROR", error),
|
||||
Err(error) => backend_tool_error("QUERY_ERROR", error),
|
||||
};
|
||||
}
|
||||
let permissions = match validate_sql_policy(connection, &resolved.policy, &database, &request.sql) {
|
||||
Ok(permissions) => permissions,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let result = self
|
||||
.backend
|
||||
.execute_agent_tool(
|
||||
&connection,
|
||||
connection,
|
||||
&database,
|
||||
"execute_query",
|
||||
json!({ "sql": request.sql, "limit": 100 }),
|
||||
default_permissions(),
|
||||
permissions,
|
||||
)
|
||||
.await;
|
||||
agent_result(result)
|
||||
@@ -284,10 +321,11 @@ impl DbxMcpServer {
|
||||
&self,
|
||||
Parameters(request): Parameters<ExecuteRedisCommandRequest>,
|
||||
) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
if connection.db_type != DatabaseType::Redis {
|
||||
return tool_error("INVALID_CONNECTION_TYPE", format!("Connection \"{}\" is not Redis.", connection.name));
|
||||
}
|
||||
@@ -296,12 +334,21 @@ impl DbxMcpServer {
|
||||
Err(error) => return tool_error("REDIS_COMMAND_BLOCKED", error),
|
||||
};
|
||||
let safety = classify_command(&argv[0]);
|
||||
let permissions = default_permissions();
|
||||
let permissions = mcp_permissions(connection, &resolved.policy);
|
||||
if safety != RedisCommandSafety::Allowed && resolved.policy.read_only {
|
||||
return tool_error("MCP_READ_ONLY", "DBX global MCP read-only mode is enabled. Redis command blocked.");
|
||||
}
|
||||
if safety != RedisCommandSafety::Allowed && connection.read_only {
|
||||
return tool_error(
|
||||
"CONNECTION_READ_ONLY",
|
||||
format!("Connection \"{}\" has read-only protection enabled. Redis command blocked.", connection.name),
|
||||
);
|
||||
}
|
||||
if safety == RedisCommandSafety::Blocked && !permissions.allow_dangerous {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_BLOCKED",
|
||||
format!(
|
||||
"Dangerous Redis command \"{}\" is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
|
||||
"Dangerous Redis command \"{}\" is disabled in DBX MCP settings.",
|
||||
argv[0].to_ascii_uppercase()
|
||||
),
|
||||
);
|
||||
@@ -309,16 +356,15 @@ impl DbxMcpServer {
|
||||
if safety != RedisCommandSafety::Allowed && !permissions.allow_writes {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_BLOCKED",
|
||||
"MCP Redis command execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write or dangerous commands.",
|
||||
"MCP Redis command execution is read-only in DBX MCP settings.",
|
||||
);
|
||||
}
|
||||
let database = request
|
||||
.db
|
||||
.or_else(|| self.scope.database.as_deref().and_then(parse_redis_database))
|
||||
.or_else(|| redis_database(&connection))
|
||||
.unwrap_or(0);
|
||||
let database = match self.resolve_redis_database(request.db, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
// Production protection is stricter than the opt-in write flags by design.
|
||||
if safety != RedisCommandSafety::Allowed && is_production_database(&connection, &database.to_string()) {
|
||||
if safety != RedisCommandSafety::Allowed && is_production_database(connection, &database.to_string()) {
|
||||
return tool_error(
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"MCP cannot execute write or dangerous Redis commands against a production database.",
|
||||
@@ -327,7 +373,7 @@ impl DbxMcpServer {
|
||||
match self
|
||||
.backend
|
||||
.execute_redis_command(
|
||||
&connection,
|
||||
connection,
|
||||
database,
|
||||
&request.command,
|
||||
safety == RedisCommandSafety::Blocked && permissions.allow_dangerous,
|
||||
@@ -335,20 +381,24 @@ impl DbxMcpServer {
|
||||
.await
|
||||
{
|
||||
Ok(result) => text(format_redis_result(&result)),
|
||||
Err(error) => tool_error("REDIS_COMMAND_ERROR", error),
|
||||
Err(error) => backend_tool_error("REDIS_COMMAND_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_get_schema_context", description = "Get compact table and column context for writing SQL")]
|
||||
async fn get_schema_context(&self, Parameters(request): Parameters<SchemaContextRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let schema = request.schema.unwrap_or_default();
|
||||
let max_tables = request.max_tables.unwrap_or(8).clamp(1, 20);
|
||||
let available = match self.backend.list_tables(&connection, &database, &schema).await {
|
||||
let available = match self.backend.list_tables(connection, &database, &schema).await {
|
||||
Ok(tables) => tables,
|
||||
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
|
||||
};
|
||||
@@ -371,7 +421,7 @@ impl DbxMcpServer {
|
||||
let mut tables = Vec::with_capacity(selected.len());
|
||||
for table in selected {
|
||||
// Keep metadata calls sequential because some embedded drivers expose a single physical connection.
|
||||
let columns = match self.backend.get_columns(&connection, &database, &schema, &table.name).await {
|
||||
let columns = match self.backend.get_columns(connection, &database, &schema, &table.name).await {
|
||||
Ok(columns) => columns,
|
||||
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
|
||||
};
|
||||
@@ -382,7 +432,17 @@ impl DbxMcpServer {
|
||||
|
||||
#[tool(name = "dbx_add_connection", description = "Add a new database connection to DBX")]
|
||||
async fn add_connection(&self, Parameters(request): Parameters<AddConnectionRequest>) -> CallToolResult {
|
||||
let mut connections = match self.backend.load_connections().await {
|
||||
let policy = match self.load_policy().await {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if policy.read_only {
|
||||
return tool_error(
|
||||
"MCP_READ_ONLY",
|
||||
"DBX global MCP read-only mode is enabled. Connection management is not allowed.",
|
||||
);
|
||||
}
|
||||
let connections = match self.backend.load_connections().await {
|
||||
Ok(connections) => connections,
|
||||
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
};
|
||||
@@ -412,16 +472,25 @@ impl DbxMcpServer {
|
||||
Ok(config) => config,
|
||||
Err(error) => return tool_error("INVALID_CONNECTION", error),
|
||||
};
|
||||
connections.push(config.clone());
|
||||
if let Err(error) = self.backend.save_connections(&connections).await {
|
||||
return tool_error("CONNECTION_SAVE_ERROR", error);
|
||||
match self.backend.add_connection_for_mcp(config).await {
|
||||
Ok(config) => text(format!("Connection \"{}\" added (id: {}).", config.name, config.id)),
|
||||
Err(error) => backend_tool_error("CONNECTION_SAVE_ERROR", error),
|
||||
}
|
||||
text(format!("Connection \"{}\" added (id: {}).", config.name, config.id))
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")]
|
||||
async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult {
|
||||
let mut connections = match self.backend.load_connections().await {
|
||||
let policy = match self.load_policy().await {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if policy.read_only {
|
||||
return tool_error(
|
||||
"MCP_READ_ONLY",
|
||||
"DBX global MCP read-only mode is enabled. Connection management is not allowed.",
|
||||
);
|
||||
}
|
||||
let connections = match self.backend.load_connections().await {
|
||||
Ok(connections) => connections,
|
||||
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
};
|
||||
@@ -444,20 +513,24 @@ impl DbxMcpServer {
|
||||
format!("Connection \"{}\" not found.", request.connection_name),
|
||||
);
|
||||
};
|
||||
connections.retain(|connection| connection.id != target.id);
|
||||
if let Err(error) = self.backend.save_connections(&connections).await {
|
||||
return tool_error("CONNECTION_SAVE_ERROR", error);
|
||||
match self.backend.remove_connection_for_mcp(&target.id).await {
|
||||
Ok(true) => text(format!("Connection \"{}\" (id: {}) removed.", target.name, target.id)),
|
||||
Ok(false) => tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{}\" not found.", target.name)),
|
||||
Err(error) => backend_tool_error("CONNECTION_SAVE_ERROR", error),
|
||||
}
|
||||
text(format!("Connection \"{}\" (id: {}) removed.", target.name, target.id))
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_open_table", description = "Open a table in DBX desktop app. Requires DBX to be running.")]
|
||||
async fn open_table(&self, Parameters(request): Parameters<OpenTableRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self
|
||||
.backend
|
||||
.bridge_request(
|
||||
@@ -473,7 +546,7 @@ impl DbxMcpServer {
|
||||
.await
|
||||
{
|
||||
Ok(()) => text(format!("Opened {} in DBX", request.table)),
|
||||
Err(error) => tool_error("DBX_NOT_RUNNING", error),
|
||||
Err(error) => backend_tool_error("DBX_NOT_RUNNING", error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,35 +555,29 @@ impl DbxMcpServer {
|
||||
description = "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running."
|
||||
)]
|
||||
async fn execute_and_show(&self, Parameters(request): Parameters<ExecuteAndShowRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
let resolved = match self.resolve_connection(&request.selector).await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let connection = &resolved.connection;
|
||||
if connection.db_type == DatabaseType::Redis {
|
||||
return tool_error("REDIS_COMMAND_REQUIRED", "Use dbx_execute_redis_command for Redis connections.");
|
||||
}
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let permissions = default_permissions();
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
if let Err(error) = validate_mongo_command(&connection, &database, &request.sql) {
|
||||
return error;
|
||||
}
|
||||
let database = match self.resolve_database(request.database, connection) {
|
||||
Ok(database) => database,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let permissions = if connection.db_type == DatabaseType::MongoDb {
|
||||
mcp_permissions(connection, &resolved.policy)
|
||||
} else {
|
||||
let risk = match classify_sql_risk_for_database(&request.sql, connection.db_type) {
|
||||
Ok(risk) => risk,
|
||||
Err(error) => return tool_error("SQL_BLOCKED", error),
|
||||
};
|
||||
if risk != SqlRisk::ReadOnly && targets_production_database(&connection, &database, &request.sql) {
|
||||
return tool_error(
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"MCP cannot send writes against a production database to DBX.",
|
||||
);
|
||||
match validate_sql_policy(connection, &resolved.policy, &database, &request.sql) {
|
||||
Ok(permissions) => permissions,
|
||||
Err(error) => return error,
|
||||
}
|
||||
if risk == SqlRisk::Transaction || (risk == SqlRisk::Ddl && !permissions.allow_dangerous) {
|
||||
return tool_error("SQL_BLOCKED", format!("{} statement is blocked for this session.", risk));
|
||||
}
|
||||
if risk == SqlRisk::Write && !permissions.allow_writes {
|
||||
return tool_error("SQL_BLOCKED", "MCP SQL execution is read-only for this session.");
|
||||
};
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
if let Err(error) = validate_mongo_command(connection, &resolved.policy, &database, &request.sql) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
match self
|
||||
@@ -529,32 +596,77 @@ impl DbxMcpServer {
|
||||
.await
|
||||
{
|
||||
Ok(()) => text("Query sent to DBX"),
|
||||
Err(error) => tool_error("DBX_NOT_RUNNING", error),
|
||||
Err(error) => backend_tool_error("DBX_NOT_RUNNING", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DbxMcpServer {
|
||||
async fn load_scoped_connections(&self) -> Result<Vec<dbx_core::models::connection::ConnectionConfig>, String> {
|
||||
let policy = self.backend.load_mcp_global_policy().await?;
|
||||
let connections = self.backend.load_connections().await?;
|
||||
if !self.scope.enabled() {
|
||||
return Ok(connections);
|
||||
}
|
||||
Ok(connections.into_iter().filter(|connection| self.scope.matches(connection)).collect())
|
||||
Ok(connections
|
||||
.into_iter()
|
||||
.filter(|connection| policy_allows_connection(&policy, connection))
|
||||
.filter(|connection| !self.scope.connection_scope_enabled() || self.scope.matches(connection))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_policy(&self) -> Result<McpGlobalPolicy, CallToolResult> {
|
||||
self.backend.load_mcp_global_policy().await.map_err(|error| backend_tool_error("MCP_POLICY_UNAVAILABLE", error))
|
||||
}
|
||||
|
||||
// CallToolResult is the rmcp wire response type; keeping it unboxed avoids conversions at every tool boundary.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn resolve_database(
|
||||
&self,
|
||||
requested: Option<String>,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> String {
|
||||
requested.or_else(|| self.scope.database.clone()).or_else(|| connection.database.clone()).unwrap_or_default()
|
||||
) -> Result<String, CallToolResult> {
|
||||
let requested = requested.map(|database| database.trim().to_string()).filter(|database| !database.is_empty());
|
||||
if let Some(scoped) = self.scope.database.as_deref() {
|
||||
if let Some(requested) = requested.as_deref() {
|
||||
if requested != scoped {
|
||||
return Err(tool_error(
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
format!("Database \"{requested}\" is outside the scoped database \"{scoped}\"."),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(scoped.to_string());
|
||||
}
|
||||
Ok(requested.or_else(|| connection.database.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn resolve_connection(
|
||||
// CallToolResult is the rmcp wire response type; keeping it unboxed avoids conversions at every tool boundary.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn resolve_redis_database(
|
||||
&self,
|
||||
selector: &ConnectionSelector,
|
||||
) -> Result<dbx_core::models::connection::ConnectionConfig, CallToolResult> {
|
||||
requested: Option<u32>,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> Result<u32, CallToolResult> {
|
||||
if let Some(scoped) = self.scope.database.as_deref() {
|
||||
let scoped_database = parse_redis_database(scoped).ok_or_else(|| {
|
||||
tool_error(
|
||||
"INVALID_DATABASE_SCOPE",
|
||||
format!("Redis database scope \"{scoped}\" must be a non-negative integer."),
|
||||
)
|
||||
})?;
|
||||
if let Some(requested) = requested {
|
||||
if requested != scoped_database {
|
||||
return Err(tool_error(
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
format!("Redis database {requested} is outside the scoped database {scoped_database}."),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(scoped_database);
|
||||
}
|
||||
Ok(requested.or_else(|| redis_database(connection)).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn resolve_connection(&self, selector: &ConnectionSelector) -> Result<ResolvedConnection, CallToolResult> {
|
||||
let policy = self.load_policy().await?;
|
||||
let connections =
|
||||
self.backend.load_connections().await.map_err(|error| tool_error("CONNECTION_LOAD_ERROR", error))?;
|
||||
if let Some(id) = selector.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
@@ -562,15 +674,21 @@ impl DbxMcpServer {
|
||||
.into_iter()
|
||||
.find(|connection| connection.id == id)
|
||||
.ok_or_else(|| tool_error("CONNECTION_NOT_FOUND", format!("Connection with id \"{id}\" not found.")))?;
|
||||
if self.scope.enabled() && !self.scope.matches(&connection) {
|
||||
if self.scope.connection_scope_enabled() && !self.scope.matches(&connection) {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{id}\" is outside this DBX AI session scope."),
|
||||
));
|
||||
}
|
||||
return Ok(connection);
|
||||
if !policy_allows_connection(&policy, &connection) {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{id}\" is not allowed by DBX MCP settings."),
|
||||
));
|
||||
}
|
||||
return Ok(ResolvedConnection { connection, policy });
|
||||
}
|
||||
if self.scope.enabled() {
|
||||
if self.scope.connection_scope_enabled() {
|
||||
let connection = connections
|
||||
.into_iter()
|
||||
.find(|connection| self.scope.matches(connection))
|
||||
@@ -583,17 +701,34 @@ impl DbxMcpServer {
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(connection);
|
||||
if !policy_allows_connection(&policy, &connection) {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
"The DBX AI session scope is outside the global MCP connection allowlist.",
|
||||
));
|
||||
}
|
||||
return Ok(ResolvedConnection { connection, policy });
|
||||
}
|
||||
let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) else {
|
||||
return Err(tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."));
|
||||
};
|
||||
let matching =
|
||||
connections.into_iter().filter(|connection| connection.name.eq_ignore_ascii_case(name)).collect::<Vec<_>>();
|
||||
match matching.as_slice() {
|
||||
[] => Err(tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{name}\" not found."))),
|
||||
[connection] => Ok(connection.clone()),
|
||||
_ => Err(tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &matching))),
|
||||
let allowed = matching
|
||||
.iter()
|
||||
.filter(|connection| policy_allows_connection(&policy, connection))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
match allowed.as_slice() {
|
||||
[] if matching.is_empty() => {
|
||||
Err(tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{name}\" not found.")))
|
||||
}
|
||||
[] => Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{name}\" is not allowed by DBX MCP settings."),
|
||||
)),
|
||||
[connection] => Ok(ResolvedConnection { connection: connection.clone(), policy }),
|
||||
_ => Err(tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &allowed))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -615,25 +750,93 @@ fn tool_error(code: &str, message: impl Into<String>) -> CallToolResult {
|
||||
CallToolResult::error(vec![ContentBlock::text(format!("Error [{code}]: {}", message.into()))])
|
||||
}
|
||||
|
||||
fn backend_tool_error(default_code: &str, error: impl Into<String>) -> CallToolResult {
|
||||
let error = error.into();
|
||||
for code in [
|
||||
"MCP_POLICY_UNAVAILABLE",
|
||||
"MCP_READ_ONLY",
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
"DATABASE_OUT_OF_SCOPE",
|
||||
"INVALID_DATABASE_SCOPE",
|
||||
"CONNECTION_READ_ONLY",
|
||||
"PRODUCTION_DATABASE_READ_ONLY",
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"SQL_BLOCKED",
|
||||
] {
|
||||
let marker = format!("{code}:");
|
||||
if let Some(index) = error.find(&marker) {
|
||||
return tool_error(code, error[index + marker.len()..].trim());
|
||||
}
|
||||
}
|
||||
tool_error(default_code, error)
|
||||
}
|
||||
|
||||
fn agent_result(result: dbx_core::agent_events::ToolResult) -> CallToolResult {
|
||||
if result.is_error {
|
||||
tool_error("DBX_TOOL_ERROR", result.content.trim_start_matches("Error: "))
|
||||
backend_tool_error("DBX_TOOL_ERROR", result.content.trim_start_matches("Error: "))
|
||||
} else {
|
||||
text(result.content)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_permissions() -> dbx_core::agent_tools::AgentSqlPermissions {
|
||||
fn policy_allows_connection(
|
||||
policy: &McpGlobalPolicy,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> bool {
|
||||
policy.allowed_connection_ids.as_ref().is_none_or(|allowed| allowed.iter().any(|id| id == &connection.id))
|
||||
}
|
||||
|
||||
fn mcp_permissions(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
) -> dbx_core::agent_tools::AgentSqlPermissions {
|
||||
dbx_core::agent_tools::AgentSqlPermissions {
|
||||
allow_writes: boolean_env("DBX_MCP_ALLOW_WRITES").unwrap_or(true),
|
||||
allow_dangerous: boolean_env("DBX_MCP_ALLOW_DANGEROUS_SQL").unwrap_or(false),
|
||||
allow_writes: !policy.read_only && !connection.read_only,
|
||||
allow_dangerous: !policy.read_only && !connection.read_only && policy.allow_dangerous_sql,
|
||||
}
|
||||
}
|
||||
|
||||
// CallToolResult is the transport-native error payload; boxing it would complicate every MCP call site.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn validate_sql_policy(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
) -> Result<dbx_core::agent_tools::AgentSqlPermissions, CallToolResult> {
|
||||
if mcp_sql_has_forbidden_database_switch(sql, connection.db_type) {
|
||||
return Err(tool_error("SQL_BLOCKED", "MCP does not allow USE or persistent database switching."));
|
||||
}
|
||||
let risk =
|
||||
classify_sql_risk_for_database(sql, connection.db_type).map_err(|error| tool_error("SQL_BLOCKED", error))?;
|
||||
if risk == SqlRisk::Transaction {
|
||||
return Err(tool_error("SQL_BLOCKED", "Transaction statements are not supported by MCP."));
|
||||
}
|
||||
let is_write = is_write_sql_for_database(sql, connection.db_type);
|
||||
if policy.read_only && is_write {
|
||||
return Err(tool_error("MCP_READ_ONLY", "DBX global MCP read-only mode is enabled. SQL write blocked."));
|
||||
}
|
||||
if connection.read_only && is_write {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_READ_ONLY",
|
||||
format!("Connection \"{}\" has read-only protection enabled. SQL write blocked.", connection.name),
|
||||
));
|
||||
}
|
||||
let high_risk = risk == SqlRisk::Ddl || is_dangerous_sql_for_database(sql, connection.db_type);
|
||||
if high_risk && !policy.allow_dangerous_sql {
|
||||
return Err(tool_error("SQL_BLOCKED", "High-risk SQL is disabled in DBX MCP settings."));
|
||||
}
|
||||
if is_write && targets_production_database(connection, database, sql) {
|
||||
return Err(tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database."));
|
||||
}
|
||||
Ok(mcp_permissions(connection, policy))
|
||||
}
|
||||
|
||||
// CallToolResult is the transport-native error payload; boxing it would complicate every MCP call site.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn validate_mongo_command(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
policy: &McpGlobalPolicy,
|
||||
database: &str,
|
||||
source: &str,
|
||||
) -> Result<MongoCommand, CallToolResult> {
|
||||
@@ -645,25 +848,28 @@ fn validate_mongo_command(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let permissions = default_permissions();
|
||||
if let Err(error) = mongo::validate_safety(
|
||||
&command,
|
||||
permissions.allow_writes,
|
||||
permissions.allow_dangerous,
|
||||
is_production_database(connection, database),
|
||||
) {
|
||||
let permissions = mcp_permissions(connection, policy);
|
||||
let production_database = match &command {
|
||||
MongoCommand::Aggregate { pipeline, .. } => {
|
||||
mongo_pipeline_targets_production_database(connection, database, pipeline)
|
||||
}
|
||||
_ => is_production_database(connection, database),
|
||||
};
|
||||
if let Err(error) =
|
||||
mongo::validate_safety(&command, permissions.allow_writes, permissions.allow_dangerous, production_database)
|
||||
{
|
||||
return Err(match error {
|
||||
MongoSafetyError::WritesDisabled => tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
|
||||
if policy.read_only { "MCP_READ_ONLY" } else { "CONNECTION_READ_ONLY" },
|
||||
"MCP MongoDB execution is read-only in DBX MCP settings.",
|
||||
),
|
||||
MongoSafetyError::EmptyFilter => tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
"MongoDB update/delete commands must include a non-empty filter unless high-risk operations are enabled in DBX MCP settings.",
|
||||
),
|
||||
MongoSafetyError::Dangerous => tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
|
||||
"Dangerous MongoDB command is disabled in DBX MCP settings.",
|
||||
),
|
||||
MongoSafetyError::ProductionWrite => {
|
||||
tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database.")
|
||||
@@ -673,18 +879,20 @@ fn validate_mongo_command(
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn boolean_env(name: &str) -> Option<bool> {
|
||||
match std::env::var(name).ok()?.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Some(true),
|
||||
"0" | "false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn scoped_connection_ids(value: Option<&str>) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
for id in value.unwrap_or_default().split(',').map(str::trim).filter(|id| !id.is_empty()) {
|
||||
if !ids.iter().any(|existing| existing == id) {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn default_port(db_type: &str) -> Option<u16> {
|
||||
match db_type.trim().to_ascii_lowercase().as_str() {
|
||||
"mysql" | "doris" | "starrocks" | "manticoresearch" => Some(3306),
|
||||
@@ -822,8 +1030,31 @@ mod tests {
|
||||
connections: Vec<ConnectionConfig>,
|
||||
}
|
||||
|
||||
fn connection(id: &str, name: &str, db_type: &str, database: &str) -> ConnectionConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"db_type": db_type,
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"database": database,
|
||||
"ssl": false
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn result_text(result: &CallToolResult) -> &str {
|
||||
result.content[0].as_text().expect("text tool result").text.as_str()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for FakeBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(McpGlobalPolicy::default())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(self.connections.clone())
|
||||
}
|
||||
@@ -845,8 +1076,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Ok(())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,7 +1125,7 @@ mod tests {
|
||||
fn scoped_server_hides_mutating_and_desktop_tools() {
|
||||
let server = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: Vec::new() }),
|
||||
McpScope { connection_id: Some("scoped".to_string()), ..Default::default() },
|
||||
McpScope { connection_ids: vec!["scoped".to_string()], ..Default::default() },
|
||||
false,
|
||||
);
|
||||
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
|
||||
@@ -900,4 +1135,93 @@ mod tests {
|
||||
assert!(!names.iter().any(|name| name == "dbx_open_table"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_connection_ids_are_deduplicated_and_take_precedence_over_name() {
|
||||
assert_eq!(scoped_connection_ids(Some(" first, second,first ,, ")), vec!["first", "second"]);
|
||||
|
||||
let first = connection("first", "other", "sqlite", ":memory:");
|
||||
let named = ConnectionConfig { id: "named".to_string(), name: "scope-name".to_string(), ..first.clone() };
|
||||
let scope = McpScope {
|
||||
connection_ids: vec!["first".to_string()],
|
||||
connection_name: Some("scope-name".to_string()),
|
||||
database: None,
|
||||
};
|
||||
|
||||
assert!(scope.matches(&first));
|
||||
assert!(!scope.matches(&named));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_scope_is_a_hard_bound_without_filtering_connections() {
|
||||
let scoped = connection("scoped", "scoped", "postgres", "configured");
|
||||
let server = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: vec![scoped.clone()] }),
|
||||
McpScope { database: Some("analytics".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(server.load_scoped_connections().await.unwrap().len(), 1);
|
||||
assert_eq!(server.resolve_database(None, &scoped).unwrap(), "analytics");
|
||||
assert_eq!(server.resolve_database(Some("analytics".to_string()), &scoped).unwrap(), "analytics");
|
||||
let error = server.resolve_database(Some("production".to_string()), &scoped).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
|
||||
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
|
||||
assert!(!names.iter().any(|name| name == "dbx_add_connection"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redis_database_scope_fails_closed_and_cannot_be_overridden() {
|
||||
let redis = connection("redis", "redis", "redis", "1");
|
||||
let scoped = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: vec![redis.clone()] }),
|
||||
McpScope { database: Some("2".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
assert_eq!(scoped.resolve_redis_database(None, &redis).unwrap(), 2);
|
||||
let error = scoped.resolve_redis_database(Some(3), &redis).unwrap_err();
|
||||
assert!(result_text(&error).contains("DATABASE_OUT_OF_SCOPE"));
|
||||
|
||||
let invalid = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: vec![redis.clone()] }),
|
||||
McpScope { database: Some("analytics".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
let error = invalid.resolve_redis_database(None, &redis).unwrap_err();
|
||||
assert!(result_text(&error).contains("INVALID_DATABASE_SCOPE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_mongo_aggregate_cannot_write_to_a_production_database() {
|
||||
let mut mongo = connection("mongo", "mongo", "mongodb", "staging");
|
||||
mongo.production_databases = vec!["production".to_string()];
|
||||
let policy = McpGlobalPolicy { read_only: false, allow_dangerous_sql: true, allowed_connection_ids: None };
|
||||
|
||||
let error = validate_mongo_command(
|
||||
&mongo,
|
||||
&policy,
|
||||
"staging",
|
||||
r#"db.items.aggregate([{"$out":{"db":"production","coll":"archive"}}])"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(result_text(&error).contains("PRODUCTION_WRITE_BLOCKED"));
|
||||
|
||||
assert!(
|
||||
validate_mongo_command(&mongo, &policy, "staging", r#"db.items.aggregate([{"$out":"archive"}])"#,).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_results_preserve_stable_backend_policy_errors() {
|
||||
let result = agent_result(dbx_core::agent_events::ToolResult {
|
||||
tool_call_id: "test".to_string(),
|
||||
tool_name: "execute_query".to_string(),
|
||||
content: "Error: API request failed: MCP_READ_ONLY: policy changed".to_string(),
|
||||
is_error: true,
|
||||
explain_data: None,
|
||||
});
|
||||
assert!(result_text(&result).contains("Error [MCP_READ_ONLY]: policy changed"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
use std::{ffi::OsString, sync::Arc};
|
||||
|
||||
use dbx_core::{models::connection::ConnectionConfig, storage::Storage};
|
||||
use dbx_mcp::{DbxMcpServer, LocalBackend, McpScope};
|
||||
@@ -6,6 +6,29 @@ use rmcp::{model::CallToolRequestParams, ServiceExt};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
struct EnvVarGuard {
|
||||
name: &'static str,
|
||||
original: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(name: &'static str, value: &str) -> Self {
|
||||
let original = std::env::var_os(name);
|
||||
std::env::set_var(name, value);
|
||||
Self { name, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = self.original.take() {
|
||||
std::env::set_var(self.name, value);
|
||||
} else {
|
||||
std::env::remove_var(self.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_backend_reads_dbx_storage_without_desktop_process() {
|
||||
let directory = tempdir().expect("temporary data directory");
|
||||
@@ -42,6 +65,51 @@ async fn local_backend_reads_dbx_storage_without_desktop_process() {
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_read_only_config_applies_before_settings_are_opened() {
|
||||
let _allow_writes = EnvVarGuard::set("DBX_MCP_ALLOW_WRITES", "0");
|
||||
let directory = tempdir().expect("temporary data directory");
|
||||
let db_path = directory.path().join("dbx.db");
|
||||
let storage = Storage::open(&db_path).await.expect("open storage");
|
||||
assert!(!storage.load_mcp_global_policy().await.expect("load MCP policy").configured);
|
||||
let connection: ConnectionConfig = serde_json::from_value(json!({
|
||||
"id": "legacy-read-only",
|
||||
"name": "legacy-read-only",
|
||||
"db_type": "sqlite",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"database": directory.path().join("legacy.sqlite").to_string_lossy(),
|
||||
"ssl": false
|
||||
}))
|
||||
.expect("minimal connection config");
|
||||
storage.save_connections(&[connection]).await.expect("save connection");
|
||||
|
||||
let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend"));
|
||||
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize client");
|
||||
let arguments = json!({
|
||||
"connection_id": "legacy-read-only",
|
||||
"sql": "INSERT INTO items (name) VALUES ('blocked')",
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_else(Map::<String, Value>::new);
|
||||
let result = client
|
||||
.peer()
|
||||
.call_tool(CallToolRequestParams::new("dbx_execute_query").with_arguments(arguments))
|
||||
.await
|
||||
.expect("execute query");
|
||||
let text = result.content[0].as_text().expect("text result");
|
||||
assert_eq!(result.is_error, Some(true));
|
||||
assert!(text.text.contains("MCP_READ_ONLY"), "unexpected MCP response: {}", text.text);
|
||||
client.cancel().await.expect("close client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_MCP_TEST_MONGO_HOST and DBX_MCP_TEST_MONGO_PASSWORD"]
|
||||
async fn executes_mongo_shell_commands_without_desktop_process() {
|
||||
@@ -74,8 +142,6 @@ async fn executes_mongo_shell_commands_without_desktop_process() {
|
||||
let (server_transport, client_transport) = tokio::io::duplex(32 * 1024);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize client");
|
||||
let original_writes = std::env::var_os("DBX_MCP_ALLOW_WRITES");
|
||||
std::env::set_var("DBX_MCP_ALLOW_WRITES", "1");
|
||||
|
||||
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
|
||||
call_query(&client, "db.items.insert({_id: 'rust-mcp-e2e', name: 'Ada'})").await;
|
||||
@@ -83,10 +149,6 @@ async fn executes_mongo_shell_commands_without_desktop_process() {
|
||||
assert!(result.contains("Ada"), "unexpected MongoDB result: {result}");
|
||||
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
|
||||
|
||||
match original_writes {
|
||||
Some(value) => std::env::set_var("DBX_MCP_ALLOW_WRITES", value),
|
||||
None => std::env::remove_var("DBX_MCP_ALLOW_WRITES"),
|
||||
}
|
||||
client.cancel().await.expect("close client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dbx_core::{agent_events::ToolResult, agent_tools::AgentSqlPermissions, models::connection::ConnectionConfig};
|
||||
use dbx_core::{
|
||||
agent_events::ToolResult, agent_tools::AgentSqlPermissions, models::connection::ConnectionConfig,
|
||||
storage::McpGlobalPolicy,
|
||||
};
|
||||
use dbx_mcp::{DbxBackend, DbxMcpServer, McpScope};
|
||||
use rmcp::{model::CallToolRequestParams, ServiceExt};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
struct EmptyBackend;
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for EmptyBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(McpGlobalPolicy::default())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -31,9 +38,69 @@ impl DbxBackend for EmptyBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Ok(())
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct PolicyBackend {
|
||||
policy: McpGlobalPolicy,
|
||||
connections: Vec<ConnectionConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for PolicyBackend {
|
||||
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
|
||||
Ok(self.policy.clone())
|
||||
}
|
||||
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(self.connections.clone())
|
||||
}
|
||||
|
||||
async fn execute_agent_tool(
|
||||
&self,
|
||||
_connection: &ConnectionConfig,
|
||||
_database: &str,
|
||||
tool_name: &str,
|
||||
_arguments: Value,
|
||||
_permissions: AgentSqlPermissions,
|
||||
) -> ToolResult {
|
||||
ToolResult {
|
||||
tool_call_id: "policy-test".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: "query should have been blocked".to_string(),
|
||||
is_error: true,
|
||||
explain_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn test_connection(id: &str, name: &str) -> ConnectionConfig {
|
||||
serde_json::from_value(json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"db_type": "sqlite",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"database": ":memory:",
|
||||
"ssl": false
|
||||
}))
|
||||
.expect("test connection")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -57,3 +124,53 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enforces_global_connection_scope_and_read_only_policy() {
|
||||
let backend = PolicyBackend {
|
||||
policy: McpGlobalPolicy {
|
||||
read_only: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["allowed".to_string()]),
|
||||
},
|
||||
connections: vec![test_connection("allowed", "allowed-db"), test_connection("blocked", "blocked-db")],
|
||||
};
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(backend), McpScope::default(), false);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize MCP client");
|
||||
|
||||
let listed =
|
||||
client.peer().call_tool(CallToolRequestParams::new("dbx_list_connections")).await.expect("list connections");
|
||||
let listed_text = listed.content[0].as_text().expect("list result").text.clone();
|
||||
assert!(listed_text.contains("allowed-db"));
|
||||
assert!(!listed_text.contains("blocked-db"));
|
||||
|
||||
let blocked = client
|
||||
.peer()
|
||||
.call_tool(CallToolRequestParams::new("dbx_execute_query").with_arguments(
|
||||
json!({ "connection_id": "blocked", "sql": "SELECT 1" }).as_object().cloned().unwrap_or_else(Map::new),
|
||||
))
|
||||
.await
|
||||
.expect("call blocked connection");
|
||||
assert_eq!(blocked.is_error, Some(true));
|
||||
assert!(blocked.content[0].as_text().expect("blocked result").text.contains("CONNECTION_OUT_OF_SCOPE"));
|
||||
|
||||
let read_only = client
|
||||
.peer()
|
||||
.call_tool(
|
||||
CallToolRequestParams::new("dbx_execute_query").with_arguments(
|
||||
json!({ "connection_id": "allowed", "sql": "DELETE FROM users" })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_else(Map::new),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("call read-only policy");
|
||||
assert_eq!(read_only.is_error, Some(true));
|
||||
assert!(read_only.content[0].as_text().expect("read-only result").text.contains("MCP_READ_ONLY"));
|
||||
|
||||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
Reference in new issue
Block a user