Skip to main content

database_mcp/db/
backend.rs

1//! Database backend trait, enum dispatch, and tool-level orchestration.
2//!
3//! Defines the [`DatabaseBackend`] trait and [`Backend`] enum that
4//! dispatches to `MySQL`, `PostgreSQL`, or `SQLite` without dynamic dispatch.
5//! The inherent `impl Backend` block provides MCP tool entry points that
6//! combine input validation, delegation, and JSON formatting.
7
8use crate::db::mysql::MysqlBackend;
9use crate::db::postgres::PostgresBackend;
10use crate::db::sqlite::SqliteBackend;
11use crate::db::validation::validate_read_only_with_dialect;
12use crate::error::AppError;
13use serde_json::Value;
14use sqlparser::dialect::Dialect;
15use tracing::{error, info};
16
17/// Operations every database backend must support.
18#[allow(async_fn_in_trait)]
19pub trait DatabaseBackend {
20    /// Lists all accessible databases.
21    async fn list_databases(&self) -> Result<Vec<String>, AppError>;
22
23    /// Lists all tables in a database.
24    async fn list_tables(&self, database: &str) -> Result<Vec<String>, AppError>;
25
26    /// Returns column definitions for a table.
27    async fn get_table_schema(&self, database: &str, table: &str) -> Result<Value, AppError>;
28
29    /// Returns column definitions with foreign key relationships.
30    async fn get_table_schema_with_relations(&self, database: &str, table: &str) -> Result<Value, AppError>;
31
32    /// Executes a SQL query and returns rows as a JSON array.
33    async fn execute_query(&self, sql: &str, database: Option<&str>) -> Result<Value, AppError>;
34
35    /// Creates a database if it doesn't exist.
36    async fn create_database(&self, name: &str) -> Result<Value, AppError>;
37
38    /// Returns the SQL dialect for this backend.
39    fn dialect(&self) -> Box<dyn Dialect>;
40
41    /// Whether read-only mode is enabled.
42    fn read_only(&self) -> bool;
43}
44
45/// Concrete database backend — dispatches to the active variant.
46///
47/// Only one instance exists for the program lifetime, so the size
48/// difference between variants is irrelevant.
49#[derive(Debug, Clone)]
50#[allow(clippy::large_enum_variant)]
51pub enum Backend {
52    /// `MySQL`/`MariaDB` via sqlx.
53    Mysql(MysqlBackend),
54    /// `PostgreSQL` via sqlx.
55    Postgres(PostgresBackend),
56    /// `SQLite` via sqlx.
57    Sqlite(SqliteBackend),
58}
59
60impl DatabaseBackend for Backend {
61    async fn list_databases(&self) -> Result<Vec<String>, AppError> {
62        match self {
63            Self::Mysql(b) => b.list_databases().await,
64            Self::Postgres(b) => b.list_databases().await,
65            Self::Sqlite(b) => b.list_databases().await,
66        }
67    }
68
69    async fn list_tables(&self, database: &str) -> Result<Vec<String>, AppError> {
70        match self {
71            Self::Mysql(b) => b.list_tables(database).await,
72            Self::Postgres(b) => b.list_tables(database).await,
73            Self::Sqlite(b) => b.list_tables(database).await,
74        }
75    }
76
77    async fn get_table_schema(&self, database: &str, table: &str) -> Result<Value, AppError> {
78        match self {
79            Self::Mysql(b) => b.get_table_schema(database, table).await,
80            Self::Postgres(b) => b.get_table_schema(database, table).await,
81            Self::Sqlite(b) => b.get_table_schema(database, table).await,
82        }
83    }
84
85    async fn get_table_schema_with_relations(&self, database: &str, table: &str) -> Result<Value, AppError> {
86        match self {
87            Self::Mysql(b) => b.get_table_schema_with_relations(database, table).await,
88            Self::Postgres(b) => b.get_table_schema_with_relations(database, table).await,
89            Self::Sqlite(b) => b.get_table_schema_with_relations(database, table).await,
90        }
91    }
92
93    async fn execute_query(&self, sql: &str, database: Option<&str>) -> Result<Value, AppError> {
94        match self {
95            Self::Mysql(b) => b.execute_query(sql, database).await,
96            Self::Postgres(b) => b.execute_query(sql, database).await,
97            Self::Sqlite(b) => b.execute_query(sql, database).await,
98        }
99    }
100
101    async fn create_database(&self, name: &str) -> Result<Value, AppError> {
102        match self {
103            Self::Mysql(b) => b.create_database(name).await,
104            Self::Postgres(b) => b.create_database(name).await,
105            Self::Sqlite(b) => b.create_database(name).await,
106        }
107    }
108
109    fn dialect(&self) -> Box<dyn Dialect> {
110        match self {
111            Self::Mysql(b) => b.dialect(),
112            Self::Postgres(b) => b.dialect(),
113            Self::Sqlite(b) => b.dialect(),
114        }
115    }
116
117    fn read_only(&self) -> bool {
118        match self {
119            Self::Mysql(b) => b.read_only(),
120            Self::Postgres(b) => b.read_only(),
121            Self::Sqlite(b) => b.read_only(),
122        }
123    }
124}
125
126impl Backend {
127    /// Lists all accessible databases as a JSON array.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`AppError`] if the backend query fails.
132    pub async fn tool_list_databases(&self) -> Result<String, AppError> {
133        info!("TOOL: list_databases called");
134        let db_list = self.list_databases().await?;
135        info!("TOOL: list_databases completed. Databases found: {}", db_list.len());
136        Ok(serde_json::to_string_pretty(&db_list).unwrap_or_else(|_| "[]".into()))
137    }
138
139    /// Lists all tables in a database as a JSON array.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`AppError`] if the identifier is invalid or the backend query fails.
144    pub async fn tool_list_tables(&self, database_name: &str) -> Result<String, AppError> {
145        info!("TOOL: list_tables called. database_name={database_name}");
146        let table_list = match self.list_tables(database_name).await {
147            Ok(t) => t,
148            Err(e) => {
149                error!("TOOL ERROR: list_tables failed for database_name={database_name}: {e}");
150                return Err(e);
151            }
152        };
153        info!("TOOL: list_tables completed. Tables found: {}", table_list.len());
154        Ok(serde_json::to_string_pretty(&table_list).unwrap_or_else(|_| "[]".into()))
155    }
156
157    /// Returns column definitions for a table as JSON.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`AppError`] if identifiers are invalid or the backend query fails.
162    pub async fn tool_get_table_schema(&self, database_name: &str, table_name: &str) -> Result<String, AppError> {
163        info!("TOOL: get_table_schema called. database_name={database_name}, table_name={table_name}");
164        let schema = self.get_table_schema(database_name, table_name).await?;
165        info!("TOOL: get_table_schema completed");
166        Ok(serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".into()))
167    }
168
169    /// Returns column definitions with foreign key relationships.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`AppError`] if identifiers are invalid or the backend query fails.
174    pub async fn tool_get_table_schema_with_relations(
175        &self,
176        database_name: &str,
177        table_name: &str,
178    ) -> Result<String, AppError> {
179        info!("TOOL: get_table_schema_with_relations called. database_name={database_name}, table_name={table_name}");
180        let result = self.get_table_schema_with_relations(database_name, table_name).await?;
181        info!("TOOL: get_table_schema_with_relations completed");
182        Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()))
183    }
184
185    /// Executes a SQL query and returns results as a JSON string.
186    ///
187    /// Includes read-only validation as defence-in-depth. The `read_query`
188    /// tool handler also validates, but this ensures safety even when
189    /// calling `tool_execute_sql` directly (e.g. from integration tests).
190    ///
191    /// # Errors
192    ///
193    /// Returns [`AppError`] if the query is blocked by read-only mode
194    /// or the backend query fails.
195    pub async fn tool_execute_sql(&self, sql_query: &str, database_name: &str) -> Result<String, AppError> {
196        info!(
197            "TOOL: execute_sql called. database_name={database_name}, sql_query={}",
198            &sql_query[..sql_query.len().min(100)]
199        );
200
201        if self.read_only() {
202            let dialect = self.dialect();
203            validate_read_only_with_dialect(sql_query, dialect.as_ref())?;
204        }
205
206        let db = if database_name.is_empty() {
207            None
208        } else {
209            Some(database_name)
210        };
211
212        let results = self.execute_query(sql_query, db).await?;
213        let row_count = results.as_array().map_or(0, Vec::len);
214        info!("TOOL: execute_sql completed. Rows returned: {row_count}");
215        Ok(serde_json::to_string_pretty(&results).unwrap_or_else(|_| "[]".into()))
216    }
217
218    /// Creates a database if it does not already exist.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`AppError`] if the identifier is invalid, the server is in
223    /// read-only mode, or the backend query fails.
224    pub async fn tool_create_database(&self, database_name: &str) -> Result<String, AppError> {
225        info!("TOOL: create_database called for database: '{database_name}'");
226        let result = self.create_database(database_name).await?;
227        info!("TOOL: create_database completed");
228        Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".into()))
229    }
230}