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::credential_storage::resolve_home_dir;
9use super::errors::McpifyError;
10
11const ENV_PREFIX: &str = "GITHUB_MCP";
12const CONFIG_DIR_NAME: &str = ".github-mcp";
13const LOCAL_CONFIG_FILE: &str = "github-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 parse_env_value(config_key: &str, value: &str) -> Value {
34    if matches!(
35        config_key,
36        "rate_limit" | "timeout_ms" | "cache_size" | "retry_attempts" | "port"
37    ) && let Ok(number) = value.parse::<u64>()
38    {
39        return Value::Number(number.into());
40    }
41    Value::String(value.to_string())
42}
43
44fn env_overrides() -> Map<String, Value> {
45    let mut overrides = Map::new();
46    for (config_key, env_suffix) in [
47        ("url", "URL"),
48        ("auth_method", "AUTH_METHOD"),
49        ("api_version", "API_VERSION"),
50        ("log_level", "LOG_LEVEL"),
51        ("transport", "TRANSPORT"),
52        ("host", "HOST"),
53        ("cors_allow", "CORS_ALLOW"),
54        ("rate_limit", "RATE_LIMIT"),
55        ("timeout_ms", "TIMEOUT_MS"),
56        ("cache_size", "CACHE_SIZE"),
57        ("retry_attempts", "RETRY_ATTEMPTS"),
58        ("port", "PORT"),
59    ] {
60        if let Ok(value) = std::env::var(format!("{ENV_PREFIX}_{env_suffix}")) {
61            overrides.insert(config_key.to_string(), parse_env_value(config_key, &value));
62        }
63    }
64    overrides
65}
66
67/// Resolves configuration through the strict, stop-at-first-match cascade
68/// (REQ-2.2): CLI flags -> env vars -> local file
69/// (`./github-mcp.config.yml`) -> home file
70/// (`~/.github-mcp/config.yml`) -> system file
71/// (`/etc/github-mcp/config.yml`) -> install-dir file -> built-in
72/// defaults (applied by `Config`'s own `#[serde(default = ...)]` fields).
73pub fn load_config(cli_flags: Map<String, Value>) -> Result<Config, McpifyError> {
74    let install_dir = std::env::current_exe()
75        .ok()
76        .and_then(|exe| exe.parent().map(Path::to_path_buf))
77        .unwrap_or_else(|| PathBuf::from("."));
78
79    let layers = [
80        cli_flags,
81        env_overrides(),
82        read_yaml_if_exists(&PathBuf::from(LOCAL_CONFIG_FILE)),
83        read_yaml_if_exists(&home_dir().join(CONFIG_DIR_NAME).join("config.yml")),
84        read_yaml_if_exists(&PathBuf::from("/etc/github-mcp/config.yml")),
85        read_yaml_if_exists(&install_dir.join("config.yml")),
86    ];
87
88    // Lowest-priority layer merged first, each higher-priority layer merged
89    // on top — `cli_flags` (index 0 above, applied last here) always wins,
90    // matching the cascade's stop-at-first-match ordering.
91    let mut merged = Map::new();
92    for layer in layers.into_iter().rev() {
93        merged.extend(layer);
94    }
95
96    serde_json::from_value(Value::Object(merged))
97        .map_err(|err| McpifyError::Configuration(format!("invalid configuration: {err}")))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use serde_json::json;
104
105    fn base_flags() -> Map<String, Value> {
106        json!({ "url": "https://api.example.com", "auth_method": "pat" })
107            .as_object()
108            .unwrap()
109            .clone()
110    }
111
112    #[test]
113    fn applies_built_in_defaults_when_nothing_else_is_set() {
114        let config = load_config(base_flags()).unwrap();
115        assert_eq!(config.log_level, "info");
116        assert_eq!(config.rate_limit, 100);
117        assert_eq!(config.port, 3000);
118    }
119
120    #[test]
121    fn cli_flags_win_over_everything_else() {
122        let mut flags = base_flags();
123        flags.insert("log_level".to_string(), json!("debug"));
124        let config = load_config(flags).unwrap();
125        assert_eq!(config.log_level, "debug");
126    }
127
128    #[test]
129    fn missing_required_fields_report_a_configuration_error() {
130        let err = load_config(Map::new()).unwrap_err();
131        assert_eq!(err.code(), "CONFIGURATION_ERROR");
132    }
133
134    #[test]
135    fn read_yaml_if_exists_parses_a_valid_yaml_object() {
136        let dir = tempfile::tempdir().unwrap();
137        let path = dir.path().join("config.yml");
138        std::fs::write(&path, "log_level: debug\n").unwrap();
139        let map = read_yaml_if_exists(&path);
140        assert_eq!(map.get("log_level"), Some(&json!("debug")));
141    }
142
143    #[test]
144    fn read_yaml_if_exists_ignores_non_object_yaml() {
145        let dir = tempfile::tempdir().unwrap();
146        let path = dir.path().join("config.yml");
147        std::fs::write(&path, "- just\n- a\n- list\n").unwrap();
148        assert!(read_yaml_if_exists(&path).is_empty());
149    }
150
151    #[test]
152    fn numeric_environment_overrides_are_typed_before_deserialization() {
153        assert_eq!(parse_env_value("port", "33017"), json!(33017));
154        assert_eq!(parse_env_value("timeout_ms", "2500"), json!(2500));
155        assert_eq!(
156            parse_env_value("url", "https://api.example"),
157            json!("https://api.example")
158        );
159        assert_eq!(
160            parse_env_value("port", "not-a-number"),
161            json!("not-a-number")
162        );
163    }
164}