Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions codex-rs/tui_app_server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::RequestId;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
Expand Down Expand Up @@ -75,6 +81,8 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FinalOutput;
use codex_protocol::protocol::ListSkillsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpAuthStatus;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
Expand Down Expand Up @@ -111,6 +119,7 @@ use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::unbounded_channel;
use tokio::task::JoinHandle;
use toml::Value as TomlValue;
use uuid::Uuid;
mod agent_navigation;
mod app_server_adapter;
mod app_server_requests;
Expand Down Expand Up @@ -1535,6 +1544,72 @@ impl App {
Ok(())
}

/// Spawn a background task that fetches the full MCP server inventory from the
/// app-server via paginated RPCs, then delivers the result back through
/// `AppEvent::McpInventoryLoaded`.
///
/// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale
/// result may arrive after the user has moved on. We currently accept that
/// tradeoff because the effect is limited to stale inventory output in history,
/// while request-token invalidation would add cross-cutting async state for a
/// low-severity path.
fn fetch_mcp_inventory(&mut self, app_server: &AppServerSession) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_all_mcp_server_statuses(request_handle)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::McpInventoryLoaded { result });
});
}

/// Process the completed MCP inventory fetch: clear the loading spinner, then
/// render either the full tool/resource listing or an error into chat history.
///
/// When both the local config and the app-server report zero servers, a special
/// "empty" cell is shown instead of the full table.
fn handle_mcp_inventory_result(&mut self, result: Result<Vec<McpServerStatus>, String>) {
let config = self.chat_widget.config_ref().clone();
self.chat_widget.clear_mcp_inventory_loading();
self.clear_committed_mcp_inventory_loading();

let statuses = match result {
Ok(statuses) => statuses,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to load MCP inventory: {err}"));
return;
}
};

if config.mcp_servers.get().is_empty() && statuses.is_empty() {
self.chat_widget
.add_to_history(history_cell::empty_mcp_output());
return;
}

self.chat_widget
.add_to_history(history_cell::new_mcp_tools_output_from_statuses(
&config, &statuses,
));
}

fn clear_committed_mcp_inventory_loading(&mut self) {
let Some(index) = self
.transcript_cells
.iter()
.rposition(|cell| cell.as_any().is::<history_cell::McpInventoryLoadingCell>())
else {
return;
};

self.transcript_cells.remove(index);
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
overlay.replace_cells(self.transcript_cells.clone());
}
}

async fn try_submit_active_thread_op_via_app_server(
&mut self,
app_server: &mut AppServerSession,
Expand Down Expand Up @@ -2999,6 +3074,12 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::FetchMcpInventory => {
self.fetch_mcp_inventory(app_server);
}
AppEvent::McpInventoryLoaded { result } => {
self.handle_mcp_inventory_result(result);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
Expand Down Expand Up @@ -4421,6 +4502,80 @@ impl App {
}
}

/// Collect every MCP server status from the app-server by walking the paginated
/// `mcpServerStatus/list` RPC until no `next_cursor` is returned.
///
/// All pages are eagerly gathered into a single `Vec` so the caller can render
/// the inventory atomically. Each page requests up to 100 entries.
async fn fetch_all_mcp_server_statuses(
request_handle: AppServerRequestHandle,
) -> Result<Vec<McpServerStatus>> {
let mut cursor = None;
let mut statuses = Vec::new();

loop {
let request_id = RequestId::String(format!("mcp-inventory-{}", Uuid::new_v4()));
let response: ListMcpServerStatusResponse = request_handle
.request_typed(ClientRequest::McpServerStatusList {
request_id,
params: ListMcpServerStatusParams {
cursor: cursor.clone(),
limit: Some(100),
},
})
.await
.wrap_err("mcpServerStatus/list failed in app-server TUI")?;
statuses.extend(response.data);
if let Some(next_cursor) = response.next_cursor {
cursor = Some(next_cursor);
} else {
break;
}
}

Ok(statuses)
}

/// Convert flat `McpServerStatus` responses into the per-server maps used by the
/// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus
/// per-server resource/template/auth maps). Test-only because the app-server TUI
/// renders directly from `McpServerStatus` rather than these maps.
#[cfg(test)]
type McpInventoryMaps = (
HashMap<String, codex_protocol::mcp::Tool>,
HashMap<String, Vec<codex_protocol::mcp::Resource>>,
HashMap<String, Vec<codex_protocol::mcp::ResourceTemplate>>,
HashMap<String, McpAuthStatus>,
);

#[cfg(test)]
fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -> McpInventoryMaps {
let mut tools = HashMap::new();
let mut resources = HashMap::new();
let mut resource_templates = HashMap::new();
let mut auth_statuses = HashMap::new();

for status in statuses {
let server_name = status.name;
auth_statuses.insert(
server_name.clone(),
match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
},
);
resources.insert(server_name.clone(), status.resources);
resource_templates.insert(server_name.clone(), status.resource_templates);
for (tool_name, tool) in status.tools {
tools.insert(format!("mcp__{server_name}__{tool_name}"), tool);
}
}

(tools, resources, resource_templates, auth_statuses)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -4446,11 +4601,13 @@ mod tests {
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::mcp::Tool;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
Expand Down Expand Up @@ -4491,6 +4648,75 @@ mod tests {
Ok(())
}

#[test]
fn mcp_inventory_maps_prefix_tool_names_by_server() {
let statuses = vec![
McpServerStatus {
name: "docs".to_string(),
tools: HashMap::from([(
"list".to_string(),
Tool {
description: None,
name: "list".to_string(),
title: None,
input_schema: serde_json::json!({"type": "object"}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
)]),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
},
McpServerStatus {
name: "disabled".to_string(),
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
},
];

let (tools, resources, resource_templates, auth_statuses) =
mcp_inventory_maps_from_statuses(statuses);
let mut resource_names = resources.keys().cloned().collect::<Vec<_>>();
resource_names.sort();
let mut template_names = resource_templates.keys().cloned().collect::<Vec<_>>();
template_names.sort();

assert_eq!(
tools.keys().cloned().collect::<Vec<_>>(),
vec!["mcp__docs__list".to_string()]
);
assert_eq!(resource_names, vec!["disabled", "docs"]);
assert_eq!(template_names, vec!["disabled", "docs"]);
assert_eq!(
auth_statuses.get("disabled"),
Some(&McpAuthStatus::Unsupported)
);
}

#[tokio::test]
async fn handle_mcp_inventory_result_clears_committed_loading_cell() {
let mut app = make_test_app().await;
app.transcript_cells
.push(Arc::new(history_cell::new_mcp_inventory_loading(
/*animations_enabled*/ false,
)));

app.handle_mcp_inventory_result(Ok(vec![McpServerStatus {
name: "docs".to_string(),
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
}]));

assert_eq!(app.transcript_cells.len(), 0);
}

#[test]
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
assert_eq!(
Expand Down
9 changes: 9 additions & 0 deletions codex-rs/tui_app_server/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use std::path::PathBuf;

use codex_app_server_protocol::McpServerStatus;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
Expand Down Expand Up @@ -165,6 +166,14 @@ pub(crate) enum AppEvent {
force_refetch: bool,
},

/// Fetch MCP inventory via app-server RPCs and render it into history.
FetchMcpInventory,

/// Result of fetching MCP inventory via app-server RPCs.
McpInventoryLoaded {
result: Result<Vec<McpServerStatus>, String>,
},

InsertHistoryCell(Box<dyn HistoryCell>),

/// Apply rollback semantics to local transcript cells.
Expand Down
38 changes: 29 additions & 9 deletions codex-rs/tui_app_server/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ use codex_core::find_thread_name_by_id;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::get_git_repo_root;
use codex_core::git_info::local_git_branches;
use codex_core::mcp::McpManager;
use codex_core::plugins::PluginsManager;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::skills::model::SkillMetadata;
Expand Down Expand Up @@ -8218,18 +8217,39 @@ impl ChatWidget {
PlainHistoryCell::new(vec![line.into()])
}

/// Begin the asynchronous MCP inventory flow: show a loading spinner and
/// request the app-server fetch via `AppEvent::FetchMcpInventory`.
///
/// The spinner lives in `active_cell` and is cleared by
/// [`clear_mcp_inventory_loading`] once the result arrives.
pub(crate) fn add_mcp_output(&mut self) {
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
self.config.codex_home.clone(),
self.flush_answer_stream_with_separator();
self.flush_active_cell();
self.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading(
self.config.animations,
)));
if mcp_manager
.effective_servers(&self.config, /*auth*/ None)
.is_empty()
self.bump_active_cell_revision();
self.request_redraw();
self.app_event_tx.send(AppEvent::FetchMcpInventory);
}

/// Remove the MCP loading spinner if it is still the active cell.
///
/// Uses `Any`-based type checking so that a late-arriving inventory result
/// does not accidentally clear an unrelated cell that was set in the meantime.
pub(crate) fn clear_mcp_inventory_loading(&mut self) {
let Some(active) = self.active_cell.as_ref() else {
return;
};
if !active
.as_any()
.is::<history_cell::McpInventoryLoadingCell>()
{
self.add_to_history(history_cell::empty_mcp_output());
} else {
self.add_app_server_stub_message("MCP tool inventory");
return;
}
self.active_cell = None;
self.bump_active_cell_revision();
self.request_redraw();
}

pub(crate) fn add_connectors_output(&mut self) {
Expand Down
11 changes: 11 additions & 0 deletions codex-rs/tui_app_server/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6041,6 +6041,17 @@ async fn slash_memory_drop_reports_stubbed_feature() {
);
}

#[tokio::test]
async fn slash_mcp_requests_inventory_via_app_server() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;

chat.dispatch_command(SlashCommand::Mcp);

assert!(active_blob(&chat).contains("Loading MCP inventory"));
assert_matches!(rx.try_recv(), Ok(AppEvent::FetchMcpInventory));
assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");
}

#[tokio::test]
async fn slash_memory_update_reports_stubbed_feature() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
Expand Down
Loading
Loading