fix(desktop): re-enable mcp_command reconciliation and harden spawn site (#909)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-08 13:54:42 -07:00
committed by GitHub
parent da80c7340f
commit 15f610dcd5
3 changed files with 188 additions and 8 deletions

View File

@@ -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).

View File

@@ -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<std::path::PathBuf> =
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<std::path::PathBuf> = 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())

View File

@@ -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(&current_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());
}
}