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#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct RawMcpConfig {
13 pub servers: BTreeMap<String, RawMcpServerConfig>,
14}
15
16#[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 #[serde(rename = "in-memory")]
51 InMemory {
52 #[serde(default)]
53 args: Vec<String>,
54
55 #[serde(default)]
56 input: Option<Value>,
57 },
58}
59
60pub 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
99pub 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
133pub 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 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 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 pub fn from_json(json: &str) -> Result<Self, ParseError> {
206 Ok(serde_json::from_str(json)?)
207 }
208
209 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 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 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 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}