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