From ef41e584f78ec1d47fae2344352ee28531f3fcb0 Mon Sep 17 00:00:00 2001
From: fredamn76 <8447218+fredamn76@users.noreply.github.com>
Date: Sat, 1 Aug 2026 09:11:52 +0200
Subject: [PATCH] feat(desktop): support MCP sidecars for custom harnesses
Persist an optional MCP command with custom harness definitions and resolve it through the shared spawn descriptor, catalog summary, and restart hash.
Co-authored-by: fredamn76 <8447218+fredamn76@users.noreply.github.com>
Signed-off-by: fredamn76 <8447218+fredamn76@users.noreply.github.com>
---
.../src-tauri/src/commands/agent_discovery.rs | 2 +-
.../src/managed_agents/custom_harnesses.rs | 33 ++++++------
.../src-tauri/src/managed_agents/discovery.rs | 4 +-
.../src/managed_agents/discovery/presets.rs | 1 +
.../src/managed_agents/discovery/tests.rs | 24 ++++-----
.../src-tauri/src/managed_agents/readiness.rs | 32 +++++------
.../src-tauri/src/managed_agents/runtime.rs | 16 +++---
.../src/managed_agents/spawn_hash.rs | 9 ++--
.../src/managed_agents/spawn_hash/tests.rs | 43 +++++++++++++++
desktop/src/features/agents/AGENTS.md | 6 ++-
.../settings/ui/CustomHarnessForm.tsx | 53 ++++++++++++++++---
.../settings/ui/harnessFormLogic.test.mjs | 21 ++++++++
.../features/settings/ui/harnessFormLogic.ts | 5 ++
desktop/src/shared/api/tauri.ts | 4 +-
.../testing/e2eBridgeCustomHarnesses.test.mjs | 12 +++++
.../src/testing/e2eBridgeCustomHarnesses.ts | 3 +-
16 files changed, 191 insertions(+), 77 deletions(-)
diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs
index cbbf4ce351..44394b9c86 100644
--- a/desktop/src-tauri/src/commands/agent_discovery.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery.rs
@@ -173,7 +173,7 @@ pub async fn save_custom_harness(
command: command_opt,
binary_path,
default_args,
- mcp_command: None,
+ mcp_command: definition.mcp_command.clone(),
model_env_var: None,
provider_env_var: None,
thinking_env_var: None,
diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs
index e6bc09496c..7c76c0b5d4 100644
--- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs
+++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs
@@ -56,6 +56,9 @@ pub(crate) struct HarnessDefinition {
/// Default CLI arguments passed to the command (array, not split-string).
#[serde(default)]
pub args: Vec,
+ /// Optional MCP sidecar passed to `buzz-acp` for this harness.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub mcp_command: Option,
/// Environment variables injected at spawn time. Definition env is applied
/// first and LOSES on conflict with Buzz-injected vars — `BUZZ_MANAGED_AGENT`
/// is always authoritative and cannot be overridden here.
@@ -173,6 +176,9 @@ fn validate_harness_definition(def: &HarnessDefinition) -> Result<(), String> {
if def.label.trim().is_empty() {
return Err("label must not be empty".into());
}
+ if matches!(def.mcp_command.as_deref(), Some(command) if command.trim().is_empty()) {
+ return Err("mcpCommand must not be blank when provided".into());
+ }
// Args travel to the harness through the comma-delimited
// `BUZZ_ACP_AGENT_ARGS` env transport (clap `value_delimiter = ','` on the
// buzz-acp side), so a literal comma inside one argument would silently
@@ -733,18 +739,13 @@ mod tests {
assert_eq!(loaded[0].id, "custom-dup");
}
- // ── Round-trip via save_custom_harness_to_dir (B-4) ─────────────────────
- //
- // These tests exercise the REAL persistence helper, not raw fs::write.
- // They prove: create, same-ID edit (backup-swap), rename (old file removed),
- // backup file cleaned up on success.
-
fn make_def(id: &str, label: &str) -> HarnessDefinition {
HarnessDefinition {
id: id.to_string(),
label: label.to_string(),
command: format!("{id}-bin"),
args: vec![],
+ mcp_command: None,
env: BTreeMap::new(),
install_instructions_url: String::new(),
install_hint: String::new(),
@@ -773,11 +774,9 @@ mod tests {
let v1 = make_def("my-harness", "V1 Label");
save_custom_harness_to_dir(dir.path(), &v1, None).unwrap();
- // Same-ID edit: label changes.
let v2 = make_def("my-harness", "V2 Label");
let outcome = save_custom_harness_to_dir(dir.path(), &v2, None).unwrap();
- // No old-path reported (id unchanged).
assert!(outcome.removed_old_path.is_none());
let loaded = load_custom_harnesses(dir.path());
@@ -794,7 +793,6 @@ mod tests {
let v2 = make_def("my-harness", "V2");
save_custom_harness_to_dir(dir.path(), &v2, None).unwrap();
- // .bak file must be gone after a successful commit.
let bak = dir.path().join("my-harness.json.bak");
assert!(
!bak.exists(),
@@ -808,11 +806,9 @@ mod tests {
let old_def = make_def("old-id", "Old");
save_custom_harness_to_dir(dir.path(), &old_def, None).unwrap();
- // Rename: new id, old_id supplied.
let new_def = make_def("new-id", "New");
let outcome = save_custom_harness_to_dir(dir.path(), &new_def, Some("old-id")).unwrap();
- // The outcome carries the old path that was removed.
let expected_old = dir.path().join("old-id.json");
assert_eq!(
outcome.removed_old_path,
@@ -820,7 +816,6 @@ mod tests {
"removed_old_path must be the old file"
);
- // Old file gone, new file present.
assert!(!expected_old.exists(), "old-id.json must be removed");
let loaded = load_custom_harnesses(dir.path());
assert_eq!(loaded.len(), 1);
@@ -829,13 +824,11 @@ mod tests {
#[test]
fn save_to_dir_rename_nonexistent_old_id_is_non_fatal() {
- // rename_old_id pointing to a file that does not exist must succeed
- // (NotFound is silently ignored by the helper).
+ // NotFound for rename_old_id is ignored.
let dir = tempfile::tempdir().unwrap();
let def = make_def("alpha", "Alpha");
let outcome = save_custom_harness_to_dir(dir.path(), &def, Some("ghost-id")).unwrap();
- // New file created, no old path removed.
assert_eq!(outcome.target_path, dir.path().join("alpha.json"));
assert!(
outcome.removed_old_path.is_none(),
@@ -854,6 +847,7 @@ mod tests {
label: "Env Harness".to_string(),
command: "env-bin".to_string(),
args: vec!["--flag".to_string()],
+ mcp_command: Some("mcp-sidecar".to_string()),
env,
install_instructions_url: "https://example.com".to_string(),
install_hint: "Install from example.com".to_string(),
@@ -864,6 +858,7 @@ mod tests {
let loaded = load_custom_harnesses(dir.path());
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].args, vec!["--flag"]);
+ assert_eq!(loaded[0].mcp_command.as_deref(), Some("mcp-sidecar"));
assert_eq!(
loaded[0].env.get("MY_KEY").map(String::as_str),
Some("my_value"),
@@ -871,8 +866,6 @@ mod tests {
);
}
- // ── B-3: env validation boundary (validate_harness_definition_pub integration) ──
-
#[test]
fn validate_rejects_malformed_key_with_equals_sign() {
// BUZZ_AUTH_TAG=x is the documented reserved-key bypass shape:
@@ -885,6 +878,7 @@ mod tests {
label: "Bad".to_string(),
command: "bad-bin".to_string(),
args: vec![],
+ mcp_command: None,
env,
install_instructions_url: String::new(),
install_hint: String::new(),
@@ -914,6 +908,7 @@ mod tests {
label: "Bad".to_string(),
command: "bad-bin".to_string(),
args: vec![],
+ mcp_command: None,
env,
install_instructions_url: String::new(),
install_hint: String::new(),
@@ -935,6 +930,7 @@ mod tests {
label: "CI".to_string(),
command: "ci-bin".to_string(),
args: vec![],
+ mcp_command: None,
env,
install_instructions_url: String::new(),
install_hint: String::new(),
@@ -956,6 +952,7 @@ mod tests {
label: "NUL".to_string(),
command: "nul-bin".to_string(),
args: vec![],
+ mcp_command: None,
env,
install_instructions_url: String::new(),
install_hint: String::new(),
@@ -978,6 +975,7 @@ mod tests {
label: "Big".to_string(),
command: "big-bin".to_string(),
args: vec![],
+ mcp_command: None,
env,
install_instructions_url: String::new(),
install_hint: String::new(),
@@ -999,6 +997,7 @@ mod tests {
label: "Good".to_string(),
command: "good-bin".to_string(),
args: vec![],
+ mcp_command: None,
env,
install_instructions_url: String::new(),
install_hint: String::new(),
diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs
index 8d1b8a5013..4f48eceeba 100644
--- a/desktop/src-tauri/src/managed_agents/discovery.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery.rs
@@ -1565,9 +1565,7 @@ pub fn discover_acp_runtimes_from(
command,
binary_path,
default_args,
- // Custom harnesses are plain ACP — no MCP sidecar, no env-var
- // model switching, no thinking knobs.
- mcp_command: None,
+ mcp_command: def.mcp_command.clone(),
model_env_var: None,
provider_env_var: None,
thinking_env_var: None,
diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs
index 3622b21c4a..9d1fdbbfe8 100644
--- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs
@@ -184,6 +184,7 @@ pub(crate) fn preset_harness_definitions(
label: preset.label.to_string(),
command: preset.command.to_string(),
args: preset.args.iter().map(|arg| arg.to_string()).collect(),
+ mcp_command: None,
env: Default::default(),
install_instructions_url: preset.install_instructions_url.to_string(),
install_hint: preset.install_hint.to_string(),
diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
index 6fe6a77521..c74971200c 100644
--- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
@@ -1622,6 +1622,7 @@ fn deleted_harness_summary_display_and_spawn_sentence_agree() {
label: "Doomed".to_string(),
command: "doomed-bin".to_string(),
args: vec![],
+ mcp_command: None,
env: Default::default(),
install_instructions_url: String::new(),
install_hint: String::new(),
@@ -1652,34 +1653,25 @@ fn deleted_harness_summary_display_and_spawn_sentence_agree() {
// ── I2: custom catalog entry carries definition_env for the edit round-trip ───
-/// A custom harness definition that includes env vars must surface those vars
-/// in the `definition_env` field of the resulting `AcpRuntimeCatalogEntry`.
-///
-/// This proves the edit-form round-trip: the backend carries env into the
-/// catalog, the frontend reads it back when opening the edit form, and Save
-/// therefore preserves existing env vars rather than silently erasing them.
+/// Custom definition fields must surface in the catalog for edit round-trips.
#[test]
-fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() {
+fn custom_catalog_entry_carries_definition_fields_for_edit_roundtrip() {
use crate::managed_agents::custom_harnesses::registry_test_lock;
use crate::managed_agents::discovery::discover_acp_runtimes_from;
use std::{collections::BTreeMap, fs};
use tempfile::tempdir;
- // Discovery's auth probes read/warm the process-global PATH and
- // login-shell-PATH caches, and its final step publishes to the global
- // harness registry — hold both guards so parallel tests (e.g. the
- // PATH-swapping resolution tests) can't observe or absorb torn state.
- // Lock order for tests that need both: path lock first, then registry.
+ // Discovery probes PATH and publishes the global harness registry.
let _path_guard = crate::managed_agents::lock_path_mutex();
let _lock = registry_test_lock();
let dir = tempdir().unwrap();
- // Write a custom definition with two env vars.
fs::write(
dir.path().join("env-harness.json"),
r#"{
"id": "env-harness",
"label": "Env Harness",
"command": "env-harness-bin",
+ "mcpCommand": "read-only-mcp",
"args": [],
"env": { "CURSOR_ACP": "1", "MY_TOKEN": "abc" }
}"#,
@@ -1703,6 +1695,11 @@ fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() {
entry.definition_env, expected,
"catalog entry must carry definition env vars so the edit form can read them back"
);
+ assert_eq!(
+ entry.mcp_command.as_deref(),
+ Some("read-only-mcp"),
+ "catalog entry must carry the MCP command so the edit form preserves it"
+ );
}
/// A builtin catalog entry must have an empty `definition_env` — their env
@@ -1766,6 +1763,7 @@ fn harness_def(
label: label.to_string(),
command: command.to_string(),
args: vec![],
+ mcp_command: None,
env: Default::default(),
install_instructions_url: String::new(),
install_hint: String::new(),
diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs
index fa8eb36fa1..7279fb31aa 100644
--- a/desktop/src-tauri/src/managed_agents/readiness.rs
+++ b/desktop/src-tauri/src/managed_agents/readiness.rs
@@ -78,18 +78,7 @@ pub(crate) struct EffectiveAgentEnv {
pub effective_command: String,
}
-// ── Typed effective-harness descriptor ───────────────────────────────────────
-//
-// A single owned type that fully describes what a spawn would run. Produced
-// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child,
-// spawn_config_hash, build_managed_agent_summary, get_agent_models, and
-// agent_readiness — so the harness-definition lookup and arg/env resolution
-// happen exactly once, in one place.
-
-/// The complete effective description of a harness spawn: resolved command,
-/// args, and layered env. This is the single source of truth for what will
-/// actually run — computed once and shared across every consumer that needs
-/// the effective values.
+/// Complete effective description of a harness spawn.
#[derive(Debug, Clone)]
pub(crate) struct EffectiveHarnessDescriptor {
/// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`).
@@ -98,15 +87,17 @@ pub(crate) struct EffectiveHarnessDescriptor {
/// Normalized effective args. Instance args win when non-empty; otherwise
/// the harness definition's args apply.
pub args: Vec,
+ /// MCP sidecar from the custom definition or compiled runtime metadata.
+ pub mcp_command: Option,
/// The full layered process env: baked floor → runtime metadata → definition
/// env → global → persona → agent.
pub env: BTreeMap,
}
/// Resolve the complete harness descriptor from a record + context — the single
-/// authoritative path for command, args, and env.
+/// authoritative path for command, args, MCP sidecar, and env.
///
-/// This is the only place where harness-definition lookup and arg/env layering
+/// This is the only place where harness lookup and capability/env layering
/// happen; spawn, hash, summary, and both model-probe paths all consume this.
///
/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked
@@ -161,14 +152,23 @@ pub(crate) fn resolve_effective_harness_descriptor(
}
};
- // Env: full layered resolution (same as resolve_effective_agent_env).
- // Pass harness_def directly to avoid a second lookup.
+ // Custom definition wins; built-ins fall back to compiled metadata.
+ let mcp_command = harness_def
+ .as_ref()
+ .and_then(|definition| definition.mcp_command.clone())
+ .or_else(|| {
+ runtime_meta
+ .and_then(|runtime| runtime.mcp_command)
+ .map(str::to_string)
+ });
+
let effective_env =
resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def);
Ok(EffectiveHarnessDescriptor {
command: effective_command,
args,
+ mcp_command,
env: effective_env.env,
})
}
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 37927961ed..5243eb2770 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -289,13 +289,11 @@ pub fn build_managed_agent_summary(
crate::managed_agents::readiness::EffectiveHarnessDescriptor {
command: cmd,
args,
+ mcp_command: None,
env: Default::default(),
}
});
- let effective_mcp_command = known_acp_runtime(&descriptor.command)
- .and_then(|r| r.mcp_command)
- .unwrap_or("")
- .to_string();
+ let effective_mcp_command = descriptor.mcp_command.clone().unwrap_or_default();
Ok(ManagedAgentSummary {
pubkey: record.pubkey.clone(),
@@ -464,9 +462,9 @@ pub fn spawn_agent_child(
let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
// Resolve the effective harness (agent command) from the linked persona, so
// persona harness edits propagate on the next spawn; an explicit per-agent
- // override wins. `agent_args` and `mcp_command` are pure derivations of the
- // command, so we recompute them from the effective value rather than the
- // frozen record snapshot. Mirrors the model resolution below.
+ // override wins. `agent_args` and `mcp_command` are resolved from the
+ // effective harness descriptor rather than the frozen record snapshot.
+ // Mirrors the model resolution below.
let personas = super::load_personas(app).unwrap_or_default();
let teams = super::load_teams(app).unwrap_or_default();
// Load global config once; used for runtime_metadata_env_vars (model/provider fallback)
@@ -524,9 +522,7 @@ pub fn spawn_agent_child(
.map_err(|error| format!("failed to clone log handle: {error}"))?;
let resolved_acp_command = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
- let effective_mcp_command = known_acp_runtime(effective_command)
- .and_then(|r| r.mcp_command)
- .unwrap_or("");
+ let effective_mcp_command = descriptor.mcp_command.as_deref().unwrap_or("");
let resolved_mcp_command: Option = if effective_mcp_command.is_empty() {
None
} else {
diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs
index 648cc62bbe..5f1467567f 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs
@@ -29,7 +29,7 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use super::{
effective_config::{resolve_effective_config, EffectiveConfigResult},
- known_acp_runtime, normalize_agent_args,
+ normalize_agent_args,
persona_events::preview_prospective_persona_snapshot,
runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR},
types::{AgentDefinition, ManagedAgentRecord, TeamRecord},
@@ -83,10 +83,10 @@ pub(crate) fn spawn_config_hash(
crate::managed_agents::readiness::EffectiveHarnessDescriptor {
command: cmd,
args,
+ mcp_command: None,
env: Default::default(),
}
});
- let runtime_meta = known_acp_runtime(&descriptor.command);
let mut hasher = DefaultHasher::new();
@@ -94,10 +94,7 @@ pub(crate) fn spawn_config_hash(
record.acp_command.hash(&mut hasher);
descriptor.command.hash(&mut hasher);
descriptor.args.hash(&mut hasher);
- runtime_meta
- .and_then(|r| r.mcp_command)
- .unwrap_or("")
- .hash(&mut hasher);
+ descriptor.mcp_command.hash(&mut hasher);
// Effective env layering (baked floor → runtime metadata → definition env
// → global → persona → agent). BTreeMap iteration is ordered, deterministic.
diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs
index f4ad404814..d41d5e4328 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs
@@ -727,6 +727,49 @@ fn spawn_hash_changes_when_definition_env_changes() {
assert_ne!(h1, h2, "adding definition env must change the spawn hash");
}
+/// Editing a custom definition's MCP sidecar changes the spawn hash, so a
+/// running agent is marked for restart before the new sidecar can take effect.
+#[test]
+fn spawn_hash_changes_when_definition_mcp_command_changes() {
+ use crate::managed_agents::custom_harnesses::{
+ registry_test_lock, warm_harness_registry_from_dir,
+ };
+ use std::fs;
+ use tempfile::tempdir;
+
+ let _lock = registry_test_lock();
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("mcp-def.json");
+
+ fs::write(
+ &path,
+ r#"{"id":"mcp-def","label":"MCP Def","command":"agent-bin","mcpCommand":"mcp-v1"}"#,
+ )
+ .unwrap();
+ warm_harness_registry_from_dir(Some(dir.path()));
+
+ let mut r = record();
+ r.runtime = Some("mcp-def".into());
+ let descriptor =
+ crate::managed_agents::resolve_effective_harness_descriptor(&r, &[], &Default::default())
+ .unwrap();
+ assert_eq!(descriptor.mcp_command.as_deref(), Some("mcp-v1"));
+ let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default());
+
+ fs::write(
+ &path,
+ r#"{"id":"mcp-def","label":"MCP Def","command":"agent-bin","mcpCommand":"mcp-v2"}"#,
+ )
+ .unwrap();
+ warm_harness_registry_from_dir(Some(dir.path()));
+
+ let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default());
+ assert_ne!(
+ h1, h2,
+ "changing the MCP command must change the spawn hash"
+ );
+}
+
/// Instance-level args win over definition default args (non-empty instance
/// args must NOT be overridden by the definition). The hash must match a record
/// that has the same effective args from either source.
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index d9222c7032..0340212c38 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -12,8 +12,10 @@ Plan of record: `Buzz/Harness-Provider-Model.md` in Morgan's Obsidian vault
**Harness capability facts have exactly one source: the Rust runtime catalog.**
`KnownAcpRuntime` (`desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs`)
-declares each harness's model/provider/effort env keys and capabilities. Spawn
-applies them; `AcpRuntimeCatalogEntry` exposes them over IPC; and
+declares each built-in harness's model/provider/effort env keys and capabilities;
+user-owned custom harness definitions may declare their optional MCP sidecar.
+Spawn resolves both through the effective harness descriptor;
+`AcpRuntimeCatalogEntry` exposes them over IPC; and
`lib/agentConfigCore.ts` projects them into field descriptors. The frontend
never maintains a rival copy of this table. Setup guidance follows the same
rule: `requires_external_cli` is derived from `KnownAcpRuntime` and projected
diff --git a/desktop/src/features/settings/ui/CustomHarnessForm.tsx b/desktop/src/features/settings/ui/CustomHarnessForm.tsx
index 52e7906265..b58db87bf6 100644
--- a/desktop/src/features/settings/ui/CustomHarnessForm.tsx
+++ b/desktop/src/features/settings/ui/CustomHarnessForm.tsx
@@ -28,6 +28,7 @@ export const EMPTY_CUSTOM_FORM: CustomFormValues = {
id: "",
label: "",
command: "",
+ mcpCommand: "",
args: [],
env: [],
installInstructionsUrl: "",
@@ -36,15 +37,25 @@ export const EMPTY_CUSTOM_FORM: CustomFormValues = {
// ── Inline command validation ─────────────────────────────────────────────────
-function CommandAvailabilityBadge({ command }: { command: string }) {
+function CommandAvailabilityBadge({
+ command,
+ kind = "acp",
+}: {
+ command: string;
+ kind?: "acp" | "mcp";
+}) {
const trimmed = command.trim();
- const prereqs = useManagedAgentPrereqsQuery(trimmed, "", {
- enabled: trimmed.length > 0,
- });
+ const prereqs = useManagedAgentPrereqsQuery(
+ kind === "acp" ? trimmed : "",
+ kind === "mcp" ? trimmed : "",
+ {
+ enabled: trimmed.length > 0,
+ },
+ );
if (!trimmed || prereqs.isLoading) return null;
- const available = prereqs.data?.acp.available;
+ const available = prereqs.data?.[kind].available;
if (available === undefined) return null;
return (
@@ -244,7 +255,12 @@ export function CustomHarnessForm({
function field(
key: keyof Pick<
CustomFormValues,
- "id" | "label" | "command" | "installInstructionsUrl" | "installHint"
+ | "id"
+ | "label"
+ | "command"
+ | "mcpCommand"
+ | "installInstructionsUrl"
+ | "installHint"
>,
) {
return (e: React.ChangeEvent) => {
@@ -384,6 +400,31 @@ export function CustomHarnessForm({
+
+
+
+
+
+
+
+
+
+ Starts this MCP sidecar only for agents using this harness.
+
+
+
Arguments
{
assert.equal(form.id, "my-harness");
assert.equal(form.label, "My Harness");
assert.equal(form.command, "my-harness");
+ assert.equal(form.mcpCommand, "my-mcp-server");
assert.deepEqual(form.args, ["acp", "--verbose"]);
assert.deepEqual(form.env, [
{ key: "FOO", value: "bar" },
@@ -211,6 +213,7 @@ test("editRoundTrip_openThenSaveWithoutChanges_losesNothing", () => {
id: FULL_ENTRY.id,
label: FULL_ENTRY.label,
command: FULL_ENTRY.command,
+ mcpCommand: FULL_ENTRY.mcpCommand,
args: FULL_ENTRY.defaultArgs,
env: FULL_ENTRY.definitionEnv,
installInstructionsUrl: FULL_ENTRY.installInstructionsUrl,
@@ -239,12 +242,14 @@ test("formValuesFromCatalogEntry_nullishOptionalFields_defaultsSafely", () => {
id: "min",
label: "Min",
command: null,
+ mcpCommand: null,
defaultArgs: undefined,
definitionEnv: undefined,
installInstructionsUrl: "",
installHint: "",
});
assert.equal(form.command, "");
+ assert.equal(form.mcpCommand, "");
assert.deepEqual(form.args, []);
assert.deepEqual(form.env, []);
});
@@ -254,6 +259,7 @@ test("definitionFromFormValues_trimsScalarFields", () => {
id: " my-id ",
label: " Label ",
command: " cmd ",
+ mcpCommand: " mcp-cmd ",
args: ["keep", " "],
env: [{ key: " K ", value: "v" }],
installInstructionsUrl: " https://x ",
@@ -262,12 +268,27 @@ test("definitionFromFormValues_trimsScalarFields", () => {
assert.equal(definition.id, "my-id");
assert.equal(definition.label, "Label");
assert.equal(definition.command, "cmd");
+ assert.equal(definition.mcpCommand, "mcp-cmd");
assert.deepEqual(definition.args, ["keep"]);
assert.deepEqual(definition.env, { K: "v" });
assert.equal(definition.installInstructionsUrl, "https://x");
assert.equal(definition.installHint, "hint");
});
+test("definitionFromFormValues_blankMcpCommand_omitsField", () => {
+ const definition = definitionFromFormValues({
+ id: "my-id",
+ label: "Label",
+ command: "cmd",
+ mcpCommand: " ",
+ args: [],
+ env: [],
+ installInstructionsUrl: "",
+ installHint: "",
+ });
+ assert.equal(definition.mcpCommand, undefined);
+});
+
// ── commaArgError ─────────────────────────────────────────────────────────────
test("commaArgError_noCommas_returnsNull", () => {
diff --git a/desktop/src/features/settings/ui/harnessFormLogic.ts b/desktop/src/features/settings/ui/harnessFormLogic.ts
index 8332f8ae71..ba659cd093 100644
--- a/desktop/src/features/settings/ui/harnessFormLogic.ts
+++ b/desktop/src/features/settings/ui/harnessFormLogic.ts
@@ -73,6 +73,7 @@ export interface CustomFormValues {
id: string;
label: string;
command: string;
+ mcpCommand: string;
/** Each element is one argument; no space-splitting round-trip. */
args: string[];
/** KEY=VALUE pairs for env injection at spawn time. */
@@ -86,6 +87,7 @@ export interface EditableCatalogEntry {
id: string;
label: string;
command: string | null;
+ mcpCommand: string | null;
defaultArgs: string[] | undefined;
definitionEnv?: Record;
installInstructionsUrl: string;
@@ -97,6 +99,7 @@ export interface HarnessDefinitionPayload {
id: string;
label: string;
command: string;
+ mcpCommand?: string;
args: string[];
env: Record;
installInstructionsUrl: string;
@@ -118,6 +121,7 @@ export function formValuesFromCatalogEntry(
id: entry.id,
label: entry.label,
command: entry.command ?? "",
+ mcpCommand: entry.mcpCommand ?? "",
args: entry.defaultArgs ?? [],
env: envPairsFromRecord(entry.definitionEnv),
installInstructionsUrl: entry.installInstructionsUrl,
@@ -133,6 +137,7 @@ export function definitionFromFormValues(
id: form.id.trim(),
label: form.label.trim(),
command: form.command.trim(),
+ mcpCommand: form.mcpCommand.trim() || undefined,
args: filterArgs(form.args),
env: buildEnvRecord(form.env),
installInstructionsUrl: form.installInstructionsUrl.trim(),
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 69e2e455ec..c3a287bf95 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -923,18 +923,17 @@ export async function discoverAcpRuntimes(): Promise {
).map(fromRawAcpRuntimeCatalogEntry);
}
-/** Input shape for creating or updating a custom harness. */
export type HarnessDefinitionInput = {
id: string;
label: string;
command: string;
+ mcpCommand?: string;
args?: string[];
env?: Record;
installInstructionsUrl?: string;
installHint?: string;
};
-/** Save (create or overwrite) a custom harness definition. Returns the catalog entry. */
export async function saveCustomHarness(
definition: HarnessDefinitionInput,
originalId?: string,
@@ -946,6 +945,7 @@ export async function saveCustomHarness(
id: definition.id,
label: definition.label,
command: definition.command,
+ mcpCommand: definition.mcpCommand,
args: definition.args ?? [],
env: definition.env ?? {},
installInstructionsUrl: definition.installInstructionsUrl ?? "",
diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs
index 0a0fb36a92..42b9076618 100644
--- a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs
+++ b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs
@@ -31,6 +31,7 @@ function makeArgs(overrides = {}) {
id: overrides.id ?? "test-harness",
label: overrides.label ?? "Test Harness",
command: overrides.command ?? "test-bin",
+ mcpCommand: overrides.mcpCommand,
args: overrides.args ?? [],
env: overrides.env ?? {},
installInstructionsUrl: overrides.installInstructionsUrl ?? "",
@@ -73,6 +74,17 @@ describe("handleSaveCustomHarness", () => {
);
});
+ it("preserves the optional MCP sidecar command", () => {
+ const entry = handleSaveCustomHarness(
+ makeArgs({ id: "mcp-rt", mcpCommand: "read-only-mcp" }),
+ );
+ assert.equal(entry.mcp_command, "read-only-mcp");
+ assert.equal(
+ mockCustomHarnesses.get("mcp-rt")?.mcp_command,
+ "read-only-mcp",
+ );
+ });
+
it("produces absent definition_env for empty env (mirrors Rust BTreeMap skip)", () => {
const entry = handleSaveCustomHarness(makeArgs({ id: "no-env", env: {} }));
assert.ok(
diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.ts b/desktop/src/testing/e2eBridgeCustomHarnesses.ts
index b72ecadd34..e3582dec45 100644
--- a/desktop/src/testing/e2eBridgeCustomHarnesses.ts
+++ b/desktop/src/testing/e2eBridgeCustomHarnesses.ts
@@ -61,6 +61,7 @@ export function handleSaveCustomHarness(args: {
id?: string;
label?: string;
command?: string;
+ mcpCommand?: string;
args?: string[];
env?: Record;
installInstructionsUrl?: string;
@@ -88,7 +89,7 @@ export function handleSaveCustomHarness(args: {
command: def.command ?? null,
binary_path: null,
default_args: def.args ?? [],
- mcp_command: null,
+ mcp_command: def.mcpCommand ?? null,
install_hint: def.installHint ?? "",
install_instructions_url: def.installInstructionsUrl ?? "",
can_auto_install: false,