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    /// HTTP bind host is empty.
32    #[error("HTTP_HOST must not be empty")]
33    EmptyHttpHost,
34}
35
36/// Supported database backends.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
38pub enum DatabaseBackend {
39    /// `MySQL` database.
40    Mysql,
41    /// `MariaDB` database (uses the `MySQL` driver).
42    Mariadb,
43    /// `PostgreSQL` database.
44    Postgres,
45    /// `SQLite` file-based database.
46    Sqlite,
47}
48
49impl std::fmt::Display for DatabaseBackend {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Mysql => write!(f, "mysql"),
53            Self::Mariadb => write!(f, "mariadb"),
54            Self::Postgres => write!(f, "postgres"),
55            Self::Sqlite => write!(f, "sqlite"),
56        }
57    }
58}
59
60impl DatabaseBackend {
61    /// Returns the default port for this backend.
62    #[must_use]
63    pub fn default_port(self) -> u16 {
64        match self {
65            Self::Postgres => 5432,
66            Self::Mysql | Self::Mariadb => 3306,
67            Self::Sqlite => 0,
68        }
69    }
70
71    /// Returns the default username for this backend.
72    #[must_use]
73    pub fn default_user(self) -> &'static str {
74        match self {
75            Self::Mysql | Self::Mariadb => "root",
76            Self::Postgres => "postgres",
77            Self::Sqlite => "",
78        }
79    }
80}
81
82/// Database connection and behavior settings.
83///
84/// All fields are fully resolved — no `Option` indirection for connection
85/// fields. Defaults are applied during construction in `From<&Cli>`.
86#[derive(Clone)]
87pub struct DatabaseConfig {
88    /// Database backend type.
89    pub backend: DatabaseBackend,
90
91    /// Database host (resolved default: `"localhost"`).
92    pub host: String,
93
94    /// Database port (resolved default: backend-dependent).
95    pub port: u16,
96
97    /// Database user (resolved default: backend-dependent).
98    pub user: String,
99
100    /// Database password.
101    pub password: Option<String>,
102
103    /// Database name or `SQLite` file path.
104    pub name: Option<String>,
105
106    /// Character set for MySQL/MariaDB connections.
107    pub charset: Option<String>,
108
109    /// Enable SSL/TLS for the database connection.
110    pub ssl: bool,
111
112    /// Path to the CA certificate for SSL.
113    pub ssl_ca: Option<String>,
114
115    /// Path to the client certificate for SSL.
116    pub ssl_cert: Option<String>,
117
118    /// Path to the client key for SSL.
119    pub ssl_key: Option<String>,
120
121    /// Whether to verify the server certificate.
122    pub ssl_verify_cert: bool,
123
124    /// Whether the server runs in read-only mode.
125    pub read_only: bool,
126
127    /// Maximum database connection pool size.
128    pub max_pool_size: u32,
129
130    /// Connection timeout in seconds (`None` = driver default).
131    pub connection_timeout: Option<u64>,
132
133    /// Query execution timeout in seconds.
134    ///
135    /// `None` means "use default" (30 s when constructed via CLI).
136    /// `Some(0)` disables the timeout entirely.
137    pub query_timeout: Option<u64>,
138}
139
140impl std::fmt::Debug for DatabaseConfig {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.debug_struct("DatabaseConfig")
143            .field("backend", &self.backend)
144            .field("host", &self.host)
145            .field("port", &self.port)
146            .field("user", &self.user)
147            .field("password", &"[REDACTED]")
148            .field("name", &self.name)
149            .field("charset", &self.charset)
150            .field("ssl", &self.ssl)
151            .field("ssl_ca", &self.ssl_ca)
152            .field("ssl_cert", &self.ssl_cert)
153            .field("ssl_key", &self.ssl_key)
154            .field("ssl_verify_cert", &self.ssl_verify_cert)
155            .field("read_only", &self.read_only)
156            .field("max_pool_size", &self.max_pool_size)
157            .field("connection_timeout", &self.connection_timeout)
158            .field("query_timeout", &self.query_timeout)
159            .finish()
160    }
161}
162
163impl DatabaseConfig {
164    /// Default database backend.
165    pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
166    /// Default database host.
167    pub const DEFAULT_HOST: &'static str = "localhost";
168    /// Default SSL enabled state.
169    pub const DEFAULT_SSL: bool = false;
170    /// Default SSL certificate verification.
171    pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
172    /// Default read-only mode.
173    pub const DEFAULT_READ_ONLY: bool = true;
174    /// Default connection pool size.
175    pub const DEFAULT_MAX_POOL_SIZE: u32 = 5;
176    /// Default idle timeout in seconds (10 minutes).
177    pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 600;
178    /// Default max lifetime in seconds (30 minutes).
179    pub const DEFAULT_MAX_LIFETIME_SECS: u64 = 1800;
180    /// Default minimum connections in pool.
181    pub const DEFAULT_MIN_CONNECTIONS: u32 = 1;
182    /// Default query execution timeout in seconds.
183    pub const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30;
184
185    /// Validates the database configuration and returns all errors found.
186    ///
187    /// # Errors
188    ///
189    /// Returns a `Vec<ConfigError>` if any validation rules fail.
190    pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
191        let mut errors = Vec::new();
192
193        if self.backend == DatabaseBackend::Sqlite && self.name.as_deref().unwrap_or_default().is_empty() {
194            errors.push(ConfigError::MissingSqliteDbName);
195        }
196
197        if self.ssl {
198            for (name, path) in [
199                ("DB_SSL_CA", &self.ssl_ca),
200                ("DB_SSL_CERT", &self.ssl_cert),
201                ("DB_SSL_KEY", &self.ssl_key),
202            ] {
203                if let Some(path) = path
204                    && !std::path::Path::new(path).exists()
205                {
206                    errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
207                }
208            }
209        }
210
211        errors.is_empty().then_some(()).ok_or(errors)
212    }
213}
214
215impl Default for DatabaseConfig {
216    fn default() -> Self {
217        Self {
218            backend: Self::DEFAULT_BACKEND,
219            host: Self::DEFAULT_HOST.into(),
220            port: Self::DEFAULT_BACKEND.default_port(),
221            user: Self::DEFAULT_BACKEND.default_user().into(),
222            password: None,
223            name: None,
224            charset: None,
225            ssl: Self::DEFAULT_SSL,
226            ssl_ca: None,
227            ssl_cert: None,
228            ssl_key: None,
229            ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
230            read_only: Self::DEFAULT_READ_ONLY,
231            max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
232            connection_timeout: None,
233            query_timeout: None,
234        }
235    }
236}
237
238/// HTTP transport binding and security settings.
239#[derive(Clone, Debug)]
240pub struct HttpConfig {
241    /// Bind host for HTTP transport.
242    pub host: String,
243
244    /// Bind port for HTTP transport.
245    pub port: u16,
246
247    /// Allowed CORS origins.
248    pub allowed_origins: Vec<String>,
249
250    /// Allowed host names.
251    pub allowed_hosts: Vec<String>,
252}
253
254impl HttpConfig {
255    /// Default HTTP bind host.
256    pub const DEFAULT_HOST: &'static str = "127.0.0.1";
257    /// Default HTTP bind port.
258    pub const DEFAULT_PORT: u16 = 9001;
259
260    /// Return default allowed CORS origins.
261    #[must_use]
262    pub fn default_allowed_origins() -> Vec<String> {
263        vec![
264            "http://localhost".into(),
265            "http://127.0.0.1".into(),
266            "https://localhost".into(),
267            "https://127.0.0.1".into(),
268        ]
269    }
270
271    /// Returns default allowed host names.
272    #[must_use]
273    pub fn default_allowed_hosts() -> Vec<String> {
274        vec!["localhost".into(), "127.0.0.1".into()]
275    }
276
277    /// Validates the HTTP configuration and returns all errors found.
278    ///
279    /// # Errors
280    ///
281    /// Returns a `Vec<ConfigError>` if any validation rules fail.
282    pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
283        let mut errors = Vec::new();
284
285        if self.host.trim().is_empty() {
286            errors.push(ConfigError::EmptyHttpHost);
287        }
288
289        errors.is_empty().then_some(()).ok_or(errors)
290    }
291}
292
293/// Runtime configuration for the MCP server.
294///
295/// Composes [`DatabaseConfig`] with an optional [`HttpConfig`].
296/// HTTP config is present only when the HTTP transport is selected
297/// (via subcommand or `MCP_TRANSPORT` env var). Logging is configured
298/// directly from CLI arguments before `Config` is constructed, so it
299/// is not part of this struct.
300#[derive(Clone, Debug)]
301pub struct Config {
302    /// Database connection and behavior settings.
303    pub database: DatabaseConfig,
304
305    /// HTTP transport settings (present only when HTTP transport is active).
306    pub http: Option<HttpConfig>,
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
314        DatabaseConfig {
315            backend,
316            port: backend.default_port(),
317            user: backend.default_user().into(),
318            ..DatabaseConfig::default()
319        }
320    }
321
322    fn base_config(backend: DatabaseBackend) -> Config {
323        Config {
324            database: db_config(backend),
325            http: None,
326        }
327    }
328
329    fn mysql_config() -> Config {
330        Config {
331            database: DatabaseConfig {
332                port: 3306,
333                user: "root".into(),
334                password: Some("secret".into()),
335                ..db_config(DatabaseBackend::Mysql)
336            },
337            ..base_config(DatabaseBackend::Mysql)
338        }
339    }
340
341    #[test]
342    fn debug_redacts_password() {
343        let config = Config {
344            database: DatabaseConfig {
345                password: Some("super_secret_password".into()),
346                ..mysql_config().database
347            },
348            ..mysql_config()
349        };
350        let debug_output = format!("{config:?}");
351        assert!(
352            !debug_output.contains("super_secret_password"),
353            "password leaked in debug output: {debug_output}"
354        );
355        assert!(
356            debug_output.contains("[REDACTED]"),
357            "expected [REDACTED] in debug output: {debug_output}"
358        );
359    }
360
361    #[test]
362    fn valid_mysql_config_passes() {
363        assert!(mysql_config().database.validate().is_ok());
364    }
365
366    #[test]
367    fn valid_postgres_config_passes() {
368        let config = Config {
369            database: DatabaseConfig {
370                user: "pguser".into(),
371                port: 5432,
372                ..db_config(DatabaseBackend::Postgres)
373            },
374            ..base_config(DatabaseBackend::Postgres)
375        };
376        assert!(config.database.validate().is_ok());
377    }
378
379    #[test]
380    fn valid_sqlite_config_passes() {
381        let config = Config {
382            database: DatabaseConfig {
383                name: Some("./test.db".into()),
384                ..db_config(DatabaseBackend::Sqlite)
385            },
386            ..base_config(DatabaseBackend::Sqlite)
387        };
388        assert!(config.database.validate().is_ok());
389    }
390
391    #[test]
392    fn defaults_resolved_at_construction() {
393        let mysql = base_config(DatabaseBackend::Mysql);
394        assert_eq!(mysql.database.host, "localhost");
395        assert_eq!(mysql.database.port, 3306);
396        assert_eq!(mysql.database.user, "root");
397
398        let pg = base_config(DatabaseBackend::Postgres);
399        assert_eq!(pg.database.port, 5432);
400        assert_eq!(pg.database.user, "postgres");
401
402        let sqlite = base_config(DatabaseBackend::Sqlite);
403        assert_eq!(sqlite.database.port, 0);
404        assert_eq!(sqlite.database.user, "");
405    }
406
407    #[test]
408    fn explicit_values_override_defaults() {
409        let config = Config {
410            database: DatabaseConfig {
411                host: "dbserver.example.com".into(),
412                port: 13306,
413                user: "myuser".into(),
414                ..db_config(DatabaseBackend::Mysql)
415            },
416            ..base_config(DatabaseBackend::Mysql)
417        };
418        assert_eq!(config.database.host, "dbserver.example.com");
419        assert_eq!(config.database.port, 13306);
420        assert_eq!(config.database.user, "myuser");
421    }
422
423    #[test]
424    fn mysql_without_user_gets_default() {
425        let config = base_config(DatabaseBackend::Mysql);
426        assert_eq!(config.database.user, "root");
427        assert!(config.database.validate().is_ok());
428    }
429
430    #[test]
431    fn sqlite_requires_db_name() {
432        let config = base_config(DatabaseBackend::Sqlite);
433        let errors = config
434            .database
435            .validate()
436            .expect_err("sqlite without db name must fail");
437        assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
438    }
439
440    #[test]
441    fn multiple_errors_accumulated() {
442        let config = Config {
443            database: DatabaseConfig {
444                ssl: true,
445                ssl_ca: Some("/nonexistent/ca.pem".into()),
446                ssl_cert: Some("/nonexistent/cert.pem".into()),
447                ssl_key: Some("/nonexistent/key.pem".into()),
448                ..db_config(DatabaseBackend::Mysql)
449            },
450            ..base_config(DatabaseBackend::Mysql)
451        };
452        let errors = config
453            .database
454            .validate()
455            .expect_err("missing ssl cert files must fail");
456        assert!(
457            errors.len() >= 3,
458            "expected at least 3 errors, got {}: {errors:?}",
459            errors.len()
460        );
461    }
462
463    #[test]
464    fn mariadb_backend_is_valid() {
465        let config = base_config(DatabaseBackend::Mariadb);
466        assert!(config.database.validate().is_ok());
467    }
468
469    #[test]
470    fn query_timeout_default_is_none() {
471        let config = DatabaseConfig::default();
472        assert!(config.query_timeout.is_none());
473    }
474
475    fn http_config() -> HttpConfig {
476        HttpConfig {
477            host: HttpConfig::DEFAULT_HOST.into(),
478            port: HttpConfig::DEFAULT_PORT,
479            allowed_origins: HttpConfig::default_allowed_origins(),
480            allowed_hosts: HttpConfig::default_allowed_hosts(),
481        }
482    }
483
484    #[test]
485    fn valid_http_config_passes() {
486        assert!(http_config().validate().is_ok());
487    }
488
489    #[test]
490    fn empty_http_host_rejected() {
491        let config = HttpConfig {
492            host: String::new(),
493            ..http_config()
494        };
495        let errors = config.validate().expect_err("empty host must fail");
496        assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
497    }
498
499    #[test]
500    fn whitespace_http_host_rejected() {
501        let config = HttpConfig {
502            host: "   ".into(),
503            ..http_config()
504        };
505        let errors = config.validate().expect_err("whitespace host must fail");
506        assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
507    }
508
509    #[test]
510    fn debug_includes_query_timeout() {
511        let config = Config {
512            database: DatabaseConfig {
513                query_timeout: Some(30),
514                ..db_config(DatabaseBackend::Mysql)
515            },
516            ..base_config(DatabaseBackend::Mysql)
517        };
518        let debug = format!("{config:?}");
519        assert!(
520            debug.contains("query_timeout: Some(30)"),
521            "expected query_timeout in debug output: {debug}"
522        );
523    }
524}