agpm_cli/mcp/
mod.rs

1//! MCP (Model Context Protocol) server configuration management for AGPM.
2//!
3//! This module handles the integration of MCP servers with AGPM, including:
4//! - Storing raw MCP server configurations in `.claude/agpm/mcp-servers/`
5//! - Writing MCP server configurations to `.mcp.json` for Claude Code
6//! - Managing AGPM-controlled MCP server configurations
7//! - Preserving user-managed server configurations
8//! - Safe atomic updates to MCP configuration files
9//! - Multi-tool support via pluggable MCP handlers
10//!
11//! Note: Hooks and permissions are handled separately and stored in `.claude/settings.local.json`
12
13pub mod handlers;
14
15use anyhow::{Context, Result, anyhow};
16use chrono::Utc;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20use std::path::Path;
21
22/// Settings structure for `.claude/settings.local.json`.
23/// This represents the complete settings file that may contain various configurations.
24#[derive(Debug, Default, Serialize, Deserialize)]
25pub struct ClaudeSettings {
26    /// Map of server names to their configurations
27    #[serde(rename = "mcpServers", skip_serializing_if = "Option::is_none")]
28    pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
29
30    /// Hook configurations for event-based automation
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub hooks: Option<Value>,
33
34    /// Permissions configuration
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub permissions: Option<Value>,
37
38    /// Other settings preserved from the original file
39    #[serde(flatten)]
40    pub other: HashMap<String, Value>,
41}
42
43/// The main MCP configuration file structure for `.mcp.json`.
44///
45/// This represents the complete MCP configuration file that Claude Code reads
46/// to connect to MCP servers. The file may contain both AGPM-managed and
47/// user-managed server configurations.
48#[derive(Debug, Default, Serialize, Deserialize)]
49pub struct McpConfig {
50    /// Map of server names to their configurations
51    #[serde(rename = "mcpServers")]
52    pub mcp_servers: HashMap<String, McpServerConfig>,
53}
54
55/// Individual MCP server configuration.
56///
57/// This structure represents a single MCP server entry in the `.mcp.json` file.
58/// It supports both command-based and HTTP transport configurations.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct McpServerConfig {
61    /// The command to execute to start the server (command-based servers)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub command: Option<String>,
64
65    /// Arguments to pass to the command (command-based servers)
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub args: Vec<String>,
68
69    /// Environment variables to set when running the server (command-based servers)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub env: Option<HashMap<String, Value>>,
72
73    /// Transport type (HTTP-based servers) - Claude Code uses "type" field
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub r#type: Option<String>,
76
77    /// Server URL (HTTP-based servers)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub url: Option<String>,
80
81    /// HTTP headers (HTTP-based servers)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub headers: Option<HashMap<String, Value>>,
84
85    /// AGPM management metadata (only present for AGPM-managed servers)
86    #[serde(rename = "_agpm", skip_serializing_if = "Option::is_none")]
87    pub agpm_metadata: Option<AgpmMetadata>,
88}
89
90/// AGPM management metadata for tracking managed servers.
91///
92/// This metadata is added to server configurations that are managed by AGPM,
93/// allowing us to distinguish between AGPM-managed and user-managed servers.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct AgpmMetadata {
96    /// Indicates this server is managed by AGPM
97    pub managed: bool,
98
99    /// Source repository
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub source: Option<String>,
102
103    /// Version or git reference
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub version: Option<String>,
106
107    /// Timestamp when the server was installed/updated
108    pub installed_at: String,
109
110    /// Original manifest dependency name
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub dependency_name: Option<String>,
113}
114
115impl ClaudeSettings {
116    /// Load an existing `.claude/settings.local.json` file or create a new configuration.
117    ///
118    /// This method preserves all existing configurations.
119    pub fn load_or_default(path: &Path) -> Result<Self> {
120        if path.exists() {
121            crate::utils::read_json_file(path).with_context(|| {
122                format!(
123                    "Failed to parse settings file: {}\n\
124                     The file may be malformed or contain invalid JSON.",
125                    path.display()
126                )
127            })
128        } else {
129            Ok(Self::default())
130        }
131    }
132
133    /// Save the settings to `.claude/settings.local.json` file.
134    ///
135    /// The file is written atomically to prevent corruption.
136    pub fn save(&self, path: &Path) -> Result<()> {
137        // Create a backup if the file exists
138        if path.exists() {
139            // Put backup in .claude/agpm directory
140            let agpm_dir =
141                path.parent().ok_or_else(|| anyhow!("Invalid settings path"))?.join("agpm");
142
143            // Ensure .claude/agpm directory exists
144            if !agpm_dir.exists() {
145                std::fs::create_dir_all(&agpm_dir).with_context(|| {
146                    format!("Failed to create directory: {}", agpm_dir.display())
147                })?;
148            }
149
150            let backup_path = agpm_dir.join("settings.local.json.backup");
151            std::fs::copy(path, &backup_path).with_context(|| {
152                format!("Failed to create backup of settings at: {}", backup_path.display())
153            })?;
154        }
155
156        // Write with pretty formatting for readability
157        crate::utils::write_json_file(path, self, true)
158            .with_context(|| format!("Failed to write settings to: {}", path.display()))?;
159
160        Ok(())
161    }
162
163    /// Update MCP servers from stored configurations.
164    ///
165    /// This method loads all MCP server configurations from `.claude/agpm/mcp-servers/`
166    /// and merges them into the settings, preserving user-managed servers.
167    pub fn update_mcp_servers(&mut self, mcp_servers_dir: &Path) -> Result<()> {
168        if !mcp_servers_dir.exists() {
169            return Ok(());
170        }
171
172        let mut agpm_servers = HashMap::new();
173
174        // Read all .json files from the mcp-servers directory
175        for entry in std::fs::read_dir(mcp_servers_dir).with_context(|| {
176            format!("Failed to read MCP servers directory: {}", mcp_servers_dir.display())
177        })? {
178            let entry = entry?;
179            let path = entry.path();
180
181            if path.extension().is_some_and(|ext| ext == "json") {
182                let server_config: McpServerConfig = crate::utils::read_json_file(&path)
183                    .with_context(|| {
184                        format!("Failed to parse MCP server file: {}", path.display())
185                    })?;
186
187                // Use the filename without extension as the server name
188                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
189                    agpm_servers.insert(name.to_string(), server_config);
190                }
191            }
192        }
193
194        // Initialize mcp_servers if None
195        if self.mcp_servers.is_none() {
196            self.mcp_servers = Some(HashMap::new());
197        }
198
199        // Update MCP servers, preserving user-managed ones
200        if let Some(servers) = &mut self.mcp_servers {
201            // Remove old AGPM-managed servers
202            servers
203                .retain(|_, config| config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed));
204
205            // Add all AGPM-managed servers
206            servers.extend(agpm_servers);
207        }
208
209        Ok(())
210    }
211}
212
213impl McpConfig {
214    /// Load an existing `.mcp.json` file or create a new empty configuration.
215    ///
216    /// This method preserves all existing server configurations, including
217    /// user-managed ones.
218    pub fn load_or_default(path: &Path) -> Result<Self> {
219        if path.exists() {
220            // Parse with lenient error handling to preserve user configurations
221            crate::utils::read_json_file(path).with_context(|| {
222                format!(
223                    "Failed to parse MCP configuration file: {}\n\
224                     The file may be malformed or contain invalid JSON.",
225                    path.display()
226                )
227            })
228        } else {
229            Ok(Self::default())
230        }
231    }
232
233    /// Save the configuration to a `.mcp.json` file.
234    ///
235    /// The file is written atomically to prevent corruption.
236    pub fn save(&self, path: &Path) -> Result<()> {
237        // Create a backup if the file exists
238        if path.exists() {
239            // Put backup in .claude/agpm directory
240            let agpm_dir = path
241                .parent()
242                .ok_or_else(|| anyhow!("Invalid MCP config path"))?
243                .join(".claude")
244                .join("agpm");
245
246            // Ensure .claude/agpm directory exists
247            if !agpm_dir.exists() {
248                std::fs::create_dir_all(&agpm_dir).with_context(|| {
249                    format!("Failed to create directory: {}", agpm_dir.display())
250                })?;
251            }
252
253            let backup_path = agpm_dir.join(".mcp.json.backup");
254            std::fs::copy(path, &backup_path).with_context(|| {
255                format!(
256                    "Failed to create backup of MCP configuration at: {}",
257                    backup_path.display()
258                )
259            })?;
260        }
261
262        // Write with pretty formatting for readability
263        crate::utils::write_json_file(path, self, true)
264            .with_context(|| format!("Failed to write MCP configuration to: {}", path.display()))?;
265
266        Ok(())
267    }
268
269    /// Update only AGPM-managed servers, preserving user configurations.
270    ///
271    /// This method:
272    /// 1. Removes old AGPM-managed servers not in the update set
273    /// 2. Adds or updates AGPM-managed servers from the update set
274    /// 3. Preserves all user-managed servers (those without AGPM metadata)
275    pub fn update_managed_servers(
276        &mut self,
277        updates: HashMap<String, McpServerConfig>,
278    ) -> Result<()> {
279        // Build set of server names being updated
280        let updating_names: std::collections::HashSet<_> = updates.keys().cloned().collect();
281
282        // Remove old AGPM-managed servers not being updated
283        self.mcp_servers.retain(|name, config| {
284            // Keep if:
285            // 1. It's not managed by AGPM (user server), OR
286            // 2. It's being updated in this operation
287            config
288                .agpm_metadata
289                .as_ref()
290                .is_none_or(|meta| !meta.managed || updating_names.contains(name))
291        });
292
293        // Add/update AGPM-managed servers
294        for (name, config) in updates {
295            self.mcp_servers.insert(name, config);
296        }
297
298        Ok(())
299    }
300
301    /// Check for conflicts with user-managed servers.
302    ///
303    /// Returns a list of server names that would conflict with existing
304    /// user-managed servers.
305    #[must_use]
306    pub fn check_conflicts(&self, new_servers: &HashMap<String, McpServerConfig>) -> Vec<String> {
307        let mut conflicts = Vec::new();
308
309        for name in new_servers.keys() {
310            if let Some(existing) = self.mcp_servers.get(name) {
311                // Conflict if the existing server is not managed by AGPM
312                if existing.agpm_metadata.is_none()
313                    || !existing.agpm_metadata.as_ref().unwrap().managed
314                {
315                    conflicts.push(name.clone());
316                }
317            }
318        }
319
320        conflicts
321    }
322
323    /// Remove all AGPM-managed servers.
324    ///
325    /// This is useful for cleanup operations.
326    pub fn remove_all_managed(&mut self) {
327        self.mcp_servers
328            .retain(|_, config| config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed));
329    }
330
331    /// Get all AGPM-managed servers.
332    #[must_use]
333    pub fn get_managed_servers(&self) -> HashMap<String, &McpServerConfig> {
334        self.mcp_servers
335            .iter()
336            .filter(|(_, config)| config.agpm_metadata.as_ref().is_some_and(|meta| meta.managed))
337            .map(|(name, config)| (name.clone(), config))
338            .collect()
339    }
340}
341
342/// Merge MCP server configurations into the config file.
343///
344/// This is a helper function used by MCP handlers to merge server configurations
345/// that have already been read from source files.
346pub async fn merge_mcp_servers(
347    mcp_config_path: &Path,
348    agpm_servers: HashMap<String, McpServerConfig>,
349) -> Result<()> {
350    if agpm_servers.is_empty() {
351        return Ok(());
352    }
353
354    // Load existing MCP configuration
355    let mut mcp_config = McpConfig::load_or_default(mcp_config_path)?;
356
357    // Check for conflicts with user-managed servers
358    let conflicts = mcp_config.check_conflicts(&agpm_servers);
359    if !conflicts.is_empty() {
360        return Err(anyhow::anyhow!(
361            "The following MCP servers already exist and are not managed by AGPM: {}\n\
362             Please rename your servers or remove the existing ones from .mcp.json",
363            conflicts.join(", ")
364        ));
365    }
366
367    // Update MCP configuration with AGPM-managed servers
368    mcp_config.update_managed_servers(agpm_servers)?;
369
370    // Save the updated MCP configuration
371    mcp_config.save(mcp_config_path)?;
372
373    Ok(())
374}
375
376pub async fn configure_mcp_servers(project_root: &Path, mcp_servers_dir: &Path) -> Result<()> {
377    if !mcp_servers_dir.exists() {
378        return Ok(());
379    }
380
381    let mcp_config_path = project_root.join(".mcp.json");
382
383    // Read all MCP server JSON files
384    let mut agpm_servers = HashMap::new();
385    let mut entries = tokio::fs::read_dir(mcp_servers_dir).await?;
386    while let Some(entry) = entries.next_entry().await? {
387        let path = entry.path();
388
389        if path.extension().is_some_and(|ext| ext == "json")
390            && let Some(name) = path.file_stem().and_then(|s| s.to_str())
391        {
392            // Read and parse the MCP server configuration
393            let config: McpServerConfig = crate::utils::read_json_file(&path)
394                .with_context(|| format!("Failed to parse MCP server file: {}", path.display()))?;
395
396            // Add AGPM metadata
397            let mut config_with_metadata = config;
398            if config_with_metadata.agpm_metadata.is_none() {
399                config_with_metadata.agpm_metadata = Some(AgpmMetadata {
400                    managed: true,
401                    source: Some("agpm".to_string()),
402                    version: None,
403                    installed_at: Utc::now().to_rfc3339(),
404                    dependency_name: Some(name.to_string()),
405                });
406            }
407
408            agpm_servers.insert(name.to_string(), config_with_metadata);
409        }
410    }
411
412    // Use the helper function to merge servers
413    merge_mcp_servers(&mcp_config_path, agpm_servers).await
414}
415
416/// Remove all AGPM-managed MCP servers from the configuration.
417pub fn clean_mcp_servers(project_root: &Path) -> Result<()> {
418    let claude_dir = project_root.join(".claude");
419    let agpm_dir = claude_dir.join("agpm");
420    let mcp_servers_dir = agpm_dir.join("mcp-servers");
421    let mcp_config_path = project_root.join(".mcp.json");
422
423    // Remove all files from mcp-servers directory
424    let mut removed_count = 0;
425    if mcp_servers_dir.exists() {
426        for entry in std::fs::read_dir(&mcp_servers_dir)? {
427            let entry = entry?;
428            let path = entry.path();
429            if path.extension().is_some_and(|ext| ext == "json") {
430                std::fs::remove_file(&path).with_context(|| {
431                    format!("Failed to remove MCP server file: {}", path.display())
432                })?;
433                removed_count += 1;
434            }
435        }
436    }
437
438    // Update MCP config to remove AGPM-managed servers
439    if mcp_config_path.exists() {
440        let mut mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
441        mcp_config.remove_all_managed();
442        mcp_config.save(&mcp_config_path)?;
443    }
444
445    if removed_count == 0 {
446        println!("No AGPM-managed MCP servers found");
447    } else {
448        println!("✓ Removed {removed_count} AGPM-managed MCP server(s)");
449    }
450
451    Ok(())
452}
453
454/// List all MCP servers, indicating which are AGPM-managed.
455pub fn list_mcp_servers(project_root: &Path) -> Result<()> {
456    let mcp_config_path = project_root.join(".mcp.json");
457
458    if !mcp_config_path.exists() {
459        println!("No .mcp.json file found");
460        return Ok(());
461    }
462
463    let mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
464
465    if mcp_config.mcp_servers.is_empty() {
466        println!("No MCP servers configured");
467        return Ok(());
468    }
469
470    let servers = &mcp_config.mcp_servers;
471    println!("MCP Servers:");
472    println!("╭─────────────────────┬──────────┬───────────╮");
473    println!("│ Name                │ Managed  │ Version   │");
474    println!("├─────────────────────┼──────────┼───────────┤");
475
476    for (name, server) in servers {
477        let (managed, version) = if let Some(meta) = &server.agpm_metadata {
478            if meta.managed {
479                ("✓ (agpm)", meta.version.as_deref().unwrap_or("-"))
480            } else {
481                ("✗", "-")
482            }
483        } else {
484            ("✗", "-")
485        };
486
487        println!("│ {name:<19} │ {managed:<8} │ {version:<9} │");
488    }
489
490    println!("╰─────────────────────┴──────────┴───────────╯");
491
492    Ok(())
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use serde_json::json;
499    use std::fs;
500    use tempfile::tempdir;
501
502    #[test]
503    fn test_claude_settings_load_save() {
504        let temp = tempdir().unwrap();
505        let settings_path = temp.path().join("settings.local.json");
506
507        let mut settings = ClaudeSettings::default();
508        let mut servers = HashMap::new();
509        servers.insert(
510            "test-server".to_string(),
511            McpServerConfig {
512                command: Some("node".to_string()),
513                args: vec!["server.js".to_string()],
514                env: None,
515                r#type: None,
516                url: None,
517                headers: None,
518                agpm_metadata: None,
519            },
520        );
521        settings.mcp_servers = Some(servers);
522
523        settings.save(&settings_path).unwrap();
524
525        let loaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
526        assert!(loaded.mcp_servers.is_some());
527        let servers = loaded.mcp_servers.unwrap();
528        assert_eq!(servers.len(), 1);
529        assert!(servers.contains_key("test-server"));
530    }
531
532    #[test]
533    fn test_claude_settings_load_nonexistent_file() {
534        let temp = tempdir().unwrap();
535        let settings_path = temp.path().join("nonexistent.json");
536
537        let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
538        assert!(settings.mcp_servers.is_none());
539        assert!(settings.permissions.is_none());
540        assert!(settings.other.is_empty());
541    }
542
543    #[test]
544    fn test_claude_settings_load_invalid_json() {
545        let temp = tempdir().unwrap();
546        let settings_path = temp.path().join("invalid.json");
547        fs::write(&settings_path, "invalid json {").unwrap();
548
549        let result = ClaudeSettings::load_or_default(&settings_path);
550        assert!(result.is_err());
551        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
552    }
553
554    #[test]
555    fn test_claude_settings_save_creates_backup() {
556        let temp = tempdir().unwrap();
557        let settings_path = temp.path().join("settings.local.json");
558        let backup_path = temp.path().join("agpm").join("settings.local.json.backup");
559
560        // Create initial file
561        fs::write(&settings_path, r#"{"test": "value"}"#).unwrap();
562
563        let settings = ClaudeSettings::default();
564        settings.save(&settings_path).unwrap();
565
566        // Backup should be created in agpm directory
567        assert!(backup_path.exists());
568        let backup_content = fs::read_to_string(backup_path).unwrap();
569        assert_eq!(backup_content, r#"{"test": "value"}"#);
570    }
571
572    #[test]
573    fn test_claude_settings_update_mcp_servers_empty_dir() {
574        let temp = tempdir().unwrap();
575        let nonexistent_dir = temp.path().join("nonexistent");
576
577        let mut settings = ClaudeSettings::default();
578        // Should not error on nonexistent directory
579        settings.update_mcp_servers(&nonexistent_dir).unwrap();
580    }
581
582    #[test]
583    fn test_update_mcp_servers_from_directory() {
584        let temp = tempdir().unwrap();
585        let mcp_servers_dir = temp.path().join("mcp-servers");
586        std::fs::create_dir(&mcp_servers_dir).unwrap();
587
588        // Create a server config file
589        let server_config = McpServerConfig {
590            command: Some("managed".to_string()),
591            args: vec![],
592            env: None,
593            r#type: None,
594            url: None,
595            headers: None,
596            agpm_metadata: Some(AgpmMetadata {
597                managed: true,
598                source: Some("test".to_string()),
599                version: Some("v1.0.0".to_string()),
600                installed_at: "2024-01-01T00:00:00Z".to_string(),
601                dependency_name: Some("agpm-server".to_string()),
602            }),
603        };
604        let config_path = mcp_servers_dir.join("agpm-server.json");
605        let json = serde_json::to_string_pretty(&server_config).unwrap();
606        std::fs::write(&config_path, json).unwrap();
607
608        // Add a user-managed server to settings
609        let mut settings = ClaudeSettings::default();
610        let mut servers = HashMap::new();
611        servers.insert(
612            "user-server".to_string(),
613            McpServerConfig {
614                command: Some("custom".to_string()),
615                args: vec![],
616                env: None,
617                r#type: None,
618                url: None,
619                headers: None,
620                agpm_metadata: None,
621            },
622        );
623        settings.mcp_servers = Some(servers);
624
625        // Update from directory
626        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
627
628        // Both servers should be present
629        assert!(settings.mcp_servers.is_some());
630        let servers = settings.mcp_servers.as_ref().unwrap();
631        assert!(servers.contains_key("user-server"));
632        assert!(servers.contains_key("agpm-server"));
633        assert_eq!(servers.len(), 2);
634    }
635
636    #[test]
637    fn test_update_mcp_servers_replaces_old_managed() {
638        let temp = tempdir().unwrap();
639        let mcp_servers_dir = temp.path().join("mcp-servers");
640        std::fs::create_dir(&mcp_servers_dir).unwrap();
641
642        // Start with existing managed and user servers
643        let mut settings = ClaudeSettings::default();
644        let mut servers = HashMap::new();
645
646        // User-managed server (should be preserved)
647        servers.insert(
648            "user-server".to_string(),
649            McpServerConfig {
650                command: Some("user-command".to_string()),
651                args: vec![],
652                env: None,
653                r#type: None,
654                url: None,
655                headers: None,
656                agpm_metadata: None,
657            },
658        );
659
660        // Old AGPM-managed server (should be removed)
661        servers.insert(
662            "old-managed".to_string(),
663            McpServerConfig {
664                command: Some("old-command".to_string()),
665                args: vec![],
666                env: None,
667                r#type: None,
668                url: None,
669                headers: None,
670                agpm_metadata: Some(AgpmMetadata {
671                    managed: true,
672                    source: Some("old-source".to_string()),
673                    version: Some("v0.1.0".to_string()),
674                    installed_at: "2023-01-01T00:00:00Z".to_string(),
675                    dependency_name: Some("old-managed".to_string()),
676                }),
677            },
678        );
679
680        settings.mcp_servers = Some(servers);
681
682        // Create new managed server config file
683        let server_config = McpServerConfig {
684            command: Some("new-managed".to_string()),
685            args: vec![],
686            env: None,
687            r#type: None,
688            url: None,
689            headers: None,
690            agpm_metadata: Some(AgpmMetadata {
691                managed: true,
692                source: Some("new-source".to_string()),
693                version: Some("v1.0.0".to_string()),
694                installed_at: "2024-01-01T00:00:00Z".to_string(),
695                dependency_name: Some("new-managed".to_string()),
696            }),
697        };
698        let config_path = mcp_servers_dir.join("new-managed.json");
699        let json = serde_json::to_string_pretty(&server_config).unwrap();
700        std::fs::write(&config_path, json).unwrap();
701
702        // Update from directory
703        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
704
705        let servers = settings.mcp_servers.as_ref().unwrap();
706        assert!(servers.contains_key("user-server")); // User server preserved
707        assert!(servers.contains_key("new-managed")); // New managed server added
708        assert!(!servers.contains_key("old-managed")); // Old managed server removed
709        assert_eq!(servers.len(), 2);
710    }
711
712    #[test]
713    fn test_update_mcp_servers_invalid_json_file() {
714        let temp = tempdir().unwrap();
715        let mcp_servers_dir = temp.path().join("mcp-servers");
716        std::fs::create_dir(&mcp_servers_dir).unwrap();
717
718        // Create invalid JSON file
719        let invalid_path = mcp_servers_dir.join("invalid.json");
720        std::fs::write(&invalid_path, "invalid json {").unwrap();
721
722        let mut settings = ClaudeSettings::default();
723        let result = settings.update_mcp_servers(&mcp_servers_dir);
724        assert!(result.is_err());
725        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
726    }
727
728    #[test]
729    fn test_update_mcp_servers_ignores_non_json_files() {
730        let temp = tempdir().unwrap();
731        let mcp_servers_dir = temp.path().join("mcp-servers");
732        std::fs::create_dir(&mcp_servers_dir).unwrap();
733
734        // Create non-JSON file
735        let txt_path = mcp_servers_dir.join("readme.txt");
736        std::fs::write(&txt_path, "This is not a JSON file").unwrap();
737
738        // Create valid JSON file
739        let server_config = McpServerConfig {
740            command: Some("test".to_string()),
741            args: vec![],
742            env: None,
743            r#type: None,
744            url: None,
745            headers: None,
746            agpm_metadata: None,
747        };
748        let json_path = mcp_servers_dir.join("valid.json");
749        let json = serde_json::to_string_pretty(&server_config).unwrap();
750        std::fs::write(&json_path, json).unwrap();
751
752        let mut settings = ClaudeSettings::default();
753        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
754
755        let servers = settings.mcp_servers.as_ref().unwrap();
756        assert!(servers.contains_key("valid"));
757        assert_eq!(servers.len(), 1);
758    }
759
760    #[test]
761    fn test_settings_preserves_other_fields() {
762        let temp = tempdir().unwrap();
763        let settings_path = temp.path().join("settings.local.json");
764
765        // Create a settings file with various fields
766        let json = r#"{
767            "permissions": {
768                "allow": ["Bash(ls)"],
769                "deny": []
770            },
771            "customField": "value",
772            "mcpServers": {
773                "test": {
774                    "command": "node",
775                    "args": []
776                }
777            }
778        }"#;
779        std::fs::write(&settings_path, json).unwrap();
780
781        // Load and save
782        let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
783        assert!(settings.permissions.is_some());
784        assert!(settings.mcp_servers.is_some());
785        assert!(settings.other.contains_key("customField"));
786
787        settings.save(&settings_path).unwrap();
788
789        // Reload and verify all fields preserved
790        let reloaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
791        assert!(reloaded.permissions.is_some());
792        assert!(reloaded.mcp_servers.is_some());
793        assert!(reloaded.other.contains_key("customField"));
794    }
795
796    // McpConfig tests
797    #[test]
798    fn test_mcp_config_load_save() {
799        let temp = tempdir().unwrap();
800        let config_path = temp.path().join("mcp.json");
801
802        let mut config = McpConfig::default();
803        config.mcp_servers.insert(
804            "test-server".to_string(),
805            McpServerConfig {
806                command: Some("node".to_string()),
807                args: vec!["server.js".to_string()],
808                env: Some({
809                    let mut env = HashMap::new();
810                    env.insert("NODE_ENV".to_string(), json!("production"));
811                    env
812                }),
813                r#type: None,
814                url: None,
815                headers: None,
816                agpm_metadata: None,
817            },
818        );
819
820        config.save(&config_path).unwrap();
821
822        let loaded = McpConfig::load_or_default(&config_path).unwrap();
823        assert!(loaded.mcp_servers.contains_key("test-server"));
824        let server = &loaded.mcp_servers["test-server"];
825        assert_eq!(server.command, Some("node".to_string()));
826        assert_eq!(server.args, vec!["server.js"]);
827        assert!(server.env.is_some());
828    }
829
830    #[test]
831    fn test_mcp_config_load_nonexistent() {
832        let temp = tempdir().unwrap();
833        let config_path = temp.path().join("nonexistent.json");
834
835        let config = McpConfig::load_or_default(&config_path).unwrap();
836        assert!(config.mcp_servers.is_empty());
837    }
838
839    #[test]
840    fn test_mcp_config_load_invalid_json() {
841        let temp = tempdir().unwrap();
842        let config_path = temp.path().join("invalid.json");
843        fs::write(&config_path, "invalid json {").unwrap();
844
845        let result = McpConfig::load_or_default(&config_path);
846        assert!(result.is_err());
847    }
848
849    #[test]
850    fn test_mcp_config_save_creates_backup() {
851        let temp = tempdir().unwrap();
852        let config_path = temp.path().join("mcp.json");
853        let backup_path = temp.path().join(".claude").join("agpm").join(".mcp.json.backup");
854
855        // Create initial file
856        fs::write(&config_path, r#"{"mcpServers": {"old": {"command": "old"}}}"#).unwrap();
857
858        let config = McpConfig::default();
859        config.save(&config_path).unwrap();
860
861        // Backup should be created in .claude/agpm directory
862        assert!(backup_path.exists());
863        let backup_content = fs::read_to_string(backup_path).unwrap();
864        assert!(backup_content.contains("old"));
865    }
866
867    #[test]
868    fn test_mcp_config_update_managed_servers() {
869        let mut config = McpConfig::default();
870
871        // Add user-managed server
872        config.mcp_servers.insert(
873            "user-server".to_string(),
874            McpServerConfig {
875                command: Some("user-command".to_string()),
876                args: vec![],
877                env: None,
878                r#type: None,
879                url: None,
880                headers: None,
881                agpm_metadata: None,
882            },
883        );
884
885        // Add old AGPM-managed server
886        config.mcp_servers.insert(
887            "old-managed".to_string(),
888            McpServerConfig {
889                command: Some("old-command".to_string()),
890                args: vec![],
891                env: None,
892                r#type: None,
893                url: None,
894                headers: None,
895                agpm_metadata: Some(AgpmMetadata {
896                    managed: true,
897                    source: None,
898                    version: None,
899                    installed_at: "old-time".to_string(),
900                    dependency_name: None,
901                }),
902            },
903        );
904
905        // Update with new managed servers
906        let mut updates = HashMap::new();
907        updates.insert(
908            "new-managed".to_string(),
909            McpServerConfig {
910                command: Some("new-command".to_string()),
911                args: vec![],
912                env: None,
913                r#type: None,
914                url: None,
915                headers: None,
916                agpm_metadata: Some(AgpmMetadata {
917                    managed: true,
918                    source: None,
919                    version: None,
920                    installed_at: "new-time".to_string(),
921                    dependency_name: None,
922                }),
923            },
924        );
925
926        config.update_managed_servers(updates).unwrap();
927
928        // User server should be preserved, old managed should be removed, new managed added
929        assert!(config.mcp_servers.contains_key("user-server"));
930        assert!(config.mcp_servers.contains_key("new-managed"));
931        assert!(!config.mcp_servers.contains_key("old-managed"));
932        assert_eq!(config.mcp_servers.len(), 2);
933    }
934
935    #[test]
936    fn test_mcp_config_update_managed_servers_preserves_updating_servers() {
937        let mut config = McpConfig::default();
938
939        // Add AGPM-managed server that will be updated
940        config.mcp_servers.insert(
941            "updating-server".to_string(),
942            McpServerConfig {
943                command: Some("old-command".to_string()),
944                args: vec![],
945                env: None,
946                r#type: None,
947                url: None,
948                headers: None,
949                agpm_metadata: Some(AgpmMetadata {
950                    managed: true,
951                    source: None,
952                    version: Some("v1.0.0".to_string()),
953                    installed_at: "old-time".to_string(),
954                    dependency_name: None,
955                }),
956            },
957        );
958
959        // Update with new version of the same server
960        let mut updates = HashMap::new();
961        updates.insert(
962            "updating-server".to_string(),
963            McpServerConfig {
964                command: Some("new-command".to_string()),
965                args: vec![],
966                env: None,
967                r#type: None,
968                url: None,
969                headers: None,
970                agpm_metadata: Some(AgpmMetadata {
971                    managed: true,
972                    source: None,
973                    version: Some("v2.0.0".to_string()),
974                    installed_at: "new-time".to_string(),
975                    dependency_name: None,
976                }),
977            },
978        );
979
980        config.update_managed_servers(updates).unwrap();
981
982        assert!(config.mcp_servers.contains_key("updating-server"));
983        let server = &config.mcp_servers["updating-server"];
984        assert_eq!(server.command, Some("new-command".to_string()));
985        assert_eq!(server.agpm_metadata.as_ref().unwrap().version, Some("v2.0.0".to_string()));
986    }
987
988    #[test]
989    fn test_mcp_config_check_conflicts() {
990        let mut config = McpConfig::default();
991
992        // Add user-managed server
993        config.mcp_servers.insert(
994            "user-server".to_string(),
995            McpServerConfig {
996                command: Some("user-command".to_string()),
997                args: vec![],
998                env: None,
999                r#type: None,
1000                url: None,
1001                headers: None,
1002                agpm_metadata: None,
1003            },
1004        );
1005
1006        // Add AGPM-managed server
1007        config.mcp_servers.insert(
1008            "managed-server".to_string(),
1009            McpServerConfig {
1010                command: Some("managed-command".to_string()),
1011                args: vec![],
1012                env: None,
1013                r#type: None,
1014                url: None,
1015                headers: None,
1016                agpm_metadata: Some(AgpmMetadata {
1017                    managed: true,
1018                    source: None,
1019                    version: None,
1020                    installed_at: "time".to_string(),
1021                    dependency_name: None,
1022                }),
1023            },
1024        );
1025
1026        let mut new_servers = HashMap::new();
1027        new_servers.insert(
1028            "user-server".to_string(), // This conflicts
1029            McpServerConfig {
1030                command: Some("new-command".to_string()),
1031                args: vec![],
1032                env: None,
1033                r#type: None,
1034                url: None,
1035                headers: None,
1036                agpm_metadata: Some(AgpmMetadata {
1037                    managed: true,
1038                    source: None,
1039                    version: None,
1040                    installed_at: "time".to_string(),
1041                    dependency_name: None,
1042                }),
1043            },
1044        );
1045        new_servers.insert(
1046            "managed-server".to_string(), // This doesn't conflict (already managed)
1047            McpServerConfig {
1048                command: Some("updated-command".to_string()),
1049                args: vec![],
1050                env: None,
1051                r#type: None,
1052                url: None,
1053                headers: None,
1054                agpm_metadata: Some(AgpmMetadata {
1055                    managed: true,
1056                    source: None,
1057                    version: None,
1058                    installed_at: "time".to_string(),
1059                    dependency_name: None,
1060                }),
1061            },
1062        );
1063        new_servers.insert(
1064            "new-server".to_string(), // This doesn't conflict (new)
1065            McpServerConfig {
1066                command: Some("new-command".to_string()),
1067                args: vec![],
1068                env: None,
1069                r#type: None,
1070                url: None,
1071                headers: None,
1072                agpm_metadata: Some(AgpmMetadata {
1073                    managed: true,
1074                    source: None,
1075                    version: None,
1076                    installed_at: "time".to_string(),
1077                    dependency_name: None,
1078                }),
1079            },
1080        );
1081
1082        let conflicts = config.check_conflicts(&new_servers);
1083        assert_eq!(conflicts, vec!["user-server"]);
1084    }
1085
1086    #[test]
1087    fn test_mcp_config_check_conflicts_unmanaged_metadata() {
1088        let mut config = McpConfig::default();
1089
1090        // Add server with metadata but managed=false
1091        config.mcp_servers.insert(
1092            "unmanaged-server".to_string(),
1093            McpServerConfig {
1094                command: Some("user-command".to_string()),
1095                args: vec![],
1096                env: None,
1097                r#type: None,
1098                url: None,
1099                headers: None,
1100                agpm_metadata: Some(AgpmMetadata {
1101                    managed: false,
1102                    source: None,
1103                    version: None,
1104                    installed_at: "time".to_string(),
1105                    dependency_name: None,
1106                }),
1107            },
1108        );
1109
1110        let mut new_servers = HashMap::new();
1111        new_servers.insert(
1112            "unmanaged-server".to_string(),
1113            McpServerConfig {
1114                command: Some("new-command".to_string()),
1115                args: vec![],
1116                env: None,
1117                r#type: None,
1118                url: None,
1119                headers: None,
1120                agpm_metadata: Some(AgpmMetadata {
1121                    managed: true,
1122                    source: None,
1123                    version: None,
1124                    installed_at: "time".to_string(),
1125                    dependency_name: None,
1126                }),
1127            },
1128        );
1129
1130        let conflicts = config.check_conflicts(&new_servers);
1131        assert_eq!(conflicts, vec!["unmanaged-server"]);
1132    }
1133
1134    #[test]
1135    fn test_mcp_config_remove_all_managed() {
1136        let mut config = McpConfig::default();
1137
1138        // Add mixed servers
1139        config.mcp_servers.insert(
1140            "user-server".to_string(),
1141            McpServerConfig {
1142                command: Some("user-command".to_string()),
1143                args: vec![],
1144                env: None,
1145                r#type: None,
1146                url: None,
1147                headers: None,
1148                agpm_metadata: None,
1149            },
1150        );
1151
1152        config.mcp_servers.insert(
1153            "managed-server".to_string(),
1154            McpServerConfig {
1155                command: Some("managed-command".to_string()),
1156                args: vec![],
1157                env: None,
1158                r#type: None,
1159                url: None,
1160                headers: None,
1161                agpm_metadata: Some(AgpmMetadata {
1162                    managed: true,
1163                    source: None,
1164                    version: None,
1165                    installed_at: "time".to_string(),
1166                    dependency_name: None,
1167                }),
1168            },
1169        );
1170
1171        config.mcp_servers.insert(
1172            "unmanaged-with-metadata".to_string(),
1173            McpServerConfig {
1174                command: Some("unmanaged-command".to_string()),
1175                args: vec![],
1176                env: None,
1177                r#type: None,
1178                url: None,
1179                headers: None,
1180                agpm_metadata: Some(AgpmMetadata {
1181                    managed: false,
1182                    source: None,
1183                    version: None,
1184                    installed_at: "time".to_string(),
1185                    dependency_name: None,
1186                }),
1187            },
1188        );
1189
1190        config.remove_all_managed();
1191
1192        assert!(config.mcp_servers.contains_key("user-server"));
1193        assert!(config.mcp_servers.contains_key("unmanaged-with-metadata"));
1194        assert!(!config.mcp_servers.contains_key("managed-server"));
1195        assert_eq!(config.mcp_servers.len(), 2);
1196    }
1197
1198    #[test]
1199    fn test_mcp_config_get_managed_servers() {
1200        let mut config = McpConfig::default();
1201
1202        // Add mixed servers
1203        config.mcp_servers.insert(
1204            "user-server".to_string(),
1205            McpServerConfig {
1206                command: Some("user-command".to_string()),
1207                args: vec![],
1208                env: None,
1209                r#type: None,
1210                url: None,
1211                headers: None,
1212                agpm_metadata: None,
1213            },
1214        );
1215
1216        config.mcp_servers.insert(
1217            "managed-server1".to_string(),
1218            McpServerConfig {
1219                command: Some("managed-command1".to_string()),
1220                args: vec![],
1221                env: None,
1222                r#type: None,
1223                url: None,
1224                headers: None,
1225                agpm_metadata: Some(AgpmMetadata {
1226                    managed: true,
1227                    source: None,
1228                    version: None,
1229                    installed_at: "time".to_string(),
1230                    dependency_name: None,
1231                }),
1232            },
1233        );
1234
1235        config.mcp_servers.insert(
1236            "managed-server2".to_string(),
1237            McpServerConfig {
1238                command: Some("managed-command2".to_string()),
1239                args: vec![],
1240                env: None,
1241                r#type: None,
1242                url: None,
1243                headers: None,
1244                agpm_metadata: Some(AgpmMetadata {
1245                    managed: true,
1246                    source: Some("source".to_string()),
1247                    version: Some("v1.0.0".to_string()),
1248                    installed_at: "time".to_string(),
1249                    dependency_name: Some("dep".to_string()),
1250                }),
1251            },
1252        );
1253
1254        let managed = config.get_managed_servers();
1255        assert_eq!(managed.len(), 2);
1256        assert!(managed.contains_key("managed-server1"));
1257        assert!(managed.contains_key("managed-server2"));
1258        assert!(!managed.contains_key("user-server"));
1259    }
1260
1261    // Tests for configure_mcp_servers function would go here
1262    // Since MCP servers now use standard ResourceDependency and file-based approach,
1263    // the old McpServerDependency tests are no longer applicable
1264
1265    // Serialization tests
1266    #[test]
1267    fn test_claude_settings_serialization() {
1268        // Add various fields
1269        let mut settings = ClaudeSettings {
1270            permissions: Some(json!({"allow": ["test"], "deny": []})),
1271            ..Default::default()
1272        };
1273
1274        let mut servers = HashMap::new();
1275        servers.insert(
1276            "test".to_string(),
1277            McpServerConfig {
1278                command: Some("test-cmd".to_string()),
1279                args: vec!["arg1".to_string()],
1280                env: Some({
1281                    let mut env = HashMap::new();
1282                    env.insert("VAR".to_string(), json!("value"));
1283                    env
1284                }),
1285                r#type: None,
1286                url: None,
1287                headers: None,
1288                agpm_metadata: Some(AgpmMetadata {
1289                    managed: true,
1290                    source: Some("test-source".to_string()),
1291                    version: Some("v1.0.0".to_string()),
1292                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1293                    dependency_name: Some("test".to_string()),
1294                }),
1295            },
1296        );
1297        settings.mcp_servers = Some(servers);
1298
1299        settings.other.insert("custom".to_string(), json!("value"));
1300
1301        // Serialize and deserialize
1302        let json = serde_json::to_string(&settings).unwrap();
1303        let deserialized: ClaudeSettings = serde_json::from_str(&json).unwrap();
1304
1305        assert_eq!(deserialized.permissions, settings.permissions);
1306        assert_eq!(deserialized.mcp_servers.as_ref().unwrap().len(), 1);
1307        assert_eq!(deserialized.other.get("custom"), settings.other.get("custom"));
1308    }
1309
1310    #[test]
1311    fn test_mcp_config_serialization() {
1312        let mut config = McpConfig::default();
1313
1314        config.mcp_servers.insert(
1315            "test".to_string(),
1316            McpServerConfig {
1317                command: Some("test-cmd".to_string()),
1318                args: vec!["arg1".to_string(), "arg2".to_string()],
1319                env: Some({
1320                    let mut env = HashMap::new();
1321                    env.insert("TEST_VAR".to_string(), json!("test_value"));
1322                    env
1323                }),
1324                r#type: None,
1325                url: None,
1326                headers: None,
1327                agpm_metadata: Some(AgpmMetadata {
1328                    managed: true,
1329                    source: Some("github.com/test/repo".to_string()),
1330                    version: Some("v2.0.0".to_string()),
1331                    installed_at: "2024-01-01T12:00:00Z".to_string(),
1332                    dependency_name: Some("test-dep".to_string()),
1333                }),
1334            },
1335        );
1336
1337        // Serialize and deserialize
1338        let json = serde_json::to_string_pretty(&config).unwrap();
1339        let deserialized: McpConfig = serde_json::from_str(&json).unwrap();
1340
1341        assert_eq!(deserialized.mcp_servers.len(), 1);
1342        let server = &deserialized.mcp_servers["test"];
1343        assert_eq!(server.command, Some("test-cmd".to_string()));
1344        assert_eq!(server.args.len(), 2);
1345        assert!(server.env.is_some());
1346        assert!(server.agpm_metadata.is_some());
1347
1348        let metadata = server.agpm_metadata.as_ref().unwrap();
1349        assert!(metadata.managed);
1350        assert_eq!(metadata.source, Some("github.com/test/repo".to_string()));
1351        assert_eq!(metadata.version, Some("v2.0.0".to_string()));
1352    }
1353
1354    #[test]
1355    fn test_mcp_server_config_minimal_serialization() {
1356        let config = McpServerConfig {
1357            command: Some("minimal".to_string()),
1358            args: vec![],
1359            env: None,
1360            r#type: None,
1361            url: None,
1362            headers: None,
1363            agpm_metadata: None,
1364        };
1365
1366        let json = serde_json::to_string(&config).unwrap();
1367        let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
1368
1369        assert_eq!(deserialized.command, Some("minimal".to_string()));
1370        assert!(deserialized.args.is_empty());
1371        assert!(deserialized.env.is_none());
1372        assert!(deserialized.agpm_metadata.is_none());
1373
1374        // Check that empty args are skipped in serialization
1375        assert!(!json.contains(r#""args":[]"#));
1376    }
1377
1378    #[test]
1379    fn test_agpm_metadata_serialization() {
1380        let metadata = AgpmMetadata {
1381            managed: true,
1382            source: Some("test-source".to_string()),
1383            version: None,
1384            installed_at: "2024-01-01T00:00:00Z".to_string(),
1385            dependency_name: Some("test-dep".to_string()),
1386        };
1387
1388        let json = serde_json::to_string(&metadata).unwrap();
1389        let deserialized: AgpmMetadata = serde_json::from_str(&json).unwrap();
1390
1391        assert!(deserialized.managed);
1392        assert_eq!(deserialized.source, Some("test-source".to_string()));
1393        assert_eq!(deserialized.version, None);
1394        assert_eq!(deserialized.installed_at, "2024-01-01T00:00:00Z");
1395        assert_eq!(deserialized.dependency_name, Some("test-dep".to_string()));
1396
1397        // Check that None version is skipped in serialization
1398        assert!(!json.contains(r#""version""#));
1399    }
1400
1401    #[test]
1402    fn test_clean_mcp_servers() {
1403        let temp = tempfile::TempDir::new().unwrap();
1404        let project_root = temp.path();
1405        let claude_dir = project_root.join(".claude");
1406        let agpm_dir = claude_dir.join("agpm");
1407        let mcp_servers_dir = agpm_dir.join("mcp-servers");
1408        let settings_path = claude_dir.join("settings.local.json");
1409        let mcp_config_path = project_root.join(".mcp.json");
1410
1411        // Create directory structure
1412        std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1413
1414        // Create MCP server files
1415        let server1_path = mcp_servers_dir.join("server1.json");
1416        let server2_path = mcp_servers_dir.join("server2.json");
1417        let server_config = McpServerConfig {
1418            command: Some("test".to_string()),
1419            args: vec![],
1420            env: None,
1421            r#type: None,
1422            url: None,
1423            headers: None,
1424            agpm_metadata: Some(AgpmMetadata {
1425                managed: true,
1426                source: Some("test-source".to_string()),
1427                version: Some("v1.0.0".to_string()),
1428                installed_at: "2024-01-01T00:00:00Z".to_string(),
1429                dependency_name: Some("test-server".to_string()),
1430            }),
1431        };
1432        crate::utils::write_json_file(&server1_path, &server_config, true).unwrap();
1433        crate::utils::write_json_file(&server2_path, &server_config, true).unwrap();
1434
1435        // Create settings with both AGPM-managed and user-managed servers
1436        let mut settings = ClaudeSettings::default();
1437        let mut servers = HashMap::new();
1438
1439        // AGPM-managed server
1440        servers.insert(
1441            "agpm-server".to_string(),
1442            McpServerConfig {
1443                command: Some("agpm-cmd".to_string()),
1444                args: vec![],
1445                env: None,
1446                r#type: None,
1447                url: None,
1448                headers: None,
1449                agpm_metadata: Some(AgpmMetadata {
1450                    managed: true,
1451                    source: Some("test".to_string()),
1452                    version: Some("v1.0.0".to_string()),
1453                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1454                    dependency_name: None,
1455                }),
1456            },
1457        );
1458
1459        // User-managed server
1460        servers.insert(
1461            "user-server".to_string(),
1462            McpServerConfig {
1463                command: Some("user-cmd".to_string()),
1464                args: vec![],
1465                env: None,
1466                r#type: None,
1467                url: None,
1468                headers: None,
1469                agpm_metadata: None,
1470            },
1471        );
1472
1473        settings.mcp_servers = Some(servers);
1474        settings.save(&settings_path).unwrap();
1475
1476        // Create .mcp.json file with the same servers
1477        let mut mcp_config = McpConfig::default();
1478        mcp_config.mcp_servers.insert(
1479            "agpm-server".to_string(),
1480            McpServerConfig {
1481                command: Some("agpm-cmd".to_string()),
1482                args: vec![],
1483                env: None,
1484                r#type: None,
1485                url: None,
1486                headers: None,
1487                agpm_metadata: Some(AgpmMetadata {
1488                    managed: true,
1489                    source: Some("test".to_string()),
1490                    version: Some("v1.0.0".to_string()),
1491                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1492                    dependency_name: None,
1493                }),
1494            },
1495        );
1496        mcp_config.mcp_servers.insert(
1497            "user-server".to_string(),
1498            McpServerConfig {
1499                command: Some("user-cmd".to_string()),
1500                args: vec![],
1501                env: None,
1502                r#type: None,
1503                url: None,
1504                headers: None,
1505                agpm_metadata: None,
1506            },
1507        );
1508        mcp_config.save(&mcp_config_path).unwrap();
1509
1510        // Run clean_mcp_servers
1511        clean_mcp_servers(project_root).unwrap();
1512
1513        // Verify MCP server files are deleted
1514        assert!(!server1_path.exists());
1515        assert!(!server2_path.exists());
1516
1517        // Verify .mcp.json only contains user-managed servers
1518        let updated_mcp_config = McpConfig::load_or_default(&mcp_config_path).unwrap();
1519        assert_eq!(updated_mcp_config.mcp_servers.len(), 1);
1520        assert!(updated_mcp_config.mcp_servers.contains_key("user-server"));
1521        assert!(!updated_mcp_config.mcp_servers.contains_key("agpm-server"));
1522    }
1523
1524    #[test]
1525    fn test_clean_mcp_servers_no_servers() {
1526        let temp = tempfile::TempDir::new().unwrap();
1527        let project_root = temp.path();
1528
1529        // Run clean_mcp_servers on empty project
1530        let result = clean_mcp_servers(project_root);
1531        assert!(result.is_ok());
1532    }
1533
1534    #[test]
1535    fn test_list_mcp_servers() {
1536        let temp = tempfile::TempDir::new().unwrap();
1537        let project_root = temp.path();
1538        let claude_dir = project_root.join(".claude");
1539        let settings_path = claude_dir.join("settings.local.json");
1540
1541        std::fs::create_dir_all(&claude_dir).unwrap();
1542
1543        // Create settings with mixed servers
1544        let mut settings = ClaudeSettings::default();
1545        let mut servers = HashMap::new();
1546
1547        servers.insert(
1548            "managed-server".to_string(),
1549            McpServerConfig {
1550                command: Some("managed".to_string()),
1551                args: vec![],
1552                env: None,
1553                r#type: None,
1554                url: None,
1555                headers: None,
1556                agpm_metadata: Some(AgpmMetadata {
1557                    managed: true,
1558                    source: Some("test".to_string()),
1559                    version: Some("v2.0.0".to_string()),
1560                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1561                    dependency_name: None,
1562                }),
1563            },
1564        );
1565
1566        servers.insert(
1567            "user-server".to_string(),
1568            McpServerConfig {
1569                command: Some("user".to_string()),
1570                args: vec![],
1571                env: None,
1572                r#type: None,
1573                url: None,
1574                headers: None,
1575                agpm_metadata: None,
1576            },
1577        );
1578
1579        settings.mcp_servers = Some(servers);
1580        settings.save(&settings_path).unwrap();
1581
1582        // Run list_mcp_servers - just verify it doesn't error
1583        let result = list_mcp_servers(project_root);
1584        assert!(result.is_ok());
1585    }
1586
1587    #[test]
1588    fn test_list_mcp_servers_no_file() {
1589        let temp = tempfile::TempDir::new().unwrap();
1590        let project_root = temp.path();
1591
1592        // Run list_mcp_servers with no settings file
1593        let result = list_mcp_servers(project_root);
1594        assert!(result.is_ok());
1595    }
1596
1597    #[test]
1598    fn test_list_mcp_servers_empty() {
1599        let temp = tempfile::TempDir::new().unwrap();
1600        let project_root = temp.path();
1601        let claude_dir = project_root.join(".claude");
1602        let settings_path = claude_dir.join("settings.local.json");
1603
1604        std::fs::create_dir_all(&claude_dir).unwrap();
1605
1606        // Create settings with no servers
1607        let settings = ClaudeSettings::default();
1608        settings.save(&settings_path).unwrap();
1609
1610        // Run list_mcp_servers
1611        let result = list_mcp_servers(project_root);
1612        assert!(result.is_ok());
1613    }
1614
1615    #[test]
1616    fn test_claude_settings_save_backup() {
1617        let temp = tempfile::TempDir::new().unwrap();
1618        let settings_path = temp.path().join("settings.local.json");
1619        let backup_path = temp.path().join("agpm").join("settings.local.json.backup");
1620
1621        // Create initial settings
1622        let settings1 = ClaudeSettings::default();
1623        settings1.save(&settings_path).unwrap();
1624        assert!(settings_path.exists());
1625        assert!(!backup_path.exists());
1626
1627        // Save again to trigger backup
1628        let settings2 = ClaudeSettings {
1629            hooks: Some(serde_json::json!({"test": "hook"})),
1630            ..Default::default()
1631        };
1632        settings2.save(&settings_path).unwrap();
1633
1634        // Verify backup was created in agpm directory
1635        assert!(backup_path.exists());
1636
1637        // Verify backup contains original content
1638        let backup_content: ClaudeSettings = crate::utils::read_json_file(&backup_path).unwrap();
1639        assert!(backup_content.hooks.is_none());
1640
1641        // Verify main file has new content
1642        let main_content: ClaudeSettings = crate::utils::read_json_file(&settings_path).unwrap();
1643        assert!(main_content.hooks.is_some());
1644    }
1645
1646    #[test]
1647    fn test_mcp_config_save_backup() {
1648        let temp = tempfile::TempDir::new().unwrap();
1649        let config_path = temp.path().join(".mcp.json");
1650        let backup_path = temp.path().join(".claude").join("agpm").join(".mcp.json.backup");
1651
1652        // Create initial config
1653        let config1 = McpConfig::default();
1654        config1.save(&config_path).unwrap();
1655        assert!(config_path.exists());
1656        assert!(!backup_path.exists());
1657
1658        // Save again with changes to trigger backup
1659        let mut config2 = McpConfig::default();
1660        config2.mcp_servers.insert(
1661            "test".to_string(),
1662            McpServerConfig {
1663                command: Some("test-cmd".to_string()),
1664                args: vec![],
1665                env: None,
1666                r#type: None,
1667                url: None,
1668                headers: None,
1669                agpm_metadata: None,
1670            },
1671        );
1672        config2.save(&config_path).unwrap();
1673
1674        // Verify backup was created in .claude/agpm directory
1675        assert!(backup_path.exists());
1676
1677        // Verify backup contains original content
1678        let backup_content: McpConfig = crate::utils::read_json_file(&backup_path).unwrap();
1679        assert!(backup_content.mcp_servers.is_empty());
1680
1681        // Verify main file has new content
1682        let main_content: McpConfig = crate::utils::read_json_file(&config_path).unwrap();
1683        assert_eq!(main_content.mcp_servers.len(), 1);
1684    }
1685
1686    #[test]
1687    fn test_update_mcp_servers_preserves_user_servers() {
1688        let temp = tempfile::TempDir::new().unwrap();
1689        let agpm_dir = temp.path().join(".claude").join("agpm");
1690        let mcp_servers_dir = agpm_dir.join("mcp-servers");
1691        std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1692
1693        // Create server config files
1694        let server1 = McpServerConfig {
1695            command: Some("server1".to_string()),
1696            args: vec!["arg1".to_string()],
1697            env: None,
1698            r#type: None,
1699            url: None,
1700            headers: None,
1701            agpm_metadata: Some(AgpmMetadata {
1702                managed: true,
1703                source: Some("source1".to_string()),
1704                version: Some("v1.0.0".to_string()),
1705                installed_at: "2024-01-01T00:00:00Z".to_string(),
1706                dependency_name: None,
1707            }),
1708        };
1709        crate::utils::write_json_file(&mcp_servers_dir.join("server1.json"), &server1, true)
1710            .unwrap();
1711
1712        // Create settings with existing user server
1713        let mut settings = ClaudeSettings::default();
1714        let mut servers = HashMap::new();
1715        servers.insert(
1716            "user-server".to_string(),
1717            McpServerConfig {
1718                command: Some("user".to_string()),
1719                args: vec![],
1720                env: None,
1721                r#type: None,
1722                url: None,
1723                headers: None,
1724                agpm_metadata: None,
1725            },
1726        );
1727        settings.mcp_servers = Some(servers);
1728
1729        // Update from directory
1730        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
1731
1732        // Verify both servers are present
1733        let servers = settings.mcp_servers.as_ref().unwrap();
1734        assert_eq!(servers.len(), 2);
1735        assert!(servers.contains_key("user-server"));
1736        assert!(servers.contains_key("server1"));
1737
1738        // Verify server1 config matches
1739        let server1_config = servers.get("server1").unwrap();
1740        assert_eq!(server1_config.command, Some("server1".to_string()));
1741        assert_eq!(server1_config.args, vec!["arg1"]);
1742    }
1743
1744    #[test]
1745    fn test_update_mcp_servers_nonexistent_dir() {
1746        let temp = tempfile::TempDir::new().unwrap();
1747        let nonexistent_dir = temp.path().join("nonexistent");
1748
1749        let mut settings = ClaudeSettings::default();
1750        let result = settings.update_mcp_servers(&nonexistent_dir);
1751        assert!(result.is_ok());
1752    }
1753
1754    #[test]
1755    fn test_mcp_config_handles_extra_fields() {
1756        // McpConfig doesn't preserve other fields, but it should parse files with extra fields
1757        let json_str = r#"{
1758            "mcpServers": {
1759                "test": {
1760                    "command": "test",
1761                    "args": []
1762                }
1763            },
1764            "customField": "value",
1765            "anotherField": {
1766                "nested": true
1767            }
1768        }"#;
1769
1770        let temp = tempdir().unwrap();
1771        let config_path = temp.path().join(".mcp.json");
1772        std::fs::write(&config_path, json_str).unwrap();
1773
1774        // Should parse successfully ignoring extra fields
1775        let config = McpConfig::load_or_default(&config_path).unwrap();
1776        assert!(config.mcp_servers.contains_key("test"));
1777        assert_eq!(config.mcp_servers.len(), 1);
1778    }
1779}