Skip to main content

sqlserver_mcp_catalog/core/
config_manager.rs

1// SQL Server 2025 - master/msdb/sandbox combined catalog MCP server — generated by mcpify. Do not hand-edit.
2
3use std::path::{Path, PathBuf};
4
5use serde_json::{Map, Value};
6
7use super::config_schema::Config;
8use super::credential_storage::resolve_home_dir;
9use super::errors::McpifyError;
10
11const ENV_PREFIX: &str = "SQLSERVER";
12const CONFIG_DIR_NAME: &str = ".sqlserver-mcp";
13const LOCAL_CONFIG_FILE: &str = "sqlserver-mcp.config.yml";
14
15/// Shares `credential_storage::resolve_home_dir`'s cross-platform lookup
16/// (`HOME`, falling back to `USERPROFILE` on Windows, falling back to `.`)
17/// rather than reading `HOME` directly, so config-file resolution doesn't
18/// silently collapse to the current directory on Windows.
19fn home_dir() -> PathBuf {
20    resolve_home_dir()
21}
22
23fn read_yaml_if_exists(path: &Path) -> Map<String, Value> {
24    let Ok(contents) = std::fs::read_to_string(path) else {
25        return Map::new();
26    };
27    match serde_yaml::from_str::<Value>(&contents) {
28        Ok(Value::Object(map)) => map,
29        _ => Map::new(),
30    }
31}
32
33fn env_overrides() -> Map<String, Value> {
34    let mut overrides = Map::new();
35    for (config_key, env_suffix) in [
36        ("url", "URL"),
37        ("auth_method", "AUTH_METHOD"),
38        ("sql_port", "SQL_PORT"),
39        ("pool_max_size", "POOL_MAX_SIZE"),
40        ("trust_server_cert", "TRUST_SERVER_CERT"),
41        ("api_version", "API_VERSION"),
42        ("log_level", "LOG_LEVEL"),
43        ("transport", "TRANSPORT"),
44        ("host", "HOST"),
45        ("cors_allow", "CORS_ALLOW"),
46        ("rate_limit", "RATE_LIMIT"),
47        ("timeout_ms", "TIMEOUT_MS"),
48        ("cache_size", "CACHE_SIZE"),
49        ("retry_attempts", "RETRY_ATTEMPTS"),
50        ("port", "PORT"),
51    ] {
52        if let Ok(value) = std::env::var(format!("{ENV_PREFIX}_{env_suffix}")) {
53            overrides.insert(config_key.to_string(), Value::String(value));
54        }
55    }
56    overrides
57}
58
59/// Resolves configuration through the strict, stop-at-first-match cascade
60/// (REQ-2.2): CLI flags -> env vars -> local file
61/// (`./sqlserver-mcp.config.yml`) -> home file
62/// (`~/.sqlserver-mcp/config.yml`) -> system file
63/// (`/etc/sqlserver-mcp/config.yml`) -> install-dir file -> built-in
64/// defaults (applied by `Config`'s own `#[serde(default = ...)]` fields).
65pub fn load_config(cli_flags: Map<String, Value>) -> Result<Config, McpifyError> {
66    let install_dir = std::env::current_exe()
67        .ok()
68        .and_then(|exe| exe.parent().map(Path::to_path_buf))
69        .unwrap_or_else(|| PathBuf::from("."));
70
71    let layers = [
72        cli_flags,
73        env_overrides(),
74        read_yaml_if_exists(&PathBuf::from(LOCAL_CONFIG_FILE)),
75        read_yaml_if_exists(&home_dir().join(CONFIG_DIR_NAME).join("config.yml")),
76        read_yaml_if_exists(&PathBuf::from("/etc/sqlserver-mcp/config.yml")),
77        read_yaml_if_exists(&install_dir.join("config.yml")),
78    ];
79
80    // Lowest-priority layer merged first, each higher-priority layer merged
81    // on top — `cli_flags` (index 0 above, applied last here) always wins,
82    // matching the cascade's stop-at-first-match ordering.
83    let mut merged = Map::new();
84    for layer in layers.into_iter().rev() {
85        merged.extend(layer);
86    }
87
88    serde_json::from_value(Value::Object(merged))
89        .map_err(|err| McpifyError::Configuration(format!("invalid configuration: {err}")))
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use serde_json::json;
96
97    fn base_flags() -> Map<String, Value> {
98        json!({ "url": "localhost", "auth_method": "sql_server" })
99            .as_object()
100            .unwrap()
101            .clone()
102    }
103
104    #[test]
105    fn applies_built_in_defaults_when_nothing_else_is_set() {
106        let config = load_config(base_flags()).unwrap();
107        assert_eq!(config.log_level, "info");
108        assert_eq!(config.rate_limit, 100);
109        assert_eq!(config.port, 3000);
110    }
111
112    #[test]
113    fn cli_flags_win_over_everything_else() {
114        let mut flags = base_flags();
115        flags.insert("log_level".to_string(), json!("debug"));
116        let config = load_config(flags).unwrap();
117        assert_eq!(config.log_level, "debug");
118    }
119
120    #[test]
121    fn missing_required_fields_report_a_configuration_error() {
122        let err = load_config(Map::new()).unwrap_err();
123        assert_eq!(err.code(), "CONFIGURATION_ERROR");
124    }
125}