@@ -111,12 +111,34 @@ pub fn generate_mcp_config(mcps: &[McpTuple]) -> Value {
111111}
112112
113113pub fn write_project_config ( project_path : & Path , mcps : & [ McpTuple ] ) -> Result < ( ) > {
114- // Write .mcp.json to project root (per official Claude Code spec)
115114 let config_path = project_path. join ( ".mcp.json" ) ;
116- let config = generate_mcp_config ( mcps) ;
117- let content = serde_json:: to_string_pretty ( & config) ?;
118115
119- std:: fs:: write ( config_path, content) ?;
116+ // Read existing .mcp.json or create new
117+ let mut existing: Value = if config_path. exists ( ) {
118+ let content = std:: fs:: read_to_string ( & config_path) ?;
119+ serde_json:: from_str ( & content) . map_err ( |e| {
120+ anyhow:: anyhow!(
121+ "Failed to parse existing .mcp.json at {}: {}. \
122+ Refusing to overwrite to prevent data loss.",
123+ config_path. display( ) ,
124+ e
125+ )
126+ } ) ?
127+ } else {
128+ json ! ( { } )
129+ } ;
130+
131+ // Merge DB-managed mcpServers into existing config
132+ let mcp_config = generate_mcp_config ( mcps) ;
133+ if let Some ( servers) = mcp_config. get ( "mcpServers" ) {
134+ existing[ "mcpServers" ] = servers. clone ( ) ;
135+ }
136+
137+ // Back up existing file before writing
138+ backup_config_file ( & config_path) ?;
139+
140+ let content = serde_json:: to_string_pretty ( & existing) ?;
141+ std:: fs:: write ( & config_path, content) ?;
120142 Ok ( ( ) )
121143}
122144
@@ -506,6 +528,106 @@ mod tests {
506528 assert ! ( servers. contains_key( "remote-sse" ) ) ;
507529 }
508530
531+ #[ test]
532+ fn test_write_project_config_preserves_existing_keys ( ) {
533+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
534+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
535+
536+ // Pre-populate .mcp.json with extra top-level keys
537+ let existing = serde_json:: json!( {
538+ "mcpServers" : {
539+ "old-server" : { "command" : "old" , "args" : [ ] }
540+ } ,
541+ "customKey" : "should-survive"
542+ } ) ;
543+ std:: fs:: write (
544+ & config_path,
545+ serde_json:: to_string_pretty ( & existing) . unwrap ( ) ,
546+ )
547+ . unwrap ( ) ;
548+
549+ // Sync with new MCPs
550+ let mcps = vec ! [ sample_stdio_mcp( ) ] ;
551+ write_project_config ( temp_dir. path ( ) , & mcps) . unwrap ( ) ;
552+
553+ let content = std:: fs:: read_to_string ( & config_path) . unwrap ( ) ;
554+ let parsed: Value = serde_json:: from_str ( & content) . unwrap ( ) ;
555+
556+ // mcpServers should be replaced with DB MCPs
557+ let servers = parsed. get ( "mcpServers" ) . unwrap ( ) . as_object ( ) . unwrap ( ) ;
558+ assert ! ( servers. contains_key( "test-mcp" ) ) ;
559+ assert ! ( !servers. contains_key( "old-server" ) ) ;
560+
561+ // Other top-level keys should be preserved
562+ assert_eq ! ( parsed. get( "customKey" ) . unwrap( ) , "should-survive" ) ;
563+ }
564+
565+ #[ test]
566+ fn test_write_project_config_creates_backup ( ) {
567+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
568+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
569+ let backup_path = temp_dir. path ( ) . join ( ".mcp.json.bak" ) ;
570+
571+ // Pre-populate .mcp.json
572+ std:: fs:: write ( & config_path, r#"{"mcpServers": {}}"# ) . unwrap ( ) ;
573+
574+ let mcps = vec ! [ sample_stdio_mcp( ) ] ;
575+ write_project_config ( temp_dir. path ( ) , & mcps) . unwrap ( ) ;
576+
577+ assert ! ( backup_path. exists( ) ) ;
578+ }
579+
580+ #[ test]
581+ fn test_write_project_config_refuses_corrupt_json ( ) {
582+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
583+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
584+
585+ // Write invalid JSON
586+ std:: fs:: write ( & config_path, "not valid json {{{" ) . unwrap ( ) ;
587+
588+ let mcps = vec ! [ sample_stdio_mcp( ) ] ;
589+ let result = write_project_config ( temp_dir. path ( ) , & mcps) ;
590+
591+ assert ! ( result. is_err( ) ) ;
592+ assert ! ( result
593+ . unwrap_err( )
594+ . to_string( )
595+ . contains( "Refusing to overwrite" ) ) ;
596+ }
597+
598+ #[ test]
599+ fn test_write_project_config_empty_mcps_preserves_existing_structure ( ) {
600+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
601+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
602+
603+ // Pre-populate with content
604+ let existing = serde_json:: json!( {
605+ "mcpServers" : {
606+ "external-server" : { "command" : "ext" , "args" : [ ] }
607+ } ,
608+ "someOtherConfig" : true
609+ } ) ;
610+ std:: fs:: write (
611+ & config_path,
612+ serde_json:: to_string_pretty ( & existing) . unwrap ( ) ,
613+ )
614+ . unwrap ( ) ;
615+
616+ // Sync with empty MCPs (the original bug scenario)
617+ let mcps: Vec < McpTuple > = vec ! [ ] ;
618+ write_project_config ( temp_dir. path ( ) , & mcps) . unwrap ( ) ;
619+
620+ let content = std:: fs:: read_to_string ( & config_path) . unwrap ( ) ;
621+ let parsed: Value = serde_json:: from_str ( & content) . unwrap ( ) ;
622+
623+ // mcpServers should be empty (DB has none)
624+ let servers = parsed. get ( "mcpServers" ) . unwrap ( ) . as_object ( ) . unwrap ( ) ;
625+ assert_eq ! ( servers. len( ) , 0 ) ;
626+
627+ // But the file should still have valid structure and preserve other keys
628+ assert_eq ! ( parsed. get( "someOtherConfig" ) . unwrap( ) , true ) ;
629+ }
630+
509631 // =========================================================================
510632 // JSON structure tests
511633 // =========================================================================
0 commit comments