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, PartialEq)]
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, PartialEq)]
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.
343///
344/// Returns the number of servers that actually changed (ignoring timestamps).
345pub async fn merge_mcp_servers(
346    mcp_config_path: &Path,
347    agpm_servers: HashMap<String, McpServerConfig>,
348) -> Result<usize> {
349    if agpm_servers.is_empty() {
350        return Ok(0);
351    }
352
353    // Load existing MCP configuration
354    let mut mcp_config = McpConfig::load_or_default(mcp_config_path)?;
355
356    // Check for conflicts with user-managed servers
357    let conflicts = mcp_config.check_conflicts(&agpm_servers);
358    if !conflicts.is_empty() {
359        return Err(anyhow::anyhow!(
360            "The following MCP servers already exist and are not managed by AGPM: {}\n\
361             Please rename your servers or remove the existing ones from .mcp.json",
362            conflicts.join(", ")
363        ));
364    }
365
366    // Count how many servers actually changed (ignoring timestamps)
367    let mut changed_count = 0;
368    for (name, new_config) in &agpm_servers {
369        match mcp_config.mcp_servers.get(name) {
370            Some(existing_config) => {
371                // Server exists - check if it's actually different
372                // Create copies without the timestamp for comparison
373                let mut existing_without_time = existing_config.clone();
374                let mut new_without_time = new_config.clone();
375
376                // Remove timestamp from metadata for comparison
377                if let Some(ref mut meta) = existing_without_time.agpm_metadata {
378                    meta.installed_at = String::new();
379                }
380                if let Some(ref mut meta) = new_without_time.agpm_metadata {
381                    meta.installed_at = String::new();
382                }
383
384                if existing_without_time != new_without_time {
385                    changed_count += 1;
386                }
387            }
388            None => {
389                // New server - will be added
390                changed_count += 1;
391            }
392        }
393    }
394
395    // Update MCP configuration with AGPM-managed servers
396    mcp_config.update_managed_servers(agpm_servers)?;
397
398    // Save the updated MCP configuration
399    mcp_config.save(mcp_config_path)?;
400
401    Ok(changed_count)
402}
403
404pub async fn configure_mcp_servers(project_root: &Path, mcp_servers_dir: &Path) -> Result<()> {
405    if !mcp_servers_dir.exists() {
406        return Ok(());
407    }
408
409    let mcp_config_path = project_root.join(".mcp.json");
410
411    // Read all MCP server JSON files
412    let mut agpm_servers = HashMap::new();
413    let mut entries = tokio::fs::read_dir(mcp_servers_dir).await?;
414    while let Some(entry) = entries.next_entry().await? {
415        let path = entry.path();
416
417        if path.extension().is_some_and(|ext| ext == "json")
418            && let Some(name) = path.file_stem().and_then(|s| s.to_str())
419        {
420            // Read and parse the MCP server configuration
421            let config: McpServerConfig = crate::utils::read_json_file(&path)
422                .with_context(|| format!("Failed to parse MCP server file: {}", path.display()))?;
423
424            // Add AGPM metadata
425            let mut config_with_metadata = config;
426            if config_with_metadata.agpm_metadata.is_none() {
427                config_with_metadata.agpm_metadata = Some(AgpmMetadata {
428                    managed: true,
429                    source: Some("agpm".to_string()),
430                    version: None,
431                    installed_at: Utc::now().to_rfc3339(),
432                    dependency_name: Some(name.to_string()),
433                });
434            }
435
436            agpm_servers.insert(name.to_string(), config_with_metadata);
437        }
438    }
439
440    // Use the helper function to merge servers
441    merge_mcp_servers(&mcp_config_path, agpm_servers).await?;
442    Ok(())
443}
444
445/// Remove all AGPM-managed MCP servers from the configuration.
446pub fn clean_mcp_servers(project_root: &Path) -> Result<()> {
447    let claude_dir = project_root.join(".claude");
448    let agpm_dir = claude_dir.join("agpm");
449    let mcp_servers_dir = agpm_dir.join("mcp-servers");
450    let mcp_config_path = project_root.join(".mcp.json");
451
452    // Remove all files from mcp-servers directory
453    let mut removed_count = 0;
454    if mcp_servers_dir.exists() {
455        for entry in std::fs::read_dir(&mcp_servers_dir)? {
456            let entry = entry?;
457            let path = entry.path();
458            if path.extension().is_some_and(|ext| ext == "json") {
459                std::fs::remove_file(&path).with_context(|| {
460                    format!("Failed to remove MCP server file: {}", path.display())
461                })?;
462                removed_count += 1;
463            }
464        }
465    }
466
467    // Update MCP config to remove AGPM-managed servers
468    if mcp_config_path.exists() {
469        let mut mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
470        mcp_config.remove_all_managed();
471        mcp_config.save(&mcp_config_path)?;
472    }
473
474    if removed_count == 0 {
475        println!("No AGPM-managed MCP servers found");
476    } else {
477        println!("✓ Removed {removed_count} AGPM-managed MCP server(s)");
478    }
479
480    Ok(())
481}
482
483/// List all MCP servers, indicating which are AGPM-managed.
484pub fn list_mcp_servers(project_root: &Path) -> Result<()> {
485    let mcp_config_path = project_root.join(".mcp.json");
486
487    if !mcp_config_path.exists() {
488        println!("No .mcp.json file found");
489        return Ok(());
490    }
491
492    let mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
493
494    if mcp_config.mcp_servers.is_empty() {
495        println!("No MCP servers configured");
496        return Ok(());
497    }
498
499    let servers = &mcp_config.mcp_servers;
500    println!("MCP Servers:");
501    println!("╭─────────────────────┬──────────┬───────────╮");
502    println!("│ Name                │ Managed  │ Version   │");
503    println!("├─────────────────────┼──────────┼───────────┤");
504
505    for (name, server) in servers {
506        let (managed, version) = if let Some(meta) = &server.agpm_metadata {
507            if meta.managed {
508                ("✓ (agpm)", meta.version.as_deref().unwrap_or("-"))
509            } else {
510                ("✗", "-")
511            }
512        } else {
513            ("✗", "-")
514        };
515
516        println!("│ {name:<19} │ {managed:<8} │ {version:<9} │");
517    }
518
519    println!("╰─────────────────────┴──────────┴───────────╯");
520
521    Ok(())
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use serde_json::json;
528    use std::fs;
529    use tempfile::tempdir;
530
531    /// Test helper: Creates agpm.toml in temp directory so find_project_root works
532    fn setup_project_root(temp_path: &std::path::Path) {
533        fs::write(temp_path.join("agpm.toml"), "[dependencies]\n").unwrap();
534    }
535
536    #[test]
537    fn test_claude_settings_load_save() {
538        let temp = tempdir().unwrap();
539        let settings_path = temp.path().join("settings.local.json");
540
541        let mut settings = ClaudeSettings::default();
542        let mut servers = HashMap::new();
543        servers.insert(
544            "test-server".to_string(),
545            McpServerConfig {
546                command: Some("node".to_string()),
547                args: vec!["server.js".to_string()],
548                env: None,
549                r#type: None,
550                url: None,
551                headers: None,
552                agpm_metadata: None,
553            },
554        );
555        settings.mcp_servers = Some(servers);
556
557        settings.save(&settings_path).unwrap();
558
559        let loaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
560        assert!(loaded.mcp_servers.is_some());
561        let servers = loaded.mcp_servers.unwrap();
562        assert_eq!(servers.len(), 1);
563        assert!(servers.contains_key("test-server"));
564    }
565
566    #[test]
567    fn test_claude_settings_load_nonexistent_file() {
568        let temp = tempdir().unwrap();
569        let settings_path = temp.path().join("nonexistent.json");
570
571        let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
572        assert!(settings.mcp_servers.is_none());
573        assert!(settings.permissions.is_none());
574        assert!(settings.other.is_empty());
575    }
576
577    #[test]
578    fn test_claude_settings_load_invalid_json() {
579        let temp = tempdir().unwrap();
580        let settings_path = temp.path().join("invalid.json");
581        fs::write(&settings_path, "invalid json {").unwrap();
582
583        let result = ClaudeSettings::load_or_default(&settings_path);
584        assert!(result.is_err());
585        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
586    }
587
588    #[test]
589    fn test_claude_settings_save_creates_backup() {
590        let temp = tempdir().unwrap();
591        setup_project_root(temp.path());
592
593        let settings_path = temp.path().join("settings.local.json");
594        let backup_path = temp
595            .path()
596            .join(".agpm")
597            .join("backups")
598            .join("claude-code")
599            .join("settings.local.json");
600
601        // Create initial file
602        fs::write(&settings_path, r#"{"test": "value"}"#).unwrap();
603
604        let settings = ClaudeSettings::default();
605        settings.save(&settings_path).unwrap();
606
607        // Backup should be created in .agpm/backups/claude-code directory
608        assert!(backup_path.exists());
609        let backup_content = fs::read_to_string(backup_path).unwrap();
610        assert_eq!(backup_content, r#"{"test": "value"}"#);
611    }
612
613    #[test]
614    fn test_claude_settings_update_mcp_servers_empty_dir() {
615        let temp = tempdir().unwrap();
616        let nonexistent_dir = temp.path().join("nonexistent");
617
618        let mut settings = ClaudeSettings::default();
619        // Should not error on nonexistent directory
620        settings.update_mcp_servers(&nonexistent_dir).unwrap();
621    }
622
623    #[test]
624    fn test_update_mcp_servers_from_directory() {
625        let temp = tempdir().unwrap();
626        let mcp_servers_dir = temp.path().join("mcp-servers");
627        std::fs::create_dir(&mcp_servers_dir).unwrap();
628
629        // Create a server config file
630        let server_config = McpServerConfig {
631            command: Some("managed".to_string()),
632            args: vec![],
633            env: None,
634            r#type: None,
635            url: None,
636            headers: None,
637            agpm_metadata: Some(AgpmMetadata {
638                managed: true,
639                source: Some("test".to_string()),
640                version: Some("v1.0.0".to_string()),
641                installed_at: "2024-01-01T00:00:00Z".to_string(),
642                dependency_name: Some("agpm-server".to_string()),
643            }),
644        };
645        let config_path = mcp_servers_dir.join("agpm-server.json");
646        let json = serde_json::to_string_pretty(&server_config).unwrap();
647        std::fs::write(&config_path, json).unwrap();
648
649        // Add a user-managed server to settings
650        let mut settings = ClaudeSettings::default();
651        let mut servers = HashMap::new();
652        servers.insert(
653            "user-server".to_string(),
654            McpServerConfig {
655                command: Some("custom".to_string()),
656                args: vec![],
657                env: None,
658                r#type: None,
659                url: None,
660                headers: None,
661                agpm_metadata: None,
662            },
663        );
664        settings.mcp_servers = Some(servers);
665
666        // Update from directory
667        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
668
669        // Both servers should be present
670        assert!(settings.mcp_servers.is_some());
671        let servers = settings.mcp_servers.as_ref().unwrap();
672        assert!(servers.contains_key("user-server"));
673        assert!(servers.contains_key("agpm-server"));
674        assert_eq!(servers.len(), 2);
675    }
676
677    #[test]
678    fn test_update_mcp_servers_replaces_old_managed() {
679        let temp = tempdir().unwrap();
680        let mcp_servers_dir = temp.path().join("mcp-servers");
681        std::fs::create_dir(&mcp_servers_dir).unwrap();
682
683        // Start with existing managed and user servers
684        let mut settings = ClaudeSettings::default();
685        let mut servers = HashMap::new();
686
687        // User-managed server (should be preserved)
688        servers.insert(
689            "user-server".to_string(),
690            McpServerConfig {
691                command: Some("user-command".to_string()),
692                args: vec![],
693                env: None,
694                r#type: None,
695                url: None,
696                headers: None,
697                agpm_metadata: None,
698            },
699        );
700
701        // Old AGPM-managed server (should be removed)
702        servers.insert(
703            "old-managed".to_string(),
704            McpServerConfig {
705                command: Some("old-command".to_string()),
706                args: vec![],
707                env: None,
708                r#type: None,
709                url: None,
710                headers: None,
711                agpm_metadata: Some(AgpmMetadata {
712                    managed: true,
713                    source: Some("old-source".to_string()),
714                    version: Some("v0.1.0".to_string()),
715                    installed_at: "2023-01-01T00:00:00Z".to_string(),
716                    dependency_name: Some("old-managed".to_string()),
717                }),
718            },
719        );
720
721        settings.mcp_servers = Some(servers);
722
723        // Create new managed server config file
724        let server_config = McpServerConfig {
725            command: Some("new-managed".to_string()),
726            args: vec![],
727            env: None,
728            r#type: None,
729            url: None,
730            headers: None,
731            agpm_metadata: Some(AgpmMetadata {
732                managed: true,
733                source: Some("new-source".to_string()),
734                version: Some("v1.0.0".to_string()),
735                installed_at: "2024-01-01T00:00:00Z".to_string(),
736                dependency_name: Some("new-managed".to_string()),
737            }),
738        };
739        let config_path = mcp_servers_dir.join("new-managed.json");
740        let json = serde_json::to_string_pretty(&server_config).unwrap();
741        std::fs::write(&config_path, json).unwrap();
742
743        // Update from directory
744        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
745
746        let servers = settings.mcp_servers.as_ref().unwrap();
747        assert!(servers.contains_key("user-server")); // User server preserved
748        assert!(servers.contains_key("new-managed")); // New managed server added
749        assert!(!servers.contains_key("old-managed")); // Old managed server removed
750        assert_eq!(servers.len(), 2);
751    }
752
753    #[test]
754    fn test_update_mcp_servers_invalid_json_file() {
755        let temp = tempdir().unwrap();
756        let mcp_servers_dir = temp.path().join("mcp-servers");
757        std::fs::create_dir(&mcp_servers_dir).unwrap();
758
759        // Create invalid JSON file
760        let invalid_path = mcp_servers_dir.join("invalid.json");
761        std::fs::write(&invalid_path, "invalid json {").unwrap();
762
763        let mut settings = ClaudeSettings::default();
764        let result = settings.update_mcp_servers(&mcp_servers_dir);
765        assert!(result.is_err());
766        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
767    }
768
769    #[test]
770    fn test_update_mcp_servers_ignores_non_json_files() {
771        let temp = tempdir().unwrap();
772        let mcp_servers_dir = temp.path().join("mcp-servers");
773        std::fs::create_dir(&mcp_servers_dir).unwrap();
774
775        // Create non-JSON file
776        let txt_path = mcp_servers_dir.join("readme.txt");
777        std::fs::write(&txt_path, "This is not a JSON file").unwrap();
778
779        // Create valid JSON file
780        let server_config = McpServerConfig {
781            command: Some("test".to_string()),
782            args: vec![],
783            env: None,
784            r#type: None,
785            url: None,
786            headers: None,
787            agpm_metadata: None,
788        };
789        let json_path = mcp_servers_dir.join("valid.json");
790        let json = serde_json::to_string_pretty(&server_config).unwrap();
791        std::fs::write(&json_path, json).unwrap();
792
793        let mut settings = ClaudeSettings::default();
794        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
795
796        let servers = settings.mcp_servers.as_ref().unwrap();
797        assert!(servers.contains_key("valid"));
798        assert_eq!(servers.len(), 1);
799    }
800
801    #[test]
802    fn test_settings_preserves_other_fields() {
803        let temp = tempdir().unwrap();
804        setup_project_root(temp.path());
805
806        let settings_path = temp.path().join("settings.local.json");
807
808        // Create a settings file with various fields
809        let json = r#"{
810            "permissions": {
811                "allow": ["Bash(ls)"],
812                "deny": []
813            },
814            "customField": "value",
815            "mcpServers": {
816                "test": {
817                    "command": "node",
818                    "args": []
819                }
820            }
821        }"#;
822        std::fs::write(&settings_path, json).unwrap();
823
824        // Load and save
825        let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
826        assert!(settings.permissions.is_some());
827        assert!(settings.mcp_servers.is_some());
828        assert!(settings.other.contains_key("customField"));
829
830        settings.save(&settings_path).unwrap();
831
832        // Reload and verify all fields preserved
833        let reloaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
834        assert!(reloaded.permissions.is_some());
835        assert!(reloaded.mcp_servers.is_some());
836        assert!(reloaded.other.contains_key("customField"));
837    }
838
839    // McpConfig tests
840    #[test]
841    fn test_mcp_config_load_save() {
842        let temp = tempdir().unwrap();
843        let config_path = temp.path().join("mcp.json");
844
845        let mut config = McpConfig::default();
846        config.mcp_servers.insert(
847            "test-server".to_string(),
848            McpServerConfig {
849                command: Some("node".to_string()),
850                args: vec!["server.js".to_string()],
851                env: Some({
852                    let mut env = HashMap::new();
853                    env.insert("NODE_ENV".to_string(), json!("production"));
854                    env
855                }),
856                r#type: None,
857                url: None,
858                headers: None,
859                agpm_metadata: None,
860            },
861        );
862
863        config.save(&config_path).unwrap();
864
865        let loaded = McpConfig::load_or_default(&config_path).unwrap();
866        assert!(loaded.mcp_servers.contains_key("test-server"));
867        let server = &loaded.mcp_servers["test-server"];
868        assert_eq!(server.command, Some("node".to_string()));
869        assert_eq!(server.args, vec!["server.js"]);
870        assert!(server.env.is_some());
871    }
872
873    #[test]
874    fn test_mcp_config_load_nonexistent() {
875        let temp = tempdir().unwrap();
876        let config_path = temp.path().join("nonexistent.json");
877
878        let config = McpConfig::load_or_default(&config_path).unwrap();
879        assert!(config.mcp_servers.is_empty());
880    }
881
882    #[test]
883    fn test_mcp_config_load_invalid_json() {
884        let temp = tempdir().unwrap();
885        let config_path = temp.path().join("invalid.json");
886        fs::write(&config_path, "invalid json {").unwrap();
887
888        let result = McpConfig::load_or_default(&config_path);
889        assert!(result.is_err());
890    }
891
892    #[test]
893    fn test_mcp_config_save_creates_backup() {
894        let temp = tempdir().unwrap();
895        setup_project_root(temp.path());
896
897        let config_path = temp.path().join("mcp.json");
898        let backup_path =
899            temp.path().join(".agpm").join("backups").join("claude-code").join("mcp.json");
900
901        // Create initial file
902        fs::write(&config_path, r#"{"mcpServers": {"old": {"command": "old"}}}"#).unwrap();
903
904        let config = McpConfig::default();
905        config.save(&config_path).unwrap();
906
907        // Backup should be created in .agpm/backups/claude-code directory
908        assert!(backup_path.exists());
909        let backup_content = fs::read_to_string(backup_path).unwrap();
910        assert!(backup_content.contains("old"));
911    }
912
913    #[test]
914    fn test_mcp_config_update_managed_servers() {
915        let mut config = McpConfig::default();
916
917        // Add user-managed server
918        config.mcp_servers.insert(
919            "user-server".to_string(),
920            McpServerConfig {
921                command: Some("user-command".to_string()),
922                args: vec![],
923                env: None,
924                r#type: None,
925                url: None,
926                headers: None,
927                agpm_metadata: None,
928            },
929        );
930
931        // Add old AGPM-managed server
932        config.mcp_servers.insert(
933            "old-managed".to_string(),
934            McpServerConfig {
935                command: Some("old-command".to_string()),
936                args: vec![],
937                env: None,
938                r#type: None,
939                url: None,
940                headers: None,
941                agpm_metadata: Some(AgpmMetadata {
942                    managed: true,
943                    source: None,
944                    version: None,
945                    installed_at: "old-time".to_string(),
946                    dependency_name: None,
947                }),
948            },
949        );
950
951        // Update with new managed servers
952        let mut updates = HashMap::new();
953        updates.insert(
954            "new-managed".to_string(),
955            McpServerConfig {
956                command: Some("new-command".to_string()),
957                args: vec![],
958                env: None,
959                r#type: None,
960                url: None,
961                headers: None,
962                agpm_metadata: Some(AgpmMetadata {
963                    managed: true,
964                    source: None,
965                    version: None,
966                    installed_at: "new-time".to_string(),
967                    dependency_name: None,
968                }),
969            },
970        );
971
972        config.update_managed_servers(updates).unwrap();
973
974        // User server should be preserved, old managed should be removed, new managed added
975        assert!(config.mcp_servers.contains_key("user-server"));
976        assert!(config.mcp_servers.contains_key("new-managed"));
977        assert!(!config.mcp_servers.contains_key("old-managed"));
978        assert_eq!(config.mcp_servers.len(), 2);
979    }
980
981    #[test]
982    fn test_mcp_config_update_managed_servers_preserves_updating_servers() {
983        let mut config = McpConfig::default();
984
985        // Add AGPM-managed server that will be updated
986        config.mcp_servers.insert(
987            "updating-server".to_string(),
988            McpServerConfig {
989                command: Some("old-command".to_string()),
990                args: vec![],
991                env: None,
992                r#type: None,
993                url: None,
994                headers: None,
995                agpm_metadata: Some(AgpmMetadata {
996                    managed: true,
997                    source: None,
998                    version: Some("v1.0.0".to_string()),
999                    installed_at: "old-time".to_string(),
1000                    dependency_name: None,
1001                }),
1002            },
1003        );
1004
1005        // Update with new version of the same server
1006        let mut updates = HashMap::new();
1007        updates.insert(
1008            "updating-server".to_string(),
1009            McpServerConfig {
1010                command: Some("new-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: Some("v2.0.0".to_string()),
1020                    installed_at: "new-time".to_string(),
1021                    dependency_name: None,
1022                }),
1023            },
1024        );
1025
1026        config.update_managed_servers(updates).unwrap();
1027
1028        assert!(config.mcp_servers.contains_key("updating-server"));
1029        let server = &config.mcp_servers["updating-server"];
1030        assert_eq!(server.command, Some("new-command".to_string()));
1031        assert_eq!(server.agpm_metadata.as_ref().unwrap().version, Some("v2.0.0".to_string()));
1032    }
1033
1034    #[test]
1035    fn test_mcp_config_check_conflicts() {
1036        let mut config = McpConfig::default();
1037
1038        // Add user-managed server
1039        config.mcp_servers.insert(
1040            "user-server".to_string(),
1041            McpServerConfig {
1042                command: Some("user-command".to_string()),
1043                args: vec![],
1044                env: None,
1045                r#type: None,
1046                url: None,
1047                headers: None,
1048                agpm_metadata: None,
1049            },
1050        );
1051
1052        // Add AGPM-managed server
1053        config.mcp_servers.insert(
1054            "managed-server".to_string(),
1055            McpServerConfig {
1056                command: Some("managed-command".to_string()),
1057                args: vec![],
1058                env: None,
1059                r#type: None,
1060                url: None,
1061                headers: None,
1062                agpm_metadata: Some(AgpmMetadata {
1063                    managed: true,
1064                    source: None,
1065                    version: None,
1066                    installed_at: "time".to_string(),
1067                    dependency_name: None,
1068                }),
1069            },
1070        );
1071
1072        let mut new_servers = HashMap::new();
1073        new_servers.insert(
1074            "user-server".to_string(), // This conflicts
1075            McpServerConfig {
1076                command: Some("new-command".to_string()),
1077                args: vec![],
1078                env: None,
1079                r#type: None,
1080                url: None,
1081                headers: None,
1082                agpm_metadata: Some(AgpmMetadata {
1083                    managed: true,
1084                    source: None,
1085                    version: None,
1086                    installed_at: "time".to_string(),
1087                    dependency_name: None,
1088                }),
1089            },
1090        );
1091        new_servers.insert(
1092            "managed-server".to_string(), // This doesn't conflict (already managed)
1093            McpServerConfig {
1094                command: Some("updated-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: true,
1102                    source: None,
1103                    version: None,
1104                    installed_at: "time".to_string(),
1105                    dependency_name: None,
1106                }),
1107            },
1108        );
1109        new_servers.insert(
1110            "new-server".to_string(), // This doesn't conflict (new)
1111            McpServerConfig {
1112                command: Some("new-command".to_string()),
1113                args: vec![],
1114                env: None,
1115                r#type: None,
1116                url: None,
1117                headers: None,
1118                agpm_metadata: Some(AgpmMetadata {
1119                    managed: true,
1120                    source: None,
1121                    version: None,
1122                    installed_at: "time".to_string(),
1123                    dependency_name: None,
1124                }),
1125            },
1126        );
1127
1128        let conflicts = config.check_conflicts(&new_servers);
1129        assert_eq!(conflicts, vec!["user-server"]);
1130    }
1131
1132    #[test]
1133    fn test_mcp_config_check_conflicts_unmanaged_metadata() {
1134        let mut config = McpConfig::default();
1135
1136        // Add server with metadata but managed=false
1137        config.mcp_servers.insert(
1138            "unmanaged-server".to_string(),
1139            McpServerConfig {
1140                command: Some("user-command".to_string()),
1141                args: vec![],
1142                env: None,
1143                r#type: None,
1144                url: None,
1145                headers: None,
1146                agpm_metadata: Some(AgpmMetadata {
1147                    managed: false,
1148                    source: None,
1149                    version: None,
1150                    installed_at: "time".to_string(),
1151                    dependency_name: None,
1152                }),
1153            },
1154        );
1155
1156        let mut new_servers = HashMap::new();
1157        new_servers.insert(
1158            "unmanaged-server".to_string(),
1159            McpServerConfig {
1160                command: Some("new-command".to_string()),
1161                args: vec![],
1162                env: None,
1163                r#type: None,
1164                url: None,
1165                headers: None,
1166                agpm_metadata: Some(AgpmMetadata {
1167                    managed: true,
1168                    source: None,
1169                    version: None,
1170                    installed_at: "time".to_string(),
1171                    dependency_name: None,
1172                }),
1173            },
1174        );
1175
1176        let conflicts = config.check_conflicts(&new_servers);
1177        assert_eq!(conflicts, vec!["unmanaged-server"]);
1178    }
1179
1180    #[test]
1181    fn test_mcp_config_remove_all_managed() {
1182        let mut config = McpConfig::default();
1183
1184        // Add mixed servers
1185        config.mcp_servers.insert(
1186            "user-server".to_string(),
1187            McpServerConfig {
1188                command: Some("user-command".to_string()),
1189                args: vec![],
1190                env: None,
1191                r#type: None,
1192                url: None,
1193                headers: None,
1194                agpm_metadata: None,
1195            },
1196        );
1197
1198        config.mcp_servers.insert(
1199            "managed-server".to_string(),
1200            McpServerConfig {
1201                command: Some("managed-command".to_string()),
1202                args: vec![],
1203                env: None,
1204                r#type: None,
1205                url: None,
1206                headers: None,
1207                agpm_metadata: Some(AgpmMetadata {
1208                    managed: true,
1209                    source: None,
1210                    version: None,
1211                    installed_at: "time".to_string(),
1212                    dependency_name: None,
1213                }),
1214            },
1215        );
1216
1217        config.mcp_servers.insert(
1218            "unmanaged-with-metadata".to_string(),
1219            McpServerConfig {
1220                command: Some("unmanaged-command".to_string()),
1221                args: vec![],
1222                env: None,
1223                r#type: None,
1224                url: None,
1225                headers: None,
1226                agpm_metadata: Some(AgpmMetadata {
1227                    managed: false,
1228                    source: None,
1229                    version: None,
1230                    installed_at: "time".to_string(),
1231                    dependency_name: None,
1232                }),
1233            },
1234        );
1235
1236        config.remove_all_managed();
1237
1238        assert!(config.mcp_servers.contains_key("user-server"));
1239        assert!(config.mcp_servers.contains_key("unmanaged-with-metadata"));
1240        assert!(!config.mcp_servers.contains_key("managed-server"));
1241        assert_eq!(config.mcp_servers.len(), 2);
1242    }
1243
1244    #[test]
1245    fn test_mcp_config_get_managed_servers() {
1246        let mut config = McpConfig::default();
1247
1248        // Add mixed servers
1249        config.mcp_servers.insert(
1250            "user-server".to_string(),
1251            McpServerConfig {
1252                command: Some("user-command".to_string()),
1253                args: vec![],
1254                env: None,
1255                r#type: None,
1256                url: None,
1257                headers: None,
1258                agpm_metadata: None,
1259            },
1260        );
1261
1262        config.mcp_servers.insert(
1263            "managed-server1".to_string(),
1264            McpServerConfig {
1265                command: Some("managed-command1".to_string()),
1266                args: vec![],
1267                env: None,
1268                r#type: None,
1269                url: None,
1270                headers: None,
1271                agpm_metadata: Some(AgpmMetadata {
1272                    managed: true,
1273                    source: None,
1274                    version: None,
1275                    installed_at: "time".to_string(),
1276                    dependency_name: None,
1277                }),
1278            },
1279        );
1280
1281        config.mcp_servers.insert(
1282            "managed-server2".to_string(),
1283            McpServerConfig {
1284                command: Some("managed-command2".to_string()),
1285                args: vec![],
1286                env: None,
1287                r#type: None,
1288                url: None,
1289                headers: None,
1290                agpm_metadata: Some(AgpmMetadata {
1291                    managed: true,
1292                    source: Some("source".to_string()),
1293                    version: Some("v1.0.0".to_string()),
1294                    installed_at: "time".to_string(),
1295                    dependency_name: Some("dep".to_string()),
1296                }),
1297            },
1298        );
1299
1300        let managed = config.get_managed_servers();
1301        assert_eq!(managed.len(), 2);
1302        assert!(managed.contains_key("managed-server1"));
1303        assert!(managed.contains_key("managed-server2"));
1304        assert!(!managed.contains_key("user-server"));
1305    }
1306
1307    // Tests for configure_mcp_servers function would go here
1308    // Since MCP servers now use standard ResourceDependency and file-based approach,
1309    // the old McpServerDependency tests are no longer applicable
1310
1311    // Serialization tests
1312    #[test]
1313    fn test_claude_settings_serialization() {
1314        // Add various fields
1315        let mut settings = ClaudeSettings {
1316            permissions: Some(json!({"allow": ["test"], "deny": []})),
1317            ..Default::default()
1318        };
1319
1320        let mut servers = HashMap::new();
1321        servers.insert(
1322            "test".to_string(),
1323            McpServerConfig {
1324                command: Some("test-cmd".to_string()),
1325                args: vec!["arg1".to_string()],
1326                env: Some({
1327                    let mut env = HashMap::new();
1328                    env.insert("VAR".to_string(), json!("value"));
1329                    env
1330                }),
1331                r#type: None,
1332                url: None,
1333                headers: None,
1334                agpm_metadata: Some(AgpmMetadata {
1335                    managed: true,
1336                    source: Some("test-source".to_string()),
1337                    version: Some("v1.0.0".to_string()),
1338                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1339                    dependency_name: Some("test".to_string()),
1340                }),
1341            },
1342        );
1343        settings.mcp_servers = Some(servers);
1344
1345        settings.other.insert("custom".to_string(), json!("value"));
1346
1347        // Serialize and deserialize
1348        let json = serde_json::to_string(&settings).unwrap();
1349        let deserialized: ClaudeSettings = serde_json::from_str(&json).unwrap();
1350
1351        assert_eq!(deserialized.permissions, settings.permissions);
1352        assert_eq!(deserialized.mcp_servers.as_ref().unwrap().len(), 1);
1353        assert_eq!(deserialized.other.get("custom"), settings.other.get("custom"));
1354    }
1355
1356    #[test]
1357    fn test_mcp_config_serialization() {
1358        let mut config = McpConfig::default();
1359
1360        config.mcp_servers.insert(
1361            "test".to_string(),
1362            McpServerConfig {
1363                command: Some("test-cmd".to_string()),
1364                args: vec!["arg1".to_string(), "arg2".to_string()],
1365                env: Some({
1366                    let mut env = HashMap::new();
1367                    env.insert("TEST_VAR".to_string(), json!("test_value"));
1368                    env
1369                }),
1370                r#type: None,
1371                url: None,
1372                headers: None,
1373                agpm_metadata: Some(AgpmMetadata {
1374                    managed: true,
1375                    source: Some("github.com/test/repo".to_string()),
1376                    version: Some("v2.0.0".to_string()),
1377                    installed_at: "2024-01-01T12:00:00Z".to_string(),
1378                    dependency_name: Some("test-dep".to_string()),
1379                }),
1380            },
1381        );
1382
1383        // Serialize and deserialize
1384        let json = serde_json::to_string_pretty(&config).unwrap();
1385        let deserialized: McpConfig = serde_json::from_str(&json).unwrap();
1386
1387        assert_eq!(deserialized.mcp_servers.len(), 1);
1388        let server = &deserialized.mcp_servers["test"];
1389        assert_eq!(server.command, Some("test-cmd".to_string()));
1390        assert_eq!(server.args.len(), 2);
1391        assert!(server.env.is_some());
1392        assert!(server.agpm_metadata.is_some());
1393
1394        let metadata = server.agpm_metadata.as_ref().unwrap();
1395        assert!(metadata.managed);
1396        assert_eq!(metadata.source, Some("github.com/test/repo".to_string()));
1397        assert_eq!(metadata.version, Some("v2.0.0".to_string()));
1398    }
1399
1400    #[test]
1401    fn test_mcp_server_config_minimal_serialization() {
1402        let config = McpServerConfig {
1403            command: Some("minimal".to_string()),
1404            args: vec![],
1405            env: None,
1406            r#type: None,
1407            url: None,
1408            headers: None,
1409            agpm_metadata: None,
1410        };
1411
1412        let json = serde_json::to_string(&config).unwrap();
1413        let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
1414
1415        assert_eq!(deserialized.command, Some("minimal".to_string()));
1416        assert!(deserialized.args.is_empty());
1417        assert!(deserialized.env.is_none());
1418        assert!(deserialized.agpm_metadata.is_none());
1419
1420        // Check that empty args are skipped in serialization
1421        assert!(!json.contains(r#""args":[]"#));
1422    }
1423
1424    #[test]
1425    fn test_agpm_metadata_serialization() {
1426        let metadata = AgpmMetadata {
1427            managed: true,
1428            source: Some("test-source".to_string()),
1429            version: None,
1430            installed_at: "2024-01-01T00:00:00Z".to_string(),
1431            dependency_name: Some("test-dep".to_string()),
1432        };
1433
1434        let json = serde_json::to_string(&metadata).unwrap();
1435        let deserialized: AgpmMetadata = serde_json::from_str(&json).unwrap();
1436
1437        assert!(deserialized.managed);
1438        assert_eq!(deserialized.source, Some("test-source".to_string()));
1439        assert_eq!(deserialized.version, None);
1440        assert_eq!(deserialized.installed_at, "2024-01-01T00:00:00Z");
1441        assert_eq!(deserialized.dependency_name, Some("test-dep".to_string()));
1442
1443        // Check that None version is skipped in serialization
1444        assert!(!json.contains(r#""version""#));
1445    }
1446
1447    #[test]
1448    fn test_clean_mcp_servers() {
1449        let temp = tempfile::TempDir::new().unwrap();
1450        setup_project_root(temp.path());
1451
1452        let project_root = temp.path();
1453        let claude_dir = project_root.join(".claude");
1454        let agpm_dir = claude_dir.join("agpm");
1455        let mcp_servers_dir = agpm_dir.join("mcp-servers");
1456        let settings_path = claude_dir.join("settings.local.json");
1457        let mcp_config_path = project_root.join(".mcp.json");
1458
1459        // Create directory structure
1460        std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1461
1462        // Create MCP server files
1463        let server1_path = mcp_servers_dir.join("server1.json");
1464        let server2_path = mcp_servers_dir.join("server2.json");
1465        let server_config = McpServerConfig {
1466            command: Some("test".to_string()),
1467            args: vec![],
1468            env: None,
1469            r#type: None,
1470            url: None,
1471            headers: None,
1472            agpm_metadata: Some(AgpmMetadata {
1473                managed: true,
1474                source: Some("test-source".to_string()),
1475                version: Some("v1.0.0".to_string()),
1476                installed_at: "2024-01-01T00:00:00Z".to_string(),
1477                dependency_name: Some("test-server".to_string()),
1478            }),
1479        };
1480        crate::utils::write_json_file(&server1_path, &server_config, true).unwrap();
1481        crate::utils::write_json_file(&server2_path, &server_config, true).unwrap();
1482
1483        // Create settings with both AGPM-managed and user-managed servers
1484        let mut settings = ClaudeSettings::default();
1485        let mut servers = HashMap::new();
1486
1487        // AGPM-managed server
1488        servers.insert(
1489            "agpm-server".to_string(),
1490            McpServerConfig {
1491                command: Some("agpm-cmd".to_string()),
1492                args: vec![],
1493                env: None,
1494                r#type: None,
1495                url: None,
1496                headers: None,
1497                agpm_metadata: Some(AgpmMetadata {
1498                    managed: true,
1499                    source: Some("test".to_string()),
1500                    version: Some("v1.0.0".to_string()),
1501                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1502                    dependency_name: None,
1503                }),
1504            },
1505        );
1506
1507        // User-managed server
1508        servers.insert(
1509            "user-server".to_string(),
1510            McpServerConfig {
1511                command: Some("user-cmd".to_string()),
1512                args: vec![],
1513                env: None,
1514                r#type: None,
1515                url: None,
1516                headers: None,
1517                agpm_metadata: None,
1518            },
1519        );
1520
1521        settings.mcp_servers = Some(servers);
1522        settings.save(&settings_path).unwrap();
1523
1524        // Create .mcp.json file with the same servers
1525        let mut mcp_config = McpConfig::default();
1526        mcp_config.mcp_servers.insert(
1527            "agpm-server".to_string(),
1528            McpServerConfig {
1529                command: Some("agpm-cmd".to_string()),
1530                args: vec![],
1531                env: None,
1532                r#type: None,
1533                url: None,
1534                headers: None,
1535                agpm_metadata: Some(AgpmMetadata {
1536                    managed: true,
1537                    source: Some("test".to_string()),
1538                    version: Some("v1.0.0".to_string()),
1539                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1540                    dependency_name: None,
1541                }),
1542            },
1543        );
1544        mcp_config.mcp_servers.insert(
1545            "user-server".to_string(),
1546            McpServerConfig {
1547                command: Some("user-cmd".to_string()),
1548                args: vec![],
1549                env: None,
1550                r#type: None,
1551                url: None,
1552                headers: None,
1553                agpm_metadata: None,
1554            },
1555        );
1556        mcp_config.save(&mcp_config_path).unwrap();
1557
1558        // Run clean_mcp_servers
1559        clean_mcp_servers(project_root).unwrap();
1560
1561        // Verify MCP server files are deleted
1562        assert!(!server1_path.exists());
1563        assert!(!server2_path.exists());
1564
1565        // Verify .mcp.json only contains user-managed servers
1566        let updated_mcp_config = McpConfig::load_or_default(&mcp_config_path).unwrap();
1567        assert_eq!(updated_mcp_config.mcp_servers.len(), 1);
1568        assert!(updated_mcp_config.mcp_servers.contains_key("user-server"));
1569        assert!(!updated_mcp_config.mcp_servers.contains_key("agpm-server"));
1570    }
1571
1572    #[test]
1573    fn test_clean_mcp_servers_no_servers() {
1574        let temp = tempfile::TempDir::new().unwrap();
1575        let project_root = temp.path();
1576
1577        // Run clean_mcp_servers on empty project
1578        let result = clean_mcp_servers(project_root);
1579        assert!(result.is_ok());
1580    }
1581
1582    #[test]
1583    fn test_list_mcp_servers() {
1584        let temp = tempfile::TempDir::new().unwrap();
1585        let project_root = temp.path();
1586        let claude_dir = project_root.join(".claude");
1587        let settings_path = claude_dir.join("settings.local.json");
1588
1589        std::fs::create_dir_all(&claude_dir).unwrap();
1590
1591        // Create settings with mixed servers
1592        let mut settings = ClaudeSettings::default();
1593        let mut servers = HashMap::new();
1594
1595        servers.insert(
1596            "managed-server".to_string(),
1597            McpServerConfig {
1598                command: Some("managed".to_string()),
1599                args: vec![],
1600                env: None,
1601                r#type: None,
1602                url: None,
1603                headers: None,
1604                agpm_metadata: Some(AgpmMetadata {
1605                    managed: true,
1606                    source: Some("test".to_string()),
1607                    version: Some("v2.0.0".to_string()),
1608                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1609                    dependency_name: None,
1610                }),
1611            },
1612        );
1613
1614        servers.insert(
1615            "user-server".to_string(),
1616            McpServerConfig {
1617                command: Some("user".to_string()),
1618                args: vec![],
1619                env: None,
1620                r#type: None,
1621                url: None,
1622                headers: None,
1623                agpm_metadata: None,
1624            },
1625        );
1626
1627        settings.mcp_servers = Some(servers);
1628        settings.save(&settings_path).unwrap();
1629
1630        // Run list_mcp_servers - just verify it doesn't error
1631        let result = list_mcp_servers(project_root);
1632        assert!(result.is_ok());
1633    }
1634
1635    #[test]
1636    fn test_list_mcp_servers_no_file() {
1637        let temp = tempfile::TempDir::new().unwrap();
1638        let project_root = temp.path();
1639
1640        // Run list_mcp_servers with no settings file
1641        let result = list_mcp_servers(project_root);
1642        assert!(result.is_ok());
1643    }
1644
1645    #[test]
1646    fn test_list_mcp_servers_empty() {
1647        let temp = tempfile::TempDir::new().unwrap();
1648        let project_root = temp.path();
1649        let claude_dir = project_root.join(".claude");
1650        let settings_path = claude_dir.join("settings.local.json");
1651
1652        std::fs::create_dir_all(&claude_dir).unwrap();
1653
1654        // Create settings with no servers
1655        let settings = ClaudeSettings::default();
1656        settings.save(&settings_path).unwrap();
1657
1658        // Run list_mcp_servers
1659        let result = list_mcp_servers(project_root);
1660        assert!(result.is_ok());
1661    }
1662
1663    #[test]
1664    fn test_claude_settings_save_backup() {
1665        let temp = tempfile::TempDir::new().unwrap();
1666        setup_project_root(temp.path());
1667
1668        let settings_path = temp.path().join("settings.local.json");
1669        let backup_path = temp
1670            .path()
1671            .join(".agpm")
1672            .join("backups")
1673            .join("claude-code")
1674            .join("settings.local.json");
1675
1676        // Create initial settings
1677        let settings1 = ClaudeSettings::default();
1678        settings1.save(&settings_path).unwrap();
1679        assert!(settings_path.exists());
1680        assert!(!backup_path.exists());
1681
1682        // Save again to trigger backup
1683        let settings2 = ClaudeSettings {
1684            hooks: Some(serde_json::json!({"test": "hook"})),
1685            ..Default::default()
1686        };
1687        settings2.save(&settings_path).unwrap();
1688
1689        // Verify backup was created in agpm directory
1690        assert!(backup_path.exists());
1691
1692        // Verify backup contains original content
1693        let backup_content: ClaudeSettings = crate::utils::read_json_file(&backup_path).unwrap();
1694        assert!(backup_content.hooks.is_none());
1695
1696        // Verify main file has new content
1697        let main_content: ClaudeSettings = crate::utils::read_json_file(&settings_path).unwrap();
1698        assert!(main_content.hooks.is_some());
1699    }
1700
1701    #[test]
1702    fn test_mcp_config_save_backup() {
1703        let temp = tempfile::TempDir::new().unwrap();
1704        setup_project_root(temp.path());
1705
1706        let config_path = temp.path().join(".mcp.json");
1707        let backup_path =
1708            temp.path().join(".agpm").join("backups").join("claude-code").join(".mcp.json");
1709
1710        // Create initial config
1711        let config1 = McpConfig::default();
1712        config1.save(&config_path).unwrap();
1713        assert!(config_path.exists());
1714        assert!(!backup_path.exists());
1715
1716        // Save again with changes to trigger backup
1717        let mut config2 = McpConfig::default();
1718        config2.mcp_servers.insert(
1719            "test".to_string(),
1720            McpServerConfig {
1721                command: Some("test-cmd".to_string()),
1722                args: vec![],
1723                env: None,
1724                r#type: None,
1725                url: None,
1726                headers: None,
1727                agpm_metadata: None,
1728            },
1729        );
1730        config2.save(&config_path).unwrap();
1731
1732        // Verify backup was created in .agpm/backups directory
1733        assert!(backup_path.exists());
1734
1735        // Verify backup contains original content
1736        let backup_content: McpConfig = crate::utils::read_json_file(&backup_path).unwrap();
1737        assert!(backup_content.mcp_servers.is_empty());
1738
1739        // Verify main file has new content
1740        let main_content: McpConfig = crate::utils::read_json_file(&config_path).unwrap();
1741        assert_eq!(main_content.mcp_servers.len(), 1);
1742    }
1743
1744    #[test]
1745    fn test_backup_fails_without_project_root() {
1746        // Test that backup creation fails gracefully when no agpm.toml exists
1747        let temp = tempfile::TempDir::new().unwrap();
1748        // Deliberately NOT calling setup_project_root here
1749
1750        let settings_path = temp.path().join("settings.local.json");
1751
1752        // Create initial file
1753        fs::write(&settings_path, r#"{"test": "value"}"#).unwrap();
1754
1755        let settings = ClaudeSettings::default();
1756        let result = settings.save(&settings_path);
1757
1758        // Should fail with helpful error message about missing project root
1759        assert!(result.is_err());
1760        let error_msg = result.unwrap_err().to_string();
1761        assert!(
1762            error_msg.contains("Failed to find project root") || error_msg.contains("agpm.toml")
1763        );
1764    }
1765
1766    #[test]
1767    fn test_update_mcp_servers_preserves_user_servers() {
1768        let temp = tempfile::TempDir::new().unwrap();
1769        let agpm_dir = temp.path().join(".claude").join("agpm");
1770        let mcp_servers_dir = agpm_dir.join("mcp-servers");
1771        std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1772
1773        // Create server config files
1774        let server1 = McpServerConfig {
1775            command: Some("server1".to_string()),
1776            args: vec!["arg1".to_string()],
1777            env: None,
1778            r#type: None,
1779            url: None,
1780            headers: None,
1781            agpm_metadata: Some(AgpmMetadata {
1782                managed: true,
1783                source: Some("source1".to_string()),
1784                version: Some("v1.0.0".to_string()),
1785                installed_at: "2024-01-01T00:00:00Z".to_string(),
1786                dependency_name: None,
1787            }),
1788        };
1789        crate::utils::write_json_file(&mcp_servers_dir.join("server1.json"), &server1, true)
1790            .unwrap();
1791
1792        // Create settings with existing user server
1793        let mut settings = ClaudeSettings::default();
1794        let mut servers = HashMap::new();
1795        servers.insert(
1796            "user-server".to_string(),
1797            McpServerConfig {
1798                command: Some("user".to_string()),
1799                args: vec![],
1800                env: None,
1801                r#type: None,
1802                url: None,
1803                headers: None,
1804                agpm_metadata: None,
1805            },
1806        );
1807        settings.mcp_servers = Some(servers);
1808
1809        // Update from directory
1810        settings.update_mcp_servers(&mcp_servers_dir).unwrap();
1811
1812        // Verify both servers are present
1813        let servers = settings.mcp_servers.as_ref().unwrap();
1814        assert_eq!(servers.len(), 2);
1815        assert!(servers.contains_key("user-server"));
1816        assert!(servers.contains_key("server1"));
1817
1818        // Verify server1 config matches
1819        let server1_config = servers.get("server1").unwrap();
1820        assert_eq!(server1_config.command, Some("server1".to_string()));
1821        assert_eq!(server1_config.args, vec!["arg1"]);
1822    }
1823
1824    #[test]
1825    fn test_update_mcp_servers_nonexistent_dir() {
1826        let temp = tempfile::TempDir::new().unwrap();
1827        let nonexistent_dir = temp.path().join("nonexistent");
1828
1829        let mut settings = ClaudeSettings::default();
1830        let result = settings.update_mcp_servers(&nonexistent_dir);
1831        assert!(result.is_ok());
1832    }
1833
1834    #[test]
1835    fn test_mcp_config_handles_extra_fields() {
1836        // McpConfig doesn't preserve other fields, but it should parse files with extra fields
1837        let json_str = r#"{
1838            "mcpServers": {
1839                "test": {
1840                    "command": "test",
1841                    "args": []
1842                }
1843            },
1844            "customField": "value",
1845            "anotherField": {
1846                "nested": true
1847            }
1848        }"#;
1849
1850        let temp = tempdir().unwrap();
1851        let config_path = temp.path().join(".mcp.json");
1852        std::fs::write(&config_path, json_str).unwrap();
1853
1854        // Should parse successfully ignoring extra fields
1855        let config = McpConfig::load_or_default(&config_path).unwrap();
1856        assert!(config.mcp_servers.contains_key("test"));
1857        assert_eq!(config.mcp_servers.len(), 1);
1858    }
1859
1860    // Change detection tests for merge_mcp_servers function
1861
1862    #[tokio::test]
1863    async fn test_merge_mcp_servers_unchanged_detection() {
1864        let temp = tempdir().unwrap();
1865        setup_project_root(temp.path());
1866        let config_path = temp.path().join(".mcp.json");
1867
1868        // Create initial config with a server
1869        let initial_config = json!({
1870            "mcpServers": {
1871                "test-server": {
1872                    "command": "node",
1873                    "args": ["server.js"],
1874                    "_agpm": {
1875                        "managed": true,
1876                        "source": "test-source",
1877                        "version": "v1.0.0",
1878                        "installed_at": "2024-01-01T00:00:00Z"
1879                    }
1880                }
1881            }
1882        });
1883
1884        tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
1885            .await
1886            .unwrap();
1887
1888        // Create "same" server configuration (only timestamp differs)
1889        let mut agpm_servers = HashMap::new();
1890        agpm_servers.insert(
1891            "test-server".to_string(),
1892            McpServerConfig {
1893                command: Some("node".to_string()),
1894                args: vec!["server.js".to_string()],
1895                env: None,
1896                r#type: None,
1897                url: None,
1898                headers: None,
1899                agpm_metadata: Some(AgpmMetadata {
1900                    managed: true,
1901                    source: Some("test-source".to_string()),
1902                    version: Some("v1.0.0".to_string()),
1903                    installed_at: "2024-01-02T00:00:00Z".to_string(), // Different timestamp
1904                    dependency_name: None,
1905                }),
1906            },
1907        );
1908
1909        // Merge should detect no changes (ignoring timestamps)
1910        let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
1911        assert_eq!(changed_count, 0, "Should detect no changes when only timestamp differs");
1912    }
1913
1914    #[tokio::test]
1915    async fn test_merge_mcp_servers_actual_change() {
1916        let temp = tempdir().unwrap();
1917        setup_project_root(temp.path());
1918        let config_path = temp.path().join(".mcp.json");
1919
1920        // Create initial config with a server
1921        let initial_config = json!({
1922            "mcpServers": {
1923                "test-server": {
1924                    "command": "node",
1925                    "args": ["server.js"],
1926                    "_agpm": {
1927                        "managed": true,
1928                        "source": "test-source",
1929                        "version": "v1.0.0",
1930                        "installed_at": "2024-01-01T00:00:00Z"
1931                    }
1932                }
1933            }
1934        });
1935
1936        tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
1937            .await
1938            .unwrap();
1939
1940        // Create modified server configuration
1941        let mut agpm_servers = HashMap::new();
1942        agpm_servers.insert(
1943            "test-server".to_string(),
1944            McpServerConfig {
1945                command: Some("python".to_string()), // Changed command
1946                args: vec!["server.py".to_string()],
1947                env: None,
1948                r#type: None,
1949                url: None,
1950                headers: None,
1951                agpm_metadata: Some(AgpmMetadata {
1952                    managed: true,
1953                    source: Some("test-source".to_string()),
1954                    version: Some("v1.0.0".to_string()),
1955                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1956                    dependency_name: None,
1957                }),
1958            },
1959        );
1960
1961        // Merge should detect changes
1962        let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
1963        assert_eq!(changed_count, 1, "Should detect changes when server configuration differs");
1964    }
1965
1966    #[tokio::test]
1967    async fn test_merge_mcp_servers_new_server() {
1968        let temp = tempdir().unwrap();
1969        setup_project_root(temp.path());
1970        let config_path = temp.path().join(".mcp.json");
1971
1972        // Create empty initial config
1973        let initial_config = json!({
1974            "mcpServers": {}
1975        });
1976
1977        tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
1978            .await
1979            .unwrap();
1980
1981        // Add a new server
1982        let mut agpm_servers = HashMap::new();
1983        agpm_servers.insert(
1984            "new-server".to_string(),
1985            McpServerConfig {
1986                command: Some("node".to_string()),
1987                args: vec!["server.js".to_string()],
1988                env: None,
1989                r#type: None,
1990                url: None,
1991                headers: None,
1992                agpm_metadata: Some(AgpmMetadata {
1993                    managed: true,
1994                    source: Some("test-source".to_string()),
1995                    version: Some("v1.0.0".to_string()),
1996                    installed_at: "2024-01-01T00:00:00Z".to_string(),
1997                    dependency_name: None,
1998                }),
1999            },
2000        );
2001
2002        // Merge should detect new server as changed
2003        let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
2004        assert_eq!(changed_count, 1, "Should detect new server as changed");
2005    }
2006
2007    #[tokio::test]
2008    async fn test_merge_mcp_servers_mixed_changes() {
2009        let temp = tempdir().unwrap();
2010        setup_project_root(temp.path());
2011        let config_path = temp.path().join(".mcp.json");
2012
2013        // Create initial config with two servers
2014        let initial_config = json!({
2015            "mcpServers": {
2016                "unchanged-server": {
2017                    "command": "node",
2018                    "args": ["server.js"],
2019                    "_agpm": {
2020                        "managed": true,
2021                        "source": "test-source",
2022                        "version": "v1.0.0",
2023                        "installed_at": "2024-01-01T00:00:00Z"
2024                    }
2025                },
2026                "changed-server": {
2027                    "command": "python",
2028                    "args": ["server.py"],
2029                    "_agpm": {
2030                        "managed": true,
2031                        "source": "test-source",
2032                        "version": "v1.0.0",
2033                        "installed_at": "2024-01-01T00:00:00Z"
2034                    }
2035                }
2036            }
2037        });
2038
2039        tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
2040            .await
2041            .unwrap();
2042
2043        // Create server configurations (one unchanged, one changed, one new)
2044        let mut agpm_servers = HashMap::new();
2045
2046        // Unchanged server (same config, different timestamp)
2047        agpm_servers.insert(
2048            "unchanged-server".to_string(),
2049            McpServerConfig {
2050                command: Some("node".to_string()),
2051                args: vec!["server.js".to_string()],
2052                env: None,
2053                r#type: None,
2054                url: None,
2055                headers: None,
2056                agpm_metadata: Some(AgpmMetadata {
2057                    managed: true,
2058                    source: Some("test-source".to_string()),
2059                    version: Some("v1.0.0".to_string()),
2060                    installed_at: "2024-01-02T00:00:00Z".to_string(), // Different timestamp only
2061                    dependency_name: None,
2062                }),
2063            },
2064        );
2065
2066        // Changed server (different command)
2067        agpm_servers.insert(
2068            "changed-server".to_string(),
2069            McpServerConfig {
2070                command: Some("ruby".to_string()), // Changed command
2071                args: vec!["server.rb".to_string()],
2072                env: None,
2073                r#type: None,
2074                url: None,
2075                headers: None,
2076                agpm_metadata: Some(AgpmMetadata {
2077                    managed: true,
2078                    source: Some("test-source".to_string()),
2079                    version: Some("v1.0.0".to_string()),
2080                    installed_at: "2024-01-01T00:00:00Z".to_string(),
2081                    dependency_name: None,
2082                }),
2083            },
2084        );
2085
2086        // New server
2087        agpm_servers.insert(
2088            "new-server".to_string(),
2089            McpServerConfig {
2090                command: Some("go".to_string()),
2091                args: vec!["server".to_string()],
2092                env: None,
2093                r#type: None,
2094                url: None,
2095                headers: None,
2096                agpm_metadata: Some(AgpmMetadata {
2097                    managed: true,
2098                    source: Some("test-source".to_string()),
2099                    version: Some("v1.0.0".to_string()),
2100                    installed_at: "2024-01-01T00:00:00Z".to_string(),
2101                    dependency_name: None,
2102                }),
2103            },
2104        );
2105
2106        // Merge should detect 2 changes (changed server + new server)
2107        let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
2108        assert_eq!(changed_count, 2, "Should detect 2 changes: 1 modified server + 1 new server");
2109    }
2110
2111    #[tokio::test]
2112    async fn test_merge_mcp_servers_empty_updates() {
2113        let temp = tempdir().unwrap();
2114        setup_project_root(temp.path());
2115        let config_path = temp.path().join(".mcp.json");
2116
2117        // Create initial config with servers
2118        let initial_config = json!({
2119            "mcpServers": {
2120                "existing-server": {
2121                    "command": "node",
2122                    "args": ["server.js"],
2123                    "_agpm": {
2124                        "managed": true,
2125                        "source": "test-source",
2126                        "version": "v1.0.0",
2127                        "installed_at": "2024-01-01T00:00:00Z"
2128                    }
2129                }
2130            }
2131        });
2132
2133        tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
2134            .await
2135            .unwrap();
2136
2137        // Empty updates should remove all managed servers
2138        let agpm_servers = HashMap::new();
2139
2140        // Merge should detect removal as changes (0 changes to add, but servers are removed)
2141        let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
2142        assert_eq!(changed_count, 0, "Should detect 0 changes when only removing servers");
2143    }
2144}