From b1954878de13cfff25793147b8985193797f932a Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 20 Jul 2026 00:06:28 +0800 Subject: [PATCH 1/3] feat(settings): read-only MCP Servers tab in the settings modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the web's "πŸ”Œ MCP Servers" settings surface in the TUI, so the cross-backend MCP registry is visible from every frontend (not just web). - protocol: `McpServerStatus` + a `mcp_servers` field on `SettingsSnapshot` (`#[serde(default)]` β†’ forward-compatible; older daemons deserialize fine). - state: `has_mcp_tab()` / `on_mcp_tab()` + `tab_count()` appends the synthetic MCP tab after the manifest tabs when the daemon reports servers. `tab_fields()` is already empty there, so field nav/edit/save no-op on the read-only tab. - ui: a header pill + a body renderer (`mcp_server_rows`) showing each server's health (connected/error/idle/disabled, colour-coded), transport Β· trust Β· tool count, error text, tool list, and backends; `built-in` badge for memory. Test: the synthetic tab appears only when servers exist, sits after the manifest tabs, exposes no fields, and disappears when the list empties. fmt clean; 284 tests pass; clippy advisory (no new error-level lints). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/codeoid-protocol/src/daemon.rs | 32 +++++++++ crates/codeoid-protocol/src/lib.rs | 10 +-- crates/codeoid-tui/src/state/mod.rs | 75 ++++++++++++++++++++- crates/codeoid-tui/src/ui/modal.rs | 94 ++++++++++++++++++++++++++- 4 files changed, 204 insertions(+), 7 deletions(-) diff --git a/crates/codeoid-protocol/src/daemon.rs b/crates/codeoid-protocol/src/daemon.rs index e27951a..c7bf0ac 100644 --- a/crates/codeoid-protocol/src/daemon.rs +++ b/crates/codeoid-protocol/src/daemon.rs @@ -291,6 +291,38 @@ pub struct SettingsSnapshot { pub secrets: HashMap, pub config_path: String, pub env_path: String, + /// Read-only registry MCP servers + live health (cross-backend mounter). + /// Absent from older daemons β€” defaults to empty so deserialization is + /// forward-compatible. + #[serde(default)] + pub mcp_servers: Vec, +} + +/// Read-only status of one registry MCP server, mirrored from the TS protocol +/// (`McpServerStatus`). Config comes from the daemon's registry; `health`/`tools` +/// reflect what the daemon-owned client has observed so far (no live probe). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpServerStatus { + pub name: String, + /// "stdio" | "http" | "in-process". + pub transport: String, + /// "readonly" | "prompt". + pub trust: String, + /// "global" | "workspace" | "session". + pub scope: String, + /// Backends this server mounts on; `None` = all. + #[serde(default)] + pub backends: Option>, + pub enabled: bool, + /// `codeoid_memory` β€” always present, not user-declared. + pub builtin: bool, + /// "connected" | "error" | "idle" | "disabled". + pub health: String, + pub tool_count: u32, + pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/codeoid-protocol/src/lib.rs b/crates/codeoid-protocol/src/lib.rs index 40ea199..63d4f11 100644 --- a/crates/codeoid-protocol/src/lib.rs +++ b/crates/codeoid-protocol/src/lib.rs @@ -42,11 +42,11 @@ pub use client::{ }; pub use daemon::{ AuthOkMsg, ClaudeConfigAgent, ClaudeConfigHook, ClaudeConfigMcpServer, ClaudeConfigScope, - ClaudeConfigSkill, DaemonMessage, ErrorCode, ModelInfo, ProviderCommand, SecretStatus, - SessionExportCounts, SessionExportManifest, SessionExportMetaSlim, SessionExportPayload, - SessionExportWorkdir, SessionSearchHit, SessionSearchSnippet, SessionUiRequestMsg, - SettingError, SettingField, SettingOption, SettingState, SettingsGroup, SettingsManifest, - SettingsSnapshot, SettingsTab, UiRequestMethod, UiResolvedReason, + ClaudeConfigSkill, DaemonMessage, ErrorCode, McpServerStatus, ModelInfo, ProviderCommand, + SecretStatus, SessionExportCounts, SessionExportManifest, SessionExportMetaSlim, + SessionExportPayload, SessionExportWorkdir, SessionSearchHit, SessionSearchSnippet, + SessionUiRequestMsg, SettingError, SettingField, SettingOption, SettingState, SettingsGroup, + SettingsManifest, SettingsSnapshot, SettingsTab, UiRequestMethod, UiResolvedReason, }; pub use message::{ ContentPart, IdentityType, MessageIdentity, MessageRole, SessionMessage, SessionMessageDelta, diff --git a/crates/codeoid-tui/src/state/mod.rs b/crates/codeoid-tui/src/state/mod.rs index 5bec62a..8ff5ff3 100644 --- a/crates/codeoid-tui/src/state/mod.rs +++ b/crates/codeoid-tui/src/state/mod.rs @@ -817,9 +817,28 @@ impl SettingsModal { .collect() } + /// True when the daemon reported registry MCP servers β€” surfaced as a + /// synthetic read-only tab appended after the manifest tabs. + #[must_use] + pub fn has_mcp_tab(&self) -> bool { + self.snapshot + .as_ref() + .is_some_and(|s| !s.mcp_servers.is_empty()) + } + + /// True when the synthetic MCP servers tab is the active tab. + #[must_use] + pub fn on_mcp_tab(&self) -> bool { + self.has_mcp_tab() + && self + .manifest + .as_ref() + .is_some_and(|m| self.tab == m.tabs.len()) + } + #[must_use] pub fn tab_count(&self) -> usize { - self.manifest.as_ref().map_or(0, |m| m.tabs.len()) + self.manifest.as_ref().map_or(0, |m| m.tabs.len()) + usize::from(self.has_mcp_tab()) } /// The field the cursor is on (cloned so callers avoid borrow conflicts). @@ -964,6 +983,60 @@ mod tests { assert_eq!(state.scroll_offset, 40); } + #[test] + fn mcp_tab_is_appended_after_manifest_tabs_when_servers_exist() { + use codeoid_protocol::{McpServerStatus, SettingsManifest, SettingsSnapshot, SettingsTab}; + let mut m = SettingsModal::new(); + m.manifest = Some(SettingsManifest { + version: 1, + tabs: vec![SettingsTab { + id: "general".into(), + title: "General".into(), + icon: None, + description: None, + groups: vec![], + }], + }); + // No snapshot β†’ no synthetic MCP tab. + assert!(!m.has_mcp_tab()); + assert_eq!(m.tab_count(), 1); + + // Snapshot with a registry server β†’ the MCP tab is appended. + m.snapshot = Some(SettingsSnapshot { + values: HashMap::new(), + secrets: HashMap::new(), + config_path: "c".into(), + env_path: "e".into(), + mcp_servers: vec![McpServerStatus { + name: "github".into(), + transport: "stdio".into(), + trust: "prompt".into(), + scope: "workspace".into(), + backends: None, + enabled: true, + builtin: false, + health: "idle".into(), + tool_count: 0, + tools: vec![], + error: None, + }], + }); + assert!(m.has_mcp_tab()); + assert_eq!(m.tab_count(), 2); + + // The last tab is the MCP tab; it's read-only, so it has no fields. + m.tab = 1; + assert!(m.on_mcp_tab()); + assert!(m.tab_fields().is_empty()); + assert!(m.selected_field().is_none()); + + // Empty server list β†’ the tab disappears again. + m.snapshot.as_mut().unwrap().mcp_servers.clear(); + assert!(!m.has_mcp_tab()); + assert_eq!(m.tab_count(), 1); + assert!(!m.on_mcp_tab()); + } + #[test] fn set_sessions_prunes_state_for_dead_sessions() { let mut state = AppState::new(AuthOkMsg { diff --git a/crates/codeoid-tui/src/ui/modal.rs b/crates/codeoid-tui/src/ui/modal.rs index 06a0378..ee7ccf1 100644 --- a/crates/codeoid-tui/src/ui/modal.rs +++ b/crates/codeoid-tui/src/ui/modal.rs @@ -73,6 +73,21 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, m: &SettingsModal) { pills.push(Span::styled(label, style)); pills.push(Span::raw(" ")); } + // Synthetic read-only tab for registry MCP servers (snapshot-backed, + // appended after the manifest tabs). + if m.has_mcp_tab() { + let active = m.tab == manifest.tabs.len(); + let style = if active { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + pills.push(Span::styled(" πŸ”Œ MCP Servers ".to_string(), style)); + pills.push(Span::raw(" ")); + } header_rows.push(Line::from(pills)); } else if m.loading { header_rows.push(hint_line("loading…")); @@ -89,7 +104,9 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, m: &SettingsModal) { Style::default().fg(Color::Red), ))); } - if let Some(manifest) = &m.manifest { + if m.on_mcp_tab() { + body_rows.extend(mcp_server_rows(m)); + } else if let Some(manifest) = &m.manifest { if let Some(tab) = manifest.tabs.get(m.tab) { if let Some(desc) = &tab.description { body_rows.push(Line::from(Span::styled( @@ -212,6 +229,81 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, m: &SettingsModal) { ); } +/// Body rows for the synthetic MCP servers tab: each registry server with its +/// health, transport/trust, tools, and any error. Read-only (no field editing). +fn mcp_server_rows(m: &SettingsModal) -> Vec> { + let mut rows: Vec> = Vec::new(); + rows.push(Line::from(Span::styled( + "Registry MCP servers, mounted on every backend (config + imported from ~/.claude.json). Read-only.".to_string(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ))); + rows.push(Line::raw("")); + let Some(snap) = &m.snapshot else { + return rows; + }; + for s in &snap.mcp_servers { + let (hlabel, hcolor) = match s.health.as_str() { + "connected" => ("connected", Color::Green), + "error" => ("error", Color::Red), + "disabled" => ("disabled", Color::DarkGray), + _ => ("idle", Color::DarkGray), + }; + let mut head: Vec> = vec![Span::styled( + format!("● {}", s.name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]; + if s.builtin { + head.push(Span::styled( + " built-in".to_string(), + Style::default().fg(Color::Magenta), + )); + } + head.push(Span::raw(" ")); + head.push(Span::styled( + format!("[{hlabel}]"), + Style::default().fg(hcolor), + )); + rows.push(Line::from(head)); + + let mut meta = format!(" {} Β· {}", s.transport, s.trust); + if s.tool_count > 0 { + let plural = if s.tool_count == 1 { "" } else { "s" }; + meta.push_str(&format!(" Β· {} tool{plural}", s.tool_count)); + } + rows.push(Line::from(Span::styled( + meta, + Style::default().fg(Color::DarkGray), + ))); + + if let Some(err) = &s.error { + rows.push(Line::from(vec![ + Span::raw(" "), + Span::styled(err.clone(), Style::default().fg(Color::Red)), + ])); + } + if !s.tools.is_empty() { + rows.push(Line::from(vec![ + Span::raw(" "), + Span::styled("tools: ", Style::default().fg(Color::DarkGray)), + Span::styled(s.tools.join(", "), Style::default().fg(Color::Gray)), + ])); + } + if let Some(backends) = &s.backends { + rows.push(Line::from(vec![ + Span::raw(" "), + Span::styled("backends: ", Style::default().fg(Color::DarkGray)), + Span::styled(backends.join(", "), Style::default().fg(Color::Gray)), + ])); + } + rows.push(Line::raw("")); + } + rows +} + /// One field row: cursor + label + its current control state + badges. fn settings_field_line( m: &SettingsModal, From b8c363a1bc634c70514b25c1e6b6b946da6b4666 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 20 Jul 2026 00:09:46 +0800 Subject: [PATCH 2/3] test(settings): render test for the MCP Servers tab (covers the render path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the settings modal on the synthetic MCP tab via TestBackend and asserts the pill, server names, built-in badge, health chip, and tool list appear β€” covering mcp_server_rows + the header pill branch (lifts patch coverage). --- crates/codeoid-tui/src/ui/modal.rs | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/codeoid-tui/src/ui/modal.rs b/crates/codeoid-tui/src/ui/modal.rs index ee7ccf1..19d56c8 100644 --- a/crates/codeoid-tui/src/ui/modal.rs +++ b/crates/codeoid-tui/src/ui/modal.rs @@ -1085,6 +1085,71 @@ mod tests { assert!(text.contains("Enter submit"), "{text}"); } + #[test] + fn settings_mcp_tab_renders_servers_with_health() { + use crate::state::SettingsModal; + use codeoid_protocol::{McpServerStatus, SettingsManifest, SettingsSnapshot, SettingsTab}; + + let mut m = SettingsModal::new(); + m.loading = false; + m.manifest = Some(SettingsManifest { + version: 1, + tabs: vec![SettingsTab { + id: "general".into(), + title: "General".into(), + icon: None, + description: None, + groups: vec![], + }], + }); + m.snapshot = Some(SettingsSnapshot { + values: std::collections::HashMap::new(), + secrets: std::collections::HashMap::new(), + config_path: "/c".into(), + env_path: "/e".into(), + mcp_servers: vec![ + McpServerStatus { + name: "codeoid_memory".into(), + transport: "in-process".into(), + trust: "readonly".into(), + scope: "session".into(), + backends: None, + enabled: true, + builtin: true, + health: "connected".into(), + tool_count: 2, + tools: vec!["recall".into(), "get_episode".into()], + error: None, + }, + McpServerStatus { + name: "github".into(), + transport: "stdio".into(), + trust: "prompt".into(), + scope: "workspace".into(), + backends: None, + enabled: true, + builtin: false, + health: "idle".into(), + tool_count: 0, + tools: vec![], + error: None, + }, + ], + }); + m.tab = 1; // the synthetic MCP tab (after the single manifest tab) + assert!(m.on_mcp_tab()); + + let mut state = mk_state(); + state.modal = Some(Modal::Settings(m)); + let text = render_to_text(&mut state); + assert!(text.contains("MCP Servers"), "{text}"); // header pill + assert!(text.contains("codeoid_memory"), "{text}"); + assert!(text.contains("built-in"), "{text}"); + assert!(text.contains("connected"), "{text}"); // health chip + assert!(text.contains("github"), "{text}"); + assert!(text.contains("recall"), "{text}"); // tool list + } + #[test] fn confirm_dialog_renders_yn_hints_and_countdown_title() { let mut state = mk_state(); From f7dd05d2b708c5ede08cf35a3bcbb5bf5045a00e Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 20 Jul 2026 00:14:08 +0800 Subject: [PATCH 3/3] fix(settings): clamp the active tab when the MCP tab vanishes (Gemini) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If parked on the synthetic MCP tab and a fresh snapshot drops mcp_servers, the tab index was left out of range (safe .get() β†’ blank body until next ←/β†’). Add SettingsModal::clamp_tab() and call it whenever the snapshot updates (settings.get/set results). No panic before; this removes the blank-body papercut. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/codeoid-tui/src/app.rs | 2 ++ crates/codeoid-tui/src/state/mod.rs | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/codeoid-tui/src/app.rs b/crates/codeoid-tui/src/app.rs index 43afb08..c9fe641 100644 --- a/crates/codeoid-tui/src/app.rs +++ b/crates/codeoid-tui/src/app.rs @@ -1174,6 +1174,7 @@ impl App { if let Some(Modal::Settings(m)) = state.modal.as_mut() { if m.pending_get_id.as_deref() == Some(request_id.as_str()) { m.snapshot = Some(snapshot); + m.clamp_tab(); m.pending_get_id = None; if m.manifest.is_some() { m.loading = false; @@ -1192,6 +1193,7 @@ impl App { if m.pending_set_id.as_deref() == Some(request_id.as_str()) { m.pending_set_id = None; m.snapshot = Some(snapshot); + m.clamp_tab(); if ok { m.dirty.clear(); if restart_required { diff --git a/crates/codeoid-tui/src/state/mod.rs b/crates/codeoid-tui/src/state/mod.rs index 8ff5ff3..49f1b0a 100644 --- a/crates/codeoid-tui/src/state/mod.rs +++ b/crates/codeoid-tui/src/state/mod.rs @@ -841,6 +841,18 @@ impl SettingsModal { self.manifest.as_ref().map_or(0, |m| m.tabs.len()) + usize::from(self.has_mcp_tab()) } + /// Clamp the active tab into range after the tab set changes β€” e.g. the + /// synthetic MCP tab appears/disappears when a fresh snapshot arrives. Keeps + /// a stale out-of-range index (parked on the MCP tab, then servers vanish) + /// from leaving a blank body until the next tab keypress. + pub fn clamp_tab(&mut self) { + let n = self.tab_count(); + if self.tab >= n { + self.tab = n.saturating_sub(1); + self.selected = 0; + } + } + /// The field the cursor is on (cloned so callers avoid borrow conflicts). #[must_use] pub fn selected_field(&self) -> Option { @@ -1030,11 +1042,15 @@ mod tests { assert!(m.tab_fields().is_empty()); assert!(m.selected_field().is_none()); - // Empty server list β†’ the tab disappears again. + // Parked on the MCP tab when it vanishes: the index is stale until a + // fresh snapshot triggers clamp_tab, which pulls it back into range. m.snapshot.as_mut().unwrap().mcp_servers.clear(); assert!(!m.has_mcp_tab()); assert_eq!(m.tab_count(), 1); assert!(!m.on_mcp_tab()); + assert_eq!(m.tab, 1); // stale + m.clamp_tab(); + assert_eq!(m.tab, 0); // clamped to the last manifest tab } #[test]