Skip to main content

database_mcp/
config.rs

1//! Configuration for the MCP server.
2//!
3//! Configuration is organized into sections:
4//! - [`DatabaseConfig`] — database connection and behavior settings
5//! - [`HttpConfig`] — HTTP transport binding and security settings
6//!
7//! The top-level [`Config`] composes these sections. Database connection is
8//! configured via individual variables (`DB_HOST`, `DB_PORT`, `DB_USER`,
9//! `DB_PASSWORD`, `DB_NAME`, `DB_BACKEND`) instead of a single DSN URL.
10//! Values are resolved with clear precedence:
11//! CLI flags > environment variables > defaults.
12//!
13//! All defaults (backend-aware port, user, host) are resolved at construction
14//! time in the `From<&Cli>` conversion — consumers access plain values directly.
15//!
16//! # Security
17//!
18//! [`DatabaseConfig`] implements [`Debug`] manually to redact the database password.
19
20/// Errors that can occur during configuration validation.
21#[derive(Debug, thiserror::Error)]
22pub enum ConfigError {
23    /// `DB_NAME` is required for `SQLite`.
24    #[error("DB_NAME (file path) is required for SQLite")]
25    MissingSqliteDbName,
26
27    /// SSL certificate file not found.
28    #[error("{0} file not found: {1}")]
29    SslCertNotFound(String, String),
30}
31
32/// Supported database backends.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
34pub enum DatabaseBackend {
35    /// `MySQL` database.
36    Mysql,
37    /// `MariaDB` database (uses the `MySQL` driver).
38    Mariadb,
39    /// `PostgreSQL` database.
40    Postgres,
41    /// `SQLite` file-based database.
42    Sqlite,
43}
44
45impl std::fmt::Display for DatabaseBackend {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Mysql => write!(f, "mysql"),
49            Self::Mariadb => write!(f, "mariadb"),
50            Self::Postgres => write!(f, "postgres"),
51            Self::Sqlite => write!(f, "sqlite"),
52        }
53    }
54}
55
56impl DatabaseBackend {
57    /// Returns the default port for this backend.
58    #[must_use]
59    pub fn default_port(self) -> u16 {
60        match self {
61            Self::Postgres => 5432,
62            Self::Mysql | Self::Mariadb => 3306,
63            Self::Sqlite => 0,
64        }
65    }
66
67    /// Returns the default username for this backend.
68    #[must_use]
69    pub fn default_user(self) -> &'static str {
70        match self {
71            Self::Mysql | Self::Mariadb => "root",
72            Self::Postgres => "postgres",
73            Self::Sqlite => "",
74        }
75    }
76}
77
78/// Database connection and behavior settings.
79///
80/// All fields are fully resolved — no `Option` indirection for connection
81/// fields. Defaults are applied during construction in `From<&Cli>`.
82#[derive(Clone)]
83pub struct DatabaseConfig {
84    /// Database backend type.
85    pub backend: DatabaseBackend,
86
87    /// Database host (resolved default: `"localhost"`).
88    pub host: String,
89
90    /// Database port (resolved default: backend-dependent).
91    pub port: u16,
92
93    /// Database user (resolved default: backend-dependent).
94    pub user: String,
95
96    /// Database password.
97    pub password: Option<String>,
98
99    /// Database name or `SQLite` file path.
100    pub name: Option<String>,
101
102    /// Character set for MySQL/MariaDB connections.
103    pub charset: Option<String>,
104
105    /// Enable SSL/TLS for the database connection.
106    pub ssl: bool,
107
108    /// Path to the CA certificate for SSL.
109    pub ssl_ca: Option<String>,
110
111    /// Path to the client certificate for SSL.
112    pub ssl_cert: Option<String>,
113
114    /// Path to the client key for SSL.
115    pub ssl_key: Option<String>,
116
117    /// Whether to verify the server certificate.
118    pub ssl_verify_cert: bool,
119
120    /// Whether the server runs in read-only mode.
121    pub read_only: bool,
122
123    /// Maximum database connection pool size.
124    pub max_pool_size: u32,
125}
126
127impl std::fmt::Debug for DatabaseConfig {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("DatabaseConfig")
130            .field("backend", &self.backend)
131            .field("host", &self.host)
132            .field("port", &self.port)
133            .field("user", &self.user)
134            .field("password", &"[REDACTED]")
135            .field("name", &self.name)
136            .field("charset", &self.charset)
137            .field("ssl", &self.ssl)
138            .field("ssl_ca", &self.ssl_ca)
139            .field("ssl_cert", &self.ssl_cert)
140            .field("ssl_key", &self.ssl_key)
141            .field("ssl_verify_cert", &self.ssl_verify_cert)
142            .field("read_only", &self.read_only)
143            .field("max_pool_size", &self.max_pool_size)
144            .finish()
145    }
146}
147
148impl DatabaseConfig {
149    /// Default database backend.
150    pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
151    /// Default database host.
152    pub const DEFAULT_HOST: &'static str = "localhost";
153    /// Default SSL enabled state.
154    pub const DEFAULT_SSL: bool = false;
155    /// Default SSL certificate verification.
156    pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
157    /// Default read-only mode.
158    pub const DEFAULT_READ_ONLY: bool = true;
159    /// Default connection pool size.
160    pub const DEFAULT_MAX_POOL_SIZE: u32 = 10;
161}
162
163impl Default for DatabaseConfig {
164    fn default() -> Self {
165        Self {
166            backend: Self::DEFAULT_BACKEND,
167            host: Self::DEFAULT_HOST.into(),
168            port: Self::DEFAULT_BACKEND.default_port(),
169            user: Self::DEFAULT_BACKEND.default_user().into(),
170            password: None,
171            name: None,
172            charset: None,
173            ssl: Self::DEFAULT_SSL,
174            ssl_ca: None,
175            ssl_cert: None,
176            ssl_key: None,
177            ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
178            read_only: Self::DEFAULT_READ_ONLY,
179            max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
180        }
181    }
182}
183
184/// HTTP transport binding and security settings.
185#[derive(Clone, Debug)]
186pub struct HttpConfig {
187    /// Bind host for HTTP transport.
188    pub host: String,
189
190    /// Bind port for HTTP transport.
191    pub port: u16,
192
193    /// Allowed CORS origins.
194    pub allowed_origins: Vec<String>,
195
196    /// Allowed host names.
197    pub allowed_hosts: Vec<String>,
198}
199
200impl HttpConfig {
201    /// Default HTTP bind host.
202    pub const DEFAULT_HOST: &'static str = "127.0.0.1";
203    /// Default HTTP bind port.
204    pub const DEFAULT_PORT: u16 = 9001;
205
206    /// Return default allowed CORS origins.
207    #[must_use]
208    pub fn default_allowed_origins() -> Vec<String> {
209        vec![
210            "http://localhost".into(),
211            "http://127.0.0.1".into(),
212            "https://localhost".into(),
213            "https://127.0.0.1".into(),
214        ]
215    }
216
217    /// Returns default allowed host names.
218    #[must_use]
219    pub fn default_allowed_hosts() -> Vec<String> {
220        vec!["localhost".into(), "127.0.0.1".into()]
221    }
222}
223
224/// Runtime configuration for the MCP server.
225///
226/// Composes [`DatabaseConfig`] with an optional [`HttpConfig`].
227/// Server config is only present when the HTTP transport is selected.
228/// Logging is configured directly from CLI arguments before `Config`
229/// is constructed, so it is not part of this struct.
230#[derive(Clone, Debug)]
231pub struct Config {
232    /// Database connection and behavior settings.
233    pub database: DatabaseConfig,
234
235    /// HTTP transport settings (present only for the `http` subcommand).
236    pub server: Option<HttpConfig>,
237}
238
239impl Config {
240    /// Validates the configuration and returns all errors found.
241    ///
242    /// # Errors
243    ///
244    /// Returns a `Vec<ConfigError>` if any validation rules fail.
245    pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
246        let mut errors = Vec::new();
247
248        if self.database.backend == DatabaseBackend::Sqlite
249            && self.database.name.as_deref().unwrap_or_default().is_empty()
250        {
251            errors.push(ConfigError::MissingSqliteDbName);
252        }
253
254        if self.database.ssl {
255            for (name, path) in [
256                ("DB_SSL_CA", &self.database.ssl_ca),
257                ("DB_SSL_CERT", &self.database.ssl_cert),
258                ("DB_SSL_KEY", &self.database.ssl_key),
259            ] {
260                if let Some(path) = path
261                    && !std::path::Path::new(path).exists()
262                {
263                    errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
264                }
265            }
266        }
267
268        if errors.is_empty() { Ok(()) } else { Err(errors) }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
277        DatabaseConfig {
278            backend,
279            port: backend.default_port(),
280            user: backend.default_user().into(),
281            ..DatabaseConfig::default()
282        }
283    }
284
285    fn base_config(backend: DatabaseBackend) -> Config {
286        Config {
287            database: db_config(backend),
288            server: None,
289        }
290    }
291
292    fn mysql_config() -> Config {
293        Config {
294            database: DatabaseConfig {
295                port: 3306,
296                user: "root".into(),
297                password: Some("secret".into()),
298                ..db_config(DatabaseBackend::Mysql)
299            },
300            ..base_config(DatabaseBackend::Mysql)
301        }
302    }
303
304    #[test]
305    fn debug_redacts_password() {
306        let config = Config {
307            database: DatabaseConfig {
308                password: Some("super_secret_password".into()),
309                ..mysql_config().database
310            },
311            ..mysql_config()
312        };
313        let debug_output = format!("{config:?}");
314        assert!(
315            !debug_output.contains("super_secret_password"),
316            "password leaked in debug output: {debug_output}"
317        );
318        assert!(
319            debug_output.contains("[REDACTED]"),
320            "expected [REDACTED] in debug output: {debug_output}"
321        );
322    }
323
324    #[test]
325    fn valid_mysql_config_passes() {
326        assert!(mysql_config().validate().is_ok());
327    }
328
329    #[test]
330    fn valid_postgres_config_passes() {
331        let config = Config {
332            database: DatabaseConfig {
333                user: "pguser".into(),
334                port: 5432,
335                ..db_config(DatabaseBackend::Postgres)
336            },
337            ..base_config(DatabaseBackend::Postgres)
338        };
339        assert!(config.validate().is_ok());
340    }
341
342    #[test]
343    fn valid_sqlite_config_passes() {
344        let config = Config {
345            database: DatabaseConfig {
346                name: Some("./test.db".into()),
347                ..db_config(DatabaseBackend::Sqlite)
348            },
349            ..base_config(DatabaseBackend::Sqlite)
350        };
351        assert!(config.validate().is_ok());
352    }
353
354    #[test]
355    fn defaults_resolved_at_construction() {
356        let mysql = base_config(DatabaseBackend::Mysql);
357        assert_eq!(mysql.database.host, "localhost");
358        assert_eq!(mysql.database.port, 3306);
359        assert_eq!(mysql.database.user, "root");
360
361        let pg = base_config(DatabaseBackend::Postgres);
362        assert_eq!(pg.database.port, 5432);
363        assert_eq!(pg.database.user, "postgres");
364
365        let sqlite = base_config(DatabaseBackend::Sqlite);
366        assert_eq!(sqlite.database.port, 0);
367        assert_eq!(sqlite.database.user, "");
368    }
369
370    #[test]
371    fn explicit_values_override_defaults() {
372        let config = Config {
373            database: DatabaseConfig {
374                host: "dbserver.example.com".into(),
375                port: 13306,
376                user: "myuser".into(),
377                ..db_config(DatabaseBackend::Mysql)
378            },
379            ..base_config(DatabaseBackend::Mysql)
380        };
381        assert_eq!(config.database.host, "dbserver.example.com");
382        assert_eq!(config.database.port, 13306);
383        assert_eq!(config.database.user, "myuser");
384    }
385
386    #[test]
387    fn mysql_without_user_gets_default() {
388        let config = base_config(DatabaseBackend::Mysql);
389        assert_eq!(config.database.user, "root");
390        assert!(config.validate().is_ok());
391    }
392
393    #[test]
394    fn sqlite_requires_db_name() {
395        let config = base_config(DatabaseBackend::Sqlite);
396        let errors = config.validate().unwrap_err();
397        assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
398    }
399
400    #[test]
401    fn multiple_errors_accumulated() {
402        let config = Config {
403            database: DatabaseConfig {
404                ssl: true,
405                ssl_ca: Some("/nonexistent/ca.pem".into()),
406                ssl_cert: Some("/nonexistent/cert.pem".into()),
407                ssl_key: Some("/nonexistent/key.pem".into()),
408                ..db_config(DatabaseBackend::Mysql)
409            },
410            ..base_config(DatabaseBackend::Mysql)
411        };
412        let errors = config.validate().unwrap_err();
413        assert!(
414            errors.len() >= 3,
415            "expected at least 3 errors, got {}: {errors:?}",
416            errors.len()
417        );
418    }
419
420    #[test]
421    fn mariadb_backend_is_valid() {
422        let config = base_config(DatabaseBackend::Mariadb);
423        assert!(config.validate().is_ok());
424    }
425}