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