Skip to main content

mcp_multiplexer/
config.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer};
3use std::collections::BTreeMap;
4use std::path::Path;
5
6#[derive(Debug, Clone, Deserialize, JsonSchema)]
7pub struct Config {
8    #[serde(rename = "mcpServers", deserialize_with = "no_dup_keys")]
9    pub mcp_servers: BTreeMap<String, ServerConfig>,
10}
11
12fn no_dup_keys<'de, D>(d: D) -> Result<BTreeMap<String, ServerConfig>, D::Error>
13where
14    D: Deserializer<'de>,
15{
16    use serde::de::{MapAccess, Visitor};
17    struct V;
18    impl<'de> Visitor<'de> for V {
19        type Value = BTreeMap<String, ServerConfig>;
20        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
21            f.write_str("a map of server name to server config")
22        }
23        fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
24            let mut out = BTreeMap::new();
25            while let Some((k, v)) = map.next_entry::<String, ServerConfig>()? {
26                if out.insert(k.clone(), v).is_some() {
27                    return Err(serde::de::Error::custom(format!(
28                        "duplicate server name: {k}"
29                    )));
30                }
31            }
32            Ok(out)
33        }
34    }
35    d.deserialize_map(V)
36}
37
38#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
39#[serde(default, deny_unknown_fields)]
40pub struct ServerConfig {
41    pub command: Option<String>,
42    pub args: Vec<String>,
43    pub env: BTreeMap<String, String>,
44    pub url: Option<String>,
45    pub headers: BTreeMap<String, String>,
46    /// Bypass the meta-tools: expose this server's tools directly as server__tool
47    pub expose: bool,
48    /// If set, only these tools are visible (exact names or prefix* globs)
49    pub allow: Option<Vec<String>>,
50    /// Always hidden/blocked; wins over allow
51    pub deny: Vec<String>,
52    /// Enable OAuth 2.0 (PKCE) for this server — url servers only
53    pub oauth: bool,
54    /// Pre-registered OAuth client ID; dynamic registration is used when unset
55    pub oauth_client_id: Option<String>,
56    /// Scopes to request; server defaults when empty
57    pub oauth_scopes: Vec<String>,
58    /// Fixed port for the 127.0.0.1 callback listener, for providers that
59    /// require an exact pre-registered redirect URI. Default: ephemeral port
60    pub oauth_redirect_port: Option<u16>,
61    /// Connection/startup timeout in seconds (default 10); raise for
62    /// slow-to-start local servers (e.g. uvx building from a git ref)
63    pub connect_timeout: Option<u64>,
64}
65
66pub fn glob_match(pat: &str, name: &str) -> bool {
67    match pat.strip_suffix('*') {
68        Some(prefix) => name.starts_with(prefix),
69        None => pat == name,
70    }
71}
72
73impl ServerConfig {
74    pub fn is_denied(&self, tool: &str) -> bool {
75        self.deny.iter().any(|p| glob_match(p, tool))
76    }
77    pub fn is_allowed(&self, tool: &str) -> bool {
78        if self.is_denied(tool) {
79            return false;
80        }
81        match &self.allow {
82            Some(pats) => pats.iter().any(|p| glob_match(p, tool)),
83            None => true,
84        }
85    }
86}
87
88/// `${VAR}` expansion using `get` for lookups. Unclosed `${` or an unset
89/// variable is an error — fail at startup, not with a broken upstream later.
90fn expand(s: &str, get: impl Fn(&str) -> Option<String>) -> anyhow::Result<String> {
91    let mut out = String::with_capacity(s.len());
92    let mut rest = s;
93    while let Some(i) = rest.find("${") {
94        let Some(j) = rest[i + 2..].find('}') else {
95            anyhow::bail!("unclosed \"${{\" in {s:?}")
96        };
97        let var = &rest[i + 2..i + 2 + j];
98        let val = get(var)
99            .ok_or_else(|| anyhow::anyhow!("env var {var:?} referenced in config is not set"))?;
100        out.push_str(&rest[..i]);
101        out.push_str(&val);
102        rest = &rest[i + 2 + j + 1..];
103    }
104    out.push_str(rest);
105    Ok(out)
106}
107
108impl Config {
109    /// Returns the parsed config and the raw file text (for cache hashing).
110    pub fn load(path: &Path) -> anyhow::Result<(Config, String)> {
111        let text = std::fs::read_to_string(path)
112            .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
113        let mut cfg: Config = serde_json::from_str(&text)
114            .map_err(|e| anyhow::anyhow!("invalid config {}: {e}", path.display()))?;
115        cfg.expand_env()?;
116        cfg.validate()?;
117        Ok((cfg, text))
118    }
119
120    /// Expand ${VAR} in command/args/env/url/headers of every server.
121    fn expand_env(&mut self) -> anyhow::Result<()> {
122        for (name, s) in &mut self.mcp_servers {
123            let r = (|| {
124                let get = |v: &str| std::env::var(v).ok();
125                if let Some(c) = &mut s.command {
126                    *c = expand(c, get)?;
127                }
128                for a in &mut s.args {
129                    *a = expand(a, get)?;
130                }
131                for v in s.env.values_mut() {
132                    *v = expand(v, get)?;
133                }
134                if let Some(u) = &mut s.url {
135                    *u = expand(u, get)?;
136                }
137                for v in s.headers.values_mut() {
138                    *v = expand(v, get)?;
139                }
140                Ok(())
141            })();
142            r.map_err(|e: anyhow::Error| anyhow::anyhow!("server {name:?}: {e}"))?;
143        }
144        Ok(())
145    }
146
147    pub fn validate(&self) -> anyhow::Result<()> {
148        for (name, s) in &self.mcp_servers {
149            match (&s.command, &s.url) {
150                (None, None) => {
151                    anyhow::bail!("server {name:?}: needs either \"command\" or \"url\"")
152                }
153                (Some(_), Some(_)) => {
154                    anyhow::bail!("server {name:?}: has both \"command\" and \"url\", pick one")
155                }
156                _ => {}
157            }
158            if s.command.is_some()
159                && (s.oauth
160                    || s.oauth_client_id.is_some()
161                    || !s.oauth_scopes.is_empty()
162                    || s.oauth_redirect_port.is_some())
163            {
164                anyhow::bail!("server {name:?}: oauth options require \"url\", not \"command\"");
165            }
166            if !s.oauth
167                && (s.oauth_client_id.is_some()
168                    || !s.oauth_scopes.is_empty()
169                    || s.oauth_redirect_port.is_some())
170            {
171                anyhow::bail!("server {name:?}: oauth_* options require \"oauth\": true");
172            }
173        }
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn expands_vars() {
184        let get = |v: &str| (v == "A").then(|| "x".to_string());
185        assert_eq!(expand("a-${A}-b", get).unwrap(), "a-x-b");
186        assert_eq!(expand("plain", get).unwrap(), "plain");
187        assert_eq!(expand("${A}${A}", get).unwrap(), "xx");
188        assert!(
189            expand("${MISSING}", get)
190                .unwrap_err()
191                .to_string()
192                .contains("MISSING")
193        );
194        assert!(expand("${unclosed", get).is_err());
195    }
196}