Skip to main content

mcp_utils/client/
config.rs

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