feat(desktop): show read-only MCP server config (#1780)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-13 16:29:21 -04:00
committed by GitHub
parent deed64a14f
commit 51ee1c473d
11 changed files with 304 additions and 40 deletions

View File

@@ -198,7 +198,8 @@ const overrides = new Map([
// loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature.
// agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with
// failed_restart_count (+2 lines). Queued to split with the rest of this list.
["src/shared/api/types.ts", 1030],
// mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling.
["src/shared/api/types.ts", 1031],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with

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, path::Path, 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,68 @@ 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");
let expected_suffix = Path::new(".config").join("goose").join("config.yaml");
assert!(
Path::new(&path).ends_with(&expected_suffix),
"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)
});
let expected_path = Path::new("/tmp/buzz-goose-root")
.join("config")
.join("config.yaml");
assert_eq!(
surface
.sources
.mcp_config_file_path
.as_deref()
.map(Path::new),
Some(expected_path.as_path())
);
}
#[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,7 +366,8 @@ export function AgentConfigPanel({
);
}
const { normalized, advanced, sources, isPreSpawn } = data;
const { normalized, advanced, extensions, runtimeId, sources, isPreSpawn } =
data;
const configFilePath = sources.configFilePath;
const normalizedEntries = (
@@ -416,8 +418,17 @@ export function AgentConfigPanel({
)}
</div>
<McpServersSection
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">
<p className="px-4 py-3 text-xs font-medium text-foreground">
Advanced
</p>
{advanced.map((field) => (
<AdvancedRow
key={field.key}

View File

@@ -0,0 +1,102 @@
import type * as React from "react";
import { CheckCircle2, CircleSlash } from "lucide-react";
import type { ExtensionEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
type McpServersSectionProps = {
extensions: ExtensionEntry[];
runtimeId: string | null;
variant?: "compact" | "profile";
buzzAgentSlot?: React.ReactNode;
};
export function McpServersSection({
buzzAgentSlot,
extensions,
runtimeId,
variant = "compact",
}: McpServersSectionProps) {
const isBuzzAgent = runtimeId === "buzz-agent";
if (!isBuzzAgent && extensions.length === 0) {
return null;
}
return (
<div
className={cn(
"border-t border-border/50",
variant === "compact" ? "mt-3 pt-2" : "divide-y divide-border/50",
)}
>
<p
className={cn(
"text-xs font-medium text-foreground",
variant === "compact" ? "py-2" : "px-4 py-3",
)}
>
MCP Servers
</p>
{isBuzzAgent && buzzAgentSlot ? buzzAgentSlot : null}
{extensions.length > 0 ? (
<div className="divide-y divide-border/50">
{extensions.map((extension) => (
<McpServerRow
extension={extension}
key={`${extension.kind}:${extension.name}`}
variant={variant}
/>
))}
</div>
) : (
<p
className={cn(
"text-sm text-muted-foreground",
variant === "compact" ? "py-2" : "px-4 py-3",
)}
>
No custom servers configured
</p>
)}
</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
@@ -1317,45 +1318,26 @@ function buildMockConfigSurface(pubkey: string): {
},
advanced: [
{
key: "extensions.developer",
label: "Extension: developer",
value: "enabled",
key: "active_provider",
label: "active_provider",
value: "openai",
origin: "configFile",
schemaType: { type: "enum", options: ["enabled", "disabled"] },
writeVia: {
type: "gooseNativeConfigWrite",
configKey: "goose.extensions.developer",
},
},
{
key: "extensions.web_search",
label: "Extension: web_search",
value: "enabled",
origin: "configFile",
schemaType: { type: "enum", options: ["enabled", "disabled"] },
writeVia: {
type: "gooseNativeConfigWrite",
configKey: "goose.extensions.web_search",
},
},
{
key: "extensions.memory",
label: "Extension: memory",
value: "disabled",
origin: "configFile",
schemaType: { type: "enum", options: ["enabled", "disabled"] },
writeVia: {
type: "gooseNativeConfigWrite",
configKey: "goose.extensions.memory",
},
schemaType: { type: "string" },
writeVia: { type: "readOnly" },
},
],
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 +1391,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 +1451,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 +1527,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 +1589,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,19 +1649,36 @@ 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",
},
};
// Map well-known test pubkeys to specific fixtures
// Synthetic agent for the multi-origin provenance showcase (not a TEST_IDENTITY).
const buzzAgentSurface = {
...gooseSurface,
runtimeId: "buzz-agent",
runtimeLabel: "Buzz Agent",
advanced: [],
extensions: [],
sources: {
...gooseSurface.sources,
configFilePath: null,
mcpConfigFilePath: null,
},
};
// Map well-known test pubkeys to specific fixtures.
// Synthetic agents are intentionally not TEST_IDENTITIES.
const PUBKEY_MULTI_ORIGIN =
"abc1230000000000000000000000000000000000000000000000000000000def";
const PUBKEY_BUZZ_AGENT =
"b0220000000000000000000000000000000000000000000000000000000000a9";
switch (pubkey) {
case ALICE_PUBKEY:
@@ -1678,6 +1691,8 @@ function buildMockConfigSurface(pubkey: string): {
return runtimeOverrideSurface;
case PUBKEY_MULTI_ORIGIN:
return multiOriginSurface;
case PUBKEY_BUZZ_AGENT:
return buzzAgentSurface;
default:
return gooseSurface;
}

View File

@@ -12,6 +12,8 @@ const RUNTIME_OVERRIDE_PUBKEY = TEST_IDENTITIES.outsider.pubkey;
// (matches PUBKEY_MULTI_ORIGIN in e2eBridge buildMockConfigSurface).
const MULTI_ORIGIN_PUBKEY =
"abc1230000000000000000000000000000000000000000000000000000000def";
const BUZZ_AGENT_PUBKEY =
"b0220000000000000000000000000000000000000000000000000000000000a9";
const MANAGED_AGENTS = [
{
@@ -243,14 +245,43 @@ test.describe("config bridge screenshots", () => {
const panel = await openAgentProfileFromChannel(page, "Goose Agent");
// Advanced runtime fields render directly in the profile panel's flat list.
await expect(panel.getByText("Extension: developer")).toBeVisible();
// Advanced runtime fields render directly in the profile panel's flat list,
// grouped under their own "Advanced" header.
await expect(panel.getByText("active_provider")).toBeVisible();
await expect(panel.getByText("Advanced", { exact: true })).toHaveCount(1);
// MCP servers render once each under their group label.
await expect(panel.getByText("developer", { exact: true })).toHaveCount(1);
await expect(panel.getByText("MCP Servers", { exact: true })).toHaveCount(
1,
);
await settleAnimations(panel);
await panel.screenshot({ path: `${SHOTS}/05-advanced-expanded.png` });
});
test("06 — profile side panel — Configuration section", async ({ page }) => {
test("06 — buzz-agent empty MCP servers", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: BUZZ_AGENT_PUBKEY,
name: "Buzz Agent",
status: "running" as const,
channelNames: ["agents"],
},
],
});
const panel = await openAgentProfileFromChannel(page, "Buzz Agent");
await expect(
panel.getByText("No custom servers configured", { exact: true }),
).toBeVisible();
await expect(panel.getByText("MCP Servers", { exact: true })).toHaveCount(
1,
);
});
test("07 — profile side panel — Configuration section", async ({ page }) => {
// charlie (554cef…) is the well-known test pubkey that the mock bridge
// seeds as a bot owned by the test viewer, so isBot + isOwner + managedAgent
// are all true — the Configuration section renders in the profile panel.