mcp_multiplexer/
config.rs1use 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 pub expose: bool,
48 pub allow: Option<Vec<String>>,
50 pub deny: Vec<String>,
52 pub oauth: bool,
54 pub oauth_client_id: Option<String>,
56 pub oauth_scopes: Vec<String>,
58 pub oauth_redirect_port: Option<u16>,
61 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
88fn 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 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 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}