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