Skip to content

Commit 4b2a0fb

Browse files
tylergraydevTyler Grayclaude
authored
fix: prevent sync config from destroying externally-managed configs (#204)
* fix: prevent sync config from overwriting externally-managed .mcp.json When the database has no MCP servers for a project, sync was replacing the mcpServers key with an empty object, destroying hand-configured servers. Now skips the overwrite when DB returns no servers. Fixes #191 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent sync config from destroying externally-managed configs + import .mcp.json on project add Fixes #191 (reopened) — Sync Config was still overwriting .mcp.json, claude.json, opencode.json, and other editor configs with empty settings when a project had no MCPs in the app's database. Root cause: The previous fix (3064d5f) only guarded write_project_config() and write_global_config(), but missed write_project_to_claude_json() and all other editor config writers (OpenCode, Copilot, Cursor, Gemini, Codex). Changes: - Guard all 7 config writers against overwriting with empty MCP data - Import MCPs from .mcp.json when a project is added (so externally-configured servers appear in the UI instead of showing an empty project) - Fix has_mcp_file check (was looking at .claude/.mcp.json, now checks .mcp.json in project root per the official spec) - Add missing Tauri commands: open_folder, update_project_editor_type Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> --------- Co-authored-by: Tyler Gray <tylerg@emergentsoftware.net> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3482eec commit 4b2a0fb

9 files changed

Lines changed: 195 additions & 16 deletions

File tree

src-tauri/src/commands/projects.rs

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ pub fn add_project(
119119
db: State<'_, Arc<Mutex<Database>>>,
120120
project: CreateProjectRequest,
121121
) -> Result<Project, String> {
122+
use crate::services::scanner;
122123
use crate::utils::paths::get_claude_paths;
123124

124125
info!(
@@ -127,9 +128,9 @@ pub fn add_project(
127128
);
128129
let db = db.lock().map_err(|e| e.to_string())?;
129130

130-
// Check if .claude/.mcp.json exists
131+
// Check if .mcp.json exists (project root is the standard location)
131132
let project_path = PathBuf::from(&project.path);
132-
let mcp_file = project_path.join(".claude").join(".mcp.json");
133+
let mcp_file = project_path.join(".mcp.json");
133134
let settings_file = project_path.join(".claude").join("settings.local.json");
134135

135136
let has_mcp_file = mcp_file.exists();
@@ -150,12 +151,27 @@ pub fn add_project(
150151

151152
let id = db.conn().last_insert_rowid();
152153

154+
// Import MCPs from existing .mcp.json so they show up in the UI
155+
let imported_mcps = if has_mcp_file {
156+
scanner::import_mcps_from_project_mcp_json(&db, id, &project.path)
157+
.map_err(|e| e.to_string())?
158+
} else {
159+
0
160+
};
161+
info!(
162+
"[Projects] Imported {} MCPs from .mcp.json for project id={}",
163+
imported_mcps, id
164+
);
165+
153166
// Register project in claude.json (even with no MCPs)
154167
if let Ok(paths) = get_claude_paths() {
155168
let empty_mcps: Vec<config_writer::McpWithEnabledTuple> = vec![];
156169
let _ = config_writer::write_project_to_claude_json(&paths, &project.path, &empty_mcps);
157170
}
158171

172+
// Fetch assigned MCPs to return in the response
173+
let assigned_mcps = get_project_assigned_mcps(&db, id);
174+
159175
Ok(Project {
160176
id,
161177
name: project.name,
@@ -167,7 +183,7 @@ pub fn add_project(
167183
is_favorite: false,
168184
created_at: chrono::Utc::now().to_rfc3339(),
169185
updated_at: chrono::Utc::now().to_rfc3339(),
170-
assigned_mcps: vec![],
186+
assigned_mcps,
171187
})
172188
}
173189

@@ -603,6 +619,48 @@ pub fn toggle_project_favorite(
603619
toggle_project_favorite_in_db(&db, id, favorite)
604620
}
605621

622+
#[tauri::command]
623+
pub fn open_folder(path: String) -> Result<(), String> {
624+
#[cfg(target_os = "windows")]
625+
{
626+
std::process::Command::new("explorer")
627+
.arg(&path)
628+
.spawn()
629+
.map_err(|e| e.to_string())?;
630+
}
631+
#[cfg(target_os = "macos")]
632+
{
633+
std::process::Command::new("open")
634+
.arg(&path)
635+
.spawn()
636+
.map_err(|e| e.to_string())?;
637+
}
638+
#[cfg(target_os = "linux")]
639+
{
640+
std::process::Command::new("xdg-open")
641+
.arg(&path)
642+
.spawn()
643+
.map_err(|e| e.to_string())?;
644+
}
645+
Ok(())
646+
}
647+
648+
#[tauri::command]
649+
pub fn update_project_editor_type(
650+
db: State<'_, Arc<Mutex<Database>>>,
651+
project_id: i64,
652+
editor_type: String,
653+
) -> Result<(), String> {
654+
let db = db.lock().map_err(|e| e.to_string())?;
655+
db.conn()
656+
.execute(
657+
"UPDATE projects SET editor_type = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
658+
params![editor_type, project_id],
659+
)
660+
.map_err(|e| e.to_string())?;
661+
Ok(())
662+
}
663+
606664
/// Toggle project favorite status in the database
607665
pub(crate) fn toggle_project_favorite_in_db(
608666
db: &Database,
@@ -618,6 +676,39 @@ pub(crate) fn toggle_project_favorite_in_db(
618676
Ok(())
619677
}
620678

679+
/// Get assigned MCPs for a project from the database
680+
fn get_project_assigned_mcps(db: &Database, project_id: i64) -> Vec<ProjectMcp> {
681+
let result: Result<Vec<ProjectMcp>, rusqlite::Error> = (|| {
682+
let mut stmt = db.conn().prepare(
683+
"SELECT pm.id, pm.mcp_id, pm.is_enabled, pm.env_overrides, pm.display_order,
684+
m.id, m.name, m.description, m.type, m.command, m.args, m.url, m.headers, m.env,
685+
m.icon, m.tags, m.source, m.source_path, m.is_enabled_global, m.is_favorite, m.created_at, m.updated_at
686+
FROM project_mcps pm
687+
JOIN mcps m ON pm.mcp_id = m.id
688+
WHERE pm.project_id = ?
689+
ORDER BY pm.display_order",
690+
)?;
691+
692+
let mcps = stmt
693+
.query_map([project_id], |row| {
694+
Ok(ProjectMcp {
695+
id: row.get(0)?,
696+
mcp_id: row.get(1)?,
697+
is_enabled: row.get::<_, i32>(2)? != 0,
698+
env_overrides: parse_json_map(row.get(3)?),
699+
display_order: row.get(4)?,
700+
mcp: row_to_mcp(row, 5)?,
701+
})
702+
})?
703+
.filter_map(|r| r.ok())
704+
.collect();
705+
706+
Ok(mcps)
707+
})();
708+
709+
result.unwrap_or_default()
710+
}
711+
621712
/// Assign an MCP to a project in the database
622713
pub(crate) fn assign_mcp_to_project_in_db(
623714
db: &Database,

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ pub fn run() {
221221
commands::projects::toggle_project_mcp,
222222
commands::projects::toggle_project_favorite,
223223
commands::projects::sync_project_config,
224+
commands::projects::open_folder,
225+
commands::projects::update_project_editor_type,
224226
// Global Settings Commands
225227
commands::config::get_global_mcps,
226228
commands::config::add_global_mcp,

src-tauri/src/services/codex_config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ pub fn write_codex_config(path: &Path, mcps: &[McpTuple]) -> Result<()> {
211211
)
212212
})?;
213213

214+
// Skip overwrite when DB has no MCPs — preserves externally-managed configs
215+
if mcps.is_empty() {
216+
return Ok(());
217+
}
218+
214219
// Create or get mcp_servers table
215220
if doc.get("mcp_servers").is_none() {
216221
doc["mcp_servers"] = Item::Table(Table::new());

src-tauri/src/services/config_writer.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,12 @@ pub fn write_project_config(project_path: &Path, mcps: &[McpTuple]) -> Result<()
113113
};
114114

115115
// Merge DB-managed mcpServers into existing config
116+
// Skip overwrite when DB has no servers — preserves externally-managed .mcp.json
116117
let mcp_config = generate_mcp_config(mcps);
117-
if let Some(servers) = mcp_config.get("mcpServers") {
118-
existing["mcpServers"] = servers.clone();
118+
if let Some(Value::Object(servers)) = mcp_config.get("mcpServers") {
119+
if !servers.is_empty() {
120+
existing["mcpServers"] = Value::Object(servers.clone());
121+
}
119122
}
120123

121124
// Back up existing file before writing
@@ -143,9 +146,12 @@ pub fn write_global_config(paths: &ClaudePathsInternal, mcps: &[McpTuple]) -> Re
143146
};
144147

145148
// Build mcpServers object
149+
// Skip overwrite when DB has no servers — preserves externally-managed config
146150
let mcp_config = generate_mcp_config(mcps);
147-
if let Some(servers) = mcp_config.get("mcpServers") {
148-
claude_json["mcpServers"] = servers.clone();
151+
if let Some(Value::Object(servers)) = mcp_config.get("mcpServers") {
152+
if !servers.is_empty() {
153+
claude_json["mcpServers"] = Value::Object(servers.clone());
154+
}
149155
}
150156

151157
// Back up the existing file before modifying it
@@ -299,8 +305,13 @@ pub fn write_project_to_claude_json(
299305
}
300306
}
301307

302-
project["mcpServers"] = Value::Object(mcp_servers);
303-
project["disabledMcpServers"] = json!(disabled_mcps);
308+
// Only update mcpServers if DB has servers — preserves externally-managed configs
309+
if !mcp_servers.is_empty() {
310+
project["mcpServers"] = Value::Object(mcp_servers);
311+
}
312+
if !disabled_mcps.is_empty() {
313+
project["disabledMcpServers"] = json!(disabled_mcps);
314+
}
304315

305316
// Back up the existing file before modifying it
306317
backup_config_file(&paths.claude_json)?;
@@ -604,11 +615,12 @@ mod tests {
604615
let content = std::fs::read_to_string(&config_path).unwrap();
605616
let parsed: Value = serde_json::from_str(&content).unwrap();
606617

607-
// mcpServers should be empty (DB has none)
618+
// mcpServers should be preserved (DB has none, so we don't overwrite)
608619
let servers = parsed.get("mcpServers").unwrap().as_object().unwrap();
609-
assert_eq!(servers.len(), 0);
620+
assert_eq!(servers.len(), 1);
621+
assert!(servers.contains_key("external-server"));
610622

611-
// But the file should still have valid structure and preserve other keys
623+
// Other keys should also be preserved
612624
assert_eq!(parsed.get("someOtherConfig").unwrap(), true);
613625
}
614626

src-tauri/src/services/copilot_config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ pub fn write_copilot_config(path: &Path, mcps: &[McpTuple]) -> Result<()> {
204204
CopilotMcpConfig::default()
205205
};
206206

207+
// Skip overwrite when DB has no MCPs — preserves externally-managed configs
208+
if mcps.is_empty() {
209+
return Ok(());
210+
}
211+
207212
// Back up the existing file before modifying it
208213
backup_config_file(path)?;
209214

src-tauri/src/services/cursor_config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ pub fn write_cursor_config(path: &Path, mcps: &[McpTuple]) -> Result<()> {
185185
CursorMcpConfig::default()
186186
};
187187

188+
// Skip overwrite when DB has no MCPs — preserves externally-managed configs
189+
if mcps.is_empty() {
190+
return Ok(());
191+
}
192+
188193
// Back up the existing file before modifying it
189194
backup_config_file(path)?;
190195

src-tauri/src/services/gemini_config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ pub fn write_gemini_config(path: &Path, mcps: &[McpTuple]) -> Result<()> {
206206
GeminiSettingsConfig::default()
207207
};
208208

209+
// Skip overwrite when DB has no MCPs — preserves externally-managed configs
210+
if mcps.is_empty() {
211+
return Ok(());
212+
}
213+
209214
// Back up the existing file before modifying it
210215
backup_settings_file(path)?;
211216

src-tauri/src/services/opencode_config.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,12 @@ pub fn write_opencode_global_config(config_path: &Path, mcps: &[McpTuple]) -> Re
248248
};
249249

250250
// Build MCP object
251+
// Skip overwrite when DB has no MCPs — preserves externally-managed configs
251252
let mcp_config = generate_opencode_mcp_config(mcps);
252-
if let Some(mcp) = mcp_config.get("mcp") {
253-
config["mcp"] = mcp.clone();
253+
if let Some(Value::Object(mcp)) = mcp_config.get("mcp") {
254+
if !mcp.is_empty() {
255+
config["mcp"] = Value::Object(mcp.clone());
256+
}
254257
}
255258

256259
// Back up the existing file before modifying it
@@ -284,9 +287,12 @@ pub fn write_opencode_project_config(project_path: &Path, mcps: &[McpTuple]) ->
284287
};
285288

286289
// Build MCP object
290+
// Skip overwrite when DB has no MCPs — preserves externally-managed configs
287291
let mcp_config = generate_opencode_mcp_config(mcps);
288-
if let Some(mcp) = mcp_config.get("mcp") {
289-
config["mcp"] = mcp.clone();
292+
if let Some(Value::Object(mcp)) = mcp_config.get("mcp") {
293+
if !mcp.is_empty() {
294+
config["mcp"] = Value::Object(mcp.clone());
295+
}
290296
}
291297

292298
// Back up the existing file before modifying it

src-tauri/src/services/scanner.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,54 @@ fn assign_mcp_to_project(
434434
Ok(())
435435
}
436436

437+
/// Import MCPs from a project's .mcp.json into the database.
438+
/// Called when a project is added so externally-configured servers show up in the UI.
439+
pub fn import_mcps_from_project_mcp_json(
440+
db: &Database,
441+
project_id: i64,
442+
project_path: &str,
443+
) -> Result<usize> {
444+
let path = std::path::PathBuf::from(project_path);
445+
let mcp_file = path.join(".mcp.json");
446+
447+
if !mcp_file.exists() {
448+
return Ok(0);
449+
}
450+
451+
let mcps = match config_parser::parse_mcp_file(&mcp_file) {
452+
Ok(m) => m,
453+
Err(e) => {
454+
log::warn!("Failed to parse .mcp.json at {}: {}", mcp_file.display(), e);
455+
return Ok(0);
456+
}
457+
};
458+
459+
let mut count = 0;
460+
for mcp in &mcps {
461+
let mcp_id = get_or_create_mcp(
462+
db,
463+
&mcp.name,
464+
&mcp.mcp_type,
465+
mcp.command.as_deref(),
466+
mcp.args.as_ref(),
467+
mcp.url.as_deref(),
468+
mcp.headers.as_ref(),
469+
mcp.env.as_ref(),
470+
project_path,
471+
)?;
472+
473+
assign_mcp_to_project(db, project_id, mcp_id, true)?;
474+
count += 1;
475+
}
476+
477+
log::info!(
478+
"Imported {} MCPs from .mcp.json for project {}",
479+
count,
480+
project_path
481+
);
482+
Ok(count)
483+
}
484+
437485
/// Scan plugins/marketplaces directory for MCPs
438486
pub fn scan_plugins(db: &Database) -> Result<usize> {
439487
let paths = get_claude_paths()?;

0 commit comments

Comments
 (0)