Skip to main content

bamboo_domain/
mcp_config.rs

1use serde::de;
2use serde::ser::SerializeMap;
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use serde_json::Value;
5use std::collections::HashMap;
6
7/// Root MCP configuration
8#[derive(Debug, Clone)]
9pub struct McpConfig {
10    pub version: u32,
11    pub servers: Vec<McpServerConfig>,
12}
13
14fn default_version() -> u32 {
15    1
16}
17
18impl Default for McpConfig {
19    fn default() -> Self {
20        Self {
21            version: 1,
22            servers: Vec::new(),
23        }
24    }
25}
26
27// -----------------------------------------------------------------------------
28// On-disk format compatibility
29// -----------------------------------------------------------------------------
30//
31// The MCP ecosystem "mainstream" config format (e.g. Claude Desktop) uses:
32//
33//   "mcpServers": {
34//     "filesystem": { "command": "...", "args": [...], "env": {...} }
35//   }
36//
37// Our internal runtime format historically used:
38//
39//   { "version": 1, "servers": [ { "id": "...", "transport": {...} } ] }
40//
41// We support reading BOTH formats, and we serialize to the mainstream map format
42// (the Config layer maps this struct under the `mcpServers` key).
43
44#[derive(Debug, Clone, Deserialize)]
45struct McpConfigLegacyDisk {
46    #[serde(default = "default_version")]
47    version: u32,
48    #[serde(default)]
49    servers: Vec<McpServerConfig>,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53struct McpServerConfigFlatDisk {
54    id: String,
55    #[serde(default)]
56    name: Option<String>,
57    #[serde(default)]
58    enabled: Option<bool>,
59    #[serde(default)]
60    disabled: bool,
61
62    // stdio (mainstream) shape
63    #[serde(default)]
64    command: Option<String>,
65    #[serde(default)]
66    args: Vec<String>,
67    #[serde(default)]
68    cwd: Option<String>,
69    #[serde(default)]
70    env: HashMap<String, String>,
71    #[serde(default)]
72    env_encrypted: HashMap<String, String>,
73    #[serde(default)]
74    startup_timeout_ms: Option<u64>,
75
76    // sse shape
77    #[serde(default)]
78    url: Option<String>,
79    #[serde(default, deserialize_with = "deserialize_headers")]
80    headers: Vec<HeaderConfig>,
81    #[serde(default)]
82    connect_timeout_ms: Option<u64>,
83
84    // Bamboo extras (optional)
85    #[serde(default)]
86    request_timeout_ms: Option<u64>,
87    #[serde(default)]
88    healthcheck_interval_ms: Option<u64>,
89    #[serde(default)]
90    reconnect: Option<ReconnectConfig>,
91    #[serde(default)]
92    allowed_tools: Vec<String>,
93    #[serde(default)]
94    denied_tools: Vec<String>,
95}
96
97fn deserialize_headers<'de, D>(deserializer: D) -> Result<Vec<HeaderConfig>, D::Error>
98where
99    D: Deserializer<'de>,
100{
101    let value = Value::deserialize(deserializer)?;
102
103    if value.is_null() {
104        return Ok(Vec::new());
105    }
106
107    // Mainstream/Claude Desktop style: "headers": { "Authorization": "Bearer ..." }
108    if let Some(map) = value.as_object() {
109        let mut headers = Vec::with_capacity(map.len());
110        for (name, raw) in map.iter() {
111            let value = raw.as_str().unwrap_or("").to_string();
112            headers.push(HeaderConfig {
113                name: name.clone(),
114                value,
115                value_encrypted: None,
116            });
117        }
118        return Ok(headers);
119    }
120
121    // Alternate style: "headers": [{ "name": "...", "value": "..." }, ...]
122    if value.is_array() {
123        return serde_json::from_value::<Vec<HeaderConfig>>(value).map_err(de::Error::custom);
124    }
125
126    Err(de::Error::custom(
127        "MCP SSE headers must be an object map or an array",
128    ))
129}
130
131impl McpServerConfigFlatDisk {
132    fn into_internal(self) -> Result<McpServerConfig, String> {
133        let enabled = self.enabled.unwrap_or(!self.disabled);
134
135        let request_timeout_ms = self
136            .request_timeout_ms
137            .unwrap_or_else(default_request_timeout);
138        let healthcheck_interval_ms = self
139            .healthcheck_interval_ms
140            .unwrap_or_else(default_healthcheck_interval);
141        let reconnect = self.reconnect.unwrap_or_default();
142
143        let transport = match (self.command, self.url) {
144            (Some(command), None) => TransportConfig::Stdio(StdioConfig {
145                command,
146                args: self.args,
147                cwd: self.cwd,
148                env: self.env,
149                env_encrypted: self.env_encrypted,
150                startup_timeout_ms: self
151                    .startup_timeout_ms
152                    .unwrap_or_else(default_startup_timeout),
153            }),
154            (None, Some(url)) => TransportConfig::Sse(SseConfig {
155                url,
156                headers: self.headers,
157                connect_timeout_ms: self
158                    .connect_timeout_ms
159                    .unwrap_or_else(default_connect_timeout),
160            }),
161            (Some(_), Some(_)) => {
162                return Err("MCP server config cannot contain both 'command' and 'url'".to_string())
163            }
164            (None, None) => {
165                return Err(
166                    "MCP server config must contain either 'command' (stdio) or 'url' (sse)"
167                        .to_string(),
168                )
169            }
170        };
171
172        Ok(McpServerConfig {
173            id: self.id,
174            name: self.name,
175            enabled,
176            transport,
177            request_timeout_ms,
178            healthcheck_interval_ms,
179            reconnect,
180            allowed_tools: self.allowed_tools,
181            denied_tools: self.denied_tools,
182        })
183    }
184}
185
186#[derive(Debug, Clone, Serialize)]
187struct McpServerDiskOut {
188    // Claude Desktop / mainstream MCP config convention:
189    // - The server id is the map key under `mcpServers`.
190    // - A server is disabled by setting `"disabled": true`.
191    #[serde(default, skip_serializing_if = "is_false")]
192    disabled: bool,
193
194    // stdio transport shape
195    #[serde(skip_serializing_if = "Option::is_none")]
196    command: Option<String>,
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    args: Vec<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    cwd: Option<String>,
201    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
202    env: HashMap<String, String>,
203
204    // sse transport shape
205    #[serde(skip_serializing_if = "Option::is_none")]
206    url: Option<String>,
207    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
208    headers: HashMap<String, String>,
209
210    // Full transport config for StreamableHttp (and future non-SSE/non-stdio transports).
211    // Stdio and SSE are serialized inline (command/url) for Claude Desktop compatibility.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    transport: Option<TransportConfig>,
214}
215
216fn is_false(value: &bool) -> bool {
217    !*value
218}
219
220impl From<&McpServerConfig> for McpServerDiskOut {
221    fn from(server: &McpServerConfig) -> Self {
222        let mut out = Self {
223            disabled: !server.enabled,
224            command: None,
225            args: Vec::new(),
226            cwd: None,
227            env: HashMap::new(),
228            url: None,
229            headers: HashMap::new(),
230            transport: None,
231        };
232
233        match &server.transport {
234            TransportConfig::Stdio(stdio) => {
235                out.command = Some(stdio.command.clone());
236                out.args = stdio.args.clone();
237                out.cwd = stdio.cwd.clone();
238                out.env = stdio.env.clone();
239            }
240            TransportConfig::Sse(sse) => {
241                out.url = Some(sse.url.clone());
242                out.headers = sse
243                    .headers
244                    .iter()
245                    .filter(|h| !h.name.trim().is_empty())
246                    .map(|h| (h.name.clone(), h.value.clone()))
247                    .collect();
248            }
249            TransportConfig::StreamableHttp(config) => {
250                out.transport = Some(TransportConfig::StreamableHttp(config.clone()));
251            }
252        }
253
254        out
255    }
256}
257
258impl Serialize for McpConfig {
259    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
260    where
261        S: Serializer,
262    {
263        let mut map = serializer.serialize_map(Some(self.servers.len()))?;
264        for server in &self.servers {
265            let entry = McpServerDiskOut::from(server);
266            map.serialize_entry(&server.id, &entry)?;
267        }
268        map.end()
269    }
270}
271
272impl<'de> Deserialize<'de> for McpConfig {
273    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
274    where
275        D: Deserializer<'de>,
276    {
277        let value = Value::deserialize(deserializer)?;
278
279        // Legacy format: { "version": 1, "servers": [ ... ] }
280        if value.get("servers").is_some() {
281            let legacy: McpConfigLegacyDisk =
282                serde_json::from_value(value).map_err(de::Error::custom)?;
283            return Ok(Self {
284                version: legacy.version,
285                servers: legacy.servers,
286            });
287        }
288
289        // Mainstream map format: { "<server_id>": { ... }, ... }
290        let Some(obj) = value.as_object() else {
291            return Err(de::Error::custom(
292                "MCP config must be an object (legacy {version,servers} or a server map)",
293            ));
294        };
295
296        let mut servers = Vec::with_capacity(obj.len());
297        for (id, raw_entry) in obj.iter() {
298            let mut entry = raw_entry.clone();
299            let entry_obj = entry
300                .as_object_mut()
301                .ok_or_else(|| de::Error::custom("MCP server entry must be an object"))?;
302            // Inject id from the map key.
303            entry_obj.insert("id".to_string(), Value::String(id.clone()));
304
305            // Accept either our internal full shape or the mainstream flattened shape.
306            if let Ok(server) = serde_json::from_value::<McpServerConfig>(entry.clone()) {
307                servers.push(server);
308                continue;
309            }
310
311            let flat: McpServerConfigFlatDisk =
312                serde_json::from_value(entry).map_err(de::Error::custom)?;
313            let server = flat.into_internal().map_err(de::Error::custom)?;
314            servers.push(server);
315        }
316
317        Ok(Self {
318            version: default_version(),
319            servers,
320        })
321    }
322}
323
324/// Single MCP server configuration
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct McpServerConfig {
327    /// Unique identifier for this server
328    pub id: String,
329    /// Human-readable name
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub name: Option<String>,
332    /// Whether this server is enabled
333    #[serde(default = "default_true")]
334    pub enabled: bool,
335    /// Transport configuration
336    pub transport: TransportConfig,
337    /// Request timeout in milliseconds
338    #[serde(default = "default_request_timeout")]
339    pub request_timeout_ms: u64,
340    /// Health check interval in milliseconds
341    #[serde(default = "default_healthcheck_interval")]
342    pub healthcheck_interval_ms: u64,
343    /// Reconnection configuration
344    #[serde(default)]
345    pub reconnect: ReconnectConfig,
346    /// List of allowed tools (empty = all allowed)
347    #[serde(default)]
348    pub allowed_tools: Vec<String>,
349    /// List of denied tools
350    #[serde(default)]
351    pub denied_tools: Vec<String>,
352}
353
354fn default_true() -> bool {
355    true
356}
357
358pub fn default_request_timeout() -> u64 {
359    60000 // 60 seconds
360}
361
362pub fn default_healthcheck_interval() -> u64 {
363    30000 // 30 seconds
364}
365
366/// Transport configuration variants
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(tag = "type", rename_all = "lowercase")]
369pub enum TransportConfig {
370    Stdio(StdioConfig),
371    Sse(SseConfig),
372    #[serde(rename = "streamable_http")]
373    StreamableHttp(StreamableHttpConfig),
374}
375
376/// Stdio transport configuration
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct StdioConfig {
379    /// Command to execute
380    pub command: String,
381    /// Arguments for the command
382    #[serde(default)]
383    pub args: Vec<String>,
384    /// Working directory
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub cwd: Option<String>,
387    /// Environment variables (plaintext, in-memory only).
388    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
389    pub env: HashMap<String, String>,
390    /// Encrypted environment variables values (nonce:ciphertext), keyed by env var name.
391    ///
392    /// Legacy/back-compat only: older Bamboo builds stored secrets encrypted-at-rest.
393    /// We still accept these values so existing configs keep working, but we no longer
394    /// persist them (standard MCP config stores plaintext env vars).
395    #[serde(default, skip_serializing)]
396    pub env_encrypted: HashMap<String, String>,
397    /// Startup timeout in milliseconds
398    #[serde(default = "default_startup_timeout")]
399    pub startup_timeout_ms: u64,
400}
401
402pub fn default_startup_timeout() -> u64 {
403    20000 // 20 seconds
404}
405
406/// SSE transport configuration
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct SseConfig {
409    /// SSE endpoint URL
410    pub url: String,
411    /// Additional headers
412    #[serde(default)]
413    pub headers: Vec<HeaderConfig>,
414    /// Connection timeout in milliseconds
415    #[serde(default = "default_connect_timeout")]
416    pub connect_timeout_ms: u64,
417}
418
419pub fn default_connect_timeout() -> u64 {
420    10000 // 10 seconds
421}
422
423/// MCP Streamable HTTP transport configuration (MCP 2.0, 2025-03-26).
424///
425/// Uses a single HTTP endpoint for both sending and receiving JSON-RPC messages.
426/// The server URL should point to the MCP endpoint (e.g. `http://localhost:3000/mcp`).
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct StreamableHttpConfig {
429    /// MCP endpoint URL
430    pub url: String,
431    /// Additional headers
432    #[serde(default)]
433    pub headers: Vec<HeaderConfig>,
434    /// Connection timeout in milliseconds
435    #[serde(default = "default_connect_timeout")]
436    pub connect_timeout_ms: u64,
437}
438
439/// HTTP header configuration
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct HeaderConfig {
442    pub name: String,
443    /// Header value (plaintext).
444    #[serde(default)]
445    pub value: String,
446    /// Encrypted header value (nonce:ciphertext).
447    ///
448    /// Legacy/back-compat only: older Bamboo builds stored secrets encrypted-at-rest.
449    /// We still accept these values so existing configs keep working, but we no longer
450    /// persist them (standard MCP config stores plaintext headers).
451    #[serde(default, skip_serializing)]
452    pub value_encrypted: Option<String>,
453}
454
455/// Reconnection configuration
456#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
457pub struct ReconnectConfig {
458    #[serde(default = "default_true")]
459    pub enabled: bool,
460    /// Initial backoff in milliseconds
461    #[serde(default = "default_initial_backoff")]
462    pub initial_backoff_ms: u64,
463    /// Maximum backoff in milliseconds
464    #[serde(default = "default_max_backoff")]
465    pub max_backoff_ms: u64,
466    /// Maximum reconnection attempts (0 = unlimited)
467    #[serde(default)]
468    pub max_attempts: u32,
469}
470
471impl Default for ReconnectConfig {
472    fn default() -> Self {
473        Self {
474            enabled: true,
475            initial_backoff_ms: 1000,
476            max_backoff_ms: 30000,
477            max_attempts: 0,
478        }
479    }
480}
481
482fn default_initial_backoff() -> u64 {
483    1000
484}
485
486fn default_max_backoff() -> u64 {
487    30000
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn test_mcp_config_default() {
496        let config = McpConfig::default();
497        assert_eq!(config.version, 1);
498        assert!(config.servers.is_empty());
499    }
500
501    #[test]
502    fn test_mcp_config_deserialization() {
503        let json = r#"{"version": 2, "servers": []}"#;
504        let config: McpConfig = serde_json::from_str(json).unwrap();
505        assert_eq!(config.version, 2);
506        assert!(config.servers.is_empty());
507    }
508
509    #[test]
510    fn test_mcp_config_default_version() {
511        let json = r#"{"servers": []}"#;
512        let config: McpConfig = serde_json::from_str(json).unwrap();
513        assert_eq!(config.version, 1);
514    }
515
516    #[test]
517    fn test_mcp_server_config_minimal() {
518        let json = r#"{
519            "id": "test-server",
520            "transport": {
521                "type": "stdio",
522                "command": "node"
523            }
524        }"#;
525        let config: McpServerConfig = serde_json::from_str(json).unwrap();
526        assert_eq!(config.id, "test-server");
527        assert!(config.enabled); // default
528        assert_eq!(config.request_timeout_ms, 60000); // default
529        assert_eq!(config.healthcheck_interval_ms, 30000); // default
530        assert!(config.reconnect.enabled); // default
531        assert!(config.allowed_tools.is_empty());
532        assert!(config.denied_tools.is_empty());
533    }
534
535    #[test]
536    fn test_mcp_server_config_full() {
537        let json = r#"{
538            "id": "test-server",
539            "name": "Test Server",
540            "enabled": false,
541            "transport": {
542                "type": "stdio",
543                "command": "node",
544                "args": ["server.js"],
545                "cwd": "/app",
546                "env": {"NODE_ENV": "production"},
547                "startup_timeout_ms": 30000
548            },
549            "request_timeout_ms": 120000,
550            "healthcheck_interval_ms": 60000,
551            "reconnect": {
552                "enabled": true,
553                "initial_backoff_ms": 2000,
554                "max_backoff_ms": 60000,
555                "max_attempts": 5
556            },
557            "allowed_tools": ["tool1", "tool2"],
558            "denied_tools": ["tool3"]
559        }"#;
560        let config: McpServerConfig = serde_json::from_str(json).unwrap();
561        assert_eq!(config.id, "test-server");
562        assert_eq!(config.name, Some("Test Server".to_string()));
563        assert!(!config.enabled);
564        assert_eq!(config.request_timeout_ms, 120000);
565        assert_eq!(config.healthcheck_interval_ms, 60000);
566        assert!(config.reconnect.enabled);
567        assert_eq!(config.reconnect.initial_backoff_ms, 2000);
568        assert_eq!(config.reconnect.max_backoff_ms, 60000);
569        assert_eq!(config.reconnect.max_attempts, 5);
570        assert_eq!(config.allowed_tools, vec!["tool1", "tool2"]);
571        assert_eq!(config.denied_tools, vec!["tool3"]);
572    }
573
574    #[test]
575    fn test_stdio_config() {
576        let json = r#"{
577            "type": "stdio",
578            "command": "python",
579            "args": ["-m", "server"],
580            "cwd": "/home/user",
581            "env": {"DEBUG": "1"},
582            "startup_timeout_ms": 15000
583        }"#;
584        let config: TransportConfig = serde_json::from_str(json).unwrap();
585        match config {
586            TransportConfig::Stdio(stdio) => {
587                assert_eq!(stdio.command, "python");
588                assert_eq!(stdio.args, vec!["-m", "server"]);
589                assert_eq!(stdio.cwd, Some("/home/user".to_string()));
590                assert_eq!(stdio.env.get("DEBUG"), Some(&"1".to_string()));
591                assert_eq!(stdio.startup_timeout_ms, 15000);
592            }
593            _ => panic!("Expected Stdio transport"),
594        }
595    }
596
597    #[test]
598    fn test_stdio_config_minimal() {
599        let json = r#"{
600            "type": "stdio",
601            "command": "node"
602        }"#;
603        let config: TransportConfig = serde_json::from_str(json).unwrap();
604        match config {
605            TransportConfig::Stdio(stdio) => {
606                assert_eq!(stdio.command, "node");
607                assert!(stdio.args.is_empty());
608                assert!(stdio.cwd.is_none());
609                assert!(stdio.env.is_empty());
610                assert_eq!(stdio.startup_timeout_ms, 20000); // default
611            }
612            _ => panic!("Expected Stdio transport"),
613        }
614    }
615
616    #[test]
617    fn test_sse_config() {
618        let json = r#"{
619            "type": "sse",
620            "url": "http://localhost:8080/sse",
621            "headers": [
622                {"name": "Authorization", "value": "Bearer token123"}
623            ],
624            "connect_timeout_ms": 5000
625        }"#;
626        let config: TransportConfig = serde_json::from_str(json).unwrap();
627        match config {
628            TransportConfig::Sse(sse) => {
629                assert_eq!(sse.url, "http://localhost:8080/sse");
630                assert_eq!(sse.headers.len(), 1);
631                assert_eq!(sse.headers[0].name, "Authorization");
632                assert_eq!(sse.headers[0].value, "Bearer token123");
633                assert_eq!(sse.connect_timeout_ms, 5000);
634            }
635            _ => panic!("Expected SSE transport"),
636        }
637    }
638
639    #[test]
640    fn test_sse_config_minimal() {
641        let json = r#"{
642            "type": "sse",
643            "url": "http://localhost:8080/sse"
644        }"#;
645        let config: TransportConfig = serde_json::from_str(json).unwrap();
646        match config {
647            TransportConfig::Sse(sse) => {
648                assert_eq!(sse.url, "http://localhost:8080/sse");
649                assert!(sse.headers.is_empty());
650                assert_eq!(sse.connect_timeout_ms, 10000); // default
651            }
652            _ => panic!("Expected SSE transport"),
653        }
654    }
655
656    #[test]
657    fn test_streamable_http_config() {
658        let json = r#"{
659            "type": "streamable_http",
660            "url": "http://localhost:3000/mcp",
661            "headers": [
662                {"name": "Authorization", "value": "Bearer token123"}
663            ],
664            "connect_timeout_ms": 5000
665        }"#;
666        let config: TransportConfig = serde_json::from_str(json).unwrap();
667        match config {
668            TransportConfig::StreamableHttp(cfg) => {
669                assert_eq!(cfg.url, "http://localhost:3000/mcp");
670                assert_eq!(cfg.headers.len(), 1);
671                assert_eq!(cfg.headers[0].name, "Authorization");
672                assert_eq!(cfg.connect_timeout_ms, 5000);
673            }
674            _ => panic!("Expected StreamableHttp transport"),
675        }
676    }
677
678    #[test]
679    fn test_streamable_http_config_minimal() {
680        let json = r#"{
681            "type": "streamable_http",
682            "url": "http://localhost:3000/mcp"
683        }"#;
684        let config: TransportConfig = serde_json::from_str(json).unwrap();
685        match config {
686            TransportConfig::StreamableHttp(cfg) => {
687                assert_eq!(cfg.url, "http://localhost:3000/mcp");
688                assert!(cfg.headers.is_empty());
689                assert_eq!(cfg.connect_timeout_ms, 10000); // default
690            }
691            _ => panic!("Expected StreamableHttp transport"),
692        }
693    }
694
695    #[test]
696    fn test_streamable_http_round_trip() {
697        let cfg = McpConfig {
698            version: 1,
699            servers: vec![McpServerConfig {
700                id: "test-sh".to_string(),
701                name: None,
702                enabled: true,
703                transport: TransportConfig::StreamableHttp(StreamableHttpConfig {
704                    url: "http://localhost:3000/mcp".to_string(),
705                    headers: vec![HeaderConfig {
706                        name: "Authorization".to_string(),
707                        value: "Bearer token".to_string(),
708                        value_encrypted: None,
709                    }],
710                    connect_timeout_ms: 5000,
711                }),
712                request_timeout_ms: default_request_timeout(),
713                healthcheck_interval_ms: default_healthcheck_interval(),
714                reconnect: ReconnectConfig::default(),
715                allowed_tools: vec![],
716                denied_tools: vec![],
717            }],
718        };
719
720        let json = serde_json::to_string(&cfg).unwrap();
721        let parsed: McpConfig = serde_json::from_str(&json).unwrap();
722        assert_eq!(parsed.servers.len(), 1);
723        match &parsed.servers[0].transport {
724            TransportConfig::StreamableHttp(sh) => {
725                assert_eq!(sh.url, "http://localhost:3000/mcp");
726                assert_eq!(sh.connect_timeout_ms, 5000);
727            }
728            _ => panic!("Expected StreamableHttp transport"),
729        }
730    }
731
732    #[test]
733    fn test_reconnect_config_default() {
734        let config = ReconnectConfig::default();
735        assert!(config.enabled);
736        assert_eq!(config.initial_backoff_ms, 1000);
737        assert_eq!(config.max_backoff_ms, 30000);
738        assert_eq!(config.max_attempts, 0); // unlimited
739    }
740
741    #[test]
742    fn test_reconnect_config_unlimited_attempts() {
743        let json = r#"{
744            "enabled": true,
745            "initial_backoff_ms": 500,
746            "max_backoff_ms": 10000
747        }"#;
748        let config: ReconnectConfig = serde_json::from_str(json).unwrap();
749        assert!(config.enabled);
750        assert_eq!(config.initial_backoff_ms, 500);
751        assert_eq!(config.max_backoff_ms, 10000);
752        assert_eq!(config.max_attempts, 0);
753    }
754
755    #[test]
756    fn test_reconnect_config_disabled() {
757        let json = r#"{"enabled": false}"#;
758        let config: ReconnectConfig = serde_json::from_str(json).unwrap();
759        assert!(!config.enabled);
760    }
761
762    #[test]
763    fn test_header_config() {
764        let header = HeaderConfig {
765            name: "Content-Type".to_string(),
766            value: "application/json".to_string(),
767            value_encrypted: None,
768        };
769        assert_eq!(header.name, "Content-Type");
770        assert_eq!(header.value, "application/json");
771    }
772
773    #[test]
774    fn test_full_mcp_config() {
775        let json = r#"{
776            "version": 1,
777            "servers": [
778                {
779                    "id": "fs-server",
780                    "transport": {
781                        "type": "stdio",
782                        "command": "mcp-server-filesystem"
783                    }
784                },
785                {
786                    "id": "web-server",
787                    "transport": {
788                        "type": "sse",
789                        "url": "http://localhost:3000/sse"
790                    }
791                }
792            ]
793        }"#;
794        let config: McpConfig = serde_json::from_str(json).unwrap();
795        assert_eq!(config.servers.len(), 2);
796        assert_eq!(config.servers[0].id, "fs-server");
797        assert_eq!(config.servers[1].id, "web-server");
798    }
799
800    #[test]
801    fn test_mcp_config_deserialization_mainstream_map_stdio() {
802        let json = r#"{
803            "filesystem": {
804                "command": "node",
805                "args": ["server.js"],
806                "env": {"MCP_ROOT": "/tmp"}
807            }
808        }"#;
809
810        let config: McpConfig = serde_json::from_str(json).unwrap();
811        assert_eq!(config.version, 1);
812        assert_eq!(config.servers.len(), 1);
813        assert_eq!(config.servers[0].id, "filesystem");
814
815        match &config.servers[0].transport {
816            TransportConfig::Stdio(stdio) => {
817                assert_eq!(stdio.command, "node");
818                assert_eq!(stdio.args, vec!["server.js"]);
819                assert_eq!(stdio.env.get("MCP_ROOT").map(|s| s.as_str()), Some("/tmp"));
820            }
821            _ => panic!("Expected stdio transport"),
822        }
823    }
824
825    #[test]
826    fn test_mcp_config_serialization_is_map() {
827        let mut env_encrypted = HashMap::new();
828        env_encrypted.insert("TOKEN".to_string(), "nonce:ciphertext".to_string());
829
830        let cfg = McpConfig {
831            version: 1,
832            servers: vec![McpServerConfig {
833                id: "demo".to_string(),
834                name: Some("Demo".to_string()),
835                enabled: true,
836                transport: TransportConfig::Stdio(StdioConfig {
837                    command: "node".to_string(),
838                    args: vec!["server.js".to_string()],
839                    cwd: None,
840                    env: HashMap::new(),
841                    env_encrypted,
842                    startup_timeout_ms: default_startup_timeout(),
843                }),
844                request_timeout_ms: default_request_timeout(),
845                healthcheck_interval_ms: default_healthcheck_interval(),
846                reconnect: ReconnectConfig::default(),
847                allowed_tools: vec![],
848                denied_tools: vec![],
849            }],
850        };
851
852        let value = serde_json::to_value(&cfg).unwrap();
853        assert!(value.get("servers").is_none());
854        assert!(value.get("demo").is_some());
855    }
856
857    #[test]
858    fn test_server_config_disabled() {
859        let json = r#"{
860            "id": "disabled-server",
861            "enabled": false,
862            "transport": {"type": "stdio", "command": "node"}
863        }"#;
864        let config: McpServerConfig = serde_json::from_str(json).unwrap();
865        assert!(!config.enabled);
866    }
867}