Skip to main content

database_mcp_config/
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    /// Connection timeout in seconds (`None` = driver default).
127    pub connection_timeout: Option<u64>,
128
129    /// Query execution timeout in seconds.
130    ///
131    /// `None` means "use default" (30 s when constructed via CLI).
132    /// `Some(0)` disables the timeout entirely.
133    pub query_timeout: Option<u64>,
134}
135
136impl std::fmt::Debug for DatabaseConfig {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("DatabaseConfig")
139            .field("backend", &self.backend)
140            .field("host", &self.host)
141            .field("port", &self.port)
142            .field("user", &self.user)
143            .field("password", &"[REDACTED]")
144            .field("name", &self.name)
145            .field("charset", &self.charset)
146            .field("ssl", &self.ssl)
147            .field("ssl_ca", &self.ssl_ca)
148            .field("ssl_cert", &self.ssl_cert)
149            .field("ssl_key", &self.ssl_key)
150            .field("ssl_verify_cert", &self.ssl_verify_cert)
151            .field("read_only", &self.read_only)
152            .field("max_pool_size", &self.max_pool_size)
153            .field("connection_timeout", &self.connection_timeout)
154            .field("query_timeout", &self.query_timeout)
155            .finish()
156    }
157}
158
159impl DatabaseConfig {
160    /// Default database backend.
161    pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
162    /// Default database host.
163    pub const DEFAULT_HOST: &'static str = "localhost";
164    /// Default SSL enabled state.
165    pub const DEFAULT_SSL: bool = false;
166    /// Default SSL certificate verification.
167    pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
168    /// Default read-only mode.
169    pub const DEFAULT_READ_ONLY: bool = true;
170    /// Default connection pool size.
171    pub const DEFAULT_MAX_POOL_SIZE: u32 = 5;
172    /// Default idle timeout in seconds (10 minutes).
173    pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 600;
174    /// Default max lifetime in seconds (30 minutes).
175    pub const DEFAULT_MAX_LIFETIME_SECS: u64 = 1800;
176    /// Default minimum connections in pool.
177    pub const DEFAULT_MIN_CONNECTIONS: u32 = 1;
178    /// Default query execution timeout in seconds.
179    pub const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30;
180}
181
182impl Default for DatabaseConfig {
183    fn default() -> Self {
184        Self {
185            backend: Self::DEFAULT_BACKEND,
186            host: Self::DEFAULT_HOST.into(),
187            port: Self::DEFAULT_BACKEND.default_port(),
188            user: Self::DEFAULT_BACKEND.default_user().into(),
189            password: None,
190            name: None,
191            charset: None,
192            ssl: Self::DEFAULT_SSL,
193            ssl_ca: None,
194            ssl_cert: None,
195            ssl_key: None,
196            ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
197            read_only: Self::DEFAULT_READ_ONLY,
198            max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
199            connection_timeout: None,
200            query_timeout: None,
201        }
202    }
203}
204
205/// HTTP transport binding and security settings.
206#[derive(Clone, Debug)]
207pub struct HttpConfig {
208    /// Bind host for HTTP transport.
209    pub host: String,
210
211    /// Bind port for HTTP transport.
212    pub port: u16,
213
214    /// Allowed CORS origins.
215    pub allowed_origins: Vec<String>,
216
217    /// Allowed host names.
218    pub allowed_hosts: Vec<String>,
219}
220
221impl HttpConfig {
222    /// Default HTTP bind host.
223    pub const DEFAULT_HOST: &'static str = "127.0.0.1";
224    /// Default HTTP bind port.
225    pub const DEFAULT_PORT: u16 = 9001;
226
227    /// Return default allowed CORS origins.
228    #[must_use]
229    pub fn default_allowed_origins() -> Vec<String> {
230        vec![
231            "http://localhost".into(),
232            "http://127.0.0.1".into(),
233            "https://localhost".into(),
234            "https://127.0.0.1".into(),
235        ]
236    }
237
238    /// Returns default allowed host names.
239    #[must_use]
240    pub fn default_allowed_hosts() -> Vec<String> {
241        vec!["localhost".into(), "127.0.0.1".into()]
242    }
243}
244
245/// Runtime configuration for the MCP server.
246///
247/// Composes [`DatabaseConfig`] with an optional [`HttpConfig`].
248/// HTTP config is present only when the HTTP transport is selected
249/// (via subcommand or `MCP_TRANSPORT` env var). Logging is configured
250/// directly from CLI arguments before `Config` is constructed, so it
251/// is not part of this struct.
252#[derive(Clone, Debug)]
253pub struct Config {
254    /// Database connection and behavior settings.
255    pub database: DatabaseConfig,
256
257    /// HTTP transport settings (present only when HTTP transport is active).
258    pub http: Option<HttpConfig>,
259}
260
261impl Config {
262    /// Validates the configuration and returns all errors found.
263    ///
264    /// # Errors
265    ///
266    /// Returns a `Vec<ConfigError>` if any validation rules fail.
267    pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
268        let mut errors = Vec::new();
269
270        if self.database.backend == DatabaseBackend::Sqlite
271            && self.database.name.as_deref().unwrap_or_default().is_empty()
272        {
273            errors.push(ConfigError::MissingSqliteDbName);
274        }
275
276        if self.database.ssl {
277            for (name, path) in [
278                ("DB_SSL_CA", &self.database.ssl_ca),
279                ("DB_SSL_CERT", &self.database.ssl_cert),
280                ("DB_SSL_KEY", &self.database.ssl_key),
281            ] {
282                if let Some(path) = path
283                    && !std::path::Path::new(path).exists()
284                {
285                    errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
286                }
287            }
288        }
289
290        if errors.is_empty() { Ok(()) } else { Err(errors) }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
299        DatabaseConfig {
300            backend,
301            port: backend.default_port(),
302            user: backend.default_user().into(),
303            ..DatabaseConfig::default()
304        }
305    }
306
307    fn base_config(backend: DatabaseBackend) -> Config {
308        Config {
309            database: db_config(backend),
310            http: None,
311        }
312    }
313
314    fn mysql_config() -> Config {
315        Config {
316            database: DatabaseConfig {
317                port: 3306,
318                user: "root".into(),
319                password: Some("secret".into()),
320                ..db_config(DatabaseBackend::Mysql)
321            },
322            ..base_config(DatabaseBackend::Mysql)
323        }
324    }
325
326    #[test]
327    fn debug_redacts_password() {
328        let config = Config {
329            database: DatabaseConfig {
330                password: Some("super_secret_password".into()),
331                ..mysql_config().database
332            },
333            ..mysql_config()
334        };
335        let debug_output = format!("{config:?}");
336        assert!(
337            !debug_output.contains("super_secret_password"),
338            "password leaked in debug output: {debug_output}"
339        );
340        assert!(
341            debug_output.contains("[REDACTED]"),
342            "expected [REDACTED] in debug output: {debug_output}"
343        );
344    }
345
346    #[test]
347    fn valid_mysql_config_passes() {
348        assert!(mysql_config().validate().is_ok());
349    }
350
351    #[test]
352    fn valid_postgres_config_passes() {
353        let config = Config {
354            database: DatabaseConfig {
355                user: "pguser".into(),
356                port: 5432,
357                ..db_config(DatabaseBackend::Postgres)
358            },
359            ..base_config(DatabaseBackend::Postgres)
360        };
361        assert!(config.validate().is_ok());
362    }
363
364    #[test]
365    fn valid_sqlite_config_passes() {
366        let config = Config {
367            database: DatabaseConfig {
368                name: Some("./test.db".into()),
369                ..db_config(DatabaseBackend::Sqlite)
370            },
371            ..base_config(DatabaseBackend::Sqlite)
372        };
373        assert!(config.validate().is_ok());
374    }
375
376    #[test]
377    fn defaults_resolved_at_construction() {
378        let mysql = base_config(DatabaseBackend::Mysql);
379        assert_eq!(mysql.database.host, "localhost");
380        assert_eq!(mysql.database.port, 3306);
381        assert_eq!(mysql.database.user, "root");
382
383        let pg = base_config(DatabaseBackend::Postgres);
384        assert_eq!(pg.database.port, 5432);
385        assert_eq!(pg.database.user, "postgres");
386
387        let sqlite = base_config(DatabaseBackend::Sqlite);
388        assert_eq!(sqlite.database.port, 0);
389        assert_eq!(sqlite.database.user, "");
390    }
391
392    #[test]
393    fn explicit_values_override_defaults() {
394        let config = Config {
395            database: DatabaseConfig {
396                host: "dbserver.example.com".into(),
397                port: 13306,
398                user: "myuser".into(),
399                ..db_config(DatabaseBackend::Mysql)
400            },
401            ..base_config(DatabaseBackend::Mysql)
402        };
403        assert_eq!(config.database.host, "dbserver.example.com");
404        assert_eq!(config.database.port, 13306);
405        assert_eq!(config.database.user, "myuser");
406    }
407
408    #[test]
409    fn mysql_without_user_gets_default() {
410        let config = base_config(DatabaseBackend::Mysql);
411        assert_eq!(config.database.user, "root");
412        assert!(config.validate().is_ok());
413    }
414
415    #[test]
416    fn sqlite_requires_db_name() {
417        let config = base_config(DatabaseBackend::Sqlite);
418        let errors = config.validate().unwrap_err();
419        assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
420    }
421
422    #[test]
423    fn multiple_errors_accumulated() {
424        let config = Config {
425            database: DatabaseConfig {
426                ssl: true,
427                ssl_ca: Some("/nonexistent/ca.pem".into()),
428                ssl_cert: Some("/nonexistent/cert.pem".into()),
429                ssl_key: Some("/nonexistent/key.pem".into()),
430                ..db_config(DatabaseBackend::Mysql)
431            },
432            ..base_config(DatabaseBackend::Mysql)
433        };
434        let errors = config.validate().unwrap_err();
435        assert!(
436            errors.len() >= 3,
437            "expected at least 3 errors, got {}: {errors:?}",
438            errors.len()
439        );
440    }
441
442    #[test]
443    fn mariadb_backend_is_valid() {
444        let config = base_config(DatabaseBackend::Mariadb);
445        assert!(config.validate().is_ok());
446    }
447
448    #[test]
449    fn query_timeout_default_is_none() {
450        let config = DatabaseConfig::default();
451        assert!(config.query_timeout.is_none());
452    }
453
454    #[test]
455    fn debug_includes_query_timeout() {
456        let config = Config {
457            database: DatabaseConfig {
458                query_timeout: Some(30),
459                ..db_config(DatabaseBackend::Mysql)
460            },
461            ..base_config(DatabaseBackend::Mysql)
462        };
463        let debug = format!("{config:?}");
464        assert!(
465            debug.contains("query_timeout: Some(30)"),
466            "expected query_timeout in debug output: {debug}"
467        );
468    }
469}