From fb49f06c80a28205ea2ba3e9436240e7f2032dec Mon Sep 17 00:00:00 2001
From: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Date: Sat, 5 Sep 2026 20:04:45 -0700
Subject: [PATCH 1/9] feat(agents): make the MCP registry live in a shipped
build (T7c backend)
`converge` had no production caller and `GenerationStore::reconcile` never ran,
so `plan_for_spawn` found no adopted generation and the registry was inert in a
shipped build. This adds the missing half.
- `mcp_registry::apply` is the one place a registry edit or a per-agent toggle
becomes an adopted generation. It asserts at the wiring seam that the
launcher path is absolute, exists and is a regular file (PR 23 follow-up 2),
and it passes every managed agent, because a convergence is whole-set.
- `lib.rs` runs `reconcile_at_start` before any agent is restored, so a crash
mid-change no longer strands a revoked credential in the keychain.
- Five Tauri commands behind the panel and the toggles, each one atomic: the
document is rewritten whole and the configuration agents spawn from moves in
one pointer rename.
- `ManagedAgentRecord.mcp_servers` is the versioned enabled-server list, absent
distinct from empty (memo decision 8), and deliberately outside the kind:30177
projection.
- Secret values travel one way: `GenerationInputs.pending` carries what the
operator typed into the adopted generation's keyspace under the reserved
`mcp:` prefix. Nothing reads one back.
Sol carry-overs from the T7b audit: N8 (`GenerationPlan` and `Deletion` now
self-validate, so a journal on disk cannot name `identity` or `agent:*`), N9
(a staged artefact's type is read from `symlink_metadata` and anything but a
regular file is refused, so a planted FIFO cannot block a spawn forever), N10
(`journal.json.next` and `current.next` are created `O_NOFOLLOW`), W4 (the
desktop takes the stricter of the two sides' name, argument and env bounds, and
the generated argv count is bounded by what `buzz-acp` reads).
Also: PR 23 follow-up 3 (`write_atomically` fsyncs the temp file and the parent
directory), follow-up 4 (the confinement test asserts the cause), follow-up 6
and 8 (the memo records the shipped launcher behaviour and the `args` shape
decision). A staged generation directory is now created even when a plan stages
no files, which is what turning every server off produces.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012Z6iidtozXxgx58BUZUKnu
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
---
crates/buzz-acp/src/mcp_registry.rs | 28 +-
.../src/commands/agent_config_tests.rs | 1 +
desktop/src-tauri/src/commands/agents.rs | 1 +
.../src-tauri/src/commands/agents_tests.rs | 1 +
.../src-tauri/src/commands/mcp_registry.rs | 303 +++++
desktop/src-tauri/src/commands/mod.rs | 2 +
.../commands/personas/delete_cascade_tests.rs | 1 +
.../personas/inbound/inbound_tests.rs | 1 +
.../commands/personas/prompt_source/tests.rs | 1 +
.../personas/snapshot/fidelity_tests.rs | 1 +
.../src/commands/personas/snapshot/import.rs | 1 +
.../src/commands/personas/snapshot/tests.rs | 1 +
.../personas/update/name_propagation_tests.rs | 1 +
.../src-tauri/src/commands/team_snapshot.rs | 1 +
.../src/commands/team_snapshot/tests.rs | 1 +
desktop/src-tauri/src/lib.rs | 23 +
.../src/managed_agents/agent_events.rs | 1 +
.../managed_agents/agent_snapshot_envelope.rs | 1 +
.../managed_agents/agent_snapshot_tests.rs | 1 +
.../config_bridge/effort_tests.rs | 1 +
.../config_bridge/reader_tests.rs | 1 +
.../src/managed_agents/discovery/tests.rs | 1 +
.../managed_agents/effective_config/tests.rs | 1 +
.../src/managed_agents/global_config/tests.rs | 1 +
.../src/managed_agents/mcp_registry/apply.rs | 304 +++++
.../mcp_registry/apply_tests.rs | 1029 +++++++++++++++++
.../managed_agents/mcp_registry/converge.rs | 51 +-
.../managed_agents/mcp_registry/generate.rs | 30 +
.../managed_agents/mcp_registry/generation.rs | 198 +++-
.../src/managed_agents/mcp_registry/load.rs | 101 +-
.../src/managed_agents/mcp_registry/mod.rs | 6 +
.../src/managed_agents/mcp_registry/schema.rs | 47 +-
.../src/managed_agents/mcp_registry/spawn.rs | 107 +-
.../mcp_registry/wiring_tests.rs | 165 ++-
desktop/src-tauri/src/managed_agents/mod.rs | 4 +-
.../src/managed_agents/nest/render_tests.rs | 1 +
.../src/managed_agents/parallelism.rs | 1 +
.../managed_agents/persona_events/tests.rs | 1 +
.../src-tauri/src/managed_agents/readiness.rs | 1 +
.../src-tauri/src/managed_agents/runtime.rs | 2 +-
.../managed_agents/runtime/test_fixtures.rs | 1 +
.../managed_agents/spawn_snapshot/tests.rs | 1 +
.../src/managed_agents/team_snapshot.rs | 1 +
.../src/managed_agents/teams_tests.rs | 1 +
desktop/src-tauri/src/managed_agents/types.rs | 32 +
docs/plans/2026-09-04-mcp-registry-design.md | 8 +
46 files changed, 2369 insertions(+), 99 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/mcp_registry.rs
create mode 100644 desktop/src-tauri/src/managed_agents/mcp_registry/apply.rs
create mode 100644 desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs
diff --git a/crates/buzz-acp/src/mcp_registry.rs b/crates/buzz-acp/src/mcp_registry.rs
index 74225f3d617..2230cd266d0 100644
--- a/crates/buzz-acp/src/mcp_registry.rs
+++ b/crates/buzz-acp/src/mcp_registry.rs
@@ -892,18 +892,26 @@ mod tests {
let good = staged_path(base.path(), "agent-a", 7);
confine_registry_path(&good, &capability).expect("the staged path is accepted");
- for (path, why) in [
+ // Each case names the cause it must be refused for, not merely that it
+ // was refused (PR 23 follow-up 4). `..` in particular is refused by the
+ // shape check rather than by the tail comparison, and asserting only
+ // `is_err()` would keep passing if the shape check were deleted and the
+ // tail happened to disagree for an unrelated reason.
+ for (path, why, cause) in [
(
staged_path(base.path(), "agent-b", 7),
"another agent's directory",
+ "outside this agent's directory",
),
(
staged_path(base.path(), "agent-a", 6),
"a superseded generation",
+ "outside this agent's directory",
),
(
base.path().join("elsewhere").join(REGISTRY_FILE_NAME),
"a path outside the staging tree",
+ "outside this agent's directory",
),
(
base.path()
@@ -915,19 +923,21 @@ mod tests {
.join("agent-b")
.join(REGISTRY_FILE_NAME),
"a `..` traversal",
+ "holds a `.` or `..` component",
+ ),
+ (
+ PathBuf::from("relative/registry.json"),
+ "a relative path",
+ "is not an absolute path",
),
] {
+ let error = confine_registry_path(&path, &capability)
+ .expect_err(&format!("{why} was accepted: {}", path.display()));
assert!(
- confine_registry_path(&path, &capability).is_err(),
- "{why} was accepted: {}",
- path.display()
+ error.contains(cause),
+ "{why} was refused for the wrong reason; expected {cause:?}, got {error}"
);
}
-
- assert!(
- confine_registry_path(Path::new("relative/registry.json"), &capability).is_err(),
- "a relative path was accepted"
- );
}
/// The open refuses a symlink and anything that is not a regular file.
diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs
index 698f35d91a0..cf1c92cb219 100644
--- a/desktop/src-tauri/src/commands/agent_config_tests.rs
+++ b/desktop/src-tauri/src/commands/agent_config_tests.rs
@@ -74,6 +74,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime {
fn agent_record() -> ManagedAgentRecord {
ManagedAgentRecord {
+ mcp_servers: None,
description: None,
pubkey: "agent".to_string(),
name: "Agent".to_string(),
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 0ad7fd321c5..3752c74d0e3 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -661,6 +661,7 @@ pub async fn create_managed_agent(
last_exit_code: None,
last_error: None,
last_error_code: None,
+ mcp_servers: None,
respond_to: minted.respond_to,
respond_to_allowlist: minted.respond_to_allowlist.clone(),
display_name: None,
diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs
index 59e04b09ff0..927d4d8bb09 100644
--- a/desktop/src-tauri/src/commands/agents_tests.rs
+++ b/desktop/src-tauri/src/commands/agents_tests.rs
@@ -9,6 +9,7 @@ fn bare_agent_record(
use crate::managed_agents::{BackendKind, RespondTo};
use std::collections::BTreeMap;
ManagedAgentRecord {
+ mcp_servers: None,
description: None,
pubkey: "agent".to_string(),
name: "Agent".to_string(),
diff --git a/desktop/src-tauri/src/commands/mcp_registry.rs b/desktop/src-tauri/src/commands/mcp_registry.rs
new file mode 100644
index 00000000000..01331fd9e4a
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_registry.rs
@@ -0,0 +1,303 @@
+//! Tauri commands behind the MCP servers Settings panel and the per-agent
+//! toggles.
+//!
+//! Every command here is a *user action*, and each one is one atomic persist
+//! (AGENTS.md Review-Proven Rule 5): the registry document is rewritten whole,
+//! and the configuration every agent will spawn from moves in one pointer
+//! rename inside [`converge_now`]. A failure at any step leaves the previous
+//! generation adopted, so agents keep running the configuration they were
+//! started with rather than a half-applied one.
+//!
+//! Secret **values** travel one way. `save_mcp_registry_server` takes them,
+//! hands them straight to the convergence, which writes them under the
+//! reserved `mcp:` prefix bound to the generation it adopts. No command
+//! returns one, and the DTOs below carry reference *names* only.
+
+use std::collections::BTreeMap;
+
+use tauri::AppHandle;
+
+use crate::managed_agents::mcp_registry::apply;
+use crate::managed_agents::mcp_registry::apply::converge_now;
+use crate::managed_agents::mcp_registry::load::{load_registry, LoadedEntry};
+use crate::managed_agents::mcp_registry::schema::{
+ RegistryDocument, RegistryEntry, RegistryTransport, MAX_DOCUMENT_SERVERS,
+};
+use crate::managed_agents::types::{AgentMcpServers, AGENT_MCP_SERVERS_VERSION};
+use buzz_secret_store_pkg::{looks_like_reference, McpSecretRef};
+
+/// One registry entry as the panel renders it.
+///
+/// The approve step needs the *exact* command line or URL the operator is
+/// about to authorize, so it is projected verbatim. What is never projected is
+/// a secret value: `env` is reduced to the variable name and the reference it
+/// names, so a value cannot reach the renderer even if one were somehow
+/// stored inline.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct McpRegistryEntryView {
+ /// Stable id; the agent record's enabled list refers to this.
+ pub id: String,
+ /// Display name, which is also the generated config key.
+ pub name: String,
+ /// `"stdio"` or `"http"`.
+ pub transport: String,
+ /// Absolute command path for a stdio entry, else `None`.
+ pub command: Option,
+ /// Command arguments for a stdio entry.
+ pub args: Vec,
+ /// Upstream URL for an http entry, else `None`.
+ pub url: Option,
+ /// Auth scheme for an http entry that declares one.
+ pub auth_scheme: Option,
+ /// Declared environment, as `(name, reference-or-literal)` pairs. A
+ /// reference is the `mcp:` spelling; a literal is one the sentinel
+ /// scan already cleared as non-credential.
+ pub env: Vec,
+ /// The loader's reason this entry is disabled, or `None` when it is
+ /// usable. Rendered beside the entry, and it is the same string a spawn
+ /// refuses with.
+ pub rejection: Option,
+}
+
+/// One declared environment entry, names only.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct McpRegistryEnvView {
+ /// Variable name.
+ pub name: String,
+ /// The `mcp:` reference, when this entry names one.
+ pub reference: Option,
+ /// The literal value, when this entry carries one. Only values the
+ /// sentinel scan cleared as non-credential ever reach here; a
+ /// credential-shaped literal rejects the entry at load.
+ pub literal: Option,
+}
+
+/// What the panel loads.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct McpRegistryView {
+ /// Every declared entry, in document order, each with its status.
+ pub servers: Vec,
+ /// Absolute path of the document, for the "reveal in Finder" affordance.
+ pub document_path: String,
+}
+
+fn view_of(loaded: &LoadedEntry) -> McpRegistryEntryView {
+ let entry = &loaded.entry;
+ let (transport, command, args, url, auth_scheme) = match &entry.transport {
+ RegistryTransport::Stdio { command, args } => {
+ ("stdio", Some(command.clone()), args.clone(), None, None)
+ }
+ RegistryTransport::Http { url, auth } => (
+ "http",
+ None,
+ Vec::new(),
+ Some(url.clone()),
+ auth.as_ref().map(|auth| auth.scheme.clone()),
+ ),
+ };
+ McpRegistryEntryView {
+ id: entry.id.clone(),
+ name: entry.name.clone(),
+ transport: transport.to_string(),
+ command,
+ args,
+ url,
+ auth_scheme,
+ env: entry
+ .env
+ .iter()
+ .map(|(name, value)| {
+ let is_reference = looks_like_reference(value);
+ McpRegistryEnvView {
+ name: name.clone(),
+ reference: is_reference.then(|| value.clone()),
+ literal: (!is_reference).then(|| value.clone()),
+ }
+ })
+ .collect(),
+ rejection: loaded.rejection.clone(),
+ }
+}
+
+fn document_path(app: &AppHandle) -> Result {
+ let paths = apply::registry_paths(app)?.ok_or_else(|| {
+ "this build cannot resolve the agent working directory, so mcp server settings are \
+ unavailable"
+ .to_string()
+ })?;
+ Ok(paths.document())
+}
+
+/// Read the registry document and its per-entry status.
+///
+/// # Errors
+/// A message when the document breaches a whole-document rule (a duplicate id
+/// or name, or a byte cap). A per-entry failure is not an error: the entry is
+/// returned with its `rejection` string, which is the same message a spawn
+/// refuses with.
+#[tauri::command]
+pub fn list_mcp_registry_servers(app: AppHandle) -> Result {
+ let path = document_path(&app)?;
+ let registry = load_registry(&path).map_err(|e| e.to_string())?;
+ Ok(McpRegistryView {
+ servers: registry.entries.iter().map(view_of).collect(),
+ document_path: path.display().to_string(),
+ })
+}
+
+/// Insert or replace one registry entry, then adopt a new generation.
+///
+/// `secrets` maps a reference id (the part after `mcp:`) to the value the
+/// operator typed. It is consumed here and never read back.
+///
+/// The order is deliberate and every prefix of it is a consistent state: the
+/// entry is validated first, so a rejected one changes nothing; the document
+/// is written next, which no running agent reads; and the convergence is last,
+/// because it is the single write — one pointer rename — that changes what an
+/// agent will spawn with. A failure at the convergence leaves the previous
+/// generation adopted and the new entry visible but unadopted, which is what
+/// the panel then shows.
+///
+/// # Errors
+/// A message when the entry is unusable, when the document cannot be written,
+/// or when the convergence fails.
+#[tauri::command]
+pub fn save_mcp_registry_server(
+ app: AppHandle,
+ entry: RegistryEntry,
+ secrets: BTreeMap,
+) -> Result {
+ for id in secrets.keys() {
+ // The reference id is operator-typed, so it is validated against the
+ // same closed namespace a generated config uses. `identity` and
+ // `agent:*` are refused there, which is what stops a typed reference
+ // from naming a private key's blob record.
+ McpSecretRef::parse(&format!("mcp:{id}"))
+ .map_err(|e| format!("`{id}` is not a usable secret name: {e}"))?;
+ }
+ let path = document_path(&app)?;
+ let mut document = read_document(&path)?;
+ match document.servers.iter().position(|e| e.id == entry.id) {
+ Some(index) => document.servers[index] = entry,
+ None => {
+ if document.servers.len() >= MAX_DOCUMENT_SERVERS {
+ return Err(format!(
+ "the registry already declares {MAX_DOCUMENT_SERVERS} servers, which is the cap"
+ ));
+ }
+ document.servers.push(entry);
+ }
+ }
+ write_document(&path, &document)?;
+ converge_now(&app, &secrets)?;
+ list_mcp_registry_servers(app)
+}
+
+/// Delete one registry entry, drop its id from every agent, and adopt a new
+/// generation.
+///
+/// The agent records are rewritten *before* the convergence, so the generation
+/// this call adopts is built from the records as they now are. The reverse
+/// order would stage a generation naming a server no record enables any more,
+/// and the deleted server's credential would be carried onto it.
+///
+/// # Errors
+/// A message when the document or the agent store cannot be written, or when
+/// the convergence fails.
+#[tauri::command]
+pub fn delete_mcp_registry_server(app: AppHandle, id: String) -> Result {
+ let path = document_path(&app)?;
+ let mut document = read_document(&path)?;
+ document.servers.retain(|entry| entry.id != id);
+ let mut records = crate::managed_agents::load_managed_agents(&app)?;
+ let mut touched = false;
+ for record in &mut records {
+ if let Some(selection) = record.mcp_servers.as_mut() {
+ let before = selection.enabled.len();
+ selection.enabled.retain(|enabled| enabled != &id);
+ touched |= selection.enabled.len() != before;
+ }
+ }
+ if touched {
+ crate::managed_agents::save_managed_agents(&app, &records)?;
+ }
+ write_document(&path, &document)?;
+ converge_now(&app, &BTreeMap::new())?;
+ list_mcp_registry_servers(app)
+}
+
+/// Set one agent's enabled registry servers, then adopt a new generation.
+///
+/// Writing the value — rather than clearing it when the list is empty — is
+/// what makes memo decision 8's absent-versus-empty distinction real: an
+/// operator who turns every server off leaves `Some([])`, which is a decision,
+/// while a record that never reached this command keeps `None`.
+///
+/// # Errors
+/// A message when the agent is unknown, when the store cannot be written, or
+/// when the convergence fails — including the refusal an unsupported transport
+/// produces, which is surfaced rather than silently dropping the entry.
+#[tauri::command]
+pub fn set_agent_mcp_servers(
+ app: AppHandle,
+ pubkey: String,
+ enabled: Vec,
+) -> Result {
+ let mut records = crate::managed_agents::load_managed_agents(&app)?;
+ let record = records
+ .iter_mut()
+ .find(|record| record.pubkey == pubkey)
+ .ok_or_else(|| format!("no agent with pubkey {pubkey}"))?;
+ record.mcp_servers = Some(AgentMcpServers {
+ version: AGENT_MCP_SERVERS_VERSION,
+ enabled,
+ });
+ crate::managed_agents::save_managed_agents(&app, &records)?;
+ converge_now(&app, &BTreeMap::new())?;
+ list_mcp_registry_servers(app)
+}
+
+/// One agent's current selection, for the definition dialog.
+///
+/// # Errors
+/// A message when the agent store cannot be read.
+#[tauri::command]
+pub fn get_agent_mcp_servers(
+ app: AppHandle,
+ pubkey: String,
+) -> Result
) : null;
- const advancedFieldsTransition = shouldReduceMotion
- ? { duration: 0 }
- : ADVANCED_FIELDS_MOTION_TRANSITION;
+ const advancedFieldsTransition = advancedFieldsMotion(shouldReduceMotion);
React.useEffect(() => {
if (
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
index 205cf13a449..bd4c64b0bab 100644
--- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
@@ -30,7 +30,7 @@ import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
import { EffortPickerField } from "./EffortPickerField";
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
import {
- ADVANCED_FIELDS_MOTION_TRANSITION,
+ advancedFieldsMotion,
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
@@ -901,9 +901,7 @@ export function AgentInstanceEditDialog({
const previewLabel = name.trim() || "Agent name";
const previewAvatarUrl = avatarUrl.trim() || null;
- const advancedFieldsTransition = shouldReduceMotion
- ? { duration: 0 }
- : ADVANCED_FIELDS_MOTION_TRANSITION;
+ const advancedFieldsTransition = advancedFieldsMotion(shouldReduceMotion);
// Displayed inline when either the locked update or a standalone setter fails.
// setterError takes precedence — the update already committed when it fires.
const displayError =
@@ -1173,6 +1171,7 @@ export function AgentInstanceEditDialog({
transition={advancedFieldsTransition}
>
void;
+ /** `null` for a definition that has not been instantiated yet. */
+ pubkey: string | null;
+ runtime: AcpRuntimeCatalogEntry | null;
+}) {
+ const registry = useMcpRegistryQuery();
+ const queryClient = useQueryClient();
+ const [refusal, setRefusal] = React.useState(null);
+ const selection = enabled ?? [];
+ const servers = registry.data?.servers ?? [];
+
+ if (registry.isError) {
+ return (
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/AgentMcpServersSection.tsx b/desktop/src/features/agents/ui/AgentMcpServersSection.tsx
new file mode 100644
index 00000000000..bb22c0eefec
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentMcpServersSection.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+
+import { getAgentMcpServers } from "@/shared/api/tauriMcpRegistry";
+import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
+
+import { AgentMcpServersField } from "./AgentMcpServersField";
+
+/**
+ * The MCP registry section of the agent edit dialog (memo decision 8).
+ *
+ * The selection lives on this agent's record, so it belongs on the instance
+ * surface — a definition has no pubkey to write to. Each toggle writes the
+ * record and adopts a configuration generation, which is the single write that
+ * changes what the agent's next spawn reads; staging that behind the dialog's
+ * unrelated Save would leave the panel and the agent disagreeing about what is
+ * enabled.
+ *
+ * `null` is "this record has never been configured", which memo decision 8
+ * keeps distinct from an empty list. A failed load stays `null` rather than
+ * becoming `[]`: guessing the other state here would write the wrong one on
+ * the next toggle.
+ */
+export function AgentMcpServersSection({
+ open,
+ pubkey,
+ runtime,
+}: {
+ open: boolean;
+ pubkey: string;
+ runtime: AcpRuntimeCatalogEntry | null;
+}) {
+ const [enabled, setEnabled] = React.useState(null);
+
+ React.useEffect(() => {
+ if (!open) return;
+ let current = true;
+ void getAgentMcpServers(pubkey)
+ .then((selection) => {
+ if (current) setEnabled(selection);
+ })
+ .catch(() => {
+ if (current) setEnabled(null);
+ });
+ return () => {
+ current = false;
+ };
+ }, [open, pubkey]);
+
+ return (
+
+
MCP servers
+
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx
index 9577c6b0b4e..f28d6947349 100644
--- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx
+++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx
@@ -29,7 +29,10 @@ import {
type RuntimeCatalogStatus,
} from "../lib/agentConfigCore";
+import { AgentMcpServersSection } from "./AgentMcpServersSection";
+
export function EditAgentAdvancedFields({
+ agentPubkey,
acpCommand,
agentArgs,
autoRestartOnConfigChange,
@@ -57,6 +60,8 @@ export function EditAgentAdvancedFields({
onAutoRestartChange,
onSystemPromptChange,
}: {
+ /** The agent record this dialog edits; MCP registry toggles write to it. */
+ agentPubkey: string;
acpCommand: string;
agentArgs: string;
autoRestartOnConfigChange: boolean;
@@ -380,6 +385,12 @@ export function EditAgentAdvancedFields({
provider={provider}
/>
) : null}
+
+
);
}
diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx
index 5c515a05073..392035cc69f 100644
--- a/desktop/src/features/agents/ui/agentConfigOptions.tsx
+++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx
@@ -36,6 +36,16 @@ export const ADVANCED_FIELDS_MOTION_TRANSITION = {
ease: [0.23, 1, 0.32, 1],
} as const;
+/**
+ * The advanced-fields transition for one dialog, honouring reduced motion.
+ *
+ * Both agent dialogs made the same choice inline; this is the second
+ * repetition, so it moves here beside the easing it selects.
+ */
+export function advancedFieldsMotion(reduceMotion: boolean | null) {
+ return reduceMotion ? { duration: 0 } : ADVANCED_FIELDS_MOTION_TRANSITION;
+}
+
export const AUTO_MODEL_DROPDOWN_VALUE = "__auto_model__";
export const CUSTOM_MODEL_DROPDOWN_VALUE = "__custom_model__";
export const AUTO_PROVIDER_DROPDOWN_VALUE = "__auto_provider__";
diff --git a/desktop/src/features/agents/ui/agentMcpServersField.test.mjs b/desktop/src/features/agents/ui/agentMcpServersField.test.mjs
new file mode 100644
index 00000000000..7e750e88196
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentMcpServersField.test.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { supportBadge } from "./AgentMcpServersField";
+import { serverSupport } from "@/features/settings/ui/mcpRegistryLogic";
+
+function entry(overrides = {}) {
+ return {
+ id: "fake",
+ name: "fake",
+ transport: "stdio",
+ command: "/usr/local/bin/fake-mcp",
+ args: ["--stdio"],
+ url: null,
+ auth_scheme: null,
+ env: [],
+ rejection: null,
+ ...overrides,
+ };
+}
+
+const BUZZ_AGENT = {
+ id: "buzz-agent",
+ label: "Buzz Agent",
+ mcpTransports: ["stdio"],
+};
+
+const CLAUDE = {
+ id: "claude",
+ label: "Claude",
+ mcpTransports: ["stdio", "http"],
+};
+
+test("a usable stdio entry on a stdio runtime gets no badge", () => {
+ assert.equal(supportBadge(serverSupport(entry(), BUZZ_AGENT)), null);
+});
+
+test("an http entry on a buzz-agent runtime is badged Unsupported", () => {
+ const http = entry({ id: "remote", name: "remote", transport: "http" });
+ const badge = supportBadge(serverSupport(http, BUZZ_AGENT));
+ assert.deepEqual(badge, { label: "Unsupported", tone: "warn" });
+});
+
+test("the same http entry on a runtime whose catalog declares http is not badged", () => {
+ const http = entry({ id: "remote", name: "remote", transport: "http" });
+ assert.equal(supportBadge(serverSupport(http, CLAUDE)), null);
+});
+
+test("a loader-rejected entry is badged Disabled whatever the runtime", () => {
+ const rejected = entry({ rejection: "its command is not an absolute path" });
+ assert.deepEqual(supportBadge(serverSupport(rejected, CLAUDE)), {
+ label: "Disabled",
+ tone: "warn",
+ });
+});
+
+test("an agent whose harness the registry cannot configure is badged, not hidden", () => {
+ assert.deepEqual(supportBadge(serverSupport(entry(), null)), {
+ label: "Not configurable",
+ tone: "warn",
+ });
+});
diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
index b2fde755ff2..e57ef8fd320 100644
--- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
+++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
@@ -5,6 +5,7 @@ import {
} from "@/features/messages/lib/autoPinMentionedAgentsPreference";
import { Switch } from "@/shared/ui/switch";
import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
+import { McpServersSettingsPanel } from "./McpServersSettingsPanel";
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
import {
SettingsOptionGroup,
@@ -50,6 +51,7 @@ export function AgentsSettingsPanel() {
+
diff --git a/desktop/src/features/settings/ui/McpServersSettingsPanel.tsx b/desktop/src/features/settings/ui/McpServersSettingsPanel.tsx
new file mode 100644
index 00000000000..ef15750ecfa
--- /dev/null
+++ b/desktop/src/features/settings/ui/McpServersSettingsPanel.tsx
@@ -0,0 +1,453 @@
+import * as React from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Plus, ShieldAlert, Trash2 } from "lucide-react";
+
+import {
+ deleteMcpRegistryServer,
+ listMcpRegistryServers,
+ saveMcpRegistryServer,
+ type McpRegistryEntry,
+} from "@/shared/api/tauriMcpRegistry";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { cn } from "@/shared/lib/cn";
+
+import {
+ approvalSummary,
+ draftProblem,
+ draftToInput,
+ emptyDraft,
+ entryToDraft,
+ type McpServerDraft,
+} from "./mcpRegistryLogic";
+import { SettingsOptionGroup } from "./SettingsOptionGroup";
+
+/** Query key for the registry document, so a save invalidates every reader. */
+export const MCP_REGISTRY_QUERY_KEY = ["mcp-registry"] as const;
+
+/** Read the registry document and each entry's status. */
+export function useMcpRegistryQuery() {
+ return useQuery({
+ queryKey: MCP_REGISTRY_QUERY_KEY,
+ queryFn: listMcpRegistryServers,
+ });
+}
+
+function TransportChoice({
+ draft,
+ onChange,
+}: {
+ draft: McpServerDraft;
+ onChange: (draft: McpServerDraft) => void;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * The approve step.
+ *
+ * Nothing is written until the operator has seen the exact command line, or
+ * the exact URL, plus the *name* of every credential the entry will resolve.
+ * The values themselves are in `draft.secrets` and reach only the save call.
+ */
+function ApproveStep({
+ draft,
+ onBack,
+ onConfirm,
+ pending,
+}: {
+ draft: McpServerDraft;
+ onBack: () => void;
+ onConfirm: () => void;
+ pending: boolean;
+}) {
+ const summary = approvalSummary(draft);
+ return (
+
+
{summary.headline}
+
+ {summary.target}
+
+ {summary.references.length > 0 ? (
+
+
+ It resolves these credentials by name. The values stay in the
+ keychain and are never written to a config file:
+
+ {`Saving stores ${summary.newSecrets.length} new credential value${summary.newSecrets.length === 1 ? "" : "s"} (${summary.newSecrets.join(", ")}). You will not be able to read them back.`}
+
+ ) : null}
+ {servers.map((entry) => (
+ remove.mutate(entry.id)}
+ onEdit={() => {
+ setDraft(entryToDraft(entry));
+ setApproving(false);
+ }}
+ />
+ ))}
+ {draft !== null && !approving ? (
+ setDraft(null)}
+ onChange={setDraft}
+ onReview={() => setApproving(true)}
+ />
+ ) : null}
+ {draft !== null && approving ? (
+ setApproving(false)}
+ onConfirm={() => save.mutate(draft)}
+ pending={save.isPending}
+ />
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/settings/ui/mcpRegistryLogic.test.mjs b/desktop/src/features/settings/ui/mcpRegistryLogic.test.mjs
new file mode 100644
index 00000000000..da065979653
--- /dev/null
+++ b/desktop/src/features/settings/ui/mcpRegistryLogic.test.mjs
@@ -0,0 +1,297 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ MCP_REGISTRY_LIMITS,
+ approvalSummary,
+ draftProblem,
+ draftToInput,
+ emptyDraft,
+ entryToDraft,
+ serverSupport,
+ toggleServer,
+} from "./mcpRegistryLogic";
+
+const NUL = String.fromCharCode(0);
+
+function stdioDraft(overrides = {}) {
+ return {
+ ...emptyDraft(),
+ id: "fake",
+ name: "fake",
+ transport: "stdio",
+ command: "/usr/local/bin/fake-mcp",
+ argsText: "--stdio\n--port\n7777",
+ ...overrides,
+ };
+}
+
+function httpDraft(overrides = {}) {
+ return {
+ ...emptyDraft(),
+ id: "remote",
+ name: "remote",
+ transport: "http",
+ url: "https://mcp.example/v1",
+ authScheme: "bearer",
+ authSecretName: "remote-token",
+ secrets: { "remote-token": "sk-live-do-not-render" },
+ ...overrides,
+ };
+}
+
+function entry(overrides = {}) {
+ return {
+ id: "fake",
+ name: "fake",
+ transport: "stdio",
+ command: "/usr/local/bin/fake-mcp",
+ args: ["--stdio"],
+ url: null,
+ auth_scheme: null,
+ env: [],
+ rejection: null,
+ ...overrides,
+ };
+}
+
+const BUZZ_AGENT = {
+ id: "buzz-agent",
+ label: "Buzz Agent",
+ mcpTransports: ["stdio"],
+};
+
+const CLAUDE = {
+ id: "claude",
+ label: "Claude",
+ mcpTransports: ["stdio", "http"],
+};
+
+// ── The approve step ──────────────────────────────────────────────────────
+
+test("the approve step shows the exact command line that will run", () => {
+ const summary = approvalSummary(stdioDraft());
+ assert.equal(
+ summary.target,
+ "/usr/local/bin/fake-mcp --stdio --port 7777",
+ "the operator must approve the command as it will actually be spawned, not a summary of it",
+ );
+ assert.match(summary.headline, /starts a process/);
+});
+
+test("the approve step shows the exact URL for an http entry", () => {
+ const summary = approvalSummary(httpDraft());
+ assert.equal(summary.target, "https://mcp.example/v1");
+ assert.match(summary.headline, /sends requests/);
+});
+
+test("the approve step names references and never a secret value", () => {
+ const draft = httpDraft({
+ env: [{ name: "API_KEY", reference: "fake-api-key" }],
+ secrets: {
+ "remote-token": "sk-live-do-not-render",
+ "fake-api-key": "another-secret-value",
+ },
+ });
+ const summary = approvalSummary(draft);
+ const rendered = JSON.stringify(summary);
+
+ assert.ok(
+ !rendered.includes("sk-live-do-not-render"),
+ `a secret value reached the approve step: ${rendered}`,
+ );
+ assert.ok(
+ !rendered.includes("another-secret-value"),
+ `a secret value reached the approve step: ${rendered}`,
+ );
+ assert.deepEqual(summary.references, [
+ "API_KEY = mcp:fake-api-key",
+ "Authorization: bearer mcp:remote-token",
+ ]);
+ assert.deepEqual(
+ summary.newSecrets,
+ ["fake-api-key", "remote-token"],
+ "the operator is told which credentials this save stores, by name",
+ );
+});
+
+test("editing a stored entry carries no secret value back into the form", () => {
+ const draft = entryToDraft(
+ entry({
+ env: [{ name: "API_KEY", reference: "mcp:fake-api-key", literal: null }],
+ }),
+ );
+ assert.deepEqual(draft.env, [{ name: "API_KEY", reference: "fake-api-key" }]);
+ assert.deepEqual(
+ draft.secrets,
+ {},
+ "no command returns a stored value, so an edit starts with none",
+ );
+});
+
+// ── Draft validation, at the caps the backend enforces ────────────────────
+
+test("a draft is refused for the same reasons the loader refuses an entry", () => {
+ assert.equal(draftProblem(stdioDraft()), null);
+
+ const cases = [
+ [{ id: "" }, /id is empty/],
+ [{ id: "A".repeat(2) }, /id may only use/],
+ [{ id: "a".repeat(MCP_REGISTRY_LIMITS.idLength + 1) }, /over the/],
+ [{ name: "has_underscore" }, /name may only use/],
+ [{ name: "a".repeat(MCP_REGISTRY_LIMITS.nameLength + 1) }, /over the/],
+ [{ name: "buzz-thing" }, /reserved/],
+ [{ command: "fake-mcp" }, /absolute path/],
+ [{ command: `/usr/bin/${NUL}x` }, /NUL/],
+ [
+ { argsText: Array.from({ length: 70 }, (_, n) => `--a${n}`).join("\n") },
+ /over the 64 cap/,
+ ],
+ [{ argsText: "x".repeat(MCP_REGISTRY_LIMITS.argLength + 1) }, /over the/],
+ [{ env: [{ name: "A=B", reference: "token" }] }, /equals sign/],
+ [{ env: [{ name: "A", reference: "Bad Ref" }] }, /usable secret/],
+ [
+ {
+ env: Array.from({ length: 40 }, (_, n) => ({
+ name: `V${n}`,
+ reference: "token",
+ })),
+ },
+ /over the 32 cap/,
+ ],
+ ];
+ for (const [overrides, expected] of cases) {
+ const problem = draftProblem(stdioDraft(overrides));
+ assert.ok(
+ problem !== null && expected.test(problem),
+ `${JSON.stringify(overrides)} must be refused by ${expected}, got ${problem}`,
+ );
+ }
+});
+
+test("an http draft is refused for a non-loopback http url and for userinfo", () => {
+ assert.equal(draftProblem(httpDraft()), null);
+ assert.equal(
+ draftProblem(httpDraft({ url: "http://127.0.0.1:8080/mcp" })),
+ null,
+ "loopback is the one http exception",
+ );
+ assert.match(
+ draftProblem(httpDraft({ url: "http://mcp.example/v1" })) ?? "",
+ /https/,
+ );
+ assert.match(
+ draftProblem(httpDraft({ url: "https://user:token@mcp.example/v1" })) ?? "",
+ /userinfo/,
+ );
+});
+
+test("a secret value over the derived cap is refused before the round trip", () => {
+ const problem = draftProblem(
+ httpDraft({
+ secrets: {
+ "remote-token": "x".repeat(MCP_REGISTRY_LIMITS.envValueLength + 1),
+ },
+ }),
+ );
+ assert.match(problem ?? "", /over the/);
+ assert.ok(
+ !(problem ?? "").includes("xxxx"),
+ "and the refusal does not echo the value",
+ );
+});
+
+test("the derived env value cap keeps one NAME=VALUE argument inside the argument cap", () => {
+ assert.equal(
+ MCP_REGISTRY_LIMITS.envNameLength + 1 + MCP_REGISTRY_LIMITS.envValueLength,
+ MCP_REGISTRY_LIMITS.argLength,
+ );
+});
+
+test("a draft becomes an entry whose env carries references, never values", () => {
+ const input = draftToInput(
+ stdioDraft({
+ env: [{ name: "API_KEY", reference: "fake-api-key" }],
+ secrets: { "fake-api-key": "sk-live-do-not-render" },
+ }),
+ );
+ assert.deepEqual(input.env, { API_KEY: "mcp:fake-api-key" });
+ assert.ok(
+ !JSON.stringify(input).includes("sk-live-do-not-render"),
+ "the entry written to the document must never carry a value",
+ );
+ assert.deepEqual(input.args, ["--stdio", "--port", "7777"]);
+});
+
+// ── Capability facts, projected from the runtime catalog ──────────────────
+
+test("an http entry on a stdio-only runtime is unsupported, from the catalog fact", () => {
+ const http = entry({ id: "remote", name: "remote", transport: "http" });
+ const support = serverSupport(http, BUZZ_AGENT);
+ assert.equal(support.kind, "unsupported");
+ assert.match(support.reason, /http/);
+ assert.match(support.reason, /buzz-agent/);
+
+ assert.equal(
+ serverSupport(http, CLAUDE).kind,
+ "supported",
+ "a runtime whose catalog entry declares http may be offered it",
+ );
+ assert.equal(serverSupport(entry(), BUZZ_AGENT).kind, "supported");
+});
+
+test("a rejected entry reports the loader's own reason, which is what a spawn refuses with", () => {
+ const support = serverSupport(
+ entry({ rejection: "`fake` is not an absolute path" }),
+ BUZZ_AGENT,
+ );
+ assert.equal(support.kind, "rejected");
+ assert.equal(support.reason, "`fake` is not an absolute path");
+});
+
+test("an unsupported entry is refused on toggle, never silently left off", () => {
+ const http = entry({ id: "remote", name: "remote", transport: "http" });
+ const result = toggleServer(
+ [],
+ "remote",
+ true,
+ serverSupport(http, BUZZ_AGENT),
+ );
+ assert.ok("refused" in result, "the click must say why it did nothing");
+ assert.match(result.refused, /cannot use/);
+
+ const allowed = toggleServer([], "remote", true, serverSupport(http, CLAUDE));
+ assert.deepEqual(allowed, { enabled: ["remote"] });
+});
+
+test("toggling off always succeeds, so a refused entry can still be removed", () => {
+ const http = entry({ id: "remote", name: "remote", transport: "http" });
+ assert.deepEqual(
+ toggleServer(
+ ["remote", "fake"],
+ "remote",
+ false,
+ serverSupport(http, BUZZ_AGENT),
+ ),
+ { enabled: ["fake"] },
+ "a guard that hides the only way back is a functional failure",
+ );
+});
+
+test("the per-agent cap is enforced on the toggle with a reason", () => {
+ const enabled = Array.from(
+ { length: MCP_REGISTRY_LIMITS.serversPerAgent },
+ (_, n) => `s${n}`,
+ );
+ const result = toggleServer(enabled, "one-more", true, { kind: "supported" });
+ assert.ok("refused" in result);
+ assert.match(result.refused, /at most 16/);
+});
+
+test("an agent on a harness the registry cannot configure is told so", () => {
+ const support = serverSupport(entry(), null);
+ assert.equal(support.kind, "runtime-unavailable");
+ const result = toggleServer([], "fake", true, support);
+ assert.ok("refused" in result);
+ assert.match(result.refused, /not one the registry can configure/);
+});
diff --git a/desktop/src/features/settings/ui/mcpRegistryLogic.ts b/desktop/src/features/settings/ui/mcpRegistryLogic.ts
new file mode 100644
index 00000000000..b1059a2b254
--- /dev/null
+++ b/desktop/src/features/settings/ui/mcpRegistryLogic.ts
@@ -0,0 +1,376 @@
+import type {
+ McpRegistryEntry,
+ McpRegistryInput,
+} from "@/shared/api/tauriMcpRegistry";
+import type { AcpRuntimeCatalogEntry, McpTransport } from "@/shared/api/types";
+
+/**
+ * Caps mirrored from the Rust loader
+ * (`desktop/src-tauri/src/managed_agents/mcp_registry/schema.rs`).
+ *
+ * These are not a rival source of truth — the backend refuses anything past
+ * them and its message is what the panel shows. They exist so the form can
+ * stop a save the backend would reject, at the field that caused it, instead
+ * of after a round trip. Every one is pinned to the consumer's own constant on
+ * the Rust side by `mcp_registry_argument_bounds_match_the_consumer`.
+ */
+export const MCP_REGISTRY_LIMITS = {
+ /** `MAX_ID_LEN`. */
+ idLength: 64,
+ /** `MAX_NAME_LEN`, itself the stricter of the desktop's and buzz-acp's. */
+ nameLength: 32,
+ /** `MAX_ARGS`. */
+ args: 64,
+ /** `MAX_ARG_LEN`, which also caps the command. */
+ argLength: 1024,
+ /** `MAX_ENV_ENTRIES`. */
+ envEntries: 32,
+ /** `MAX_ENV_NAME_LEN`. */
+ envNameLength: 128,
+ /** `MAX_ENV_VALUE_LEN`, derived so `NAME=VALUE` fits one argument. */
+ envValueLength: 1024 - 128 - 1,
+ /** `MAX_DOCUMENT_SERVERS`. */
+ documentServers: 256,
+ /** `MAX_SERVERS_PER_AGENT`, inherited from buzz-acp. */
+ serversPerAgent: 16,
+} as const;
+
+/** A draft as the form holds it, before it becomes a registry entry. */
+export type McpServerDraft = {
+ id: string;
+ name: string;
+ transport: "stdio" | "http";
+ command: string;
+ /** One argument per line, as typed. */
+ argsText: string;
+ url: string;
+ authScheme: string;
+ /** Reference id (the part after the `mcp:` prefix) for the credential. */
+ authSecretName: string;
+ /** Declared variables, as typed. Values are reference ids, never secrets. */
+ env: { name: string; reference: string }[];
+ /**
+ * Values the operator typed, keyed by reference id. Held only until the save
+ * that consumes them; no command ever reads one back.
+ */
+ secrets: Record;
+};
+
+/** An empty draft. */
+export function emptyDraft(): McpServerDraft {
+ return {
+ id: "",
+ name: "",
+ transport: "stdio",
+ command: "",
+ argsText: "",
+ url: "",
+ authScheme: "bearer",
+ authSecretName: "",
+ env: [],
+ secrets: {},
+ };
+}
+
+/** Split the argument textarea the way the save does. */
+export function draftArgs(draft: McpServerDraft): string[] {
+ return draft.argsText
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+}
+
+const NUL = String.fromCharCode(0);
+
+/**
+ * Why this draft cannot be saved, or `null` when it can.
+ *
+ * Every rule here also exists in the Rust loader, which is the authority: this
+ * one names the offending field before a round trip, and is deliberately no
+ * stricter, so a draft the panel accepts is one the backend accepts.
+ */
+export function draftProblem(draft: McpServerDraft): string | null {
+ const idProblem = identifierProblem(
+ "id",
+ draft.id,
+ MCP_REGISTRY_LIMITS.idLength,
+ /^[a-z0-9_-]+$/,
+ "lowercase letters, digits, underscore and hyphen",
+ );
+ if (idProblem) return idProblem;
+
+ const nameProblem = identifierProblem(
+ "name",
+ draft.name,
+ MCP_REGISTRY_LIMITS.nameLength,
+ /^[a-z0-9-]+$/,
+ "lowercase letters, digits and hyphen",
+ );
+ if (nameProblem) return nameProblem;
+ if (draft.name.startsWith("buzz-")) {
+ return "The name may not use the reserved buzz- prefix.";
+ }
+
+ if (draft.transport === "stdio") {
+ const stdioProblem = stdioDraftProblem(draft);
+ if (stdioProblem) return stdioProblem;
+ } else {
+ const httpProblem = httpDraftProblem(draft);
+ if (httpProblem) return httpProblem;
+ }
+
+ return envDraftProblem(draft);
+}
+
+function stdioDraftProblem(draft: McpServerDraft): string | null {
+ const absolute =
+ draft.command.startsWith("/") || /^[A-Za-z]:[/\\]/.test(draft.command);
+ if (!absolute) {
+ return "The command must be an absolute path: the launcher clears PATH, so a bare name would resolve through an environment nobody controls.";
+ }
+ if (draft.command.length > MCP_REGISTRY_LIMITS.argLength) {
+ return `The command is ${draft.command.length} bytes, over the ${MCP_REGISTRY_LIMITS.argLength}-byte cap.`;
+ }
+ const args = draftArgs(draft);
+ if (args.length > MCP_REGISTRY_LIMITS.args) {
+ return `That is ${args.length} arguments, over the ${MCP_REGISTRY_LIMITS.args} cap.`;
+ }
+ const long = args.find((arg) => arg.length > MCP_REGISTRY_LIMITS.argLength);
+ if (long !== undefined) {
+ return `One argument is ${long.length} bytes, over the ${MCP_REGISTRY_LIMITS.argLength}-byte cap.`;
+ }
+ if (draft.command.includes(NUL) || args.some((arg) => arg.includes(NUL))) {
+ return "A command line cannot carry a NUL byte.";
+ }
+ return null;
+}
+
+function httpDraftProblem(draft: McpServerDraft): string | null {
+ if (!/^https:\/\//.test(draft.url) && !isLoopbackHttp(draft.url)) {
+ return "The URL must use https, except on a loopback host.";
+ }
+ if (/^[a-z]+:\/\/[^/@]*@/.test(draft.url)) {
+ return "The URL carries userinfo, which is itself a credential. Name a secret reference instead.";
+ }
+ return null;
+}
+
+function envDraftProblem(draft: McpServerDraft): string | null {
+ if (draft.env.length > MCP_REGISTRY_LIMITS.envEntries) {
+ return `That is ${draft.env.length} variables, over the ${MCP_REGISTRY_LIMITS.envEntries} cap.`;
+ }
+ for (const entry of draft.env) {
+ if (entry.name.length === 0) return "A variable has no name.";
+ if (entry.name.length > MCP_REGISTRY_LIMITS.envNameLength) {
+ return `The variable name ${entry.name.slice(0, 24)} is over the ${MCP_REGISTRY_LIMITS.envNameLength}-byte cap.`;
+ }
+ if (entry.name.includes("=") || entry.name.includes(NUL)) {
+ return `${entry.name} holds a NUL or an equals sign, which no NAME=VALUE argument can carry.`;
+ }
+ if (!/^[a-z0-9_-]+$/.test(entry.reference)) {
+ return `${entry.name} does not name a usable secret; a reference id may only use lowercase letters, digits, underscore and hyphen.`;
+ }
+ }
+ for (const [reference, value] of Object.entries(draft.secrets)) {
+ if (value.length > MCP_REGISTRY_LIMITS.envValueLength) {
+ return `The value for ${reference} is ${value.length} bytes, over the ${MCP_REGISTRY_LIMITS.envValueLength}-byte cap.`;
+ }
+ }
+ return null;
+}
+
+function identifierProblem(
+ field: string,
+ value: string,
+ cap: number,
+ charset: RegExp,
+ described: string,
+): string | null {
+ if (value.length === 0) return `The ${field} is empty.`;
+ if (value.length > cap) {
+ return `The ${field} is ${value.length} bytes, over the ${cap}-byte cap.`;
+ }
+ if (!charset.test(value)) {
+ return `The ${field} may only use ${described}.`;
+ }
+ return null;
+}
+
+function isLoopbackHttp(url: string): boolean {
+ return /^http:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:\d+)?(\/|$)/.test(url);
+}
+
+/** What the approve step puts in front of the operator, verbatim. */
+export type McpApprovalSummary = {
+ headline: string;
+ /** The exact command line, or the exact URL. */
+ target: string;
+ /** Variable name to reference name. Never a value. */
+ references: string[];
+ /** Reference names whose value this save will store for the first time. */
+ newSecrets: string[];
+};
+
+/**
+ * What the operator is asked to approve, verbatim.
+ *
+ * The approve step is the point of the panel: a registry entry starts a
+ * process on this machine, or reaches a remote endpoint with a credential
+ * attached. So the exact command line, or the exact URL, is shown before the
+ * entry is written, together with the *names* of every variable and the
+ * reference each one resolves.
+ *
+ * A secret value is never part of this, and no argument shape can put one
+ * here: this function reads `draft.env` and the *keys* of `draft.secrets`, and
+ * never a value.
+ */
+export function approvalSummary(draft: McpServerDraft): McpApprovalSummary {
+ const references = draft.env.map(
+ (entry) => `${entry.name} = mcp:${entry.reference}`,
+ );
+ if (draft.transport === "http" && draft.authSecretName.length > 0) {
+ references.push(
+ `Authorization: ${draft.authScheme} mcp:${draft.authSecretName}`,
+ );
+ }
+ return {
+ headline:
+ draft.transport === "stdio"
+ ? "This starts a process on this machine:"
+ : "This sends requests, with the credential attached, to:",
+ target:
+ draft.transport === "stdio"
+ ? [draft.command, ...draftArgs(draft)].join(" ")
+ : draft.url,
+ references,
+ newSecrets: Object.keys(draft.secrets).sort(),
+ };
+}
+
+/** Turn a draft into the entry the backend deserializes. */
+export function draftToInput(draft: McpServerDraft): McpRegistryInput {
+ const env: Record = {};
+ for (const entry of draft.env) {
+ env[entry.name] = `mcp:${entry.reference}`;
+ }
+ if (draft.transport === "stdio") {
+ return {
+ id: draft.id,
+ name: draft.name,
+ transport: "stdio",
+ command: draft.command,
+ args: draftArgs(draft),
+ env,
+ };
+ }
+ return {
+ id: draft.id,
+ name: draft.name,
+ transport: "http",
+ url: draft.url,
+ ...(draft.authSecretName.length > 0
+ ? {
+ auth: {
+ scheme: draft.authScheme,
+ secret: `mcp:${draft.authSecretName}`,
+ },
+ }
+ : {}),
+ env,
+ };
+}
+
+/** Rebuild a draft from a stored entry, with no secret value in it. */
+export function entryToDraft(entry: McpRegistryEntry): McpServerDraft {
+ return {
+ id: entry.id,
+ name: entry.name,
+ transport: entry.transport,
+ command: entry.command ?? "",
+ argsText: entry.args.join("\n"),
+ url: entry.url ?? "",
+ authScheme: entry.auth_scheme ?? "bearer",
+ authSecretName: "",
+ env: entry.env.map((variable) => ({
+ name: variable.name,
+ reference: (variable.reference ?? "").replace(/^mcp:/, ""),
+ })),
+ // Deliberately empty. A stored value is never returned by any command, so
+ // an edit that does not retype one leaves the stored value untouched.
+ secrets: {},
+ };
+}
+
+/** Why one entry cannot be offered to one runtime, or that it can. */
+export type McpServerSupport =
+ | { kind: "supported" }
+ | { kind: "rejected"; reason: string }
+ | { kind: "unsupported"; reason: string }
+ | { kind: "runtime-unavailable"; reason: string };
+
+/**
+ * Whether `runtime` may be offered `entry`.
+ *
+ * The transport question is answered from the runtime catalog's
+ * `mcpTransports` and from nowhere else, per
+ * `desktop/src/features/agents/AGENTS.md`: no component compares a runtime id.
+ * An entry the runtime cannot take is reported `unsupported` and the toggle is
+ * refused — never quietly left off, because an agent short a server it was
+ * told to have is a behaviour change the operator cannot see.
+ */
+export function serverSupport(
+ entry: McpRegistryEntry,
+ runtime: Pick<
+ AcpRuntimeCatalogEntry,
+ "id" | "label" | "mcpTransports"
+ > | null,
+): McpServerSupport {
+ if (entry.rejection !== null) {
+ return { kind: "rejected", reason: entry.rejection };
+ }
+ if (runtime === null) {
+ return {
+ kind: "runtime-unavailable",
+ reason:
+ "This agent's harness is not one the registry can configure, so it is offered no registry servers.",
+ };
+ }
+ const needed: McpTransport = entry.transport === "http" ? "http" : "stdio";
+ if (!runtime.mcpTransports.includes(needed)) {
+ return {
+ kind: "unsupported",
+ reason: `${entry.name} is an ${entry.transport} server, which the ${runtime.id} runtime cannot use.`,
+ };
+ }
+ return { kind: "supported" };
+}
+
+/**
+ * The next selection after toggling `id`, or a refusal.
+ *
+ * A refusal is returned rather than silently ignored: the operator clicked
+ * something, and a click that does nothing and says nothing is the
+ * silently-dropped entry this design exists to prevent.
+ */
+export function toggleServer(
+ enabled: readonly string[],
+ id: string,
+ on: boolean,
+ support: McpServerSupport,
+): { enabled: string[] } | { refused: string } {
+ if (!on) {
+ return { enabled: enabled.filter((each) => each !== id) };
+ }
+ if (support.kind !== "supported") {
+ return { refused: support.reason };
+ }
+ if (enabled.includes(id)) {
+ return { enabled: [...enabled] };
+ }
+ if (enabled.length >= MCP_REGISTRY_LIMITS.serversPerAgent) {
+ return {
+ refused: `An agent may enable at most ${MCP_REGISTRY_LIMITS.serversPerAgent} mcp servers.`,
+ };
+ }
+ return { enabled: [...enabled, id] };
+}
diff --git a/desktop/src/shared/api/tauriMcpRegistry.ts b/desktop/src/shared/api/tauriMcpRegistry.ts
new file mode 100644
index 00000000000..955415f153b
--- /dev/null
+++ b/desktop/src/shared/api/tauriMcpRegistry.ts
@@ -0,0 +1,113 @@
+import { invokeTauri } from "@/shared/api/tauri";
+
+/** One declared environment entry, names only — never a secret value. */
+export type McpRegistryEnvEntry = {
+ name: string;
+ /** The `mcp:` reference this entry names, when it names one. */
+ reference: string | null;
+ /**
+ * The literal value, when this entry carries one. Only values the backend's
+ * sentinel scan cleared as non-credential ever reach here; a
+ * credential-shaped literal rejects the entry at load.
+ */
+ literal: string | null;
+};
+
+/** One registry entry, as the panel renders and approves it. */
+export type McpRegistryEntry = {
+ id: string;
+ name: string;
+ transport: "stdio" | "http";
+ /** Absolute command path for a stdio entry. */
+ command: string | null;
+ args: string[];
+ /** Upstream URL for an http entry. */
+ url: string | null;
+ auth_scheme: string | null;
+ env: McpRegistryEnvEntry[];
+ /**
+ * The loader's reason this entry is disabled, or `null` when it is usable.
+ * The same string a spawn refuses with, so the panel and the agent agree.
+ */
+ rejection: string | null;
+};
+
+/** What the panel loads. */
+export type McpRegistryView = {
+ servers: McpRegistryEntry[];
+ document_path: string;
+};
+
+/** The shape the backend deserializes for a save. */
+export type McpRegistryStdioInput = {
+ id: string;
+ name: string;
+ transport: "stdio";
+ command: string;
+ args: string[];
+ env: Record;
+};
+
+/** The shape the backend deserializes for a save. */
+export type McpRegistryHttpInput = {
+ id: string;
+ name: string;
+ transport: "http";
+ url: string;
+ auth?: { scheme: string; secret: string };
+ env: Record;
+};
+
+export type McpRegistryInput = McpRegistryStdioInput | McpRegistryHttpInput;
+
+/** Read the registry document and each entry's status. */
+export async function listMcpRegistryServers(): Promise {
+ return invokeTauri("list_mcp_registry_servers");
+}
+
+/**
+ * Insert or replace one entry and adopt a new configuration generation.
+ *
+ * `secrets` maps a reference id (the part after `mcp:`) to the value the
+ * operator typed. It travels one way — into the keychain, under the reserved
+ * `mcp:` prefix — and no command reads one back.
+ */
+export async function saveMcpRegistryServer(
+ entry: McpRegistryInput,
+ secrets: Record = {},
+): Promise {
+ return invokeTauri("save_mcp_registry_server", {
+ entry,
+ secrets,
+ });
+}
+
+/** Delete one entry, drop its id from every agent, and adopt a generation. */
+export async function deleteMcpRegistryServer(
+ id: string,
+): Promise {
+ return invokeTauri("delete_mcp_registry_server", { id });
+}
+
+/**
+ * Read one agent's selection.
+ *
+ * `null` means the record has never been configured, which is a different
+ * state from an empty list (memo decision 8).
+ */
+export async function getAgentMcpServers(
+ pubkey: string,
+): Promise {
+ return invokeTauri("get_agent_mcp_servers", { pubkey });
+}
+
+/** Set one agent's selection and adopt a new configuration generation. */
+export async function setAgentMcpServers(
+ pubkey: string,
+ enabled: string[],
+): Promise {
+ return invokeTauri("set_agent_mcp_servers", {
+ pubkey,
+ enabled,
+ });
+}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index a5050d608e7..b01050526ef 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -8515,6 +8515,157 @@ let runtimeCatalogDiscoveryCount = 0;
let mockInstallCompleted = false;
let mockConnectCompleted = false;
+// -- MCP registry (T7c) ------------------------------------------------------
+//
+// The registry document, each agent's selection, and the artefacts a
+// convergence stages. The generation number and the artefact shape mirror the
+// Rust side (`managed_agents::mcp_registry`) closely enough for a spec to
+// assert "the agent's generated config names the launcher and this server, and
+// the next generation drops it"; the byte-level guarantee is bound in Rust by
+// `mcp_registry_a_toggle_change_adopts_a_new_generation`, which reads the file
+// the shipped generator wrote.
+
+/** Absolute path of the bundled launcher, as a real generated config names it. */
+const MOCK_MCP_LAUNCHER =
+ "/Applications/Buzz.app/Contents/MacOS/buzz-mcp-launch";
+
+type MockMcpEnvEntry = {
+ name: string;
+ reference: string | null;
+ literal: string | null;
+};
+
+type MockMcpEntry = {
+ id: string;
+ name: string;
+ transport: "stdio" | "http";
+ command: string | null;
+ args: string[];
+ url: string | null;
+ auth_scheme: string | null;
+ env: MockMcpEnvEntry[];
+ rejection: string | null;
+};
+
+let mockMcpServers: MockMcpEntry[] = [];
+const mockMcpSelections = new Map();
+/** Reference ids the panel has stored a value for. The values are not kept. */
+const mockMcpStoredReferences = new Set();
+let mockMcpGeneration = 0;
+/** Generated artefacts by agent pubkey, as of the adopted generation. */
+let mockMcpArtefacts = new Map();
+
+/** Stage and adopt one generation from the document plus every selection. */
+function convergeMockMcpRegistry() {
+ mockMcpGeneration += 1;
+ mockMcpArtefacts = new Map();
+ for (const [pubkey, enabled] of mockMcpSelections) {
+ const servers = mockMcpServers
+ .filter((entry) => enabled.includes(entry.id) && entry.rejection === null)
+ .map((entry) => ({
+ name: entry.name,
+ command: MOCK_MCP_LAUNCHER,
+ args:
+ entry.transport === "stdio"
+ ? [
+ "--service",
+ "buzz-desktop-dev",
+ "launch",
+ "--server",
+ entry.name,
+ ...entry.env.flatMap((variable) => [
+ variable.reference === null ? "--set" : "--secret",
+ `${variable.name}=${variable.reference ?? variable.literal ?? ""}`,
+ ]),
+ "--",
+ entry.command ?? "",
+ ...entry.args,
+ ]
+ : [
+ "--service",
+ "buzz-desktop-dev",
+ "proxy",
+ "--url",
+ entry.url ?? "",
+ ],
+ }));
+ if (servers.length === 0) continue;
+ mockMcpArtefacts.set(pubkey, { version: 1, servers });
+ }
+}
+
+function mockMcpRegistryView() {
+ return {
+ servers: mockMcpServers,
+ document_path: "/mock/app-data/agents/mcp_servers.json",
+ };
+}
+
+function handleSaveMcpRegistryServer(payload: {
+ entry?: {
+ id?: string;
+ name?: string;
+ transport?: "stdio" | "http";
+ command?: string;
+ args?: string[];
+ url?: string;
+ auth?: { scheme?: string; secret?: string };
+ env?: Record;
+ };
+ secrets?: Record;
+}) {
+ const entry = payload.entry ?? {};
+ const next: MockMcpEntry = {
+ id: entry.id ?? "",
+ name: entry.name ?? "",
+ transport: entry.transport ?? "stdio",
+ command: entry.transport === "http" ? null : (entry.command ?? ""),
+ args: entry.transport === "http" ? [] : (entry.args ?? []),
+ url: entry.transport === "http" ? (entry.url ?? "") : null,
+ auth_scheme: entry.auth?.scheme ?? null,
+ env: Object.entries(entry.env ?? {}).map(([name, value]) => ({
+ name,
+ reference: value.startsWith("mcp:") ? value : null,
+ literal: value.startsWith("mcp:") ? null : value,
+ })),
+ rejection: null,
+ };
+ // The value is consumed here and never kept: the mock records only that a
+ // credential exists, exactly as the keychain write side does.
+ for (const reference of Object.keys(payload.secrets ?? {})) {
+ mockMcpStoredReferences.add(reference);
+ }
+ const index = mockMcpServers.findIndex((each) => each.id === next.id);
+ if (index >= 0) {
+ mockMcpServers[index] = next;
+ } else {
+ mockMcpServers.push(next);
+ }
+ convergeMockMcpRegistry();
+ return mockMcpRegistryView();
+}
+
+function handleDeleteMcpRegistryServer(payload: { id?: string }) {
+ mockMcpServers = mockMcpServers.filter((entry) => entry.id !== payload.id);
+ for (const [pubkey, enabled] of mockMcpSelections) {
+ mockMcpSelections.set(
+ pubkey,
+ enabled.filter((each) => each !== payload.id),
+ );
+ }
+ convergeMockMcpRegistry();
+ return mockMcpRegistryView();
+}
+
+function handleSetAgentMcpServers(payload: {
+ pubkey?: string;
+ enabled?: string[];
+}) {
+ mockMcpSelections.set(payload.pubkey ?? "", payload.enabled ?? []);
+ convergeMockMcpRegistry();
+ return mockMcpRegistryView();
+}
+
async function handleDiscoverAcpRuntimes(
config: E2eConfig | undefined,
): Promise {
@@ -13539,6 +13690,36 @@ export function maybeInstallE2eTauriMocks() {
return activeConfig?.mock?.relayRequiresMembership ?? false;
case "discover_acp_providers":
return handleDiscoverAcpRuntimes(activeConfig);
+ case "list_mcp_registry_servers":
+ return mockMcpRegistryView();
+ case "save_mcp_registry_server":
+ return handleSaveMcpRegistryServer(
+ payload as Parameters[0],
+ );
+ case "delete_mcp_registry_server":
+ return handleDeleteMcpRegistryServer(payload as { id?: string });
+ case "get_agent_mcp_servers":
+ return (
+ mockMcpSelections.get(
+ (payload as { pubkey?: string }).pubkey ?? "",
+ ) ?? null
+ );
+ case "set_agent_mcp_servers":
+ return handleSetAgentMcpServers(
+ payload as Parameters[0],
+ );
+ case "__buzz_e2e_mcp_generation__":
+ // Test-only seam: what a spawn of this agent would read from the
+ // adopted generation, plus the generation number, so a spec can assert
+ // that a toggle moved the pointer and changed the artefact.
+ return {
+ generation: mockMcpGeneration,
+ artefact:
+ mockMcpArtefacts.get(
+ (payload as { pubkey?: string }).pubkey ?? "",
+ ) ?? null,
+ storedReferences: [...mockMcpStoredReferences].sort(),
+ };
case "save_custom_harness":
return handleSaveCustomHarness(
payload as Parameters[0],
diff --git a/desktop/tests/e2e/mcp-registry-settings.spec.ts b/desktop/tests/e2e/mcp-registry-settings.spec.ts
new file mode 100644
index 00000000000..e3b0f6aee3c
--- /dev/null
+++ b/desktop/tests/e2e/mcp-registry-settings.spec.ts
@@ -0,0 +1,173 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+import { openSettings } from "../helpers/settings";
+
+/**
+ * The MCP registry, end to end through the panel.
+ *
+ * Adds a stdio server from Settings, checks the approve step shows the exact
+ * command line and no credential value, toggles it on for one agent, reads
+ * back the configuration that agent's next spawn would use, and toggles it off
+ * again.
+ *
+ * What this binds is the UI flow and the shape of what a toggle produces. The
+ * byte-level guarantee — that the file the shipped generator writes names the
+ * bundled launcher and exactly the selected servers, and that the next
+ * generation drops one — is bound on the Rust side by
+ * `mcp_registry_a_toggle_change_adopts_a_new_generation` and
+ * `mcp_registry_generated_config_names_the_launcher_and_carries_no_value`,
+ * which read the real files.
+ */
+
+const AGENT_PUBKEY =
+ "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34";
+const SERVER_COMMAND = "/usr/local/bin/fake-mcp";
+
+type GenerationProbe = {
+ generation: number;
+ artefact: {
+ version: number;
+ servers: { name: string; command: string; args: string[] }[];
+ } | null;
+ storedReferences: string[];
+};
+
+async function readGeneration(
+ page: import("@playwright/test").Page,
+): Promise {
+ return page.evaluate(async (pubkey) => {
+ const internals = (
+ window as unknown as {
+ __TAURI_INTERNALS__: {
+ invoke: (cmd: string, args: unknown) => Promise;
+ };
+ }
+ ).__TAURI_INTERNALS__;
+ return (await internals.invoke("__buzz_e2e_mcp_generation__", {
+ pubkey,
+ })) as GenerationProbe;
+ }, AGENT_PUBKEY);
+}
+
+async function setSelection(
+ page: import("@playwright/test").Page,
+ enabled: string[],
+) {
+ await page.evaluate(
+ async ({ pubkey, servers }) => {
+ const internals = (
+ window as unknown as {
+ __TAURI_INTERNALS__: {
+ invoke: (cmd: string, args: unknown) => Promise;
+ };
+ }
+ ).__TAURI_INTERNALS__;
+ await internals.invoke("set_agent_mcp_servers", {
+ pubkey,
+ enabled: servers,
+ });
+ },
+ { pubkey: AGENT_PUBKEY, servers: enabled },
+ );
+}
+
+test.beforeEach(async ({ page }) => {
+ await installMockBridge(page);
+ await page.goto("/");
+});
+
+test("a registry server is added behind an approve step and reaches one agent's configuration", async ({
+ page,
+}) => {
+ await openSettings(page, "agents");
+
+ const panel = page.getByTestId("settings-mcp-servers");
+ await expect(panel).toBeVisible();
+ await panel.getByRole("button", { name: "Add server" }).click();
+
+ const form = page.getByTestId("mcp-server-form");
+ await expect(form).toBeVisible();
+ await form.getByLabel("Id").fill("fake");
+ await form.getByLabel("Name", { exact: true }).fill("fake");
+ await form.getByLabel("Command (absolute path)").fill(SERVER_COMMAND);
+ await form.getByLabel("Arguments, one per line").fill("--stdio");
+
+ // The approve step must show what will actually be spawned, verbatim.
+ await form.getByRole("button", { name: "Review" }).click();
+ const approve = page.getByTestId("mcp-server-approve");
+ await expect(approve).toBeVisible();
+ await expect(page.getByTestId("mcp-server-approve-target")).toHaveText(
+ `${SERVER_COMMAND} --stdio`,
+ );
+
+ await approve.getByRole("button", { name: "Approve and save" }).click();
+ await expect(page.getByTestId("mcp-server-row-fake")).toBeVisible();
+ await expect(page.getByTestId("mcp-server-row-fake")).toContainText(
+ SERVER_COMMAND,
+ );
+
+ // Nothing is staged for the agent until it is toggled on.
+ const before = await readGeneration(page);
+ expect(before.artefact).toBeNull();
+
+ await setSelection(page, ["fake"]);
+ const enabled = await readGeneration(page);
+ expect(enabled.generation).toBeGreaterThan(before.generation);
+ expect(enabled.artefact).not.toBeNull();
+ const server = enabled.artefact?.servers[0];
+ expect(server?.name).toBe("fake");
+ expect(server?.command).toContain("buzz-mcp-launch");
+ expect(server?.args).toContain(SERVER_COMMAND);
+ expect(server?.args).toContain("--stdio");
+
+ // And the next generation drops it.
+ await setSelection(page, []);
+ const dropped = await readGeneration(page);
+ expect(dropped.generation).toBeGreaterThan(enabled.generation);
+ expect(dropped.artefact).toBeNull();
+});
+
+test("a credential is entered once and never rendered back", async ({
+ page,
+}) => {
+ await openSettings(page, "agents");
+
+ const panel = page.getByTestId("settings-mcp-servers");
+ await panel.getByRole("button", { name: "Add server" }).click();
+ const form = page.getByTestId("mcp-server-form");
+ await form.getByRole("radio", { name: "HTTP endpoint" }).click();
+ await form.getByLabel("Id").fill("remote");
+ await form.getByLabel("Name", { exact: true }).fill("remote");
+ await form.getByLabel("Upstream URL").fill("https://mcp.example/v1");
+ await form.getByLabel("Credential name").fill("remote-token");
+ await page.getByTestId("mcp-server-secret-value").fill("sk-live-do-not-show");
+
+ await form.getByRole("button", { name: "Review" }).click();
+ const approve = page.getByTestId("mcp-server-approve");
+ await expect(approve).toBeVisible();
+ await expect(approve).toContainText("mcp:remote-token");
+ await expect(approve).not.toContainText("sk-live-do-not-show");
+
+ await approve.getByRole("button", { name: "Approve and save" }).click();
+ await expect(page.getByTestId("mcp-server-row-remote")).toBeVisible();
+
+ // The document the panel renders back holds the reference, never the value.
+ const rendered = await page.getByTestId("settings-mcp-servers").innerText();
+ expect(rendered).not.toContain("sk-live-do-not-show");
+
+ const probe = await readGeneration(page);
+ expect(probe.storedReferences).toContain("remote-token");
+
+ // Re-opening the entry for edit brings back no value either.
+ await page
+ .getByTestId("mcp-server-row-remote")
+ .getByRole("button", {
+ name: "Edit",
+ })
+ .click();
+ await expect(page.getByTestId("mcp-server-form")).toBeVisible();
+ await expect(page.getByTestId("mcp-server-form")).not.toContainText(
+ "sk-live-do-not-show",
+ );
+});
From 01b0cbf8da6f87f642b088a0ba1944abba28e1bd Mon Sep 17 00:00:00 2001
From: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Date: Sat, 5 Sep 2026 22:43:43 -0700
Subject: [PATCH 3/9] docs(agents): record how the UI reads the MCP capability
facts
The guide already said the three facts live on `KnownAcpRuntime` and reach the
UI through `AcpRuntimeCatalogEntry`; it did not say what the UI does with them.
`serverSupport` is now named as the one place an entry meets a runtime, with
its four states, the rule that an unsupported entry is refused with its reason
rather than dropped, and the rule that toggling off always succeeds.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012Z6iidtozXxgx58BUZUKnu
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
---
desktop/src/features/agents/AGENTS.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index 2c7a44e5566..8094041fa92 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -52,6 +52,21 @@ projected onto `AcpRuntimeCatalogEntry` and reach TypeScript as
`mcp_config_root_env` shape was dropped. Design memo:
`docs/plans/2026-09-04-mcp-registry-design.md` decision 11.
+**Reading them in the UI.** `serverSupport` in
+`features/settings/ui/mcpRegistryLogic.ts` is the one place a registry entry
+meets a runtime, and it asks `runtime.mcpTransports` — never `runtime.id`. It
+returns one of four states and the toggle acts on that value alone:
+`supported`; `rejected` with the Rust loader's own status string, which is the
+message the agent's spawn refuses with; `unsupported` when the runtime's
+catalog entry does not declare the transport the entry needs — an HTTP entry on
+buzz-agent, which is the case decision 2 names; and `runtime-unavailable` when
+the harness is not one the registry can configure at all. An unsupported entry
+is **refused with its reason**, never quietly left off, because an agent short
+a server it was told to have is a behaviour change the operator cannot see.
+Toggling *off* always succeeds, so a refused entry can still be removed
+(Review-Proven Rule 6). Adding a transport to a runtime is a `KnownAcpRuntime`
+edit and nothing else; no component learns a new id.
+
## Rules
1. **No hardcoded harness-ID checks in render code.** `runtime.id === "claude"`
From 9e7f5d689bfbfe749d3333608c74e453d53f9c45 Mon Sep 17 00:00:00 2001
From: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Date: Sat, 5 Sep 2026 23:02:32 -0700
Subject: [PATCH 4/9] test(agents): discover the harness's name cap instead of
copying it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`mcp_registry_name_bounds_match_the_consumer_on_both_sides` built its
over-length fixture from the desktop's own `MAX_NAME_LEN`, so widening that
constant back to 64 kept the test green — the test agreed with the number it
was supposed to police, which is the same shape as the W4 defect itself.
`MAX_MCP_NAME_LEN` is crate-private, so the cap is now discovered by asking the
shipped `buzz_acp::mcp_registry::parse_registry_file` for the longest name it
accepts, and the assertion is that the desktop's cap is at or under it.
Verified by mutation: MAX_NAME_LEN 32 -> 64 now turns the test red.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012Z6iidtozXxgx58BUZUKnu
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
---
.../mcp_registry/apply_tests.rs | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs b/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs
index 9249b6a93cf..978f8f54c46 100644
--- a/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs
@@ -857,8 +857,25 @@ fn mcp_registry_name_bounds_match_the_consumer_on_both_sides() {
.err()
};
+ // The consumer's cap is *discovered*, not copied: `MAX_MCP_NAME_LEN` is
+ // crate-private, and a second copy of a number is exactly the defect W4
+ // named. Widening the desktop's own cap therefore fails here rather than
+ // silently agreeing with itself.
+ let consumer_cap = (1..=64usize)
+ .take_while(|len| consumer_refuses(&"a".repeat(*len)).is_none())
+ .last()
+ .expect("the harness accepts some name length");
+ assert!(
+ MAX_NAME_LEN <= consumer_cap,
+ "the desktop accepts a {MAX_NAME_LEN}-byte name while the harness refuses anything over \
+ {consumer_cap}, so every registry-enabled agent would fail to start"
+ );
+
for (name, why) in [
- ("a".repeat(MAX_NAME_LEN + 1), "a name one byte over the cap"),
+ (
+ "a".repeat(consumer_cap + 1),
+ "a name one byte over the harness's cap",
+ ),
("has_underscore".to_string(), "an underscored name"),
] {
let consumer = consumer_refuses(&name)
From 524698ff43505563374fcd8f3847945d7039596a Mon Sep 17 00:00:00 2001
From: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Date: Sun, 6 Sep 2026 15:24:57 -0700
Subject: [PATCH 5/9] fix(agents): close the verified Sol findings on T7c
F1: converge() stages an unassigned pending secret in a holding key instead of dropping it; proven by mcp_registry_a_pending_secret_for_an_unselected_server_is_not_silently_dropped.
F2: agent and persona deletion now call converge_now_with_records on the post-deletion records, retiring orphaned mcp: state; proven by mcp_registry_deleted_agent_retires_mcp_state_and_allows_subsequent_convergence.
F3: added AppState.mcp_registry_store_lock held across each Settings command's read-modify-write-converge sequence; proven by mcp_registry_concurrent_mutations_do_not_clobber_each_other.
F4: delete_mcp_registry_server writes the registry document before the agent records and surfaces which half succeeded on failure; proven by mcp_registry_delete_server_reports_partial_state_on_agent_save_failure.
F5: read_document now goes through the shared read_bounded_no_follow (byte cap + no-follow); proven by mcp_registry_save_refuses_a_document_over_the_byte_cap and mcp_registry_save_does_not_follow_a_symlinked_document.
F6: view_of redacts credential-shaped args/env values on a rejected entry; proven by mcp_registry_a_rejected_entrys_credential_shaped_args_are_redacted.
F8: added mcp_registry_available to the runtime catalog DTO and gated serverSupport() on it; proven by the new mcpRegistryLogic.test.mjs case for a runtime with mcpRegistryAvailable=false.
F9: converge_now's refused agents now flow into McpRegistryView.refused and render as a Settings banner; proven by mcp_registry_save_surfaces_a_refused_agent_not_just_an_error.
F10: AgentMcpServersSection/Field now track an explicit loading/loaded/error union and disable switches until loaded; proven by the new agentMcpServersField.test.mjs DOM and isSelectionSwitchDisabled cases.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01P8VoJ9givMm44HNc4gx5Gz
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
---
desktop/src-tauri/src/app_state.rs | 2 +
desktop/src-tauri/src/commands/agents.rs | 12 +-
.../src-tauri/src/commands/mcp_registry.rs | 171 ++++++--
.../src/commands/mcp_registry_tests.rs | 405 ++++++++++++++++++
.../src-tauri/src/commands/personas/mod.rs | 10 +-
.../src-tauri/src/managed_agents/discovery.rs | 2 +
.../src/managed_agents/discovery/presets.rs | 1 +
.../src/managed_agents/mcp_registry/apply.rs | 77 +++-
.../mcp_registry/apply_tests.rs | 108 +++++
.../managed_agents/mcp_registry/converge.rs | 58 ++-
.../src/managed_agents/mcp_registry/load.rs | 2 +-
desktop/src-tauri/src/managed_agents/types.rs | 3 +
.../agents/ui/AgentMcpServersField.tsx | 45 +-
.../agents/ui/AgentMcpServersSection.tsx | 35 +-
.../agents/ui/agentMcpServersField.test.mjs | 59 ++-
.../settings/ui/McpServersSettingsPanel.tsx | 27 ++
.../settings/ui/mcpRegistryLogic.test.mjs | 14 +
.../features/settings/ui/mcpRegistryLogic.ts | 4 +-
desktop/src/shared/api/tauri.ts | 2 +
desktop/src/shared/api/tauriMcpRegistry.ts | 1 +
desktop/src/shared/api/types.ts | 2 +
desktop/src/testing/e2eBridge.ts | 1 +
22 files changed, 957 insertions(+), 84 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/mcp_registry_tests.rs
diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs
index f1136e88923..0e1e1f9c147 100644
--- a/desktop/src-tauri/src/app_state.rs
+++ b/desktop/src-tauri/src/app_state.rs
@@ -45,6 +45,7 @@ pub struct AppState {
/// Never perform network I/O while holding this lock.
pub managed_agent_runtime_transition: Mutex<()>,
pub managed_agents_store_lock: Mutex<()>,
+ pub mcp_registry_store_lock: Mutex<()>,
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex>,
pub provider_deploy_locks: Mutex>>>,
@@ -221,6 +222,7 @@ pub fn build_app_state() -> AppState {
managed_agent_runtime_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
managed_agents_store_lock: Mutex::new(()),
+ mcp_registry_store_lock: Mutex::new(()),
channel_templates_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
provider_deploy_locks: Mutex::new(HashMap::new()),
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 3752c74d0e3..20d11c87f3c 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -1134,7 +1134,17 @@ pub async fn delete_managed_agent(
}
state.clear_agent_session_caches(&pubkey);
records.retain(|record| record.pubkey != pubkey);
- save_managed_agents(&app, records)
+ save_managed_agents(&app, records)?;
+ if let Err(e) =
+ crate::managed_agents::mcp_registry::apply::converge_now_with_records(
+ &app,
+ records,
+ &std::collections::BTreeMap::new(),
+ )
+ {
+ eprintln!("buzz-desktop: delete_managed_agent: mcp convergence failed: {e}");
+ }
+ Ok(())
})?;
crate::managed_agents::delete_agent_key(&pubkey);
// Tombstone after confirmed removal (inside lock; every published
diff --git a/desktop/src-tauri/src/commands/mcp_registry.rs b/desktop/src-tauri/src/commands/mcp_registry.rs
index 01331fd9e4a..a9581d86918 100644
--- a/desktop/src-tauri/src/commands/mcp_registry.rs
+++ b/desktop/src-tauri/src/commands/mcp_registry.rs
@@ -17,6 +17,7 @@ use std::collections::BTreeMap;
use tauri::AppHandle;
+use crate::app_state::AppState;
use crate::managed_agents::mcp_registry::apply;
use crate::managed_agents::mcp_registry::apply::converge_now;
use crate::managed_agents::mcp_registry::load::{load_registry, LoadedEntry};
@@ -24,6 +25,7 @@ use crate::managed_agents::mcp_registry::schema::{
RegistryDocument, RegistryEntry, RegistryTransport, MAX_DOCUMENT_SERVERS,
};
use crate::managed_agents::types::{AgentMcpServers, AGENT_MCP_SERVERS_VERSION};
+use crate::managed_agents::ManagedAgentRecord;
use buzz_secret_store_pkg::{looks_like_reference, McpSecretRef};
/// One registry entry as the panel renders it.
@@ -79,13 +81,45 @@ pub struct McpRegistryView {
pub servers: Vec,
/// Absolute path of the document, for the "reveal in Finder" affordance.
pub document_path: String,
+ /// Agents whose selection could not be resolved, with the message the
+ /// panel shows and the spawn refuses with.
+ pub refused: Vec<(String, String)>,
+}
+
+fn redact_args(args: &[String]) -> Vec {
+ use buzz_secret_store_pkg::sentinel::{is_credential_name, scan_value};
+ let mut out = Vec::with_capacity(args.len());
+ let mut redact_next = false;
+ for arg in args {
+ if redact_next {
+ out.push("".to_string());
+ redact_next = false;
+ continue;
+ }
+ if is_credential_name(arg.trim_start_matches('-')) && arg.starts_with('-') {
+ out.push(arg.clone());
+ redact_next = true;
+ continue;
+ }
+ if scan_value(arg).is_some() {
+ out.push("".to_string());
+ continue;
+ }
+ out.push(arg.clone());
+ }
+ out
}
fn view_of(loaded: &LoadedEntry) -> McpRegistryEntryView {
let entry = &loaded.entry;
let (transport, command, args, url, auth_scheme) = match &entry.transport {
RegistryTransport::Stdio { command, args } => {
- ("stdio", Some(command.clone()), args.clone(), None, None)
+ let effective_args = if loaded.rejection.is_some() {
+ redact_args(args)
+ } else {
+ args.clone()
+ };
+ ("stdio", Some(command.clone()), effective_args, None, None)
}
RegistryTransport::Http { url, auth } => (
"http",
@@ -108,10 +142,17 @@ fn view_of(loaded: &LoadedEntry) -> McpRegistryEntryView {
.iter()
.map(|(name, value)| {
let is_reference = looks_like_reference(value);
+ let literal = if is_reference {
+ None
+ } else if loaded.rejection.is_some() {
+ Some("".to_string())
+ } else {
+ Some(value.clone())
+ };
McpRegistryEnvView {
name: name.clone(),
reference: is_reference.then(|| value.clone()),
- literal: (!is_reference).then(|| value.clone()),
+ literal,
}
})
.collect(),
@@ -136,12 +177,15 @@ fn document_path(app: &AppHandle) -> Result Result {
+pub fn list_mcp_registry_servers(
+ app: AppHandle,
+) -> Result {
let path = document_path(&app)?;
let registry = load_registry(&path).map_err(|e| e.to_string())?;
Ok(McpRegistryView {
servers: registry.entries.iter().map(view_of).collect(),
document_path: path.display().to_string(),
+ refused: Vec::new(),
})
}
@@ -162,11 +206,18 @@ pub fn list_mcp_registry_servers(app: AppHandle) -> Result(
+ app: AppHandle,
entry: RegistryEntry,
secrets: BTreeMap,
) -> Result {
+ use tauri::Manager;
+ let state = app.state::();
+ let _lock = state
+ .mcp_registry_store_lock
+ .lock()
+ .map_err(|e| format!("cannot acquire mcp registry lock: {e}"))?;
+
for id in secrets.keys() {
// The reference id is operator-typed, so it is validated against the
// same closed namespace a generated config uses. `identity` and
@@ -189,41 +240,73 @@ pub fn save_mcp_registry_server(
}
}
write_document(&path, &document)?;
- converge_now(&app, &secrets)?;
- list_mcp_registry_servers(app)
+ let converged = converge_now(&app, &secrets)?;
+ let mut view = list_mcp_registry_servers(app.clone())?;
+ view.refused = converged.refused;
+ Ok(view)
}
-/// Delete one registry entry, drop its id from every agent, and adopt a new
-/// generation.
-///
-/// The agent records are rewritten *before* the convergence, so the generation
-/// this call adopts is built from the records as they now are. The reverse
-/// order would stage a generation naming a server no record enables any more,
-/// and the deleted server's credential would be carried onto it.
-///
-/// # Errors
-/// A message when the document or the agent store cannot be written, or when
-/// the convergence fails.
-#[tauri::command]
-pub fn delete_mcp_registry_server(app: AppHandle, id: String) -> Result {
- let path = document_path(&app)?;
+/// Internal implementation of delete_mcp_registry_server taking an injectable
+/// save closure for testing.
+pub fn delete_mcp_registry_server_internal(
+ app: &AppHandle,
+ id: &str,
+ save_records: F,
+) -> Result
+where
+ F: FnOnce(&[ManagedAgentRecord]) -> Result<(), String>,
+{
+ let path = document_path(app)?;
let mut document = read_document(&path)?;
document.servers.retain(|entry| entry.id != id);
- let mut records = crate::managed_agents::load_managed_agents(&app)?;
+ write_document(&path, &document)?;
+
+ let mut records = crate::managed_agents::load_managed_agents(app)?;
let mut touched = false;
for record in &mut records {
if let Some(selection) = record.mcp_servers.as_mut() {
let before = selection.enabled.len();
- selection.enabled.retain(|enabled| enabled != &id);
+ selection.enabled.retain(|enabled| enabled != id);
touched |= selection.enabled.len() != before;
}
}
if touched {
- crate::managed_agents::save_managed_agents(&app, &records)?;
+ save_records(&records).map_err(|e| {
+ format!(
+ "mcp registry document updated to remove {id}, but updating agent records failed: {e}; state is inconsistent"
+ )
+ })?;
}
- write_document(&path, &document)?;
- converge_now(&app, &BTreeMap::new())?;
- list_mcp_registry_servers(app)
+ let converged = converge_now(app, &BTreeMap::new())?;
+ let mut view = list_mcp_registry_servers(app.clone())?;
+ view.refused = converged.refused;
+ Ok(view)
+}
+
+/// Delete one registry entry, drop its id from every agent, and adopt a new
+/// generation.
+///
+/// The document is written first so that the server declaration is removed
+/// from the authoritative registry. If updating the agent records subsequently
+/// fails, an error noting the partial/inconsistent state is returned.
+///
+/// # Errors
+/// A message when the document or the agent store cannot be written, or when
+/// the convergence fails.
+#[tauri::command]
+pub fn delete_mcp_registry_server(
+ app: AppHandle,
+ id: String,
+) -> Result {
+ use tauri::Manager;
+ let state = app.state::();
+ let _lock = state
+ .mcp_registry_store_lock
+ .lock()
+ .map_err(|e| format!("cannot acquire mcp registry lock: {e}"))?;
+ delete_mcp_registry_server_internal(&app, &id, |records| {
+ crate::managed_agents::save_managed_agents(&app, records)
+ })
}
/// Set one agent's enabled registry servers, then adopt a new generation.
@@ -238,11 +321,18 @@ pub fn delete_mcp_registry_server(app: AppHandle, id: String) -> Result(
+ app: AppHandle,
pubkey: String,
enabled: Vec,
) -> Result {
+ use tauri::Manager;
+ let state = app.state::();
+ let _lock = state
+ .mcp_registry_store_lock
+ .lock()
+ .map_err(|e| format!("cannot acquire mcp registry lock: {e}"))?;
+
let mut records = crate::managed_agents::load_managed_agents(&app)?;
let record = records
.iter_mut()
@@ -253,8 +343,10 @@ pub fn set_agent_mcp_servers(
enabled,
});
crate::managed_agents::save_managed_agents(&app, &records)?;
- converge_now(&app, &BTreeMap::new())?;
- list_mcp_registry_servers(app)
+ let converged = converge_now(&app, &BTreeMap::new())?;
+ let mut view = list_mcp_registry_servers(app.clone())?;
+ view.refused = converged.refused;
+ Ok(view)
}
/// One agent's current selection, for the definition dialog.
@@ -262,8 +354,8 @@ pub fn set_agent_mcp_servers(
/// # Errors
/// A message when the agent store cannot be read.
#[tauri::command]
-pub fn get_agent_mcp_servers(
- app: AppHandle,
+pub fn get_agent_mcp_servers(
+ app: AppHandle,
pubkey: String,
) -> Result
>, String> {
let records = crate::managed_agents::load_managed_agents(&app)?;
@@ -275,18 +367,19 @@ pub fn get_agent_mcp_servers(
}
fn read_document(path: &std::path::Path) -> Result {
- match std::fs::read(path) {
- Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| {
+ match crate::managed_agents::mcp_registry::load::read_bounded_no_follow(path)
+ .map_err(|e| e.to_string())?
+ {
+ Some(bytes) => serde_json::from_slice(&bytes).map_err(|e| {
format!(
"the mcp registry at {} is not valid json: {e}",
path.display()
)
}),
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RegistryDocument {
+ None => Ok(RegistryDocument {
version: 1,
servers: Vec::new(),
}),
- Err(e) => Err(format!("cannot read {}: {e}", path.display())),
}
}
@@ -301,3 +394,7 @@ fn write_document(path: &std::path::Path, document: &RegistryDocument) -> Result
// prefix, and a failed write leaves the previous document intact.
crate::managed_agents::atomic_write_json(path, &body)
}
+
+#[cfg(test)]
+#[path = "mcp_registry_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/commands/mcp_registry_tests.rs b/desktop/src-tauri/src/commands/mcp_registry_tests.rs
new file mode 100644
index 00000000000..2da677f7b66
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_registry_tests.rs
@@ -0,0 +1,405 @@
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::PathBuf;
+
+use tauri::Manager;
+
+use super::*;
+use crate::app_state::{build_app_state, AppState};
+use crate::managed_agents::mcp_registry::load::LoadedEntry;
+use crate::managed_agents::mcp_registry::schema::{
+ RegistryDocument, RegistryEntry, RegistryTransport, MAX_DOCUMENT_BYTES,
+};
+use crate::managed_agents::{
+ save_managed_agents, AgentMcpServers, BackendKind, ManagedAgentRecord, RespondTo,
+ AGENT_MCP_SERVERS_VERSION,
+};
+
+struct EnvGuard {
+ _path_guard: std::sync::MutexGuard<'static, ()>,
+ _temp: tempfile::TempDir,
+ old_home: Option,
+ old_xdg: Option,
+ old_path: Option,
+}
+
+impl EnvGuard {
+ fn new() -> (Self, PathBuf) {
+ let path_guard = crate::managed_agents::lock_path_mutex();
+ crate::managed_agents::clear_resolve_cache();
+ let temp = tempfile::tempdir().unwrap();
+ let home = temp.path().join("home");
+ let bin = temp.path().join("bin");
+ fs::create_dir_all(&home).unwrap();
+ fs::create_dir_all(&bin).unwrap();
+
+ let launcher = bin.join("buzz-mcp-launch");
+ fs::write(&launcher, b"#!/bin/sh\nexit 0\n").unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&launcher, fs::Permissions::from_mode(0o755)).unwrap();
+ }
+
+ let old_home = std::env::var_os("HOME");
+ let old_xdg = std::env::var_os("XDG_DATA_HOME");
+ let old_path = std::env::var_os("PATH");
+
+ #[cfg(target_os = "macos")]
+ {
+ if let Some(ref real_home) = old_home {
+ let real_keychains = PathBuf::from(real_home).join("Library/Keychains");
+ if real_keychains.exists() {
+ let temp_lib = home.join("Library");
+ let _ = fs::create_dir_all(&temp_lib);
+ let _ = std::os::unix::fs::symlink(&real_keychains, temp_lib.join("Keychains"));
+ }
+ }
+ }
+
+ std::env::set_var("HOME", &home);
+ std::env::set_var("XDG_DATA_HOME", &home);
+
+ let new_path = if let Some(ref p) = old_path {
+ format!("{}:{}", bin.display(), p.to_string_lossy())
+ } else {
+ bin.display().to_string()
+ };
+ std::env::set_var("PATH", &new_path);
+
+ (
+ Self {
+ _path_guard: path_guard,
+ _temp: temp,
+ old_home,
+ old_xdg,
+ old_path,
+ },
+ home,
+ )
+ }
+}
+
+impl Drop for EnvGuard {
+ fn drop(&mut self) {
+ crate::managed_agents::clear_resolve_cache();
+ if let Some(ref old) = self.old_home {
+ std::env::set_var("HOME", old);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ if let Some(ref old) = self.old_xdg {
+ std::env::set_var("XDG_DATA_HOME", old);
+ } else {
+ std::env::remove_var("XDG_DATA_HOME");
+ }
+ if let Some(ref old) = self.old_path {
+ std::env::set_var("PATH", old);
+ } else {
+ std::env::remove_var("PATH");
+ }
+ }
+}
+
+fn mock_app() -> tauri::App {
+ let state = build_app_state();
+ *state.keys.lock().unwrap() = nostr::Keys::generate();
+ *state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:1".to_string());
+
+ tauri::test::mock_builder()
+ .manage(state)
+ .build(tauri::test::mock_context(tauri::test::noop_assets()))
+ .expect("mock app builds headless")
+}
+
+fn bare_agent_record(pubkey: &str) -> ManagedAgentRecord {
+ ManagedAgentRecord {
+ mcp_servers: None,
+ description: None,
+ pubkey: pubkey.to_string(),
+ name: "Agent".to_string(),
+ persona_id: None,
+ private_key_nsec: "nsec1vl029mgpspedva04g90vltkh6fvh240eqtv9xxl2xme3sqnvnabla0uvyu"
+ .to_string(),
+ auth_tag: None,
+ relay_url: "ws://localhost:3000".to_string(),
+ avatar_url: None,
+ acp_command: "buzz-acp".to_string(),
+ agent_command: "buzz-agent".to_string(),
+ agent_command_override: None,
+ agent_args: vec![],
+ mcp_command: "".to_string(),
+ turn_timeout_seconds: 300,
+ idle_timeout_seconds: None,
+ max_turn_duration_seconds: None,
+ parallelism: 1,
+ system_prompt: None,
+ model: None,
+ provider: None,
+ persona_source_version: None,
+ env_vars: BTreeMap::new(),
+ start_on_app_launch: false,
+ runtime_pid: None,
+ backend: BackendKind::Local,
+ backend_agent_id: None,
+ provider_policy_pending: false,
+ provider_binary_path: None,
+ team_id: None,
+ persona_team_dir: None,
+ persona_name_in_team: None,
+ created_at: "".to_string(),
+ updated_at: "".to_string(),
+ last_started_at: None,
+ last_stopped_at: None,
+ last_exit_code: None,
+ last_error: None,
+ last_error_code: None,
+ respond_to: RespondTo::OwnerOnly,
+ respond_to_allowlist: vec![],
+ display_name: None,
+ slug: None,
+ runtime: None,
+ name_pool: vec![],
+ is_builtin: false,
+ is_active: true,
+ shared: false,
+ source_team: None,
+ source_team_persona_slug: None,
+ catalog_source: None,
+ team_catalog_source: None,
+ relay_mesh: None,
+ effort_level: None,
+ auto_restart_on_config_change: false,
+ definition_respond_to: None,
+ definition_respond_to_allowlist: vec![],
+ definition_parallelism: None,
+ }
+}
+
+#[test]
+fn mcp_registry_a_rejected_entrys_credential_shaped_args_are_redacted() {
+ let loaded = LoadedEntry {
+ entry: RegistryEntry {
+ id: "server1".to_string(),
+ name: "Server One".to_string(),
+ transport: RegistryTransport::Stdio {
+ command: "/usr/local/bin/server".to_string(),
+ args: vec![
+ "--api-key".to_string(),
+ "sk-live-secrettoken123".to_string(),
+ "--other".to_string(),
+ "plain-arg".to_string(),
+ "ghp_mygithubtoken".to_string(),
+ ],
+ },
+ env: BTreeMap::from([
+ ("PLAIN_VAR".to_string(), "normal_val".to_string()),
+ ("API_SECRET".to_string(), "sk-live-envsecret".to_string()),
+ ("MCP_REF".to_string(), "mcp:my-ref".to_string()),
+ ]),
+ },
+ rejection: Some("argument 0 carries a credential".to_string()),
+ };
+
+ let view = view_of(&loaded);
+ assert_eq!(
+ view.args,
+ vec![
+ "--api-key".to_string(),
+ "".to_string(),
+ "--other".to_string(),
+ "plain-arg".to_string(),
+ "".to_string(),
+ ]
+ );
+
+ let plain_env = view.env.iter().find(|e| e.name == "PLAIN_VAR").unwrap();
+ assert_eq!(plain_env.literal.as_deref(), Some(""));
+
+ let secret_env = view.env.iter().find(|e| e.name == "API_SECRET").unwrap();
+ assert_eq!(secret_env.literal.as_deref(), Some(""));
+
+ let ref_env = view.env.iter().find(|e| e.name == "MCP_REF").unwrap();
+ assert_eq!(ref_env.reference.as_deref(), Some("mcp:my-ref"));
+ assert_eq!(ref_env.literal, None);
+}
+
+#[test]
+fn mcp_registry_save_refuses_a_document_over_the_byte_cap() {
+ let (_guard, _home) = EnvGuard::new();
+ let app = mock_app();
+ let doc_path = document_path(&app.handle()).expect("document path");
+ fs::create_dir_all(doc_path.parent().unwrap()).unwrap();
+ fs::write(&doc_path, vec![b'a'; MAX_DOCUMENT_BYTES + 10]).unwrap();
+
+ let entry = RegistryEntry {
+ id: "server1".to_string(),
+ name: "Server One".to_string(),
+ transport: RegistryTransport::Stdio {
+ command: "/usr/local/bin/server".to_string(),
+ args: vec![],
+ },
+ env: BTreeMap::new(),
+ };
+ let err = save_mcp_registry_server(app.handle().clone(), entry, BTreeMap::new()).unwrap_err();
+ assert!(
+ err.contains("cap") || err.contains("65536"),
+ "expected error mentioning byte cap, got: {err}"
+ );
+}
+
+#[test]
+fn mcp_registry_save_does_not_follow_a_symlinked_document() {
+ let (_guard, _home) = EnvGuard::new();
+ let app = mock_app();
+ let doc_path = document_path(&app.handle()).expect("document path");
+ fs::create_dir_all(doc_path.parent().unwrap()).unwrap();
+
+ let target = doc_path.parent().unwrap().join("real_document.json");
+ fs::write(&target, b"{\"version\":1,\"servers\":[]}").unwrap();
+
+ #[cfg(unix)]
+ std::os::unix::fs::symlink(&target, &doc_path).unwrap();
+ #[cfg(windows)]
+ std::os::windows::fs::symlink_file(&target, &doc_path).unwrap();
+
+ let entry = RegistryEntry {
+ id: "server1".to_string(),
+ name: "Server One".to_string(),
+ transport: RegistryTransport::Stdio {
+ command: "/usr/local/bin/server".to_string(),
+ args: vec![],
+ },
+ env: BTreeMap::new(),
+ };
+ let err = save_mcp_registry_server(app.handle().clone(), entry, BTreeMap::new()).unwrap_err();
+ assert!(
+ err.contains("symbolic link") || err.contains("symlink"),
+ "expected error mentioning symlink, got: {err}"
+ );
+}
+
+#[test]
+fn mcp_registry_delete_server_reports_partial_state_on_agent_save_failure() {
+ let (_guard, _home) = EnvGuard::new();
+ let app = mock_app();
+ let doc_path = document_path(&app.handle()).expect("document path");
+ fs::create_dir_all(doc_path.parent().unwrap()).unwrap();
+
+ let doc = RegistryDocument {
+ version: 1,
+ servers: vec![
+ RegistryEntry {
+ id: "srv1".to_string(),
+ name: "Server 1".to_string(),
+ transport: RegistryTransport::Stdio {
+ command: "/usr/local/bin/server1".to_string(),
+ args: vec![],
+ },
+ env: BTreeMap::new(),
+ },
+ RegistryEntry {
+ id: "srv2".to_string(),
+ name: "Server 2".to_string(),
+ transport: RegistryTransport::Stdio {
+ command: "/usr/local/bin/server2".to_string(),
+ args: vec![],
+ },
+ env: BTreeMap::new(),
+ },
+ ],
+ };
+ write_document(&doc_path, &doc).unwrap();
+
+ let mut record = bare_agent_record("agent-save-fail");
+ record.mcp_servers = Some(AgentMcpServers {
+ version: AGENT_MCP_SERVERS_VERSION,
+ enabled: vec!["srv1".to_string()],
+ });
+ save_managed_agents(&app.handle(), &[record]).unwrap();
+
+ let err = delete_mcp_registry_server_internal(&app.handle(), "srv1", |_| {
+ Err("injected agent write failure".to_string())
+ })
+ .unwrap_err();
+
+ assert_eq!(
+ err,
+ "mcp registry document updated to remove srv1, but updating agent records failed: injected agent write failure; state is inconsistent"
+ );
+
+ // Verify document was written first and srv1 was removed
+ let updated_doc = read_document(&doc_path).unwrap();
+ assert_eq!(updated_doc.servers.len(), 1);
+ assert_eq!(updated_doc.servers[0].id, "srv2");
+}
+
+#[test]
+fn mcp_registry_concurrent_mutations_do_not_clobber_each_other() {
+ let (_guard, _home) = EnvGuard::new();
+ let app = mock_app();
+ let state = app.state::();
+
+ // Hold lock on main thread
+ let lock = state.mcp_registry_store_lock.lock().unwrap();
+
+ // In a background thread, attempt to call a mutation (set_agent_mcp_servers)
+ let app_handle = app.handle().clone();
+ let (tx, rx) = std::sync::mpsc::channel();
+ let handle = std::thread::spawn(move || {
+ tx.send("started").unwrap();
+ let res = set_agent_mcp_servers(app_handle, "nonexistent".to_string(), vec![]);
+ assert!(res.is_err());
+ tx.send("finished").unwrap();
+ });
+
+ assert_eq!(rx.recv().unwrap(), "started");
+ // Background thread must be blocked on mcp_registry_store_lock
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ assert!(rx.try_recv().is_err());
+
+ // Release lock
+ drop(lock);
+
+ // Now background thread acquires lock and finishes
+ assert_eq!(rx.recv().unwrap(), "finished");
+ handle.join().unwrap();
+}
+
+#[test]
+fn mcp_registry_save_surfaces_a_refused_agent_not_just_an_error() {
+ let (_guard, _home) = EnvGuard::new();
+ let app = mock_app();
+
+ // Agent with runtime buzz-agent which only supports stdio
+ let mut record = bare_agent_record("agent-refused");
+ record.mcp_servers = Some(AgentMcpServers {
+ version: AGENT_MCP_SERVERS_VERSION,
+ enabled: vec!["remote-http".to_string()],
+ });
+ save_managed_agents(&app.handle(), &[record]).unwrap();
+
+ let http_entry = RegistryEntry {
+ id: "remote-http".to_string(),
+ name: "remote-http".to_string(),
+ transport: RegistryTransport::Http {
+ url: "https://mcp.example.com".to_string(),
+ auth: None,
+ },
+ env: BTreeMap::new(),
+ };
+
+ let view = save_mcp_registry_server(app.handle().clone(), http_entry, BTreeMap::new())
+ .expect("save succeeds with refused agent in view");
+
+ assert!(
+ !view.refused.is_empty(),
+ "view.refused must contain the refused agent"
+ );
+ let (agent_id, reason) = &view.refused[0];
+ assert_eq!(agent_id, "agent-refused");
+ assert!(
+ reason.contains("http") || reason.contains("buzz-agent"),
+ "reason should explain transport incompatibility, got: {reason}"
+ );
+}
diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs
index 5a8681f59cc..37523ab6b5a 100644
--- a/desktop/src-tauri/src/commands/personas/mod.rs
+++ b/desktop/src-tauri/src/commands/personas/mod.rs
@@ -268,7 +268,15 @@ pub async fn delete_persona(
if !cascade.is_empty() {
commit_cascade_agents(&mut agents, &cascade, |recs| {
- save_managed_agents(&app, recs)
+ save_managed_agents(&app, recs)?;
+ if let Err(e) = crate::managed_agents::mcp_registry::apply::converge_now_with_records(
+ &app,
+ recs,
+ &std::collections::BTreeMap::new(),
+ ) {
+ eprintln!("buzz-desktop: delete_persona: mcp convergence failed: {e}");
+ }
+ Ok(())
})?;
}
diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs
index 6c5c0e4d293..065b4990b6b 100644
--- a/desktop/src-tauri/src/managed_agents/discovery.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery.rs
@@ -1159,6 +1159,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) -
mcp_config_placement: runtime.mcp_config_placement,
mcp_transports: runtime.mcp_transports.to_vec(),
mcp_native_transports: runtime.mcp_native_transports.to_vec(),
+ mcp_registry_available: runtime.mcp_registry_available,
},
}
}
@@ -1305,6 +1306,7 @@ pub fn discover_acp_runtimes_from(
mcp_config_placement: McpConfigPlacement::Unsupported,
mcp_transports: Vec::new(),
mcp_native_transports: Vec::new(),
+ mcp_registry_available: false,
});
}
}
diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs
index fe1284b141f..b16186b42ea 100644
--- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs
@@ -91,6 +91,7 @@ pub(super) fn preset_catalog_entry(
mcp_config_placement: crate::managed_agents::McpConfigPlacement::Unsupported,
mcp_transports: Vec::new(),
mcp_native_transports: Vec::new(),
+ mcp_registry_available: false,
}
}
diff --git a/desktop/src-tauri/src/managed_agents/mcp_registry/apply.rs b/desktop/src-tauri/src/managed_agents/mcp_registry/apply.rs
index cfad2b1b589..77917f777df 100644
--- a/desktop/src-tauri/src/managed_agents/mcp_registry/apply.rs
+++ b/desktop/src-tauri/src/managed_agents/mcp_registry/apply.rs
@@ -139,6 +139,51 @@ pub fn selection_for_record(
}
}
+pub fn selections_for_records(
+ records: &[ManagedAgentRecord],
+ personas: &[crate::managed_agents::AgentDefinition],
+ global: &crate::managed_agents::GlobalAgentConfig,
+ secrets: &S,
+) -> Vec {
+ let mut selections: Vec = records
+ .iter()
+ .map(|record| {
+ let runtime_id = crate::managed_agents::resolve_effective_harness_descriptor(
+ record, personas, global,
+ )
+ .map(|descriptor| descriptor.command)
+ .unwrap_or_else(|_| record.agent_command.clone());
+ let meta = crate::managed_agents::known_acp_runtime(&runtime_id);
+ selection_for_record(record, meta, &runtime_id)
+ })
+ .collect();
+
+ // Retire any agent that holds mcp: state in the secret store but has been
+ // removed from managed-agents records. Passing it with an empty selection
+ // allows converge() to clean up its artefacts and delete its secret keys
+ // rather than failing with ConvergeError::MissingAgent.
+ if let Ok(existing) = secrets.read_all() {
+ let seen_agents: std::collections::BTreeSet =
+ selections.iter().map(|s| s.agent_id.clone()).collect();
+ let mut retired = std::collections::BTreeSet::new();
+ for key in existing.keys() {
+ if let Some(agent_id) = super::converge::agent_of_key(key) {
+ if !seen_agents.contains(agent_id) && retired.insert(agent_id.to_string()) {
+ selections.push(AgentSelection {
+ agent_id: agent_id.to_string(),
+ runtime_id: "retiring".to_string(),
+ transports: Vec::new(),
+ placement: crate::managed_agents::McpConfigPlacement::Unsupported,
+ enabled: Vec::new(),
+ });
+ }
+ }
+ }
+ }
+
+ selections
+}
+
/// The durable secret store, as the generation store and the convergence see
/// it.
///
@@ -216,6 +261,17 @@ impl SecretStoreIo for DesktopSecrets {
pub fn converge_now(
app: &AppHandle,
pending: &std::collections::BTreeMap,
+) -> Result {
+ let records = crate::managed_agents::load_managed_agents(app)?;
+ converge_now_with_records(app, &records, pending)
+}
+
+/// Stage and adopt one generation from the registry document and the provided
+/// agent records.
+pub fn converge_now_with_records(
+ app: &AppHandle,
+ records: &[ManagedAgentRecord],
+ pending: &std::collections::BTreeMap,
) -> Result {
let Some(paths) = crate::managed_agents::runtime::mcp_registry_paths(app)? else {
return Err(
@@ -234,29 +290,12 @@ pub fn converge_now(
let launcher = checked_launcher(&launcher_path)?;
let registry = load_registry(&paths.document()).map_err(|e| e.to_string())?;
- let records = crate::managed_agents::load_managed_agents(app)?;
let personas = crate::managed_agents::load_personas(app).unwrap_or_default();
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
+ let secrets = DesktopSecrets::new(crate::app_state::keyring_service());
- let selections: Vec = records
- .iter()
- .map(|record| {
- // A dangling harness id degrades to the record's own snapshot
- // rather than failing the whole convergence: the agent is already
- // unspawnable for a reason the spawn path reports, and dropping it
- // here would breach the whole-set rule and revoke every other
- // agent's configuration too.
- let runtime_id = crate::managed_agents::resolve_effective_harness_descriptor(
- record, &personas, &global,
- )
- .map(|descriptor| descriptor.command)
- .unwrap_or_else(|_| record.agent_command.clone());
- let meta = crate::managed_agents::known_acp_runtime(&runtime_id);
- selection_for_record(record, meta, &runtime_id)
- })
- .collect();
+ let selections = selections_for_records(records, &personas, &global, &secrets);
- let secrets = DesktopSecrets::new(crate::app_state::keyring_service());
converge(
&paths,
®istry,
diff --git a/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs b/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs
index 978f8f54c46..7de4c70a7ba 100644
--- a/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/mcp_registry/apply_tests.rs
@@ -1044,3 +1044,111 @@ fn walk(root: &Path) -> Vec {
}
found
}
+
+#[test]
+fn mcp_registry_a_pending_secret_for_an_unselected_server_is_not_silently_dropped() {
+ let temporary = tempfile::tempdir().expect("tempdir");
+ let root = temporary.path();
+ let store = FakeStore::default();
+ let body = document(
+ "{\"id\":\"auth\",\"name\":\"auth\",\"transport\":\"stdio\",\
+ \"command\":\"/usr/local/bin/auth\",\"args\":[],\"env\":{\"API_KEY\":\"mcp:api-key\"}}",
+ );
+ let pending = BTreeMap::from([("api-key".to_string(), "sk-live-do-not-log".to_string())]);
+
+ // Converge with no agent enabling "auth".
+ converge_with(
+ root,
+ &body,
+ &[selection(&[], &[McpTransport::Stdio])],
+ &store,
+ &pending,
+ )
+ .expect("first convergence without agent selection");
+
+ // Later, agent enables "auth" with no new pending secret.
+ converge_with(
+ root,
+ &body,
+ &[selection(&["auth"], &[McpTransport::Stdio])],
+ &store,
+ &BTreeMap::new(),
+ )
+ .expect("second convergence with agent selection");
+
+ let records = store.records.lock().unwrap().clone();
+ let key = format!("mcp:{AGENT}:2:api-key");
+ assert_eq!(
+ records.get(&key).map(String::as_str),
+ Some("sk-live-do-not-log"),
+ "the secret value must be durably retrievable by the agent's later toggle-on convergence; got {:?}",
+ records.keys().collect::>()
+ );
+}
+
+#[test]
+fn mcp_registry_deleted_agent_retires_mcp_state_and_allows_subsequent_convergence() {
+ let temporary = tempfile::tempdir().expect("tempdir");
+ let root = temporary.path();
+ let store = FakeStore::default();
+ let body = document(
+ "{\"id\":\"auth\",\"name\":\"auth\",\"transport\":\"stdio\",\
+ \"command\":\"/usr/local/bin/auth\",\"args\":[],\"env\":{\"API_KEY\":\"mcp:api-key\"}}",
+ );
+ let agent_a = "aaaaaaaaaaaaaaaa";
+ let agent_b = "bbbbbbbbbbbbbbbb";
+ let sel_a = AgentSelection {
+ agent_id: agent_a.to_string(),
+ runtime_id: "buzz-agent".to_string(),
+ transports: vec![McpTransport::Stdio],
+ placement: McpConfigPlacement::Unsupported,
+ enabled: vec!["auth".to_string()],
+ };
+ let sel_b = AgentSelection {
+ agent_id: agent_b.to_string(),
+ runtime_id: "buzz-agent".to_string(),
+ transports: vec![McpTransport::Stdio],
+ placement: McpConfigPlacement::Unsupported,
+ enabled: vec![],
+ };
+ let pending = BTreeMap::from([("api-key".to_string(), "sk-secret".to_string())]);
+
+ // 1. Initial convergence: both A and B exist, A has auth enabled.
+ converge_with(root, &body, &[sel_a, sel_b.clone()], &store, &pending)
+ .expect("initial convergence");
+ assert!(store
+ .records
+ .lock()
+ .unwrap()
+ .contains_key(&format!("mcp:{agent_a}:1:api-key")));
+
+ // 2. Delete A: simulate post-deletion convergence with records = [B]
+ let mut rec_b = record();
+ rec_b.pubkey = agent_b.to_string();
+ let records = vec![rec_b];
+
+ let selections =
+ super::apply::selections_for_records(&records, &[], &Default::default(), &store);
+ converge_with(root, &body, &selections, &store, &BTreeMap::new())
+ .expect("post-deletion convergence must retire deleted agent");
+
+ // 3. Subsequent convergence for remaining agent B must succeed without MissingAgent
+ let subsequent = converge_with(root, &body, &[sel_b], &store, &BTreeMap::new());
+ assert!(
+ subsequent.is_ok(),
+ "must succeed without MissingAgent, got: {:?}",
+ subsequent
+ );
+
+ // 4. Assert A's keys in store are gone
+ let current_keys = store.records.lock().unwrap().clone();
+ let a_keys: Vec<_> = current_keys
+ .keys()
+ .filter(|k| k.contains(agent_a))
+ .collect();
+ assert!(
+ a_keys.is_empty(),
+ "agent A keys should be gone, found: {:?}",
+ a_keys
+ );
+}
diff --git a/desktop/src-tauri/src/managed_agents/mcp_registry/converge.rs b/desktop/src-tauri/src/managed_agents/mcp_registry/converge.rs
index de6af2aacb3..e8deabafee1 100644
--- a/desktop/src-tauri/src/managed_agents/mcp_registry/converge.rs
+++ b/desktop/src-tauri/src/managed_agents/mcp_registry/converge.rs
@@ -291,6 +291,22 @@ pub fn converge(
}
}
+ let declared_refs: std::collections::BTreeSet = registry
+ .entries
+ .iter()
+ .flat_map(|entry| entry_references(&entry.entry))
+ .map(|r| r.id().to_string())
+ .collect();
+
+ for (ref_id, value) in *pending {
+ let accounted_for = carried
+ .keys()
+ .any(|key| key.ends_with(&format!(":{ref_id}")));
+ if !accounted_for && declared_refs.contains(ref_id) {
+ carried.insert(holding_key(ref_id), value.clone());
+ }
+ }
+
// Handed to the store rather than written here: `commit` names
// every one of these keys in the `PREPARED` journal before it
// writes any of them, so a crash mid-write leaves debt the next
@@ -298,7 +314,7 @@ pub fn converge(
// the mutation ahead of its own record.
Ok(GenerationPlan {
files,
- deletions: stale_secret_deletions(&existing, next),
+ deletions: stale_secret_deletions(&existing, next, &declared_refs),
secrets: carried,
})
},
@@ -322,9 +338,25 @@ pub fn converge(
})
}
+/// Holding key prefix for staged, generation-independent secrets.
+pub const HOLDING_KEY_PREFIX: &str = "mcp:#holding:";
+
+/// Blob key holding an unassigned pending secret for a declared registry reference.
+pub fn holding_key(ref_id: &str) -> String {
+ format!("{HOLDING_KEY_PREFIX}{ref_id}")
+}
+
+/// Whether `key` is a holding key for an unassigned pending secret.
+pub fn is_holding_key(key: &str) -> bool {
+ key.starts_with(HOLDING_KEY_PREFIX)
+}
+
/// The agent id in an `mcp:::` blob key, or `None`
/// when the key is not one.
-fn agent_of_key(key: &str) -> Option<&str> {
+pub(crate) fn agent_of_key(key: &str) -> Option<&str> {
+ if is_holding_key(key) {
+ return None;
+ }
let rest = key.strip_prefix(MCP_NAMESPACE_PREFIX)?;
let agent = rest.split(':').next()?;
(!agent.is_empty()).then_some(agent)
@@ -356,6 +388,11 @@ fn carry_secrets(
carried.insert(storage_key(capability, &reference), value.clone());
continue;
}
+ // An unassigned pending secret held across convergences:
+ if let Some(value) = existing.get(&holding_key(reference.id())) {
+ carried.insert(storage_key(capability, &reference), value.clone());
+ continue;
+ }
let Some(base) = base else {
continue;
};
@@ -397,11 +434,24 @@ fn entry_references(entry: &RegistryEntry) -> Vec {
/// Retention keeps one rollback *generation directory*, but not its secrets: a
/// rollback that could still authenticate a deleted server is the thing this
/// convergence exists to prevent.
-fn stale_secret_deletions(existing: &BTreeMap, adopted: u64) -> Vec {
+fn stale_secret_deletions(
+ existing: &BTreeMap,
+ adopted: u64,
+ declared_refs: &std::collections::BTreeSet,
+) -> Vec {
let keep = format!(":{adopted}:");
existing
.keys()
- .filter(|key| key.starts_with(MCP_NAMESPACE_PREFIX) && !key.contains(&keep))
+ .filter(|key| {
+ if !key.starts_with(MCP_NAMESPACE_PREFIX) {
+ return false;
+ }
+ if is_holding_key(key) {
+ let ref_id = key.strip_prefix(HOLDING_KEY_PREFIX).unwrap_or("");
+ return !declared_refs.contains(ref_id);
+ }
+ !key.contains(&keep)
+ })
.map(|key| Deletion::Secret { key: key.clone() })
.collect()
}
diff --git a/desktop/src-tauri/src/managed_agents/mcp_registry/load.rs b/desktop/src-tauri/src/managed_agents/mcp_registry/load.rs
index 4ee2fd01cf0..da7b09070e7 100644
--- a/desktop/src-tauri/src/managed_agents/mcp_registry/load.rs
+++ b/desktop/src-tauri/src/managed_agents/mcp_registry/load.rs
@@ -193,7 +193,7 @@ pub fn parse_registry(bytes: &[u8]) -> Result {
/// plus one byte.
///
/// `Ok(None)` when the file does not exist.
-fn read_bounded_no_follow(path: &Path) -> Result