Skip to content

Commit 6de7ade

Browse files
authored
Merge pull request #192 from tylergraydev/fix/sync-config-destroys-mcp-json
Fix Sync Config destroying .mcp.json
2 parents 9c90d2d + b01f1b1 commit 6de7ade

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
@@ -111,12 +111,34 @@ pub fn generate_mcp_config(mcps: &[McpTuple]) -> Value {
111111
}
112112

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

Comments
 (0)