feat(desktop): surface read-only MCP servers

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-07-12 20:09:29 -04:00
parent bc14052d39
commit 09a9263bff
9 changed files with 277 additions and 4 deletions

View File

@@ -122,7 +122,7 @@ fn toml_string(table: &toml::Table, key: &str) -> Option<String> {
.map(str::to_string)
}
fn codex_config_path() -> Option<std::path::PathBuf> {
pub(crate) fn codex_config_path() -> Option<std::path::PathBuf> {
if let Ok(home) = std::env::var("CODEX_HOME") {
return Some(std::path::PathBuf::from(home).join("config.toml"));
}

View File

@@ -154,7 +154,7 @@ fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option<String> {
.map(str::to_string)
}
fn goose_config_path() -> Option<PathBuf> {
pub(crate) fn goose_config_path() -> Option<PathBuf> {
if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") {
return Some(PathBuf::from(root).join("config").join("config.yaml"));
}

View File

@@ -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<String> {
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<String>,

View File

@@ -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<T>(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();

View File

@@ -123,6 +123,7 @@ pub struct ConfigSourceReport {
pub env_vars: ConfigTierStatus,
pub config_file: ConfigTierStatus,
pub config_file_path: Option<String>,
pub mcp_config_file_path: Option<String>,
}
/// 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<ConfigField>,
pub extensions: Vec<ExtensionEntry>,
pub sources: ConfigSourceReport,
}

View File

@@ -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({
)}
</div>
<McpServersSection
configFilePath={mcpConfigFilePath}
extensions={extensions}
runtimeId={runtimeId}
variant={advancedMode === "flat" ? "profile" : "compact"}
/>
{advanced.length > 0 && advancedMode === "flat" ? (
<div className="divide-y divide-border/50 border-t border-border/50">
{advanced.map((field) => (

View File

@@ -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 (
<div
className={cn(
"border-t border-border/50",
variant === "compact" ? "mt-3 pt-2" : "divide-y divide-border/50",
)}
>
<div
className={cn(
"flex min-w-0 items-start gap-3",
variant === "compact" ? "py-2" : "px-4 py-3",
)}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Plug className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-foreground">
MCP Servers
</div>
<div className="mt-0.5 truncate text-sm text-muted-foreground">
{extensions.length > 0
? `${extensions.length} configured`
: isBuzzAgent
? "No custom servers configured"
: "No servers found"}
</div>
</div>
{configFilePath ? (
<button
aria-label="Reveal MCP config file"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={revealConfigFile}
title="Reveal MCP config file"
type="button"
>
<FolderOpen className="h-4 w-4" />
</button>
) : null}
</div>
{configFilePath ? (
<div className="mt-1 truncate text-2xs text-muted-foreground/70">
Edit in {configFilePath}
</div>
) : null}
</div>
</div>
{isBuzzAgent && buzzAgentSlot ? buzzAgentSlot : null}
{extensions.length > 0 ? (
<div
className={cn(
"divide-y divide-border/50",
variant === "compact" ? "mt-1" : "",
)}
>
{extensions.map((extension) => (
<McpServerRow
extension={extension}
key={`${extension.kind}:${extension.name}`}
variant={variant}
/>
))}
</div>
) : null}
</div>
);
}
function McpServerRow({
extension,
variant,
}: {
extension: ExtensionEntry;
variant: "compact" | "profile";
}) {
const StatusIcon = extension.enabled ? CheckCircle2 : CircleSlash;
return (
<div
className={cn(
"flex min-w-0 items-center gap-3",
variant === "compact" ? "py-2" : "px-4 py-3",
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/50">
<StatusIcon
className={cn(
"h-4 w-4",
extension.enabled ? "text-emerald-600" : "text-muted-foreground",
)}
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{extension.name}
</span>
<span className="mt-0.5 block truncate text-2xs text-muted-foreground/70">
{extension.kind}
{extension.enabled ? " enabled" : " disabled"}
</span>
</span>
</div>
);
}

View File

@@ -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;
};

View File

@@ -1265,6 +1265,7 @@ function buildMockConfigSurface(pubkey: string): {
isPreSpawn: boolean;
normalized: Record<string, unknown>;
advanced: unknown[];
extensions: unknown[];
sources: Record<string, unknown>;
} {
// 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",
},
};