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#[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#[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 #[serde(rename = "in-memory")]
79 InMemory {
80 #[serde(default)]
81 args: Vec<String>,
82
83 #[serde(default)]
84 input: Option<Value>,
85 },
86}
87
88pub 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
127pub 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
161pub 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 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 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 pub fn from_json(json: &str) -> Result<Self, ParseError> {
234 Ok(serde_json::from_str(json)?)
235 }
236
237 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 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 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 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 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}