Files
dbx/crates/dbx-mcp/src/server.rs
T

2102 lines
91 KiB
Rust

use std::sync::Arc;
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router, ServerHandler,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::backend::{format_query_result, new_connection_config, parse_database_type, ConnectionSummary, DbxBackend};
use crate::mongo::{self, MongoCommand, MongoSafetyError};
use crate::session::{McpSession, McpSessionStore};
use dbx_core::{
agent_tools::{format_query_result_as_text, QueryCellWindow},
database_manifest,
db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety},
models::connection::DatabaseType,
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)]
pub struct ListConnectionsRequest {}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ConnectionSelector {
#[schemars(description = "Unique ID of the DBX connection")]
#[schemars(extend("type" = "string"))]
pub connection_id: Option<String>,
#[schemars(description = "Name of the DBX connection")]
#[schemars(extend("type" = "string"))]
pub connection_name: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListTablesRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Database name")]
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
#[schemars(description = "Schema name")]
#[schemars(extend("type" = "string"))]
pub schema: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DescribeTableRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Table name")]
pub table: String,
#[schemars(description = "Database name")]
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
#[schemars(description = "Schema name")]
#[schemars(extend("type" = "string"))]
pub schema: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExecuteQueryRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Database name")]
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
#[schemars(description = "SQL query to execute")]
pub sql: String,
#[schemars(
description = "Session ID from dbx_open_session. When set, the query runs on the session's pinned connection, preserving USE/SET and other session state across calls."
)]
#[schemars(extend("type" = "string"))]
pub session_id: Option<String>,
#[schemars(
description = "Start character offset for every string cell (default 0, max 1000000). Use the next offset reported by a truncated result to slide through a long value; narrow the query to the target row and column first."
)]
#[schemars(extend("type" = "integer"))]
pub cell_char_offset: Option<u64>,
#[schemars(
description = "Maximum characters returned per string cell (default 200, max 4000). Increase only for an explicit long-value expansion."
)]
#[schemars(extend("type" = "integer"))]
pub cell_char_limit: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct OpenSessionRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Database name")]
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CloseSessionRequest {
#[schemars(description = "Session ID returned by dbx_open_session")]
pub session_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct AddConnectionRequest {
pub name: String,
pub db_type: String,
pub host: String,
#[schemars(extend("type" = "integer"))]
pub port: Option<u16>,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
#[serde(default)]
pub ssl: bool,
#[schemars(extend("type" = "string"))]
pub driver_profile: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DuplicateConnectionRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Name for the copied connection")]
pub new_name: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RemoveConnectionRequest {
pub connection_name: String,
#[schemars(extend("type" = "string"))]
pub connection_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExecuteRedisCommandRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Redis logical database number")]
#[schemars(extend("type" = "integer"))]
pub db: Option<u32>,
#[schemars(description = "Redis command to execute, for example GET mykey or INFO")]
pub command: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SchemaContextRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
#[schemars(extend("type" = "string"))]
pub schema: Option<String>,
#[schemars(description = "Specific table names to include")]
#[schemars(extend("type" = "array"))]
pub tables: Option<Vec<String>>,
#[schemars(description = "Maximum number of tables to include, from 1 to 20")]
#[schemars(extend("type" = "integer"))]
pub max_tables: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct OpenTableRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
pub table: String,
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
#[schemars(extend("type" = "string"))]
pub schema: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExecuteAndShowRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
pub sql: String,
#[schemars(extend("type" = "string"))]
pub database: Option<String>,
}
#[derive(Clone)]
pub struct DbxMcpServer {
backend: Arc<dyn DbxBackend>,
scope: McpScope,
sessions: Arc<McpSessionStore>,
tool_router: ToolRouter<Self>,
}
#[derive(Clone, Debug, Default)]
pub struct McpScope {
pub connection_ids: Vec<String>,
pub connection_name: Option<String>,
pub database: Option<String>,
pub schema: 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_ids,
connection_name: non_empty_env("DBX_MCP_SCOPE_CONNECTION_NAME"),
database: non_empty_env("DBX_MCP_SCOPE_DATABASE"),
schema: non_empty_env("DBX_MCP_SCOPE_SCHEMA"),
}
}
fn enabled(&self) -> bool {
self.connection_scope_enabled() || self.database.is_some() || self.schema.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 {
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())
}
}
impl DbxMcpServer {
pub fn new(backend: Arc<dyn DbxBackend>) -> Self {
Self::with_runtime_options(backend, McpScope::from_env(), std::env::var_os("DBX_WEB_URL").is_some())
}
pub fn with_runtime_options(backend: Arc<dyn DbxBackend>, scope: McpScope, web_mode: bool) -> Self {
let mut tool_router = Self::tool_router();
if scope.enabled() {
tool_router.disable_route("dbx_add_connection");
tool_router.disable_route("dbx_duplicate_connection");
tool_router.disable_route("dbx_remove_connection");
}
// Desktop UI bridge operations are intentionally unavailable remotely and in scoped AI sessions.
if web_mode || scope.enabled() {
tool_router.disable_route("dbx_open_table");
tool_router.disable_route("dbx_execute_and_show");
}
Self { backend, scope, sessions: McpSessionStore::new(), tool_router }
}
async fn close_backend_sessions_best_effort(&self, sessions: Vec<McpSession>) {
for session in sessions {
let _ = self
.backend
.close_client_session(&session.connection_id, &session.database, &session.client_session_id)
.await;
}
}
}
#[tool_router]
impl DbxMcpServer {
#[tool(
name = "dbx_list_connections",
description = "List database connections configured in DBX. Returns connection IDs, names, group paths, database types, endpoints, and selected databases."
)]
async fn list_connections(
&self,
Parameters(ListConnectionsRequest {}): Parameters<ListConnectionsRequest>,
) -> CallToolResult {
match self.load_scoped_connections().await {
Ok(connections) if connections.is_empty() => text("No connections configured in DBX."),
Ok(connections) => {
let group_paths = self.backend.load_connection_group_paths().await.unwrap_or_default();
let rows = connections
.iter()
.map(|connection| {
let mut summary = ConnectionSummary::from(connection);
summary.group_path = group_paths.get(&connection.id).cloned().unwrap_or_default();
summary
})
.collect::<Vec<_>>();
text(format_connections(&rows))
}
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 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 schema = match self.resolve_schema(request.schema) {
Ok(schema) => schema,
Err(error) => return error,
};
match self.backend.list_tables(&resolved.connection, &database, &schema).await {
Ok(tables) if tables.is_empty() => text("No tables found."),
Ok(tables) => text(
tables
.into_iter()
.map(|table| {
let comment = table
.comment
.filter(|comment| !comment.is_empty())
.map(|comment| format!(" -- {comment}"))
.unwrap_or_default();
format!("- {} ({}){}", table.name, table.table_type, comment)
})
.collect::<Vec<_>>()
.join("\n"),
),
Err(error) => tool_error("TABLE_LIST_ERROR", error),
}
}
#[tool(name = "dbx_describe_table", description = "Get column definitions for a table")]
async fn describe_table(&self, Parameters(request): Parameters<DescribeTableRequest>) -> CallToolResult {
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 schema = match self.resolve_schema(request.schema) {
Ok(schema) => schema,
Err(error) => return error,
};
match self.backend.get_columns(&resolved.connection, &database, &schema, &request.table).await {
Ok(columns) if columns.is_empty() => text("No columns found."),
Ok(columns) => text(format_columns(&columns)),
Err(error) => tool_error("TABLE_DESCRIPTION_ERROR", error),
}
}
#[tool(
name = "dbx_execute_query",
description = "Execute a SQL query on a database connection (max 100 rows returned)"
)]
async fn execute_query(&self, Parameters(request): Parameters<ExecuteQueryRequest>) -> CallToolResult {
let explicit_cell_window = (request.cell_char_offset.is_some() || request.cell_char_limit.is_some())
.then(|| QueryCellWindow::from_options(request.cell_char_offset, request.cell_char_limit));
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.",
);
}
// Resolve the session before the database so its connection/database
// binding is enforced on every stateful query.
let session = match request.session_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
Some(session_id) => {
let (session, expired) = self.sessions.resolve(session_id).await.into_parts();
self.close_backend_sessions_best_effort(expired).await;
match session {
Some(session) if session.connection_id == connection.id => Some(session),
Some(_) => {
return tool_error(
"SESSION_CONNECTION_MISMATCH",
format!("Session \"{session_id}\" is bound to a different connection."),
)
}
None => {
return tool_error(
"SESSION_NOT_FOUND",
format!(
"Session \"{session_id}\" not found or expired. Open a new one with dbx_open_session."
),
)
}
}
}
None => None,
};
let database = match self.resolve_database(request.database, connection) {
Ok(database) => database,
Err(error) => return error,
};
if let Some(session) = &session {
if session.database != database {
return tool_error(
"SESSION_DATABASE_MISMATCH",
format!(
"Session \"{}\" is bound to database \"{}\", not \"{database}\".",
session.id, session.database
),
);
}
}
if connection.db_type == DatabaseType::MongoDb {
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 {
Ok(result) => match explicit_cell_window {
Some(window) => match format_query_result_as_text(&result, 100, window) {
Ok(output) => text(output),
Err(error) => backend_tool_error("QUERY_FORMAT_ERROR", error),
},
None => text(format_query_result(&result, 100)),
},
Err(error) => backend_tool_error("QUERY_ERROR", error),
};
}
// A pinned session makes USE/SET CATALOG meaningful, so database
// switching is allowed — unless a hard database scope is configured,
// which a USE statement could otherwise escape.
let allow_database_switch = session.is_some() && self.scope.database.is_none();
let permissions =
match validate_sql_policy(connection, &resolved.policy, &database, &request.sql, allow_database_switch) {
Ok(permissions) => permissions,
Err(error) => return error,
};
let mut arguments = json!({ "sql": request.sql, "limit": 100 });
if let Some(schema) = self.scope.schema.as_deref() {
arguments["schema"] = json!(schema);
}
if let Some(session) = &session {
arguments["client_session_id"] = json!(session.client_session_id);
}
if let Some(offset) = request.cell_char_offset {
arguments["cell_char_offset"] = json!(offset);
}
if let Some(limit) = request.cell_char_limit {
arguments["cell_char_limit"] = json!(limit);
}
let result =
self.backend.execute_agent_tool(connection, &database, "execute_query", arguments, permissions).await;
agent_result(result)
}
#[tool(
name = "dbx_open_session",
description = "Open a stateful query session pinned to a single backend connection. Returns a session ID for dbx_execute_query: USE, SET CATALOG, session variables and temporary tables persist across calls within the session. Close with dbx_close_session when done; idle sessions expire after 30 minutes."
)]
async fn open_session(&self, Parameters(request): Parameters<OpenSessionRequest>) -> CallToolResult {
let resolved = match self.resolve_connection(&request.selector).await {
Ok(resolved) => resolved,
Err(error) => return error,
};
let connection = &resolved.connection;
if matches!(connection.db_type, DatabaseType::Redis | DatabaseType::MongoDb) {
return tool_error(
"SESSION_UNSUPPORTED",
format!("Sessions are only supported for SQL connections; \"{}\" is not one.", connection.name),
);
}
let database = match self.resolve_database(request.database, connection) {
Ok(database) => database,
Err(error) => return error,
};
let (session, expired) = self.sessions.open(&connection.id, &database).await.into_parts();
self.close_backend_sessions_best_effort(expired).await;
match session {
Ok(session) => text(format!(
"Session opened.\nsession_id: {}\nconnection: {} (id: {})\ndatabase: {}\n\nPass session_id to dbx_execute_query to run every query on the same pinned connection. Close with dbx_close_session when done.",
session.id, connection.name, connection.id, database
)),
Err(error) => tool_error("SESSION_LIMIT", error),
}
}
#[tool(
name = "dbx_close_session",
description = "Close a stateful query session and release its pinned backend connection"
)]
async fn close_session(&self, Parameters(request): Parameters<CloseSessionRequest>) -> CallToolResult {
let (session, expired) = self.sessions.begin_close(&request.session_id).await.into_parts();
self.close_backend_sessions_best_effort(expired).await;
let Some(session) = session else {
return tool_error(
"SESSION_NOT_FOUND",
format!("Session \"{}\" not found or already closed.", request.session_id),
);
};
match self
.backend
.close_client_session(&session.connection_id, &session.database, &session.client_session_id)
.await
{
Ok(_) => {
self.sessions.finish_close(&session.id).await;
text(format!("Session \"{}\" closed.", session.id))
}
Err(error) => {
self.sessions.restore_after_failed_close(session).await;
backend_tool_error("SESSION_CLOSE_ERROR", error)
}
}
}
#[tool(name = "dbx_execute_redis_command", description = "Execute a Redis command on a Redis connection")]
async fn execute_redis_command(
&self,
Parameters(request): Parameters<ExecuteRedisCommandRequest>,
) -> CallToolResult {
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));
}
let argv = match parse_command_argv(&request.command) {
Ok(argv) => argv,
Err(error) => return tool_error("REDIS_COMMAND_BLOCKED", error),
};
let safety = classify_command(&argv[0]);
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 disabled in DBX MCP settings.",
argv[0].to_ascii_uppercase()
),
);
}
if safety != RedisCommandSafety::Allowed && !permissions.allow_writes {
return tool_error(
"REDIS_COMMAND_BLOCKED",
"MCP Redis command execution is read-only in DBX MCP settings.",
);
}
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()) {
return tool_error(
"PRODUCTION_WRITE_BLOCKED",
"MCP cannot execute write or dangerous Redis commands against a production database.",
);
}
match self
.backend
.execute_redis_command(
connection,
database,
&request.command,
safety == RedisCommandSafety::Blocked && permissions.allow_dangerous,
)
.await
{
Ok(result) => text(format_redis_result(&result)),
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 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 schema = match self.resolve_schema(request.schema) {
Ok(schema) => schema,
Err(error) => return error,
};
let max_tables = request.max_tables.unwrap_or(8).clamp(1, 20);
let available = match self.backend.list_tables(connection, &database, &schema).await {
Ok(tables) => tables,
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
};
let requested = request
.tables
.unwrap_or_default()
.into_iter()
.map(|name| name.to_ascii_lowercase())
.collect::<std::collections::HashSet<_>>();
let mut selected = if requested.is_empty() {
available.iter().collect::<Vec<_>>()
} else {
available.iter().filter(|table| requested.contains(&table.name.to_ascii_lowercase())).collect::<Vec<_>>()
};
let truncated = selected.len() > max_tables || (requested.is_empty() && available.len() > max_tables);
selected.truncate(max_tables);
if selected.is_empty() {
return text("No matching tables found.");
}
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 {
Ok(columns) => columns,
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
};
tables.push((table.clone(), columns));
}
text(format_schema_context(&connection.name, &database, &schema, &tables, truncated))
}
#[tool(name = "dbx_add_connection", description = "Add a new database connection to DBX")]
async fn add_connection(&self, Parameters(request): Parameters<AddConnectionRequest>) -> CallToolResult {
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),
};
if connections.iter().any(|connection| connection.name.eq_ignore_ascii_case(&request.name)) {
return text(format!("Connection \"{}\" already exists.", request.name));
}
let db_type = match parse_database_type(&request.db_type) {
Ok(db_type) => db_type,
Err(error) => return tool_error("INVALID_CONNECTION_TYPE", error),
};
let port = match request.port.or_else(|| database_manifest::default_port(&db_type)) {
Some(port) => port,
None => return text("Port is required for this database type."),
};
let config = match new_connection_config(
Uuid::new_v4().to_string(),
request.name,
db_type,
request.host,
port,
request.username,
request.password,
request.database,
request.ssl,
request.driver_profile,
) {
Ok(config) => config,
Err(error) => return tool_error("INVALID_CONNECTION", 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),
}
}
#[tool(
name = "dbx_duplicate_connection",
description = "Duplicate a DBX connection with its complete settings, credentials, tunnels, and sidebar group"
)]
async fn duplicate_connection(
&self,
Parameters(request): Parameters<DuplicateConnectionRequest>,
) -> CallToolResult {
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),
};
let allowed = connections
.iter()
.filter(|connection| policy_allows_connection(&policy, connection))
.cloned()
.collect::<Vec<_>>();
let source =
if let Some(id) = request.selector.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
allowed.iter().find(|connection| connection.id == id).cloned()
} else if let Some(name) =
request.selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty())
{
let matching = allowed
.iter()
.filter(|connection| connection.name.eq_ignore_ascii_case(name))
.cloned()
.collect::<Vec<_>>();
if matching.len() > 1 {
return tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &matching));
}
matching.into_iter().next()
} else {
return tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required.");
};
let Some(source) = source else {
return tool_error("CONNECTION_NOT_FOUND", "The source connection was not found or is outside MCP scope.");
};
let new_name = request.new_name.trim();
if new_name.is_empty() {
return tool_error("INVALID_CONNECTION", "The copied connection name must not be empty.");
}
if connections.iter().any(|connection| connection.name.eq_ignore_ascii_case(new_name)) {
return tool_error("CONNECTION_ALREADY_EXISTS", format!("Connection \"{new_name}\" already exists."));
}
match self.backend.duplicate_connection_for_mcp(&source.id, &Uuid::new_v4().to_string(), new_name).await {
Ok(copy) => text(format!("Connection \"{}\" duplicated (id: {}).", copy.name, copy.id)),
Err(error) => backend_tool_error("CONNECTION_SAVE_ERROR", error),
}
}
#[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")]
async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult {
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),
};
let target = if let Some(id) = request.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
connections.iter().find(|connection| connection.id == id).cloned()
} else {
let matching = connections
.iter()
.filter(|connection| connection.name.eq_ignore_ascii_case(&request.connection_name))
.cloned()
.collect::<Vec<_>>();
if matching.len() > 1 {
return tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(&request.connection_name, &matching));
}
matching.into_iter().next()
};
let Some(target) = target else {
return tool_error(
"CONNECTION_NOT_FOUND",
format!("Connection \"{}\" not found.", request.connection_name),
);
};
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),
}
}
#[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 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 schema = match self.resolve_schema(request.schema) {
Ok(schema) => schema,
Err(error) => return error,
};
match self
.backend
.bridge_request(
"/open-table",
json!({
"connection_id": connection.id,
"connection_name": connection.name,
"table": request.table,
"database": database,
"schema": schema,
}),
)
.await
{
Ok(()) => text(format!("Opened {} in DBX", request.table)),
Err(error) => backend_tool_error("DBX_NOT_RUNNING", error),
}
}
#[tool(
name = "dbx_execute_and_show",
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 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 = 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 {
match validate_sql_policy(connection, &resolved.policy, &database, &request.sql, false) {
Ok(permissions) => permissions,
Err(error) => return error,
}
};
if connection.db_type == DatabaseType::MongoDb {
if let Err(error) = validate_mongo_command(connection, &resolved.policy, &database, &request.sql) {
return error;
}
}
match self
.backend
.bridge_request(
"/execute-query",
json!({
"connection_id": connection.id,
"connection_name": connection.name,
"sql": request.sql,
"database": database,
"allow_writes": permissions.allow_writes,
"allow_dangerous": permissions.allow_dangerous,
}),
)
.await
{
Ok(()) => text("Query sent to DBX"),
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?;
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,
) -> 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())
}
/// Resolve the schema for scoped CLI agents. A selected schema is a hard
/// bound, matching the existing database scope behavior.
#[allow(clippy::result_large_err)]
fn resolve_schema(&self, requested: Option<String>) -> Result<String, CallToolResult> {
let requested = requested.map(|schema| schema.trim().to_string()).filter(|schema| !schema.is_empty());
if let Some(scoped) = self.scope.schema.as_deref() {
if let Some(requested) = requested.as_deref() {
if requested != scoped {
return Err(tool_error(
"SCHEMA_OUT_OF_SCOPE",
format!("Schema \"{requested}\" is outside the scoped schema \"{scoped}\"."),
));
}
}
return Ok(scoped.to_string());
}
Ok(requested.unwrap_or_default())
}
// 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,
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()) {
let connection = connections
.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.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."),
));
}
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.connection_scope_enabled() {
let connection = connections
.into_iter()
.find(|connection| self.scope.matches(connection))
.ok_or_else(|| tool_error("CONNECTION_NOT_FOUND", "Scoped DBX connection was not found."))?;
if let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
if name != connection.name && name != connection.id {
return Err(tool_error(
"CONNECTION_OUT_OF_SCOPE",
format!("Connection \"{name}\" is outside this DBX AI session scope."),
));
}
}
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<_>>();
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))),
}
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for DbxMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("dbx", env!("CARGO_PKG_VERSION")))
.with_instructions("Use DBX connections to inspect schemas and query databases safely.")
}
}
fn text(value: impl Into<String>) -> CallToolResult {
CallToolResult::success(vec![ContentBlock::text(value)])
}
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 {
backend_tool_error("DBX_TOOL_ERROR", result.content.trim_start_matches("Error: "))
} else {
text(result.content)
}
}
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: !policy.read_only && !connection.read_only,
allow_dangerous: !policy.read_only && !connection.read_only && policy.allow_dangerous_sql,
confirmed_write_sql: mcp_confirmed_write_sql_from_env(),
}
}
/// Read the DBX_MCP_CONFIRMED_WRITE_SQL env var (set by the CLI agent when the
/// user confirmed a specific write SQL). Returns None when the var is unset or
/// empty, so desktop-embedded MCP contexts (which don't set this var) continue
/// to work without a confirmed-SQL binding.
fn mcp_confirmed_write_sql_from_env() -> Option<String> {
normalize_confirmed_write_sql(std::env::var("DBX_MCP_CONFIRMED_WRITE_SQL").ok())
}
fn normalize_confirmed_write_sql(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
// 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,
allow_database_switch: bool,
) -> Result<dbx_core::agent_tools::AgentSqlPermissions, CallToolResult> {
if !allow_database_switch && 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 outside a session. Open one with dbx_open_session to run stateful queries.",
));
}
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."));
}
// The keyword scan alone misses write-capable SQL that the risk classifier
// does recognize (locking reads, side-effect functions, writable CTEs), and
// those statements would otherwise reach the database whenever high-risk SQL
// is permitted. Fail closed on either signal so read-only stays read-only.
let is_write = risk != SqlRisk::ReadOnly || 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> {
let command = mongo::parse(source).map_err(|error| {
tool_error(
"QUERY_ERROR",
format!(
"{error} Use MongoDB shell-style commands such as db.collection.find({{}}), db.collection.aggregate([]), or db.collection.countDocuments({{}})."
),
)
})?;
if matches!(command, MongoCommand::RunCommand { .. }) {
return Err(tool_error(
"SQL_BLOCKED",
"MongoDB runCommand is not available through MCP; review and execute it manually in DBX.",
));
}
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(
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 high-risk operations are enabled in DBX MCP settings.",
),
MongoSafetyError::Dangerous => tool_error(
"SQL_BLOCKED",
"Dangerous MongoDB command is disabled in DBX MCP settings.",
),
MongoSafetyError::ProductionWrite => {
tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database.")
}
});
}
Ok(command)
}
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 ambiguous_connections(name: &str, connections: &[dbx_core::models::connection::ConnectionConfig]) -> String {
let lines = connections
.iter()
.map(|connection| {
format!("- {}: {:?} @ {}:{}", connection.id, connection.db_type, connection.host, connection.port)
})
.collect::<Vec<_>>()
.join("\n");
format!("Multiple connections found with name \"{name}\". Please specify connection_id:\n{lines}")
}
fn format_connections(connections: &[ConnectionSummary]) -> String {
let mut output = String::from(
"| ID | Name | Group Path | Type | Host | Port | Database |\n| --- | --- | --- | --- | --- | --- | --- |",
);
for connection in connections {
output.push_str(&format!(
"\n| {} | {} | {} | {} | {} | {} | {} |",
escape_cell(&connection.id),
escape_cell(&connection.name),
escape_cell(&connection.group_path.join(" / ")),
escape_cell(&connection.db_type),
escape_cell(&connection.host),
connection.port,
escape_cell(&connection.database),
));
}
output
}
fn format_columns(columns: &[dbx_core::db::ColumnInfo]) -> String {
let rows = columns
.iter()
.map(|column| {
vec![
if column.is_primary_key { format!("{} (PK)", column.name) } else { column.name.clone() },
column.data_type.clone(),
if column.is_nullable { "YES".to_string() } else { "NO".to_string() },
column.column_default.clone().unwrap_or_default(),
column.comment.clone().unwrap_or_default(),
]
})
.collect::<Vec<_>>();
markdown_table(&["Column", "Type", "Nullable", "Default", "Comment"], &rows)
}
fn markdown_table(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut output = format!("| {} |\n| {} |", headers.join(" | "), vec!["---"; headers.len()].join(" | "));
for row in rows {
output
.push_str(&format!("\n| {} |", row.iter().map(|value| escape_cell(value)).collect::<Vec<_>>().join(" | ")));
}
output
}
fn escape_cell(value: &str) -> String {
value.replace('|', "\\|").replace('\n', " ")
}
fn redis_database(connection: &dbx_core::models::connection::ConnectionConfig) -> Option<u32> {
connection.database.as_deref().and_then(parse_redis_database)
}
fn parse_redis_database(value: &str) -> Option<u32> {
value.trim().parse().ok()
}
fn format_redis_result(result: &RedisCommandResult) -> String {
let value =
result.value.as_str().map(ToOwned::to_owned).unwrap_or_else(|| {
serde_json::to_string_pretty(&result.value).unwrap_or_else(|_| result.value.to_string())
});
let safety = serde_json::to_value(&result.safety)
.ok()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| format!("{:?}", result.safety).to_ascii_lowercase());
format!("Command: {}\nSafety: {}\n\n{}", result.command, safety, value)
}
fn format_schema_context(
connection: &str,
database: &str,
schema: &str,
tables: &[(dbx_core::db::TableInfo, Vec<dbx_core::db::ColumnInfo>)],
truncated: bool,
) -> String {
let mut output = format!("Connection: {connection}");
if !database.is_empty() {
output.push_str(&format!("\nDatabase: {database}"));
}
if !schema.is_empty() {
output.push_str(&format!("\nSchema: {schema}"));
}
for (table, columns) in tables {
output.push_str(&format!("\n\n## {}\nType: {}", table.name, table.table_type));
for column in columns {
output.push_str(&format!(
"\n- {} {} {}{}{}",
column.name,
column.data_type,
if column.is_nullable { "NULL" } else { "NOT NULL" },
if column.is_primary_key { " PK" } else { "" },
column.comment.as_ref().map(|comment| format!(" -- {comment}")).unwrap_or_default(),
));
}
}
if truncated {
output.push_str("\n\nNote: table list was truncated; request specific table names for more context.");
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use dbx_core::models::connection::ConnectionConfig;
use std::collections::HashSet;
struct FakeBackend {
connections: Vec<ConnectionConfig>,
recorded_arguments: std::sync::Mutex<Vec<(String, serde_json::Value)>>,
closed_sessions: std::sync::Mutex<Vec<String>>,
pinned_sessions: std::sync::Mutex<HashSet<String>>,
close_failures_remaining: std::sync::Mutex<usize>,
}
impl Default for FakeBackend {
fn default() -> Self {
Self {
connections: Vec::new(),
recorded_arguments: std::sync::Mutex::new(Vec::new()),
closed_sessions: std::sync::Mutex::new(Vec::new()),
pinned_sessions: std::sync::Mutex::new(HashSet::new()),
close_failures_remaining: std::sync::Mutex::new(0),
}
}
}
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()
}
fn opened_session_id(result: &CallToolResult) -> String {
result_text(result)
.lines()
.find_map(|line| line.strip_prefix("session_id: "))
.expect("open_session returns a session_id")
.to_string()
}
#[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())
}
async fn execute_agent_tool(
&self,
_connection: &ConnectionConfig,
_database: &str,
tool_name: &str,
arguments: serde_json::Value,
_permissions: dbx_core::agent_tools::AgentSqlPermissions,
) -> dbx_core::agent_events::ToolResult {
if let Some(client_session_id) =
arguments.get("client_session_id").and_then(serde_json::Value::as_str).filter(|id| !id.is_empty())
{
self.pinned_sessions.lock().unwrap().insert(client_session_id.to_string());
}
self.recorded_arguments.lock().unwrap().push((tool_name.to_string(), arguments));
dbx_core::agent_events::ToolResult {
tool_call_id: "test".to_string(),
tool_name: tool_name.to_string(),
content: "ok".to_string(),
is_error: false,
explain_data: None,
}
}
async fn close_client_session(
&self,
_connection_id: &str,
_database: &str,
client_session_id: &str,
) -> Result<bool, String> {
let mut failures = self.close_failures_remaining.lock().unwrap();
if *failures > 0 {
*failures -= 1;
return Err("temporary close failure".to_string());
}
drop(failures);
self.closed_sessions.lock().unwrap().push(client_session_id.to_string());
self.pinned_sessions.lock().unwrap().remove(client_session_id);
Ok(true)
}
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
Ok(config)
}
async fn duplicate_connection_for_mcp(
&self,
source_id: &str,
copy_id: &str,
copy_name: &str,
) -> Result<ConnectionConfig, String> {
let mut copy = self
.connections
.iter()
.find(|connection| connection.id == source_id)
.cloned()
.ok_or_else(|| "source not found".to_string())?;
copy.id = copy_id.to_string();
copy.name = copy_name.to_string();
Ok(copy)
}
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
Ok(true)
}
}
#[test]
fn connection_table_escapes_markdown_cells() {
let output = format_connections(&[ConnectionSummary {
id: "id|1".to_string(),
name: "local\npg".to_string(),
db_type: "postgres".to_string(),
host: "127.0.0.1".to_string(),
port: 5432,
database: "app".to_string(),
group_path: vec!["Project|A".to_string(), "Staging\nWest".to_string()],
}]);
assert!(output.contains("id\\|1"));
assert!(output.contains("local pg"));
assert!(output.contains("Project\\|A / Staging West"));
}
#[test]
fn server_registers_list_connections_tool() {
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
let tools = server.tool_router.list_all();
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
assert_eq!(tools.len(), 13);
assert!(names.contains(&"dbx_list_connections"));
assert!(names.contains(&"dbx_list_tables"));
assert!(names.contains(&"dbx_describe_table"));
assert!(names.contains(&"dbx_execute_query"));
assert!(names.contains(&"dbx_add_connection"));
assert!(names.contains(&"dbx_duplicate_connection"));
assert!(names.contains(&"dbx_remove_connection"));
assert!(names.contains(&"dbx_execute_redis_command"));
assert!(names.contains(&"dbx_get_schema_context"));
assert!(names.contains(&"dbx_open_table"));
assert!(names.contains(&"dbx_execute_and_show"));
assert!(names.contains(&"dbx_open_session"));
assert!(names.contains(&"dbx_close_session"));
}
#[test]
fn schema_context_tables_schema_is_gemini_compatible() {
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
let tool = server
.tool_router
.list_all()
.into_iter()
.find(|tool| tool.name == "dbx_get_schema_context")
.expect("schema context tool should be registered");
let tables = tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.and_then(|properties| properties.get("tables"))
.expect("tables property should be published");
assert_eq!(tables.get("type"), Some(&serde_json::json!("array")));
assert_eq!(tables.pointer("/items/type"), Some(&serde_json::json!("string")));
assert!(!tool
.input_schema
.get("required")
.and_then(serde_json::Value::as_array)
.is_some_and(|required| required.iter().any(|field| field == "tables")));
}
#[test]
fn connection_selector_schema_uses_optional_strings() {
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
let tools = server.tool_router.list_all();
for tool_name in ["dbx_execute_query", "dbx_list_tables", "dbx_open_session"] {
let tool = tools.iter().find(|tool| tool.name == tool_name).expect("selector tool should be registered");
let properties = tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("selector tool should publish object properties");
let required = tool
.input_schema
.get("required")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.collect::<Vec<_>>();
for field in ["connection_id", "connection_name"] {
let selector = properties.get(field).expect("selector field should be published");
assert_eq!(selector.get("type"), Some(&serde_json::json!("string")), "{tool_name}.{field}");
assert!(!required.iter().any(|required| *required == field), "{tool_name}.{field} must stay optional");
}
}
}
#[test]
fn optional_fields_never_publish_nullable_union_types() {
// Some MCP clients (e.g. OpenCode, see #6344) cannot resolve a JSON Schema
// `"type": ["string", "null"]` union and fall back to wrapping the argument in a
// nested error object instead of passing the value through. b521d0377 fixed this for
// `ConnectionSelector`'s connection_id/connection_name but left every other optional
// field on these request structs emitting the same union shape. Every optional field
// must instead publish a single concrete `type`, relying on omission from `required`
// (not a `"null"` union member) to signal optionality.
let server = DbxMcpServer::with_runtime_options(Arc::new(FakeBackend::default()), McpScope::default(), false);
let tools = server.tool_router.list_all();
let checks: &[(&str, &[&str])] = &[
("dbx_list_tables", &["database", "schema"]),
("dbx_describe_table", &["database", "schema"]),
("dbx_execute_query", &["database", "session_id", "cell_char_offset", "cell_char_limit"]),
("dbx_open_session", &["database"]),
("dbx_open_table", &["database", "schema"]),
("dbx_execute_and_show", &["database"]),
("dbx_add_connection", &["port", "database", "driver_profile"]),
("dbx_remove_connection", &["connection_id"]),
("dbx_execute_redis_command", &["db"]),
("dbx_get_schema_context", &["database", "schema", "max_tables"]),
];
for (tool_name, fields) in checks {
let tool = tools.iter().find(|tool| tool.name == *tool_name).expect("tool should be registered");
let properties = tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("tool should publish object properties");
let required = tool
.input_schema
.get("required")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.collect::<Vec<_>>();
for field in *fields {
let schema =
properties.get(*field).unwrap_or_else(|| panic!("{tool_name}.{field} should be published"));
let type_value =
schema.get("type").unwrap_or_else(|| panic!("{tool_name}.{field} should publish a type"));
assert!(
type_value.is_string(),
"{tool_name}.{field} must publish a single concrete type, not a union: {type_value:?}"
);
assert!(!required.iter().any(|required| *required == field), "{tool_name}.{field} must stay optional");
}
}
}
#[test]
fn execute_query_selector_preserves_serde_inputs() {
let omitted: ExecuteQueryRequest = serde_json::from_str(r#"{"sql":"SELECT 1"}"#).unwrap();
let explicit_nulls: ExecuteQueryRequest =
serde_json::from_str(r#"{"connection_id":null,"connection_name":null,"sql":"SELECT 1"}"#).unwrap();
let by_name: ExecuteQueryRequest =
serde_json::from_str(r#"{"connection_name":"test_conn","sql":"SELECT 1"}"#).unwrap();
let by_id: ExecuteQueryRequest =
serde_json::from_str(r#"{"connection_id":"123e4567-e89b-12d3-a456-426614174000","sql":"SELECT 1"}"#)
.unwrap();
assert!(omitted.selector.connection_id.is_none());
assert!(omitted.selector.connection_name.is_none());
assert!(explicit_nulls.selector.connection_id.is_none());
assert!(explicit_nulls.selector.connection_name.is_none());
assert_eq!(by_name.selector.connection_name.as_deref(), Some("test_conn"));
assert_eq!(by_id.selector.connection_id.as_deref(), Some("123e4567-e89b-12d3-a456-426614174000"));
let nested = serde_json::from_str::<ExecuteQueryRequest>(
r#"{"connection_name":{"tool":"dbx_dbx_execute_query","error":"Invalid input"},"sql":"SELECT 1"}"#,
)
.unwrap_err();
assert!(nested.to_string().contains("invalid type: map, expected a string"));
}
#[test]
fn schema_context_tables_preserve_optional_inputs() {
let omitted: SchemaContextRequest = serde_json::from_str("{}").unwrap();
let explicit_null: SchemaContextRequest = serde_json::from_str(r#"{"tables":null}"#).unwrap();
let empty: SchemaContextRequest = serde_json::from_str(r#"{"tables":[]}"#).unwrap();
let populated: SchemaContextRequest = serde_json::from_str(r#"{"tables":["users","orders"]}"#).unwrap();
assert_eq!(omitted.tables, None);
assert_eq!(explicit_null.tables, None);
assert_eq!(empty.tables, Some(Vec::new()));
assert_eq!(populated.tables, Some(vec!["users".to_string(), "orders".to_string()]));
}
#[test]
fn scoped_server_hides_mutating_and_desktop_tools() {
let server = DbxMcpServer::with_runtime_options(
Arc::new(FakeBackend::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<_>>();
assert_eq!(names.len(), 8);
assert!(!names.iter().any(|name| name == "dbx_add_connection"));
assert!(!names.iter().any(|name| name == "dbx_duplicate_connection"));
assert!(!names.iter().any(|name| name == "dbx_remove_connection"));
assert!(!names.iter().any(|name| name == "dbx_open_table"));
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
assert!(names.iter().any(|name| name == "dbx_open_session"));
assert!(names.iter().any(|name| name == "dbx_close_session"));
}
#[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,
schema: 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()], ..Default::default() }),
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 schema_scope_is_a_hard_bound() {
let dameng = connection("dameng-1", "Dameng", "dameng", "APPDB");
let server = DbxMcpServer::with_runtime_options(
Arc::new(FakeBackend::default()),
McpScope {
database: Some("APPDB".to_string()),
schema: Some("REPORTING".to_string()),
..Default::default()
},
false,
);
assert_eq!(server.resolve_database(None, &dameng).unwrap(), "APPDB");
assert_eq!(server.resolve_schema(None).unwrap(), "REPORTING");
assert_eq!(server.resolve_schema(Some("REPORTING".to_string())).unwrap(), "REPORTING");
let error = server.resolve_schema(Some("APP_USER".to_string())).unwrap_err();
assert!(result_text(&error).contains("SCHEMA_OUT_OF_SCOPE"));
}
#[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()], ..Default::default() }),
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()], ..Default::default() }),
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 mongo_run_command_is_never_exposed_through_mcp() {
let mongo = connection("mongo", "mongo", "mongodb", "staging");
let source = r#"db.runCommand({ping: 1})"#;
for policy in [
McpGlobalPolicy { read_only: true, allow_dangerous_sql: false, allowed_connection_ids: None },
McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: None },
McpGlobalPolicy { read_only: false, allow_dangerous_sql: true, allowed_connection_ids: None },
] {
let error = validate_mongo_command(&mongo, &policy, "staging", source).unwrap_err();
assert!(result_text(&error).contains("SQL_BLOCKED"));
assert!(result_text(&error).contains("runCommand"));
}
}
#[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"));
}
#[test]
fn mcp_confirmation_and_policy_guards_fail_closed() {
assert_eq!(
normalize_confirmed_write_sql(Some(" DELETE FROM sessions WHERE id = 7 ".to_string())),
Some("DELETE FROM sessions WHERE id = 7".to_string())
);
assert_eq!(normalize_confirmed_write_sql(Some(" \n ".to_string())), None);
let read_only = ConnectionConfig { read_only: true, ..connection("readonly", "readonly", "postgres", "app") };
let writable_policy =
McpGlobalPolicy { read_only: false, allow_dangerous_sql: true, allowed_connection_ids: None };
let read_only_error =
validate_sql_policy(&read_only, &writable_policy, "app", "DELETE FROM sessions", false).unwrap_err();
assert!(result_text(&read_only_error).contains("CONNECTION_READ_ONLY"));
let mut production = connection("production", "production", "postgres", "app");
production.production_databases = vec!["app".to_string()];
let production_error =
validate_sql_policy(&production, &writable_policy, "app", "DROP TABLE sessions", false).unwrap_err();
assert!(result_text(&production_error).contains("PRODUCTION_WRITE_BLOCKED"));
}
/// RAII guard that sets an env var and restores the original value (or
/// removes the var) on drop. Panic-safe — cleanup runs even when an
/// assertion fails.
struct EnvGuard {
key: &'static str,
original: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, original }
}
fn remove(key: &'static str) -> Self {
let original = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, original }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.original {
Some(original) => std::env::set_var(self.key, original),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn confirmed_sql_binding_cannot_elevate_central_policy() {
let connection = connection("dev", "dev", "postgres", "app");
// An exact confirmation remains available as a narrowing constraint,
// but cannot turn the central safe-write policy into full access.
let _guard = EnvGuard::set("DBX_MCP_CONFIRMED_WRITE_SQL", "CREATE TABLE metrics (id INT)");
let safe_write = McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: None };
let permissions = mcp_permissions(&connection, &safe_write);
assert!(!permissions.allow_dangerous, "confirmed SQL must NOT elevate allow_dangerous for Redis/Mongo paths");
assert!(permissions.allow_writes);
assert_eq!(permissions.confirmed_write_sql.as_deref(), Some("CREATE TABLE metrics (id INT)"));
let error =
validate_sql_policy(&connection, &safe_write, "app", "CREATE TABLE metrics (id INT)", false).unwrap_err();
assert!(result_text(&error).contains("SQL_BLOCKED"));
assert!(result_text(&error).contains("High-risk SQL is disabled"));
drop(_guard);
// A missing binding cannot change the same safe-write boundary.
let _guard = EnvGuard::remove("DBX_MCP_CONFIRMED_WRITE_SQL");
let error = validate_sql_policy(&connection, &safe_write, "app", "DROP TABLE sessions", false).unwrap_err();
assert!(result_text(&error).contains("SQL_BLOCKED"));
assert!(result_text(&error).contains("High-risk SQL is disabled"));
drop(_guard);
// Full access still permits an exact confirmed DDL statement and keeps
// the binding for the execution-time anti-replay check.
let _guard = EnvGuard::set("DBX_MCP_CONFIRMED_WRITE_SQL", "CREATE TABLE metrics (id INT)");
let full_access = McpGlobalPolicy { read_only: false, allow_dangerous_sql: true, allowed_connection_ids: None };
let permissions =
validate_sql_policy(&connection, &full_access, "app", "CREATE TABLE metrics (id INT)", false).unwrap();
assert!(permissions.allow_writes);
assert!(permissions.allow_dangerous);
assert_eq!(permissions.confirmed_write_sql.as_deref(), Some("CREATE TABLE metrics (id INT)"));
// A global read-only policy is still authoritative even for the exact
// confirmed statement, while read queries remain available.
let read_only_policy =
McpGlobalPolicy { read_only: true, allow_dangerous_sql: false, allowed_connection_ids: None };
let error = validate_sql_policy(&connection, &read_only_policy, "app", "CREATE TABLE metrics (id INT)", false)
.unwrap_err();
assert!(result_text(&error).contains("MCP_READ_ONLY"), "confirmed SQL must not bypass global read_only");
assert!(validate_sql_policy(&connection, &read_only_policy, "app", "SELECT 1", false).is_ok());
drop(_guard);
// Safe-write DML remains allowed, and an exact binding never grants the
// DDL/high-risk bit implicitly.
let _guard = EnvGuard::set("DBX_MCP_CONFIRMED_WRITE_SQL", "INSERT INTO metrics (id) VALUES (1)");
let permissions =
validate_sql_policy(&connection, &safe_write, "app", "INSERT INTO metrics (id) VALUES (1)", false).unwrap();
assert!(permissions.allow_writes);
assert!(!permissions.allow_dangerous);
assert_eq!(permissions.confirmed_write_sql.as_deref(), Some("INSERT INTO metrics (id) VALUES (1)"));
}
#[test]
fn use_statements_require_a_session() {
let starrocks = connection("sr", "sr", "starrocks", "default_catalog");
let policy = McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: None };
let blocked = validate_sql_policy(&starrocks, &policy, "default_catalog", "USE analytics", false).unwrap_err();
assert!(result_text(&blocked).contains("SQL_BLOCKED"));
assert!(result_text(&blocked).contains("dbx_open_session"));
// Inside a pinned session, USE is meaningful and passes policy checks.
assert!(validate_sql_policy(&starrocks, &policy, "default_catalog", "USE analytics", true).is_ok());
}
fn selector(id: &str) -> ConnectionSelector {
ConnectionSelector { connection_id: Some(id.to_string()), connection_name: None }
}
#[tokio::test]
async fn session_queries_pin_client_session_and_close_releases_pool() {
let starrocks = connection("sr", "sr", "starrocks", "default_catalog");
let backend = Arc::new(FakeBackend { connections: vec![starrocks], ..Default::default() });
let server = DbxMcpServer::with_runtime_options(backend.clone(), McpScope::default(), false);
let opened =
server.open_session(Parameters(OpenSessionRequest { selector: selector("sr"), database: None })).await;
let session_id = opened_session_id(&opened);
// A USE statement is allowed inside the session and runs with the
// session's pinned client_session_id.
let result = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("sr"),
database: None,
sql: "USE analytics".to_string(),
session_id: Some(session_id.clone()),
cell_char_offset: None,
cell_char_limit: None,
}))
.await;
assert_eq!(result_text(&result), "ok");
let pinned_client_session = {
let recorded = backend.recorded_arguments.lock().unwrap();
let (_, arguments) = recorded.iter().find(|(name, _)| name == "execute_query").unwrap();
arguments["client_session_id"].as_str().unwrap().to_string()
};
assert_eq!(pinned_client_session, format!("mcp:{session_id}"));
// Queries bound to another database are rejected.
let mismatch = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("sr"),
database: Some("other".to_string()),
sql: "SELECT 1".to_string(),
session_id: Some(session_id.clone()),
cell_char_offset: None,
cell_char_limit: None,
}))
.await;
assert!(result_text(&mismatch).contains("SESSION_DATABASE_MISMATCH"));
let closed = server.close_session(Parameters(CloseSessionRequest { session_id: session_id.clone() })).await;
assert!(result_text(&closed).contains("closed"));
assert_eq!(backend.closed_sessions.lock().unwrap().as_slice(), [format!("mcp:{session_id}")]);
// The session is gone: further queries fail instead of silently
// falling back to an unpinned connection.
let missing = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("sr"),
database: None,
sql: "SELECT 1".to_string(),
session_id: Some(session_id.clone()),
cell_char_offset: None,
cell_char_limit: None,
}))
.await;
assert!(result_text(&missing).contains("SESSION_NOT_FOUND"));
let second_close = server.close_session(Parameters(CloseSessionRequest { session_id })).await;
assert!(result_text(&second_close).contains("SESSION_NOT_FOUND"));
}
#[tokio::test]
async fn execute_query_forwards_character_window_options() {
let elasticsearch = connection("es", "es", "elasticsearch", "");
let backend = Arc::new(FakeBackend { connections: vec![elasticsearch], ..Default::default() });
let server = DbxMcpServer::with_runtime_options(backend.clone(), McpScope::default(), false);
let result = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("es"),
database: None,
sql: "GET /logs/_search".to_string(),
session_id: None,
cell_char_offset: Some(200),
cell_char_limit: Some(800),
}))
.await;
assert_eq!(result_text(&result), "ok");
let recorded = backend.recorded_arguments.lock().unwrap();
let (_, arguments) = recorded.iter().find(|(name, _)| name == "execute_query").unwrap();
assert_eq!(arguments["cell_char_offset"], 200);
assert_eq!(arguments["cell_char_limit"], 800);
}
#[tokio::test]
async fn expired_sessions_close_backend_pools_without_accumulating() {
let starrocks = connection("sr", "sr", "starrocks", "default_catalog");
let backend = Arc::new(FakeBackend { connections: vec![starrocks], ..Default::default() });
let server = DbxMcpServer::with_runtime_options(backend.clone(), McpScope::default(), false);
let mut expired_client_session_ids = Vec::new();
for _ in 0..3 {
let opened =
server.open_session(Parameters(OpenSessionRequest { selector: selector("sr"), database: None })).await;
let session_id = opened_session_id(&opened);
let result = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("sr"),
database: None,
sql: "SELECT 1".to_string(),
session_id: Some(session_id.clone()),
cell_char_offset: None,
cell_char_limit: None,
}))
.await;
assert_eq!(result_text(&result), "ok");
assert_eq!(backend.pinned_sessions.lock().unwrap().len(), 1);
server.sessions.expire_for_test(&session_id).await;
expired_client_session_ids.push(format!("mcp:{session_id}"));
}
let opened =
server.open_session(Parameters(OpenSessionRequest { selector: selector("sr"), database: None })).await;
let final_session_id = opened_session_id(&opened);
assert!(backend.pinned_sessions.lock().unwrap().is_empty());
assert_eq!(backend.closed_sessions.lock().unwrap().as_slice(), expired_client_session_ids.as_slice());
let closed = server.close_session(Parameters(CloseSessionRequest { session_id: final_session_id })).await;
assert!(result_text(&closed).contains("closed"));
}
#[tokio::test]
async fn failed_session_close_can_be_retried() {
let starrocks = connection("sr", "sr", "starrocks", "default_catalog");
let backend = Arc::new(FakeBackend { connections: vec![starrocks], ..Default::default() });
*backend.close_failures_remaining.lock().unwrap() = 1;
let server = DbxMcpServer::with_runtime_options(backend.clone(), McpScope::default(), false);
let opened =
server.open_session(Parameters(OpenSessionRequest { selector: selector("sr"), database: None })).await;
let session_id = opened_session_id(&opened);
let query = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("sr"),
database: None,
sql: "SELECT 1".to_string(),
session_id: Some(session_id.clone()),
cell_char_offset: None,
cell_char_limit: None,
}))
.await;
assert_eq!(result_text(&query), "ok");
let failed = server.close_session(Parameters(CloseSessionRequest { session_id: session_id.clone() })).await;
assert!(result_text(&failed).contains("SESSION_CLOSE_ERROR"));
assert_eq!(backend.pinned_sessions.lock().unwrap().len(), 1);
let retry_query = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("sr"),
database: None,
sql: "SELECT 1".to_string(),
session_id: Some(session_id.clone()),
cell_char_offset: None,
cell_char_limit: None,
}))
.await;
assert_eq!(result_text(&retry_query), "ok");
let closed = server.close_session(Parameters(CloseSessionRequest { session_id })).await;
assert!(result_text(&closed).contains("closed"));
assert!(backend.pinned_sessions.lock().unwrap().is_empty());
assert_eq!(backend.closed_sessions.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn open_session_rejects_non_sql_connections_and_unknown_sessions_fail_closed() {
let redis = connection("redis", "redis", "redis", "0");
let pg = connection("pg", "pg", "postgres", "app");
let server = DbxMcpServer::with_runtime_options(
Arc::new(FakeBackend { connections: vec![redis, pg], ..Default::default() }),
McpScope::default(),
false,
);
let rejected =
server.open_session(Parameters(OpenSessionRequest { selector: selector("redis"), database: None })).await;
assert!(result_text(&rejected).contains("SESSION_UNSUPPORTED"));
let missing = server
.execute_query(Parameters(ExecuteQueryRequest {
selector: selector("pg"),
database: None,
sql: "SELECT 1".to_string(),
session_id: Some("mcp-session-nope".to_string()),
cell_char_offset: None,
cell_char_limit: None,
}))
.await;
assert!(result_text(&missing).contains("SESSION_NOT_FOUND"));
}
}