Skip to content

Commit b01f1b1

Browse files
committed
Fix Sync Config destroying .mcp.json (#191)
write_project_config() now reads the existing .mcp.json before writing, backs it up, and merges DB-managed mcpServers into the existing config instead of overwriting the entire file. This mirrors the pattern already used by write_global_config() for ~/.claude.json. Refuses to overwrite if the existing file contains invalid JSON to prevent silent data loss.
1 parent 851d0bc commit b01f1b1

1 file changed

Lines changed: 126 additions & 4 deletions

File tree

src-tauri/src/services/config_writer.rs

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,34 @@ pub fn generate_mcp_config(mcps: &[McpTuple]) -> Value {
9696
}
9797

9898
pub 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

Comments
 (0)