diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f419a761b..f63a79f46 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -499,6 +499,7 @@ pub fn run() { // restore_managed_agents_on_launch (which reads managed-agents.json). migration::sync_shared_agent_data(&app_handle); migration::reconcile_persona_pack_paths(&app_handle); + migration::reconcile_provider_mcp_commands(&app_handle); migration::migrate_persona_provider_to_runtime(&app_handle); // Resolve persisted identity key (env var → file → generate+save). diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index e7ba8277a..cca31c72c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -797,14 +797,20 @@ pub fn spawn_agent_child( let agent_args = normalize_agent_args(&record.agent_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - let resolved_mcp_command: Option = - if record.mcp_command.is_empty() { - None - } else { - Some(resolve_command(&record.mcp_command).ok_or_else(|| { - missing_command_message(&record.mcp_command, "MCP server command") - })?) - }; + let resolved_mcp_command: Option = if record.mcp_command.is_empty() { + None + } else { + match resolve_command(&record.mcp_command) { + Some(path) => Some(path), + None => { + eprintln!( + "sprout-desktop: mcp_command {:?} not found, skipping", + record.mcp_command + ); + None + } + } + }; // Resolve agent command to a full path (DMG launches have minimal PATH). let resolved_agent_command = resolve_command(&record.agent_command) .map(|p| p.display().to_string()) diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 8b1436eb2..eda5cae74 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -318,6 +318,65 @@ pub fn reconcile_persona_pack_paths(app: &tauri::AppHandle) { reconcile_pack_paths_in_file(&path, &canonical_dir); } +fn reconcile_mcp_commands_in_file(path: &Path) { + patch_json_records(path, |obj| { + let agent_command = match obj.get("agent_command").and_then(|v| v.as_str()) { + Some(cmd) => cmd.to_string(), + None => return false, + }; + let Some(runtime) = crate::managed_agents::known_acp_runtime(&agent_command) else { + return false; + }; + let expected = runtime.mcp_command.unwrap_or(""); + let current = obj + .get("mcp_command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if current == expected { + return false; + } + // Only fix values that are clearly stale (empty or a removed binary). + // Leave user-customized values untouched. + if !current.is_empty() && current != "sprout-mcp-server" { + return false; + } + eprintln!( + "sprout-desktop: runtime-reconcile: {:?} ({:?}): mcp_command {:?} → {:?}", + obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), + agent_command, + current, + expected, + ); + obj.insert( + "mcp_command".to_string(), + serde_json::Value::String(expected.to_string()), + ); + true + }); +} + +/// Reconcile `mcp_command` values in managed-agents.json against the +/// discovery table. Known runtimes get their canonical mcp_command; +/// unknown/custom agents are left untouched. Covers both the current +/// app data dir and the canonical dev data dir (for worktree instances). +pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) { + let Ok(current_dir) = app.path().app_data_dir() else { + return; + }; + let mut dirs = vec![current_dir.clone()]; + if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { + if canonical.exists() && canonical != current_dir { + dirs.push(canonical); + } + } + for dir in dirs { + let path = dir.join("agents/managed-agents.json"); + if path.exists() { + reconcile_mcp_commands_in_file(&path); + } + } +} + fn rename_provider_to_runtime_in_personas(path: &Path) { patch_json_records(path, |obj| { if obj.contains_key("runtime") { @@ -899,4 +958,118 @@ mod tests { // provider key should still be there since the closure returns false when runtime exists assert_eq!(records[0]["provider"], "old-value"); } + + #[test] + fn reconcile_mcp_commands_clears_stale_sprout_mcp_server() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Solo", + "agent_command": "goose", + "mcp_command": "sprout-mcp-server" + }]), + ); + reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["mcp_command"], ""); + } + + #[test] + fn reconcile_mcp_commands_sets_canonical_for_sprout_agent() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Stilgar", + "agent_command": "sprout-agent", + "mcp_command": "sprout-mcp-server" + }]), + ); + reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp"); + } + + #[test] + fn reconcile_mcp_commands_leaves_custom_value_untouched() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "Solo", + "agent_command": "goose", + "mcp_command": "my-custom-mcp" + }]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); + } + + #[test] + fn reconcile_mcp_commands_leaves_unknown_runtime_untouched() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "Custom", + "agent_command": "my-custom-agent", + "mcp_command": "sprout-mcp-server" + }]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); + } + + #[test] + fn reconcile_mcp_commands_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Solo", + "agent_command": "goose", + "mcp_command": "sprout-mcp-server" + }]), + ); + let path = dir.path().join("agents/managed-agents.json"); + reconcile_mcp_commands_in_file(&path); + let after_first = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(after_first, std::fs::read_to_string(&path).unwrap()); + } + + #[test] + fn reconcile_mcp_commands_handles_mixed_agents() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([ + {"name": "Stale Goose", "agent_command": "goose", "mcp_command": "sprout-mcp-server"}, + {"name": "Clean Goose", "agent_command": "goose", "mcp_command": ""}, + {"name": "Custom Agent", "agent_command": "goose", "mcp_command": "my-custom-mcp"}, + {"name": "Stale Sprout", "agent_command": "sprout-agent", "mcp_command": "sprout-mcp-server"} + ]), + ); + reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["mcp_command"], ""); + assert_eq!(records[1]["mcp_command"], ""); + assert_eq!(records[2]["mcp_command"], "my-custom-mcp"); + assert_eq!(records[3]["mcp_command"], "sprout-dev-mcp"); + } + + #[test] + fn reconcile_mcp_commands_skips_record_without_agent_command() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "No Command", + "mcp_command": "sprout-mcp-server" + }]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); + } }