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