mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
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:
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
144
desktop/src/features/agents/ui/McpServersSection.tsx
Normal file
144
desktop/src/features/agents/ui/McpServersSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user