Skip to main content

mcp_utils/client/
config.rs

1use futures::future::BoxFuture;
2use rmcp::{
3    RoleServer, service::DynService,
4    transport::streamable_http_client::StreamableHttpClientTransportConfig,
5};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::path::Path;
10
11use super::variables::{VarError, expand_env_vars};
12
13/// Top-level MCP configuration
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct RawMcpConfig {
16    pub servers: HashMap<String, RawMcpServerConfig>,
17}
18
19/// Server connection definition
20#[derive(Debug, Clone, Deserialize, Serialize)]
21#[serde(tag = "type", rename_all = "lowercase")]
22pub enum RawMcpServerConfig {
23    Stdio {
24        command: String,
25
26        #[serde(default)]
27        args: Vec<String>,
28
29        #[serde(default)]
30        env: HashMap<String, String>,
31    },
32
33    Http {
34        url: String,
35
36        #[serde(default)]
37        headers: HashMap<String, String>,
38    },
39
40    Sse {
41        url: String,
42
43        #[serde(default)]
44        headers: HashMap<String, String>,
45    },
46
47    /// In-memory transport (Aether extension) - requires a registered factory
48    /// The factory is looked up using the server name from the mcp.json key.
49    ///
50    /// When `input` contains a `"servers"` key, this is treated as a tool-proxy
51    /// configuration: nested server configs are parsed and validated, producing
52    /// a `McpServerConfig::ToolProxy` at runtime.
53    #[serde(rename = "in-memory")]
54    InMemory {
55        #[serde(default)]
56        args: Vec<String>,
57
58        #[serde(default)]
59        input: Option<Value>,
60    },
61}
62
63/// A single connectable MCP server endpoint.
64pub enum ServerConfig {
65    Http {
66        name: String,
67        config: StreamableHttpClientTransportConfig,
68    },
69
70    Stdio {
71        name: String,
72        command: String,
73        args: Vec<String>,
74        env: HashMap<String, String>,
75    },
76
77    InMemory {
78        name: String,
79        server: Box<dyn DynService<RoleServer>>,
80    },
81}
82
83impl ServerConfig {
84    pub fn name(&self) -> &str {
85        match self {
86            ServerConfig::Http { name, .. }
87            | ServerConfig::Stdio { name, .. }
88            | ServerConfig::InMemory { name, .. } => name,
89        }
90    }
91}
92
93impl std::fmt::Debug for ServerConfig {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            ServerConfig::Http { name, config } => f
97                .debug_struct("Http")
98                .field("name", name)
99                .field("config", config)
100                .finish(),
101            ServerConfig::Stdio {
102                name,
103                command,
104                args,
105                env,
106            } => f
107                .debug_struct("Stdio")
108                .field("name", name)
109                .field("command", command)
110                .field("args", args)
111                .field("env", env)
112                .finish(),
113            ServerConfig::InMemory { name, .. } => f
114                .debug_struct("InMemory")
115                .field("name", name)
116                .field("server", &"<DynService>")
117                .finish(),
118        }
119    }
120}
121
122/// Top-level MCP config: a single server OR a tool-proxy of single servers.
123pub enum McpServerConfig {
124    Server(ServerConfig),
125    ToolProxy {
126        name: String,
127        servers: Vec<ServerConfig>,
128    },
129}
130
131impl McpServerConfig {
132    pub fn name(&self) -> &str {
133        match self {
134            McpServerConfig::Server(cfg) => cfg.name(),
135            McpServerConfig::ToolProxy { name, .. } => name,
136        }
137    }
138}
139
140impl From<ServerConfig> for McpServerConfig {
141    fn from(cfg: ServerConfig) -> Self {
142        McpServerConfig::Server(cfg)
143    }
144}
145
146impl std::fmt::Debug for McpServerConfig {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            McpServerConfig::Server(cfg) => cfg.fmt(f),
150            McpServerConfig::ToolProxy { name, servers } => f
151                .debug_struct("ToolProxy")
152                .field("name", name)
153                .field("servers", &format!("{} nested servers", servers.len()))
154                .finish(),
155        }
156    }
157}
158
159/// Factory function that creates an MCP server instance asynchronously.
160/// The factory receives parsed CLI arguments and an optional structured input from
161/// the `"input"` field in the config JSON.
162pub type ServerFactory = Box<
163    dyn Fn(Vec<String>, Option<Value>) -> BoxFuture<'static, Box<dyn DynService<RoleServer>>>
164        + Send
165        + Sync,
166>;
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 std::fmt::Display for ParseError {
178    fn fmt(&self, f: &mut std::fmt::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 MCP configuration from a JSON string
221    pub fn from_json(json: &str) -> Result<Self, ParseError> {
222        Ok(serde_json::from_str(json)?)
223    }
224
225    /// Convert to runtime configuration with the provided factory registry
226    pub async fn into_configs(
227        self,
228        factories: &HashMap<String, ServerFactory>,
229    ) -> Result<Vec<McpServerConfig>, ParseError> {
230        let mut configs = Vec::with_capacity(self.servers.len());
231        for (name, raw_config) in self.servers {
232            configs.push(raw_config.into_config(name, factories).await?);
233        }
234        Ok(configs)
235    }
236}
237
238impl RawMcpServerConfig {
239    /// Convert to runtime configuration with the provided factory registry
240    pub async fn into_config(
241        self,
242        name: String,
243        factories: &HashMap<String, ServerFactory>,
244    ) -> Result<McpServerConfig, ParseError> {
245        match self {
246            RawMcpServerConfig::Stdio { command, args, env } => Ok(ServerConfig::Stdio {
247                name,
248                command: expand_env_vars(&command)?,
249                args: args
250                    .into_iter()
251                    .map(|a| expand_env_vars(&a))
252                    .collect::<Result<Vec<_>, _>>()?,
253                env: env
254                    .into_iter()
255                    .map(|(k, v)| Ok((k, expand_env_vars(&v)?)))
256                    .collect::<Result<HashMap<_, _>, VarError>>()?,
257            }
258            .into()),
259
260            RawMcpServerConfig::Http { url, headers }
261            | RawMcpServerConfig::Sse { url, headers } => {
262                // Extract Authorization header if present (only header currently supported)
263                let auth_header = headers
264                    .get("Authorization")
265                    .map(|v| expand_env_vars(v))
266                    .transpose()?;
267
268                let mut config =
269                    StreamableHttpClientTransportConfig::with_uri(expand_env_vars(&url)?);
270                if let Some(auth) = auth_header {
271                    config = config.auth_header(auth);
272                }
273                Ok(ServerConfig::Http { name, config }.into())
274            }
275
276            RawMcpServerConfig::InMemory { args, input } => {
277                let servers_val = input.as_ref().and_then(|v| v.get("servers"));
278
279                if let Some(servers_val) = servers_val {
280                    return parse_tool_proxy(name, servers_val, factories).await;
281                }
282
283                let server_factory = factories
284                    .get(&name)
285                    .ok_or_else(|| ParseError::FactoryNotFound(name.clone()))?;
286
287                let expanded_args = args
288                    .into_iter()
289                    .map(|a| expand_env_vars(&a))
290                    .collect::<Result<Vec<_>, VarError>>()?;
291
292                let server = server_factory(expanded_args, input).await;
293                Ok(ServerConfig::InMemory { name, server }.into())
294            }
295        }
296    }
297
298    /// Convert to a `ServerConfig` (non-composite). Used by `parse_tool_proxy`
299    /// where the result must be a single server, not a top-level `McpServerConfig`.
300    async fn into_server_config(
301        self,
302        name: String,
303        factories: &HashMap<String, ServerFactory>,
304    ) -> Result<ServerConfig, ParseError> {
305        match self.into_config(name, factories).await? {
306            McpServerConfig::Server(cfg) => Ok(cfg),
307            McpServerConfig::ToolProxy { name, .. } => Err(ParseError::InvalidNestedConfig(
308                format!("tool-proxy '{name}' cannot be nested inside another tool-proxy"),
309            )),
310        }
311    }
312}
313
314async fn parse_tool_proxy(
315    name: String,
316    servers_val: &Value,
317    factories: &HashMap<String, ServerFactory>,
318) -> Result<McpServerConfig, ParseError> {
319    let nested_raw: HashMap<String, RawMcpServerConfig> =
320        serde_json::from_value(servers_val.clone()).map_err(|e| {
321            ParseError::InvalidNestedConfig(format!("failed to parse input.servers: {e}"))
322        })?;
323
324    let mut nested_configs = Vec::with_capacity(nested_raw.len());
325    for (nested_name, nested_raw_cfg) in nested_raw {
326        if matches!(nested_raw_cfg, RawMcpServerConfig::InMemory { .. }) {
327            return Err(ParseError::InvalidNestedConfig(format!(
328                "in-memory servers cannot be nested inside tool-proxy (server: '{nested_name}')"
329            )));
330        }
331
332        nested_configs
333            .push(Box::pin(nested_raw_cfg.into_server_config(nested_name, factories)).await?);
334    }
335
336    Ok(McpServerConfig::ToolProxy {
337        name,
338        servers: nested_configs,
339    })
340}