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 #[serde(rename = "type", default)]
33 pub type_: StdioType,
34
35 pub command: String,
37
38 #[serde(default)]
40 pub args: Vec<String>,
41
42 #[serde(default)]
44 pub env: HashMap<String, String>,
45
46 #[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 #[serde(rename = "type")]
56 pub type_: HttpType,
57
58 pub url: String,
60
61 #[serde(default)]
63 pub headers: HashMap<String, String>,
64
65 #[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 #[serde(rename = "type")]
75 pub type_: SseType,
76
77 pub url: String,
79
80 #[serde(default)]
82 pub headers: HashMap<String, String>,
83
84 #[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 #[serde(rename = "type")]
94 pub type_: InMemoryType,
95
96 #[serde(default)]
98 pub args: Vec<String>,
99
100 #[serde(default)]
102 pub input: Option<Value>,
103
104 #[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 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()?;
314 let mut config = StreamableHttpClientTransportConfig::with_uri(vars.expand(&url)?);
315 if let Some(auth) = auth_header {
316 config = config.auth_header(auth);
317 }
318 Ok(McpTransport::Http { config })
319 }
320
321 McpServerConfig::InMemory(InMemoryServerConfig { args, input, .. }) => {
322 let server_factory = factories.get(&name).ok_or_else(|| ParseError::FactoryNotFound(name.clone()))?;
323 let expanded_args = args.into_iter().map(|a| vars.expand(&a)).collect::<Result<Vec<_>, VarError>>()?;
324 let server = server_factory(expanded_args, input).await;
325 Ok(McpTransport::InMemory { server })
326 }
327 }
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use std::fs;
335 use tempfile::tempdir;
336
337 fn write_config(dir: &Path, name: &str, json: &str) -> std::path::PathBuf {
338 let path = dir.join(name);
339 fs::write(&path, json).unwrap();
340 path
341 }
342
343 fn stdio_config(command: &str) -> String {
344 format!(r#"{{"servers": {{"coding": {{"type": "stdio", "command": "{command}"}}}}}}"#)
345 }
346
347 #[test]
348 fn from_json_accepts_mcp_servers_key() {
349 let config = McpConfig::from_json(r#"{"mcpServers": {"alpha": {"type": "stdio", "command": "a"}}}"#).unwrap();
350 assert_eq!(config.servers.len(), 1);
351 assert!(config.servers.contains_key("alpha"));
352 }
353
354 #[test]
355 fn from_json_defaults_missing_type_to_stdio() {
356 let config = McpConfig::from_json(
357 r#"{"mcpServers": {"devtools": {"command": "npx", "args": ["-y", "chrome-devtools-mcp"]}}}"#,
358 )
359 .unwrap();
360 match config.servers.get("devtools").unwrap() {
361 McpServerConfig::Stdio(StdioServerConfig { command, args, proxy, .. }) => {
362 assert_eq!(command, "npx");
363 assert_eq!(args, &["-y", "chrome-devtools-mcp"]);
364 assert!(!proxy);
365 }
366 other => panic!("expected Stdio server, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn from_json_accepts_server_proxy_true() {
372 let config =
373 McpConfig::from_json(r#"{"servers": {"playwright": {"type": "stdio", "command": "npx", "proxy": true}}}"#)
374 .unwrap();
375 assert!(config.servers.get("playwright").unwrap().proxy());
376 }
377
378 #[test]
379 fn from_json_rejects_proxy_server_type() {
380 let result = McpConfig::from_json(r#"{"servers":{"tools":{"type":"proxy","servers":{}}}}"#);
381 assert!(result.is_err());
382 }
383
384 #[test]
385 fn false_proxy_omits_during_serialization() {
386 let config =
387 McpConfig::from_json(r#"{"servers": {"coding": {"type": "stdio", "command": "a", "proxy": false}}}"#)
388 .unwrap();
389 let serialized = serde_json::to_string(&config).unwrap();
390 assert!(!serialized.contains("proxy"));
391 }
392
393 #[test]
394 fn true_proxy_serializes() {
395 let config =
396 McpConfig::from_json(r#"{"servers": {"coding": {"type": "stdio", "command": "a", "proxy": true}}}"#)
397 .unwrap();
398 let serialized = serde_json::to_string(&config).unwrap();
399 assert!(serialized.contains("proxy"));
400 }
401
402 #[test]
403 fn from_json_rejects_unknown_type() {
404 let result = McpConfig::from_json(r#"{"servers": {"bad": {"type": "htp", "url": "https://example.com"}}}"#);
405 assert!(result.is_err());
406 }
407
408 #[test]
409 fn from_json_files_empty_returns_empty_servers() {
410 let result = McpConfig::from_json_files::<&str>(&[]).unwrap();
411 assert!(result.servers.is_empty());
412 }
413
414 #[test]
415 fn from_json_files_single_file_matches_from_json_file() {
416 let dir = tempdir().unwrap();
417 let path = write_config(dir.path(), "a.json", &stdio_config("ls"));
418
419 let single = McpConfig::from_json_file(&path).unwrap();
420 let multi = McpConfig::from_json_files(&[&path]).unwrap();
421
422 assert_eq!(single.servers.len(), multi.servers.len());
423 assert!(multi.servers.contains_key("coding"));
424 }
425
426 #[test]
427 fn from_json_files_merges_disjoint_servers() {
428 let dir = tempdir().unwrap();
429 let a = write_config(dir.path(), "a.json", r#"{"servers": {"alpha": {"type": "stdio", "command": "a"}}}"#);
430 let b = write_config(dir.path(), "b.json", r#"{"servers": {"beta": {"type": "stdio", "command": "b"}}}"#);
431
432 let merged = McpConfig::from_json_files(&[a, b]).unwrap();
433 assert_eq!(merged.servers.len(), 2);
434 assert!(merged.servers.contains_key("alpha"));
435 assert!(merged.servers.contains_key("beta"));
436 }
437
438 #[test]
439 fn from_json_files_last_file_wins_on_collision_including_proxy() {
440 let dir = tempdir().unwrap();
441 let a = write_config(
442 dir.path(),
443 "a.json",
444 r#"{"servers":{"coding":{"type":"stdio","command":"from_a","proxy":true}}}"#,
445 );
446 let b = write_config(dir.path(), "b.json", r#"{"servers":{"coding":{"type":"stdio","command":"from_b"}}}"#);
447
448 let merged_ab = McpConfig::from_json_files(&[&a, &b]).unwrap();
449 match merged_ab.servers.get("coding").unwrap() {
450 McpServerConfig::Stdio(StdioServerConfig { command, proxy, .. }) => {
451 assert_eq!(command, "from_b");
452 assert!(!proxy);
453 }
454 other => panic!("expected Stdio, got {other:?}"),
455 }
456
457 let merged_ba = McpConfig::from_json_files(&[&b, &a]).unwrap();
458 match merged_ba.servers.get("coding").unwrap() {
459 McpServerConfig::Stdio(StdioServerConfig { command, proxy, .. }) => {
460 assert_eq!(command, "from_a");
461 assert!(*proxy);
462 }
463 other => panic!("expected Stdio, got {other:?}"),
464 }
465 }
466
467 #[test]
468 fn mark_all_proxy_sets_every_server() {
469 let mut config = McpConfig::from_json(
470 r#"{"servers":{"a":{"type":"stdio","command":"a"},"b":{"type":"http","url":"https://example.com"}}}"#,
471 )
472 .unwrap();
473 config.mark_all_proxy();
474 assert!(config.servers.values().all(McpServerConfig::proxy));
475 }
476
477 #[test]
478 fn from_json_files_propagates_io_error_on_missing_file() {
479 let dir = tempdir().unwrap();
480 let missing = dir.path().join("does-not-exist.json");
481 let result = McpConfig::from_json_files(&[missing]);
482 assert!(matches!(result, Err(ParseError::IoError(_))));
483 }
484
485 #[test]
486 fn from_json_files_propagates_json_error_on_invalid_file() {
487 let dir = tempdir().unwrap();
488 let bad = write_config(dir.path(), "bad.json", "not valid json");
489 let result = McpConfig::from_json_files(&[bad]);
490 assert!(matches!(result, Err(ParseError::JsonError(_))));
491 }
492
493 #[tokio::test]
494 async fn into_servers_preserves_proxy_flags() {
495 let json = r#"{
496 "servers": {
497 "github": {"type": "stdio", "command": "g"},
498 "playwright": {"type": "stdio", "command": "p", "proxy": true}
499 }
500 }"#;
501 let config = McpConfig::from_json(json).unwrap();
502 let servers = config.into_servers(&HashMap::new(), &Vars::new()).await.unwrap();
503
504 assert_eq!(servers.len(), 2);
505 assert!(!servers.iter().find(|s| s.name == "github").unwrap().proxy);
506 assert!(servers.iter().find(|s| s.name == "playwright").unwrap().proxy);
507 }
508
509 #[tokio::test]
510 async fn into_servers_with_proxy_forces_proxy_flags() {
511 let config =
512 McpConfig::from_json(r#"{"servers":{"github":{"type":"stdio","command":"g","proxy":false}}}"#).unwrap();
513 let servers = config.into_servers_with_proxy(&HashMap::new(), &Vars::new(), true).await.unwrap();
514 assert!(servers[0].proxy);
515 }
516
517 #[tokio::test]
518 async fn into_transport_expands_workspace_var_in_stdio_args() {
519 let config = McpConfig::from_json(
520 r#"{"servers":{"coding":{"type":"stdio","command":"server","args":["--root","${WORKSPACE}/src"]}}}"#,
521 )
522 .unwrap();
523 let vars = Vars::new().with("WORKSPACE", "/workspace");
524 let servers = config.into_servers(&HashMap::new(), &vars).await.unwrap();
525
526 match &servers[0].transport {
527 McpTransport::Stdio { args, .. } => {
528 assert_eq!(args, &["--root", "/workspace/src"]);
529 }
530 other => panic!("expected Stdio transport, got {other:?}"),
531 }
532 }
533}