Skip to main content

github_mcp/core/
config_manager.rs

1// GitHub v3 REST API 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::errors::McpifyError;
9
10const ENV_PREFIX: &str = "GITHUB_MCP";
11const CONFIG_DIR_NAME: &str = ".github-mcp";
12const LOCAL_CONFIG_FILE: &str = "github-mcp.config.yml";
13
14/// Env vars this crate reads directly (`HOME`) rather than via a `dirs`-style
15/// crate: keeps the dependency list matched to the toolchain table, at the
16/// cost of `~` resolution only working where `$HOME` is set (true for every
17/// deployment target this project's Dockerfile/docker-compose.yml target).
18fn home_dir() -> PathBuf {
19    std::env::var_os("HOME")
20        .map(PathBuf::from)
21        .unwrap_or_else(|| PathBuf::from("."))
22}
23
24fn read_yaml_if_exists(path: &Path) -> Map<String, Value> {
25    let Ok(contents) = std::fs::read_to_string(path) else {
26        return Map::new();
27    };
28    match serde_yaml::from_str::<Value>(&contents) {
29        Ok(Value::Object(map)) => map,
30        _ => Map::new(),
31    }
32}
33
34fn env_overrides() -> Map<String, Value> {
35    let mut overrides = Map::new();
36    for (config_key, env_suffix) in [
37        ("url", "URL"),
38        ("auth_method", "AUTH_METHOD"),
39        ("api_version", "API_VERSION"),
40        ("log_level", "LOG_LEVEL"),
41        ("transport", "TRANSPORT"),
42        ("host", "HOST"),
43        ("cors_allow", "CORS_ALLOW"),
44        ("rate_limit", "RATE_LIMIT"),
45        ("timeout_ms", "TIMEOUT_MS"),
46        ("cache_size", "CACHE_SIZE"),
47        ("retry_attempts", "RETRY_ATTEMPTS"),
48        ("port", "PORT"),
49    ] {
50        if let Ok(value) = std::env::var(format!("{ENV_PREFIX}_{env_suffix}")) {
51            overrides.insert(config_key.to_string(), Value::String(value));
52        }
53    }
54    overrides
55}
56
57/// Resolves configuration through the strict, stop-at-first-match cascade
58/// (REQ-2.2): CLI flags -> env vars -> local file
59/// (`./github-mcp.config.yml`) -> home file
60/// (`~/.github-mcp/config.yml`) -> system file
61/// (`/etc/github-mcp/config.yml`) -> install-dir file -> built-in
62/// defaults (applied by `Config`'s own `#[serde(default = ...)]` fields).
63pub fn load_config(cli_flags: Map<String, Value>) -> Result<Config, McpifyError> {
64    let install_dir = std::env::current_exe()
65        .ok()
66        .and_then(|exe| exe.parent().map(Path::to_path_buf))
67        .unwrap_or_else(|| PathBuf::from("."));
68
69    let layers = [
70        cli_flags,
71        env_overrides(),
72        read_yaml_if_exists(&PathBuf::from(LOCAL_CONFIG_FILE)),
73        read_yaml_if_exists(&home_dir().join(CONFIG_DIR_NAME).join("config.yml")),
74        read_yaml_if_exists(&PathBuf::from("/etc/github-mcp/config.yml")),
75        read_yaml_if_exists(&install_dir.join("config.yml")),
76    ];
77
78    // Lowest-priority layer merged first, each higher-priority layer merged
79    // on top — `cli_flags` (index 0 above, applied last here) always wins,
80    // matching the cascade's stop-at-first-match ordering.
81    let mut merged = Map::new();
82    for layer in layers.into_iter().rev() {
83        merged.extend(layer);
84    }
85
86    serde_json::from_value(Value::Object(merged))
87        .map_err(|err| McpifyError::Configuration(format!("invalid configuration: {err}")))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serde_json::json;
94
95    fn base_flags() -> Map<String, Value> {
96        json!({ "url": "https://api.example.com", "auth_method": "pat" })
97            .as_object()
98            .unwrap()
99            .clone()
100    }
101
102    #[test]
103    fn applies_built_in_defaults_when_nothing_else_is_set() {
104        let config = load_config(base_flags()).unwrap();
105        assert_eq!(config.log_level, "info");
106        assert_eq!(config.rate_limit, 100);
107        assert_eq!(config.port, 3000);
108    }
109
110    #[test]
111    fn cli_flags_win_over_everything_else() {
112        let mut flags = base_flags();
113        flags.insert("log_level".to_string(), json!("debug"));
114        let config = load_config(flags).unwrap();
115        assert_eq!(config.log_level, "debug");
116    }
117
118    #[test]
119    fn missing_required_fields_report_a_configuration_error() {
120        let err = load_config(Map::new()).unwrap_err();
121        assert_eq!(err.code(), "CONFIGURATION_ERROR");
122    }
123}