Skip to main content

mcp_utils/client/
config.rs

1use futures::future::BoxFuture;
2use rmcp::{RoleServer, service::DynService, transport::streamable_http_client::StreamableHttpClientTransportConfig};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::{BTreeMap, HashMap};
7use std::fmt::{Debug, Formatter};
8use std::path::Path;
9use utils::is_false;
10use utils::variables::{VarError, Vars};
11
12#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
13pub struct McpConfig {
14    #[serde(alias = "mcpServers")]
15    pub servers: BTreeMap<String, McpServerConfig>,
16}
17
18#[doc = include_str!("../docs/mcp_server_config.md")]
19#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
20#[serde(untagged)]
21pub enum McpServerConfig {
22    Stdio(StdioServerConfig),
23    Http(HttpServerConfig),
24    Sse(SseServerConfig),
25    InMemory(InMemoryServerConfig),
26}
27
28#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
29#[serde(deny_unknown_fields)]
30pub struct StdioServerConfig {
31    /// Transport discriminant; always `stdio`.
32    #[serde(rename = "type", default)]
33    pub type_: StdioType,
34
35    /// Executable launched to run the MCP server over stdio.
36    pub command: String,
37
38    /// Command-line arguments passed to the executable.
39    #[serde(default)]
40    pub args: Vec<String>,
41
42    /// Environment variables set for the server process.
43    #[serde(default)]
44    pub env: HashMap<String, String>,
45
46    /// Expose this server's tools through Aether's tool proxy.
47    #[serde(default, skip_serializing_if = "is_false")]
48    pub proxy: bool,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
52#[serde(deny_unknown_fields)]
53pub struct HttpServerConfig {
54    /// Transport discriminant; always `http`.
55    #[serde(rename = "type")]
56    pub type_: HttpType,
57
58    /// Base URL of the streamable HTTP MCP server.
59    pub url: String,
60
61    /// Extra HTTP headers sent with every request.
62    #[serde(default)]
63    pub headers: HashMap<String, String>,
64
65    /// Expose this server's tools through Aether's tool proxy.
66    #[serde(default, skip_serializing_if = "is_false")]
67    pub proxy: bool,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
71#[serde(deny_unknown_fields)]
72pub struct SseServerConfig {
73    /// Transport discriminant; always `sse`.
74    #[serde(rename = "type")]
75    pub type_: SseType,
76
77    /// Base URL of the Server-Sent Events MCP server.
78    pub url: String,
79
80    /// Extra HTTP headers sent with every request.
81    #[serde(default)]
82    pub headers: HashMap<String, String>,
83
84    /// Expose this server's tools through Aether's tool proxy.
85    #[serde(default, skip_serializing_if = "is_false")]
86    pub proxy: bool,
87}
88
89#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
90#[serde(deny_unknown_fields)]
91pub struct InMemoryServerConfig {
92    /// Transport discriminant; always `in-memory`.
93    #[serde(rename = "type")]
94    pub type_: InMemoryType,
95
96    /// Arguments passed to the built-in (in-process) server.
97    #[serde(default)]
98    pub args: Vec<String>,
99
100    /// Optional JSON input passed to the built-in server at startup.
101    #[serde(default)]
102    pub input: Option<Value>,
103
104    /// Expose this server's tools through Aether's tool proxy.
105    #[serde(default, skip_serializing_if = "is_false")]
106    pub proxy: bool,
107}
108
109#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema, PartialEq)]
110pub enum StdioType {
111    #[default]
112    #[serde(rename = "stdio")]
113    Stdio,
114}
115
116#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
117pub enum HttpType {
118    #[serde(rename = "http")]
119    Http,
120}
121
122#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
123pub enum SseType {
124    #[serde(rename = "sse")]
125    Sse,
126}
127
128#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
129pub enum InMemoryType {
130    #[serde(rename = "in-memory")]
131    InMemory,
132}
133
134pub struct McpServer {
135    pub name: String,
136    pub transport: McpTransport,
137    pub proxy: bool,
138}
139
140pub enum McpTransport {
141    Stdio { command: String, args: Vec<String>, env: HashMap<String, String> },
142    Http { config: StreamableHttpClientTransportConfig },
143    InMemory { server: Box<dyn DynService<RoleServer>> },
144}
145
146impl McpServer {
147    pub fn new(name: impl Into<String>, transport: McpTransport, proxy: bool) -> Self {
148        Self { name: name.into(), transport, proxy }
149    }
150
151    /// Clone this server config. Fails for [`McpTransport::InMemory`], whose
152    /// boxed service cannot be duplicated and so cannot be shared across
153    /// independently-spawned MCP managers.
154    pub fn try_clone(&self) -> Result<Self, McpServerCloneError> {
155        let transport = match &self.transport {
156            McpTransport::Stdio { command, args, env } => {
157                McpTransport::Stdio { command: command.clone(), args: args.clone(), env: env.clone() }
158            }
159            McpTransport::Http { config } => McpTransport::Http { config: config.clone() },
160            McpTransport::InMemory { .. } => return Err(McpServerCloneError(self.name.clone())),
161        };
162        Ok(Self { name: self.name.clone(), transport, proxy: self.proxy })
163    }
164}
165
166#[derive(Debug, thiserror::Error)]
167#[error("in-memory MCP server `{0}` cannot be cloned across runtimes")]
168pub struct McpServerCloneError(pub String);
169
170impl Debug for McpServer {
171    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("McpServer")
173            .field("name", &self.name)
174            .field("transport", &self.transport)
175            .field("proxy", &self.proxy)
176            .finish()
177    }
178}
179
180impl Debug for McpTransport {
181    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
182        match self {
183            McpTransport::Stdio { command, args, env } => {
184                f.debug_struct("Stdio").field("command", command).field("args", args).field("env", env).finish()
185            }
186            McpTransport::Http { config } => f.debug_struct("Http").field("config", config).finish(),
187            McpTransport::InMemory { .. } => f.debug_struct("InMemory").field("server", &"<DynService>").finish(),
188        }
189    }
190}
191
192pub type ServerFactory =
193    Box<dyn Fn(Vec<String>, Option<Value>) -> BoxFuture<'static, Box<dyn DynService<RoleServer>>> + Send + Sync>;
194
195#[derive(Debug, thiserror::Error)]
196pub enum ParseError {
197    #[error("Failed to read config file: {0}")]
198    IoError(#[from] std::io::Error),
199
200    #[error("Invalid JSON: {0}")]
201    JsonError(#[from] serde_json::Error),
202
203    #[error("Variable expansion failed: {0}")]
204    VarError(#[from] VarError),
205
206    #[error("InMemory server factory '{0}' not registered")]
207    FactoryNotFound(String),
208
209    #[error("Invalid nested config in tool-proxy: {0}")]
210    InvalidNestedConfig(String),
211}
212
213impl McpConfig {
214    pub fn new(servers: BTreeMap<String, McpServerConfig>) -> Self {
215        Self { servers }
216    }
217
218    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self, ParseError> {
219        let content = std::fs::read_to_string(path)?;
220        Self::from_json(&content)
221    }
222
223    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::new(merged))
230    }
231
232    pub fn from_json(json: &str) -> Result<Self, ParseError> {
233        Ok(serde_json::from_str(json)?)
234    }
235
236    pub async fn into_servers(
237        self,
238        factories: &HashMap<String, ServerFactory>,
239        vars: &Vars,
240    ) -> Result<Vec<McpServer>, ParseError> {
241        self.into_servers_with_proxy(factories, vars, false).await
242    }
243
244    pub async fn into_servers_with_proxy(
245        self,
246        factories: &HashMap<String, ServerFactory>,
247        vars: &Vars,
248        force_proxy: bool,
249    ) -> Result<Vec<McpServer>, ParseError> {
250        let mut servers = Vec::with_capacity(self.servers.len());
251        for (name, config) in self.servers {
252            servers.push(config.into_server(name, factories, vars, force_proxy).await?);
253        }
254        Ok(servers)
255    }
256
257    pub fn mark_all_proxy(&mut self) {
258        for server in self.servers.values_mut() {
259            server.set_proxy(true);
260        }
261    }
262}
263
264impl McpServerConfig {
265    pub fn proxy(&self) -> bool {
266        match self {
267            McpServerConfig::Stdio(config) => config.proxy,
268            McpServerConfig::Http(config) => config.proxy,
269            McpServerConfig::Sse(config) => config.proxy,
270            McpServerConfig::InMemory(config) => config.proxy,
271        }
272    }
273
274    pub fn set_proxy(&mut self, value: bool) {
275        match self {
276            McpServerConfig::Stdio(config) => config.proxy = value,
277            McpServerConfig::Http(config) => config.proxy = value,
278            McpServerConfig::Sse(config) => config.proxy = value,
279            McpServerConfig::InMemory(config) => config.proxy = value,
280        }
281    }
282
283    pub async fn into_server(
284        self,
285        name: String,
286        factories: &HashMap<String, ServerFactory>,
287        vars: &Vars,
288        force_proxy: bool,
289    ) -> Result<McpServer, ParseError> {
290        let proxy = force_proxy || self.proxy();
291        let transport = self.into_transport(name.clone(), factories, vars).await?;
292        Ok(McpServer::new(name, transport, proxy))
293    }
294
295    async fn into_transport(
296        self,
297        name: String,
298        factories: &HashMap<String, ServerFactory>,
299        vars: &Vars,
300    ) -> Result<McpTransport, ParseError> {
301        match self {
302            McpServerConfig::Stdio(StdioServerConfig { command, args, env, .. }) => Ok(McpTransport::Stdio {
303                command: vars.expand(&command)?,
304                args: args.into_iter().map(|a| vars.expand(&a)).collect::<Result<Vec<_>, _>>()?,
305                env: env
306                    .into_iter()
307                    .map(|(k, v)| Ok((k, vars.expand(&v)?)))
308                    .collect::<Result<HashMap<_, _>, VarError>>()?,
309            }),
310
311            McpServerConfig::Http(HttpServerConfig { url, headers, .. })
312            | McpServerConfig::Sse(SseServerConfig { url, headers, .. }) => {
313                let auth_header = headers.get("Authorization").map(|v| vars.expand(v)).transpose()?.map(|auth| {
314                    // rmcp adds `Bearer`  to the auth header.
315                    auth.split_once(' ')
316                        .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Bearer"))
317                        .map_or(auth.as_str(), |(_, rest)| rest)
318                        .to_string()
319                });
320
321                let mut config = StreamableHttpClientTransportConfig::with_uri(vars.expand(&url)?);
322                if let Some(auth) = auth_header {
323                    config = config.auth_header(auth);
324                }
325
326                Ok(McpTransport::Http { config })
327            }
328
329            McpServerConfig::InMemory(InMemoryServerConfig { args, input, .. }) => {
330                let server_factory = factories.get(&name).ok_or_else(|| ParseError::FactoryNotFound(name.clone()))?;
331                let expanded_args = args.into_iter().map(|a| vars.expand(&a)).collect::<Result<Vec<_>, VarError>>()?;
332                let server = server_factory(expanded_args, input).await;
333                Ok(McpTransport::InMemory { server })
334            }
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::fs;
343    use tempfile::tempdir;
344
345    fn write_config(dir: &Path, name: &str, json: &str) -> std::path::PathBuf {
346        let path = dir.join(name);
347        fs::write(&path, json).unwrap();
348        path
349    }
350
351    fn stdio_config(command: &str) -> String {
352        format!(r#"{{"servers": {{"coding": {{"type": "stdio", "command": "{command}"}}}}}}"#)
353    }
354
355    #[test]
356    fn from_json_accepts_mcp_servers_key() {
357        let config = McpConfig::from_json(r#"{"mcpServers": {"alpha": {"type": "stdio", "command": "a"}}}"#).unwrap();
358        assert_eq!(config.servers.len(), 1);
359        assert!(config.servers.contains_key("alpha"));
360    }
361
362    #[test]
363    fn from_json_defaults_missing_type_to_stdio() {
364        let config = McpConfig::from_json(
365            r#"{"mcpServers": {"devtools": {"command": "npx", "args": ["-y", "chrome-devtools-mcp"]}}}"#,
366        )
367        .unwrap();
368        match config.servers.get("devtools").unwrap() {
369            McpServerConfig::Stdio(StdioServerConfig { command, args, proxy, .. }) => {
370                assert_eq!(command, "npx");
371                assert_eq!(args, &["-y", "chrome-devtools-mcp"]);
372                assert!(!proxy);
373            }
374            other => panic!("expected Stdio server, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn from_json_accepts_server_proxy_true() {
380        let config =
381            McpConfig::from_json(r#"{"servers": {"playwright": {"type": "stdio", "command": "npx", "proxy": true}}}"#)
382                .unwrap();
383        assert!(config.servers.get("playwright").unwrap().proxy());
384    }
385
386    #[test]
387    fn from_json_rejects_proxy_server_type() {
388        let result = McpConfig::from_json(r#"{"servers":{"tools":{"type":"proxy","servers":{}}}}"#);
389        assert!(result.is_err());
390    }
391
392    #[test]
393    fn false_proxy_omits_during_serialization() {
394        let config =
395            McpConfig::from_json(r#"{"servers": {"coding": {"type": "stdio", "command": "a", "proxy": false}}}"#)
396                .unwrap();
397        let serialized = serde_json::to_string(&config).unwrap();
398        assert!(!serialized.contains("proxy"));
399    }
400
401    #[test]
402    fn true_proxy_serializes() {
403        let config =
404            McpConfig::from_json(r#"{"servers": {"coding": {"type": "stdio", "command": "a", "proxy": true}}}"#)
405                .unwrap();
406        let serialized = serde_json::to_string(&config).unwrap();
407        assert!(serialized.contains("proxy"));
408    }
409
410    #[test]
411    fn from_json_rejects_unknown_type() {
412        let result = McpConfig::from_json(r#"{"servers": {"bad": {"type": "htp", "url": "https://example.com"}}}"#);
413        assert!(result.is_err());
414    }
415
416    #[test]
417    fn from_json_files_empty_returns_empty_servers() {
418        let result = McpConfig::from_json_files::<&str>(&[]).unwrap();
419        assert!(result.servers.is_empty());
420    }
421
422    #[test]
423    fn from_json_files_single_file_matches_from_json_file() {
424        let dir = tempdir().unwrap();
425        let path = write_config(dir.path(), "a.json", &stdio_config("ls"));
426
427        let single = McpConfig::from_json_file(&path).unwrap();
428        let multi = McpConfig::from_json_files(&[&path]).unwrap();
429
430        assert_eq!(single.servers.len(), multi.servers.len());
431        assert!(multi.servers.contains_key("coding"));
432    }
433
434    #[test]
435    fn from_json_files_merges_disjoint_servers() {
436        let dir = tempdir().unwrap();
437        let a = write_config(dir.path(), "a.json", r#"{"servers": {"alpha": {"type": "stdio", "command": "a"}}}"#);
438        let b = write_config(dir.path(), "b.json", r#"{"servers": {"beta": {"type": "stdio", "command": "b"}}}"#);
439
440        let merged = McpConfig::from_json_files(&[a, b]).unwrap();
441        assert_eq!(merged.servers.len(), 2);
442        assert!(merged.servers.contains_key("alpha"));
443        assert!(merged.servers.contains_key("beta"));
444    }
445
446    #[test]
447    fn from_json_files_last_file_wins_on_collision_including_proxy() {
448        let dir = tempdir().unwrap();
449        let a = write_config(
450            dir.path(),
451            "a.json",
452            r#"{"servers":{"coding":{"type":"stdio","command":"from_a","proxy":true}}}"#,
453        );
454        let b = write_config(dir.path(), "b.json", r#"{"servers":{"coding":{"type":"stdio","command":"from_b"}}}"#);
455
456        let merged_ab = McpConfig::from_json_files(&[&a, &b]).unwrap();
457        match merged_ab.servers.get("coding").unwrap() {
458            McpServerConfig::Stdio(StdioServerConfig { command, proxy, .. }) => {
459                assert_eq!(command, "from_b");
460                assert!(!proxy);
461            }
462            other => panic!("expected Stdio, got {other:?}"),
463        }
464
465        let merged_ba = McpConfig::from_json_files(&[&b, &a]).unwrap();
466        match merged_ba.servers.get("coding").unwrap() {
467            McpServerConfig::Stdio(StdioServerConfig { command, proxy, .. }) => {
468                assert_eq!(command, "from_a");
469                assert!(*proxy);
470            }
471            other => panic!("expected Stdio, got {other:?}"),
472        }
473    }
474
475    #[test]
476    fn mark_all_proxy_sets_every_server() {
477        let mut config = McpConfig::from_json(
478            r#"{"servers":{"a":{"type":"stdio","command":"a"},"b":{"type":"http","url":"https://example.com"}}}"#,
479        )
480        .unwrap();
481        config.mark_all_proxy();
482        assert!(config.servers.values().all(McpServerConfig::proxy));
483    }
484
485    #[test]
486    fn from_json_files_propagates_io_error_on_missing_file() {
487        let dir = tempdir().unwrap();
488        let missing = dir.path().join("does-not-exist.json");
489        let result = McpConfig::from_json_files(&[missing]);
490        assert!(matches!(result, Err(ParseError::IoError(_))));
491    }
492
493    #[test]
494    fn from_json_files_propagates_json_error_on_invalid_file() {
495        let dir = tempdir().unwrap();
496        let bad = write_config(dir.path(), "bad.json", "not valid json");
497        let result = McpConfig::from_json_files(&[bad]);
498        assert!(matches!(result, Err(ParseError::JsonError(_))));
499    }
500
501    #[tokio::test]
502    async fn into_servers_preserves_proxy_flags() {
503        let json = r#"{
504            "servers": {
505                "github": {"type": "stdio", "command": "g"},
506                "playwright": {"type": "stdio", "command": "p", "proxy": true}
507            }
508        }"#;
509        let config = McpConfig::from_json(json).unwrap();
510        let servers = config.into_servers(&HashMap::new(), &Vars::new()).await.unwrap();
511
512        assert_eq!(servers.len(), 2);
513        assert!(!servers.iter().find(|s| s.name == "github").unwrap().proxy);
514        assert!(servers.iter().find(|s| s.name == "playwright").unwrap().proxy);
515    }
516
517    #[tokio::test]
518    async fn into_servers_with_proxy_forces_proxy_flags() {
519        let config =
520            McpConfig::from_json(r#"{"servers":{"github":{"type":"stdio","command":"g","proxy":false}}}"#).unwrap();
521        let servers = config.into_servers_with_proxy(&HashMap::new(), &Vars::new(), true).await.unwrap();
522        assert!(servers[0].proxy);
523    }
524
525    #[tokio::test]
526    async fn into_transport_expands_workspace_var_in_stdio_args() {
527        let config = McpConfig::from_json(
528            r#"{"servers":{"coding":{"type":"stdio","command":"server","args":["--root","${WORKSPACE}/src"]}}}"#,
529        )
530        .unwrap();
531        let vars = Vars::new().with("WORKSPACE", "/workspace");
532        let servers = config.into_servers(&HashMap::new(), &vars).await.unwrap();
533
534        match &servers[0].transport {
535            McpTransport::Stdio { args, .. } => {
536                assert_eq!(args, &["--root", "/workspace/src"]);
537            }
538            other => panic!("expected Stdio transport, got {other:?}"),
539        }
540    }
541
542    #[tokio::test]
543    async fn into_transport_strips_bearer_prefix_from_auth_header() -> Result<(), String> {
544        let config = McpConfig::from_json(
545            r#"{"servers":{"weather":{"type":"http","url":"http://127.0.0.1:9000/mcp","headers":{"Authorization":"Bearer secret-token"}}}}"#,
546        )
547        .map_err(|e| e.to_string())?;
548
549        let servers = config.into_servers(&HashMap::new(), &Vars::new()).await.map_err(|e| e.to_string())?;
550        let McpTransport::Http { config } = &servers[0].transport else {
551            return Err(format!("expected Http transport, got {:?}", servers[0].transport));
552        };
553
554        assert_eq!(config.auth_header.as_deref(), Some("secret-token"));
555        Ok(())
556    }
557
558    #[tokio::test]
559    async fn into_transport_keeps_non_bearer_auth_header_verbatim() -> Result<(), String> {
560        let config = McpConfig::from_json(
561            r#"{"servers":{"weather":{"type":"http","url":"http://127.0.0.1:9000/mcp","headers":{"Authorization":"Basic dXNlcjpwYXNz"}}}}"#,
562        )
563        .map_err(|e| e.to_string())?;
564        let servers = config.into_servers(&HashMap::new(), &Vars::new()).await.map_err(|e| e.to_string())?;
565
566        let McpTransport::Http { config } = &servers[0].transport else {
567            return Err(format!("expected Http transport, got {:?}", servers[0].transport));
568        };
569        assert_eq!(config.auth_header.as_deref(), Some("Basic dXNlcjpwYXNz"));
570        Ok(())
571    }
572
573    #[tokio::test]
574    async fn into_transport_expands_vars_in_auth_header() -> Result<(), String> {
575        let config = McpConfig::from_json(
576            r#"{"servers":{"weather":{"type":"http","url":"http://127.0.0.1:9000/mcp","headers":{"Authorization":"Bearer ${TOKEN}"}}}}"#,
577        )
578        .map_err(|e| e.to_string())?;
579        let vars = Vars::new().with("TOKEN", "expanded-token");
580        let servers = config.into_servers(&HashMap::new(), &vars).await.map_err(|e| e.to_string())?;
581
582        let McpTransport::Http { config } = &servers[0].transport else {
583            return Err(format!("expected Http transport, got {:?}", servers[0].transport));
584        };
585        assert_eq!(config.auth_header.as_deref(), Some("expanded-token"));
586        Ok(())
587    }
588}