@@ -96,12 +96,34 @@ pub fn generate_mcp_config(mcps: &[McpTuple]) -> Value {
9696}
9797
9898pub fn write_project_config ( project_path : & Path , mcps : & [ McpTuple ] ) -> Result < ( ) > {
99- // Write .mcp.json to project root (per official Claude Code spec)
10099 let config_path = project_path. join ( ".mcp.json" ) ;
101- let config = generate_mcp_config ( mcps) ;
102- let content = serde_json:: to_string_pretty ( & config) ?;
103100
104- std:: fs:: write ( config_path, content) ?;
101+ // Read existing .mcp.json or create new
102+ let mut existing: Value = if config_path. exists ( ) {
103+ let content = std:: fs:: read_to_string ( & config_path) ?;
104+ serde_json:: from_str ( & content) . map_err ( |e| {
105+ anyhow:: anyhow!(
106+ "Failed to parse existing .mcp.json at {}: {}. \
107+ Refusing to overwrite to prevent data loss.",
108+ config_path. display( ) ,
109+ e
110+ )
111+ } ) ?
112+ } else {
113+ json ! ( { } )
114+ } ;
115+
116+ // Merge DB-managed mcpServers into existing config
117+ let mcp_config = generate_mcp_config ( mcps) ;
118+ if let Some ( servers) = mcp_config. get ( "mcpServers" ) {
119+ existing[ "mcpServers" ] = servers. clone ( ) ;
120+ }
121+
122+ // Back up existing file before writing
123+ backup_config_file ( & config_path) ?;
124+
125+ let content = serde_json:: to_string_pretty ( & existing) ?;
126+ std:: fs:: write ( & config_path, content) ?;
105127 Ok ( ( ) )
106128}
107129
@@ -491,6 +513,106 @@ mod tests {
491513 assert ! ( servers. contains_key( "remote-sse" ) ) ;
492514 }
493515
516+ #[ test]
517+ fn test_write_project_config_preserves_existing_keys ( ) {
518+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
519+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
520+
521+ // Pre-populate .mcp.json with extra top-level keys
522+ let existing = serde_json:: json!( {
523+ "mcpServers" : {
524+ "old-server" : { "command" : "old" , "args" : [ ] }
525+ } ,
526+ "customKey" : "should-survive"
527+ } ) ;
528+ std:: fs:: write (
529+ & config_path,
530+ serde_json:: to_string_pretty ( & existing) . unwrap ( ) ,
531+ )
532+ . unwrap ( ) ;
533+
534+ // Sync with new MCPs
535+ let mcps = vec ! [ sample_stdio_mcp( ) ] ;
536+ write_project_config ( temp_dir. path ( ) , & mcps) . unwrap ( ) ;
537+
538+ let content = std:: fs:: read_to_string ( & config_path) . unwrap ( ) ;
539+ let parsed: Value = serde_json:: from_str ( & content) . unwrap ( ) ;
540+
541+ // mcpServers should be replaced with DB MCPs
542+ let servers = parsed. get ( "mcpServers" ) . unwrap ( ) . as_object ( ) . unwrap ( ) ;
543+ assert ! ( servers. contains_key( "test-mcp" ) ) ;
544+ assert ! ( !servers. contains_key( "old-server" ) ) ;
545+
546+ // Other top-level keys should be preserved
547+ assert_eq ! ( parsed. get( "customKey" ) . unwrap( ) , "should-survive" ) ;
548+ }
549+
550+ #[ test]
551+ fn test_write_project_config_creates_backup ( ) {
552+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
553+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
554+ let backup_path = temp_dir. path ( ) . join ( ".mcp.json.bak" ) ;
555+
556+ // Pre-populate .mcp.json
557+ std:: fs:: write ( & config_path, r#"{"mcpServers": {}}"# ) . unwrap ( ) ;
558+
559+ let mcps = vec ! [ sample_stdio_mcp( ) ] ;
560+ write_project_config ( temp_dir. path ( ) , & mcps) . unwrap ( ) ;
561+
562+ assert ! ( backup_path. exists( ) ) ;
563+ }
564+
565+ #[ test]
566+ fn test_write_project_config_refuses_corrupt_json ( ) {
567+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
568+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
569+
570+ // Write invalid JSON
571+ std:: fs:: write ( & config_path, "not valid json {{{" ) . unwrap ( ) ;
572+
573+ let mcps = vec ! [ sample_stdio_mcp( ) ] ;
574+ let result = write_project_config ( temp_dir. path ( ) , & mcps) ;
575+
576+ assert ! ( result. is_err( ) ) ;
577+ assert ! ( result
578+ . unwrap_err( )
579+ . to_string( )
580+ . contains( "Refusing to overwrite" ) ) ;
581+ }
582+
583+ #[ test]
584+ fn test_write_project_config_empty_mcps_preserves_existing_structure ( ) {
585+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
586+ let config_path = temp_dir. path ( ) . join ( ".mcp.json" ) ;
587+
588+ // Pre-populate with content
589+ let existing = serde_json:: json!( {
590+ "mcpServers" : {
591+ "external-server" : { "command" : "ext" , "args" : [ ] }
592+ } ,
593+ "someOtherConfig" : true
594+ } ) ;
595+ std:: fs:: write (
596+ & config_path,
597+ serde_json:: to_string_pretty ( & existing) . unwrap ( ) ,
598+ )
599+ . unwrap ( ) ;
600+
601+ // Sync with empty MCPs (the original bug scenario)
602+ let mcps: Vec < McpTuple > = vec ! [ ] ;
603+ write_project_config ( temp_dir. path ( ) , & mcps) . unwrap ( ) ;
604+
605+ let content = std:: fs:: read_to_string ( & config_path) . unwrap ( ) ;
606+ let parsed: Value = serde_json:: from_str ( & content) . unwrap ( ) ;
607+
608+ // mcpServers should be empty (DB has none)
609+ let servers = parsed. get ( "mcpServers" ) . unwrap ( ) . as_object ( ) . unwrap ( ) ;
610+ assert_eq ! ( servers. len( ) , 0 ) ;
611+
612+ // But the file should still have valid structure and preserve other keys
613+ assert_eq ! ( parsed. get( "someOtherConfig" ) . unwrap( ) , true ) ;
614+ }
615+
494616 // =========================================================================
495617 // JSON structure tests
496618 // =========================================================================
0 commit comments