diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs index 5ef8677c64..c7c7135ccb 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs @@ -122,7 +122,7 @@ fn toml_string(table: &toml::Table, key: &str) -> Option { .map(str::to_string) } -fn codex_config_path() -> Option { +pub(crate) fn codex_config_path() -> Option { if let Ok(home) = std::env::var("CODEX_HOME") { return Some(std::path::PathBuf::from(home).join("config.toml")); } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index 914acf3f59..fce54edc40 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -154,7 +154,7 @@ fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option { .map(str::to_string) } -fn goose_config_path() -> Option { +pub(crate) fn goose_config_path() -> Option { if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { return Some(PathBuf::from(root).join("config").join("config.yaml")); } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 3564b19423..372d2cfde1 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -167,6 +167,8 @@ pub(crate) fn read_config_surface( let config_file_path = runtime_meta .and_then(|m| m.config_file_path) .map(resolve_tilde); + let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime); + let extensions = file_config.extensions.clone(); let sources = ConfigSourceReport { acp_native: if supports_acp_native { @@ -197,6 +199,7 @@ pub(crate) fn read_config_surface( ConfigTierStatus::NotApplicable }, config_file_path, + mcp_config_file_path, }; RuntimeConfigSurface { @@ -205,10 +208,24 @@ pub(crate) fn read_config_surface( is_pre_spawn, normalized, advanced, + extensions, sources, } } +fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option { + match runtime.id { + "goose" => { + super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) + } + "claude" => Some(resolve_tilde("~/.claude.json")), + "codex" => { + super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) + } + _ => None, + } +} + #[allow(clippy::too_many_arguments)] fn build_model_field( record_model: &Option, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 1444bc71d6..367168896a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -2,12 +2,31 @@ //! `reader.rs` stays under the 1000-line budget; `#[path]`-included from //! there). -use std::collections::BTreeMap; +use std::{collections::BTreeMap, sync::Mutex}; use super::*; use crate::managed_agents::discovery::KnownAcpRuntime; use crate::managed_agents::types::ManagedAgentRecord; +static GOOSE_PATH_ROOT_LOCK: Mutex<()> = Mutex::new(()); + +fn with_goose_path_root(value: Option<&str>, body: impl FnOnce() -> T) -> T { + let _guard = GOOSE_PATH_ROOT_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let prior = std::env::var_os("GOOSE_PATH_ROOT"); + match value { + Some(value) => std::env::set_var("GOOSE_PATH_ROOT", value), + None => std::env::remove_var("GOOSE_PATH_ROOT"), + } + let output = body(); + match prior { + Some(value) => std::env::set_var("GOOSE_PATH_ROOT", value), + None => std::env::remove_var("GOOSE_PATH_ROOT"), + } + output +} + fn test_runtime() -> &'static KnownAcpRuntime { &KnownAcpRuntime { id: "goose", @@ -111,6 +130,60 @@ fn pre_spawn_surface_reports_pending_acp_tiers() { assert_eq!(surface.sources.env_vars, ConfigTierStatus::Available); } +#[test] +fn surface_reports_mcp_specific_config_path() { + let record = test_record(); + let runtime = test_runtime(); + let surface = with_goose_path_root(None, || { + read_config_surface(&record, Some(runtime), None, None) + }); + + let path = surface + .sources + .mcp_config_file_path + .expect("mcp config path"); + assert!( + path.ends_with(".config/goose/config.yaml"), + "unexpected MCP config path: {path}" + ); +} + +#[test] +fn goose_mcp_config_path_follows_path_root_override() { + let record = test_record(); + let runtime = test_runtime(); + let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || { + read_config_surface(&record, Some(runtime), None, None) + }); + + assert_eq!( + surface.sources.mcp_config_file_path.as_deref(), + Some("/tmp/buzz-goose-root/config/config.yaml") + ); +} + +#[test] +fn claude_surface_uses_mcp_config_path_not_settings_path() { + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let surface = read_config_surface(&record, Some(runtime), None, None); + + assert!(surface + .sources + .config_file_path + .as_deref() + .is_some_and(|path| path.ends_with(".claude/settings.json"))); + assert!(surface + .sources + .mcp_config_file_path + .as_deref() + .is_some_and(|path| path.ends_with(".claude.json"))); +} + #[test] fn record_model_overrides_file_model() { let mut record = test_record(); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 17589bad53..15ccb718e7 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -123,6 +123,7 @@ pub struct ConfigSourceReport { pub env_vars: ConfigTierStatus, pub config_file: ConfigTierStatus, pub config_file_path: Option, + pub mcp_config_file_path: Option, } /// Full config surface returned to the frontend. @@ -134,6 +135,7 @@ pub struct RuntimeConfigSurface { pub is_pre_spawn: bool, pub normalized: NormalizedConfig, pub advanced: Vec, + pub extensions: Vec, pub sources: ConfigSourceReport, } diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index df314a6bf8..950df90cb9 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -17,6 +17,7 @@ import { useAgentConfigSurface } from "../hooks"; import { cn } from "@/shared/lib/cn"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { Spinner } from "@/shared/ui/spinner"; +import { McpServersSection } from "./McpServersSection"; import type { ConfigField, ConfigOrigin, @@ -365,8 +366,10 @@ export function AgentConfigPanel({ ); } - const { normalized, advanced, sources, isPreSpawn } = data; + const { normalized, advanced, extensions, runtimeId, sources, isPreSpawn } = + data; const configFilePath = sources.configFilePath; + const mcpConfigFilePath = sources.mcpConfigFilePath; const normalizedEntries = ( Object.entries(normalized) as [ @@ -416,6 +419,13 @@ export function AgentConfigPanel({ )} + + {advanced.length > 0 && advancedMode === "flat" ? (
{advanced.map((field) => ( diff --git a/desktop/src/features/agents/ui/McpServersSection.tsx b/desktop/src/features/agents/ui/McpServersSection.tsx new file mode 100644 index 0000000000..521d80979f --- /dev/null +++ b/desktop/src/features/agents/ui/McpServersSection.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; +import { CheckCircle2, CircleSlash, FolderOpen, Plug } from "lucide-react"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import type { ExtensionEntry } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +type McpServersSectionProps = { + configFilePath: string | null; + extensions: ExtensionEntry[]; + runtimeId: string | null; + variant?: "compact" | "profile"; + buzzAgentSlot?: React.ReactNode; +}; + +export function McpServersSection({ + buzzAgentSlot, + configFilePath, + extensions, + runtimeId, + variant = "compact", +}: McpServersSectionProps) { + const isBuzzAgent = runtimeId === "buzz-agent"; + const shouldRender = + isBuzzAgent || extensions.length > 0 || configFilePath !== null; + + const revealConfigFile = React.useCallback(() => { + if (!configFilePath) { + return; + } + void revealItemInDir(configFilePath); + }, [configFilePath]); + + if (!shouldRender) { + return null; + } + + return ( +
+
+ + + +
+
+
+
+ MCP Servers +
+
+ {extensions.length > 0 + ? `${extensions.length} configured` + : isBuzzAgent + ? "No custom servers configured" + : "No servers found"} +
+
+ {configFilePath ? ( + + ) : null} +
+ {configFilePath ? ( +
+ Edit in {configFilePath} +
+ ) : null} +
+
+ + {isBuzzAgent && buzzAgentSlot ? buzzAgentSlot : null} + + {extensions.length > 0 ? ( +
+ {extensions.map((extension) => ( + + ))} +
+ ) : null} +
+ ); +} + +function McpServerRow({ + extension, + variant, +}: { + extension: ExtensionEntry; + variant: "compact" | "profile"; +}) { + const StatusIcon = extension.enabled ? CheckCircle2 : CircleSlash; + + return ( +
+ + + + + + {extension.name} + + + {extension.kind} + {extension.enabled ? " enabled" : " disabled"} + + +
+ ); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 2156325634..5066b2a370 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -679,8 +679,11 @@ export type ConfigSourceReport = { envVars: ConfigTierStatus; configFile: ConfigTierStatus; configFilePath: string | null; + mcpConfigFilePath: string | null; }; +export type ExtensionEntry = { name: string; kind: string; enabled: boolean }; + export type NormalizedConfig = { model: NormalizedField | null; provider: NormalizedField | null; @@ -697,6 +700,7 @@ export type RuntimeConfigSurface = { isPreSpawn: boolean; normalized: NormalizedConfig; advanced: ConfigField[]; + extensions: ExtensionEntry[]; sources: ConfigSourceReport; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9b96c36d35..b8b244bb72 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1265,6 +1265,7 @@ function buildMockConfigSurface(pubkey: string): { isPreSpawn: boolean; normalized: Record; advanced: unknown[]; + extensions: unknown[]; sources: Record; } { // Goose running — mixed origins, override on model @@ -1350,12 +1351,18 @@ function buildMockConfigSurface(pubkey: string): { }, }, ], + extensions: [ + { name: "developer", kind: "stdio", enabled: true }, + { name: "web_search", kind: "stdio", enabled: true }, + { name: "memory", kind: "stdio", enabled: false }, + ], sources: { acpNative: "available", acpConfigOptions: "available", envVars: "available", configFile: "available", configFilePath: "~/.config/goose/config.yaml", + mcpConfigFilePath: "~/.config/goose/config.yaml", }, }; @@ -1409,12 +1416,17 @@ function buildMockConfigSurface(pubkey: string): { systemPrompt: null, }, advanced: [], + extensions: [ + { name: "filesystem", kind: "mcp", enabled: true }, + { name: "github", kind: "mcp", enabled: true }, + ], sources: { acpNative: "available", acpConfigOptions: "available", envVars: "notApplicable", configFile: "available", configFilePath: "~/.claude/settings.json", + mcpConfigFilePath: "~/.claude.json", }, }; @@ -1464,12 +1476,14 @@ function buildMockConfigSurface(pubkey: string): { systemPrompt: null, }, advanced: [], + extensions: [{ name: "developer", kind: "stdio", enabled: true }], sources: { acpNative: "pending", acpConfigOptions: "pending", envVars: "available", configFile: "available", configFilePath: "~/.config/goose/config.yaml", + mcpConfigFilePath: "~/.config/goose/config.yaml", }, }; @@ -1538,12 +1552,17 @@ function buildMockConfigSurface(pubkey: string): { writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_SANDBOX_MODE" }, }, ], + extensions: [ + { name: "filesystem", kind: "mcp", enabled: true }, + { name: "github", kind: "mcp", enabled: true }, + ], sources: { acpNative: "notApplicable", acpConfigOptions: "notApplicable", envVars: "available", configFile: "available", configFilePath: "~/.codex/config.toml", + mcpConfigFilePath: "~/.codex/config.toml", }, }; @@ -1595,12 +1614,14 @@ function buildMockConfigSurface(pubkey: string): { systemPrompt: null, }, advanced: [], + extensions: [{ name: "web_search", kind: "stdio", enabled: true }], sources: { acpNative: "available", acpConfigOptions: "available", envVars: "available", configFile: "available", configFilePath: "~/.config/goose/config.yaml", + mcpConfigFilePath: "~/.config/goose/config.yaml", }, }; @@ -1653,12 +1674,14 @@ function buildMockConfigSurface(pubkey: string): { systemPrompt: null, }, advanced: [], + extensions: [], sources: { acpNative: "available", acpConfigOptions: "available", envVars: "available", configFile: "available", configFilePath: "~/.config/goose/config.yaml", + mcpConfigFilePath: "~/.config/goose/config.yaml", }, };