Skip to main content

mcp_utils/client/
config.rs

1use super::variables::{VarError, expand_env_vars};
2use futures::future::BoxFuture;
3use rmcp::{RoleServer, service::DynService, transport::streamable_http_client::StreamableHttpClientTransportConfig};
4use serde::{Deserialize, Deserializer, Serialize};
5use serde_json::{Value, from_value};
6use std::collections::{BTreeMap, HashMap};
7use std::fmt::{Debug, Display, Formatter};
8use std::path::Path;
9
10/// Top-level MCP configuration
11#[derive(Debug, Clone, Serialize)]
12pub struct RawMcpConfig {
13    pub servers: BTreeMap<String, RawMcpServerConfig>,
14}
15
16impl<'a> Deserialize<'a> for RawMcpConfig {
17    fn deserialize<T: Deserializer<'a>>(deserializer: T) -> Result<Self, T::Error> {
18        #[derive(Deserialize)]
19        struct Raw {
20            #[serde(alias = "mcpServers")]
21            servers: BTreeMap<String, Value>,
22        }
23
24        let raw = Raw::deserialize(deserializer)?;
25        let mut servers = BTreeMap::new();
26
27        for (name, mut value) in raw.servers {
28            if let Some(map) = value.as_object_mut()
29                && !map.contains_key("type")
30            {
31                map.insert("type".to_string(), Value::String("stdio".to_string()));
32            }
33            let config: RawMcpServerConfig = from_value(value).map_err(serde::de::Error::custom)?;
34            servers.insert(name, config);
35        }
36
37        Ok(Self { servers })
38    }
39}
40
41/// Server connection definition.
42///
43/// When `"type"` is omitted, defaults to `"stdio"` for compatibility with
44/// Claude Code's `.mcp.json` format.
45#[derive(Debug, Clone, Deserialize, Serialize)]
46#[serde(tag = "type", rename_all = "lowercase")]
47pub enum RawMcpServerConfig {
48    Stdio {
49        command: String,
50
51        #[serde(default)]
52        args: Vec<String>,
53
54        #[serde(default)]
55        env: HashMap<String, String>,
56    },
57
58    Http {
59        url: String,
60
61        #[serde(default)]
62        headers: HashMap<String, String>,
63    },
64
65    Sse {
66        url: String,
67
68        #[serde(default)]
69        headers: HashMap<String, String>,
70    },
71
72    /// In-memory transport (Aether extension) - requires a registered factory
73    /// The factory is looked up using the server name from the mcp.json key.
74    ///
75    /// When `input` contains a `"servers"` key, this is treated as a tool-proxy
76    /// configuration: nested server configs are parsed and validated, producing
77    /// a `McpServerConfig::ToolProxy` at runtime.
78    #[serde(rename = "in-memory")]
79    InMemory {
80        #[serde(default)]
81        args: Vec<String>,
82
83        #[serde(default)]
84        input: Option<Value>,
85    },
86}
87
88/// A single connectable MCP server endpoint.
89pub enum ServerConfig {
90    Http { name: String, config: StreamableHttpClientTransportConfig },
91
92    Stdio { name: String, command: String, args: Vec<String>, env: HashMap<String, String> },
93
94    InMemory { name: String, server: Box<dyn DynService<RoleServer>> },
95}
96
97impl ServerConfig {
98    pub fn name(&self) -> &str {
99        match self {
100            ServerConfig::Http { name, .. }
101            | ServerConfig::Stdio { name, .. }
102            | ServerConfig::InMemory { name, .. } => name,
103        }
104    }
105}
106
107impl Debug for ServerConfig {
108    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
109        match self {
110            ServerConfig::Http { name, config } => {
111                f.debug_struct("Http").field("name", name).field("config", config).finish()
112            }
113            ServerConfig::Stdio { name, command, args, env } => f
114                .debug_struct("Stdio")
115                .field("name", name)
116                .field("command", command)
117                .field("args", args)
118                .field("env", env)
119                .finish(),
120            ServerConfig::InMemory { name, .. } => {
121                f.debug_struct("InMemory").field("name", name).field("server", &"<DynService>").finish()
122            }
123        }
124    }
125}
126
127/// Top-level MCP config: a single server OR a tool-proxy of single servers.
128pub enum McpServerConfig {
129    Server(ServerConfig),
130    ToolProxy { name: String, servers: Vec<ServerConfig> },
131}
132
133impl McpServerConfig {
134    pub fn name(&self) -> &str {
135        match self {
136            McpServerConfig::Server(cfg) => cfg.name(),
137            McpServerConfig::ToolProxy { name, .. } => name,
138        }
139    }
140}
141
142impl From<ServerConfig> for McpServerConfig {
143    fn from(cfg: ServerConfig) -> Self {
144        McpServerConfig::Server(cfg)
145    }
146}
147
148impl Debug for McpServerConfig {
149    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
150        match self {
151            McpServerConfig::Server(cfg) => cfg.fmt(f),
152            McpServerConfig::ToolProxy { name, servers } => f
153                .debug_struct("ToolProxy")
154                .field("name", name)
155                .field("servers", &format!("{} nested servers", servers.len()))
156                .finish(),
157        }
158    }
159}
160
161/// Factory function that creates an MCP server instance asynchronously.
162/// The factory receives parsed CLI arguments and an optional structured input from
163/// the `"input"` field in the config JSON.
164pub type ServerFactory =
165    Box<dyn Fn(Vec<String>, Option<Value>) -> BoxFuture<'static, Box<dyn DynService<RoleServer>>> + Send + Sync>;
166
167#[derive(Debug)]
168pub enum ParseError {
169    IoError(std::io::Error),
170    JsonError(serde_json::Error),
171    VarError(VarError),
172    FactoryNotFound(String),
173    InvalidNestedConfig(String),
174}
175
176impl Display for ParseError {
177    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
178        match self {
179            ParseError::IoError(e) => write!(f, "Failed to read config file: {e}"),
180            ParseError::JsonError(e) => write!(f, "Invalid JSON: {e}"),
181            ParseError::VarError(e) => write!(f, "Variable expansion failed: {e}"),
182            ParseError::FactoryNotFound(name) => {
183                write!(f, "InMemory server factory '{name}' not registered")
184            }
185            ParseError::InvalidNestedConfig(msg) => {
186                write!(f, "Invalid nested config in tool-proxy: {msg}")
187            }
188        }
189    }
190}
191
192impl std::error::Error for ParseError {}
193
194impl From<std::io::Error> for ParseError {
195    fn from(error: std::io::Error) -> Self {
196        ParseError::IoError(error)
197    }
198}
199
200impl From<serde_json::Error> for ParseError {
201    fn from(error: serde_json::Error) -> Self {
202        ParseError::JsonError(error)
203    }
204}
205
206impl From<VarError> for ParseError {
207    fn from(error: VarError) -> Self {
208        ParseError::VarError(error)
209    }
210}
211
212impl RawMcpConfig {
213    /// Parse MCP configuration from a JSON file
214    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self, ParseError> {
215        let content = std::fs::read_to_string(path)?;
216        Self::from_json(&content)
217    }
218
219    /// Parse and merge multiple MCP config files in order.
220    ///
221    /// Server name collisions are resolved by "last file wins" via `HashMap::extend`,
222    /// so the rightmost file in `paths` takes precedence on overlap.
223    pub fn from_json_files<T: AsRef<Path>>(paths: &[T]) -> Result<Self, ParseError> {
224        let mut merged = BTreeMap::new();
225        for path in paths {
226            let raw = Self::from_json_file(path)?;
227            merged.extend(raw.servers);
228        }
229        Ok(Self { servers: merged })
230    }
231
232    /// Parse MCP configuration from a JSON string
233    pub fn from_json(json: &str) -> Result<Self, ParseError> {
234        Ok(serde_json::from_str(json)?)
235    }
236
237    /// Convert to runtime configuration with the provided factory registry
238    pub async fn into_configs(
239        self,
240        factories: &HashMap<String, ServerFactory>,
241    ) -> Result<Vec<McpServerConfig>, ParseError> {
242        let mut configs = Vec::with_capacity(self.servers.len());
243        for (name, raw_config) in self.servers {
244            configs.push(raw_config.into_config(name, factories).await?);
245        }
246        Ok(configs)
247    }
248
249    /// Convert all servers to flat `ServerConfig`s suitable for a `ToolProxy`.
250    ///
251    /// Rejects `in-memory` entries (same restriction as nested tool-proxy servers).
252    pub async fn into_proxy_server_configs(
253        self,
254        factories: &HashMap<String, ServerFactory>,
255    ) -> Result<Vec<ServerConfig>, ParseError> {
256        let mut configs = Vec::with_capacity(self.servers.len());
257        for (name, raw_config) in self.servers {
258            if matches!(raw_config, RawMcpServerConfig::InMemory { .. }) {
259                return Err(ParseError::InvalidNestedConfig(format!(
260                    "in-memory server '{name}' cannot be used inside a proxy-wrapped config file"
261                )));
262            }
263            configs.push(raw_config.into_server_config(name, factories).await?);
264        }
265        Ok(configs)
266    }
267}
268
269impl RawMcpServerConfig {
270    /// Convert to runtime configuration with the provided factory registry
271    pub async fn into_config(
272        self,
273        name: String,
274        factories: &HashMap<String, ServerFactory>,
275    ) -> Result<McpServerConfig, ParseError> {
276        match self {
277            RawMcpServerConfig::Stdio { command, args, env } => Ok(ServerConfig::Stdio {
278                name,
279                command: expand_env_vars(&command)?,
280                args: args.into_iter().map(|a| expand_env_vars(&a)).collect::<Result<Vec<_>, _>>()?,
281                env: env
282                    .into_iter()
283                    .map(|(k, v)| Ok((k, expand_env_vars(&v)?)))
284                    .collect::<Result<HashMap<_, _>, VarError>>()?,
285            }
286            .into()),
287
288            RawMcpServerConfig::Http { url, headers } | RawMcpServerConfig::Sse { url, headers } => {
289                // Extract Authorization header if present (only header currently supported)
290                let auth_header = headers.get("Authorization").map(|v| expand_env_vars(v)).transpose()?;
291
292                let mut config = StreamableHttpClientTransportConfig::with_uri(expand_env_vars(&url)?);
293                if let Some(auth) = auth_header {
294                    config = config.auth_header(auth);
295                }
296                Ok(ServerConfig::Http { name, config }.into())
297            }
298
299            RawMcpServerConfig::InMemory { args, input } => {
300                let servers_val = input.as_ref().and_then(|v| v.get("servers"));
301
302                if let Some(servers_val) = servers_val {
303                    return parse_tool_proxy(name, servers_val, factories).await;
304                }
305
306                let server_factory = factories.get(&name).ok_or_else(|| ParseError::FactoryNotFound(name.clone()))?;
307
308                let expanded_args =
309                    args.into_iter().map(|a| expand_env_vars(&a)).collect::<Result<Vec<_>, VarError>>()?;
310
311                let server = server_factory(expanded_args, input).await;
312                Ok(ServerConfig::InMemory { name, server }.into())
313            }
314        }
315    }
316
317    /// Convert to a `ServerConfig` (non-composite). Used by `parse_tool_proxy`
318    /// where the result must be a single server, not a top-level `McpServerConfig`.
319    async fn into_server_config(
320        self,
321        name: String,
322        factories: &HashMap<String, ServerFactory>,
323    ) -> Result<ServerConfig, ParseError> {
324        match self.into_config(name, factories).await? {
325            McpServerConfig::Server(cfg) => Ok(cfg),
326            McpServerConfig::ToolProxy { name, .. } => Err(ParseError::InvalidNestedConfig(format!(
327                "tool-proxy '{name}' cannot be nested inside another tool-proxy"
328            ))),
329        }
330    }
331}
332
333async fn parse_tool_proxy(
334    name: String,
335    servers_val: &Value,
336    factories: &HashMap<String, ServerFactory>,
337) -> Result<McpServerConfig, ParseError> {
338    let nested_raw: HashMap<String, RawMcpServerConfig> = serde_json::from_value(servers_val.clone())
339        .map_err(|e| ParseError::InvalidNestedConfig(format!("failed to parse input.servers: {e}")))?;
340
341    let mut nested_configs = Vec::with_capacity(nested_raw.len());
342    for (nested_name, nested_raw_cfg) in nested_raw {
343        if matches!(nested_raw_cfg, RawMcpServerConfig::InMemory { .. }) {
344            return Err(ParseError::InvalidNestedConfig(format!(
345                "in-memory servers cannot be nested inside tool-proxy (server: '{nested_name}')"
346            )));
347        }
348
349        nested_configs.push(Box::pin(nested_raw_cfg.into_server_config(nested_name, factories)).await?);
350    }
351
352    Ok(McpServerConfig::ToolProxy { name, servers: nested_configs })
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use std::fs;
359    use tempfile::tempdir;
360
361    fn write_config(dir: &Path, name: &str, json: &str) -> std::path::PathBuf {
362        let path = dir.join(name);
363        fs::write(&path, json).unwrap();
364        path
365    }
366
367    fn stdio_config(command: &str) -> String {
368        format!(r#"{{"servers": {{"coding": {{"type": "stdio", "command": "{command}"}}}}}}"#)
369    }
370
371    #[test]
372    fn from_json_accepts_mcp_servers_key() {
373        let config =
374            RawMcpConfig::from_json(r#"{"mcpServers": {"alpha": {"type": "stdio", "command": "a"}}}"#).unwrap();
375        assert_eq!(config.servers.len(), 1);
376        assert!(config.servers.contains_key("alpha"));
377    }
378
379    #[test]
380    fn from_json_defaults_missing_type_to_stdio() {
381        let config = RawMcpConfig::from_json(
382            r#"{"mcpServers": {"devtools": {"command": "npx", "args": ["-y", "chrome-devtools-mcp"]}}}"#,
383        )
384        .unwrap();
385        match config.servers.get("devtools").unwrap() {
386            RawMcpServerConfig::Stdio { command, args, .. } => {
387                assert_eq!(command, "npx");
388                assert_eq!(args, &["-y", "chrome-devtools-mcp"]);
389            }
390            other => panic!("expected Stdio, got {other:?}"),
391        }
392    }
393
394    #[test]
395    fn from_json_files_empty_returns_empty_servers() {
396        let result = RawMcpConfig::from_json_files::<&str>(&[]).unwrap();
397        assert!(result.servers.is_empty());
398    }
399
400    #[test]
401    fn from_json_files_single_file_matches_from_json_file() {
402        let dir = tempdir().unwrap();
403        let path = write_config(dir.path(), "a.json", &stdio_config("ls"));
404
405        let single = RawMcpConfig::from_json_file(&path).unwrap();
406        let multi = RawMcpConfig::from_json_files(&[&path]).unwrap();
407
408        assert_eq!(single.servers.len(), multi.servers.len());
409        assert!(multi.servers.contains_key("coding"));
410    }
411
412    #[test]
413    fn from_json_files_merges_disjoint_servers() {
414        let dir = tempdir().unwrap();
415        let a = write_config(dir.path(), "a.json", r#"{"servers": {"alpha": {"type": "stdio", "command": "a"}}}"#);
416        let b = write_config(dir.path(), "b.json", r#"{"servers": {"beta": {"type": "stdio", "command": "b"}}}"#);
417
418        let merged = RawMcpConfig::from_json_files(&[a, b]).unwrap();
419        assert_eq!(merged.servers.len(), 2);
420        assert!(merged.servers.contains_key("alpha"));
421        assert!(merged.servers.contains_key("beta"));
422    }
423
424    #[test]
425    fn from_json_files_last_file_wins_on_collision() {
426        let dir = tempdir().unwrap();
427        let a = write_config(dir.path(), "a.json", &stdio_config("from_a"));
428        let b = write_config(dir.path(), "b.json", &stdio_config("from_b"));
429
430        let merged_ab = RawMcpConfig::from_json_files(&[&a, &b]).unwrap();
431        match merged_ab.servers.get("coding").unwrap() {
432            RawMcpServerConfig::Stdio { command, .. } => assert_eq!(command, "from_b"),
433            other => panic!("expected Stdio, got {other:?}"),
434        }
435
436        let merged_ba = RawMcpConfig::from_json_files(&[&b, &a]).unwrap();
437        match merged_ba.servers.get("coding").unwrap() {
438            RawMcpServerConfig::Stdio { command, .. } => assert_eq!(command, "from_a"),
439            other => panic!("expected Stdio, got {other:?}"),
440        }
441    }
442
443    #[test]
444    fn from_json_files_propagates_io_error_on_missing_file() {
445        let dir = tempdir().unwrap();
446        let missing = dir.path().join("does-not-exist.json");
447        let result = RawMcpConfig::from_json_files(&[missing]);
448        assert!(matches!(result, Err(ParseError::IoError(_))));
449    }
450
451    #[test]
452    fn from_json_files_propagates_json_error_on_invalid_file() {
453        let dir = tempdir().unwrap();
454        let bad = write_config(dir.path(), "bad.json", "not valid json");
455        let result = RawMcpConfig::from_json_files(&[bad]);
456        assert!(matches!(result, Err(ParseError::JsonError(_))));
457    }
458
459    #[tokio::test]
460    async fn into_proxy_server_configs_converts_stdio() {
461        let config = RawMcpConfig::from_json(
462            r#"{"servers": {"alpha": {"type": "stdio", "command": "a"}, "beta": {"type": "stdio", "command": "b"}}}"#,
463        )
464        .unwrap();
465
466        let factories = HashMap::new();
467        let configs = config.into_proxy_server_configs(&factories).await.unwrap();
468        assert_eq!(configs.len(), 2);
469        let names: Vec<&str> = configs.iter().map(ServerConfig::name).collect();
470        assert!(names.contains(&"alpha"));
471        assert!(names.contains(&"beta"));
472    }
473
474    #[tokio::test]
475    async fn into_proxy_server_configs_rejects_in_memory() {
476        let config = RawMcpConfig::from_json(r#"{"servers": {"bad": {"type": "in-memory"}}}"#).unwrap();
477
478        let factories = HashMap::new();
479        let result = config.into_proxy_server_configs(&factories).await;
480        assert!(matches!(result, Err(ParseError::InvalidNestedConfig(_))));
481    }
482}