Use thread config for TUI MCP inventory (#24532)

## Summary
`/mcp` in the TUI should reflect the current loaded thread, including
project-local MCP servers from that thread config. Before this change,
`mcpServerStatus/list` only read the latest global MCP config, so the
active chat could miss project-local servers.

This adds optional `threadId` to `mcpServerStatus/list`. When present,
app-server resolves the loaded thread and lists MCP status from the
refreshed effective config for that thread; when omitted, existing
global config behavior stays unchanged.

The TUI now sends the active chat thread id for `/mcp` and `/mcp
verbose`, carries that origin through the async inventory result, and
ignores stale completions if the user has switched threads before the
fetch returns. The app-server schemas were regenerated.

## Follow-up
Once this app-server API change lands, the desktop app should make the
same `threadId` plumbing so its MCP inventory also uses the current
thread config.

Fixes #23874
This commit is contained in:
Eric Traut
2026-05-26 07:44:04 -07:00
committed by GitHub
parent c4e53d103c
commit 0f91e869bd
15 changed files with 228 additions and 19 deletions
+47 -2
View File
@@ -28,17 +28,33 @@ impl App {
&mut self,
app_server: &AppServerSession,
detail: McpServerStatusDetail,
thread_id: Option<ThreadId>,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let request_thread_id = self.mcp_inventory_request_thread_id(thread_id);
tokio::spawn(async move {
let result = fetch_all_mcp_server_statuses(request_handle, detail)
let result = fetch_all_mcp_server_statuses(request_handle, detail, request_thread_id)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::McpInventoryLoaded { result, detail });
app_event_tx.send(AppEvent::McpInventoryLoaded {
result,
detail,
thread_id,
});
});
}
fn mcp_inventory_request_thread_id(&self, thread_id: Option<ThreadId>) -> Option<ThreadId> {
thread_id.filter(|thread_id| {
self.active_thread_id == Some(*thread_id)
&& self
.agent_navigation
.get(thread_id)
.is_none_or(|entry| !entry.is_closed)
})
}
/// Spawns a background task to fetch account rate limits and deliver the
/// result as a `RateLimitsLoaded` event.
///
@@ -535,7 +551,12 @@ impl App {
&mut self,
result: Result<Vec<McpServerStatus>, String>,
detail: McpServerStatusDetail,
thread_id: Option<ThreadId>,
) {
if thread_id.is_some() && thread_id != self.current_displayed_thread_id() {
return;
}
self.chat_widget.clear_mcp_inventory_loading();
self.clear_committed_mcp_inventory_loading();
@@ -579,9 +600,11 @@ impl App {
pub(super) async fn fetch_all_mcp_server_statuses(
request_handle: AppServerRequestHandle,
detail: McpServerStatusDetail,
thread_id: Option<ThreadId>,
) -> Result<Vec<McpServerStatus>> {
let mut cursor = None;
let mut statuses = Vec::new();
let thread_id = thread_id.map(|id| id.to_string());
loop {
let request_id = RequestId::String(format!("mcp-inventory-{}", Uuid::new_v4()));
@@ -592,6 +615,7 @@ pub(super) async fn fetch_all_mcp_server_statuses(
cursor: cursor.clone(),
limit: Some(100),
detail: Some(detail),
thread_id: thread_id.clone(),
},
})
.await
@@ -963,6 +987,7 @@ pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -
#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_support::make_test_app;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_protocol::mcp::Tool;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -1081,6 +1106,26 @@ mod tests {
);
}
#[tokio::test]
async fn mcp_inventory_omits_thread_id_for_closed_agent_thread() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.agent_navigation.upsert(
thread_id, /*agent_nickname*/ None, /*agent_role*/ None,
/*is_closed*/ false,
);
assert_eq!(
app.mcp_inventory_request_thread_id(Some(thread_id)),
Some(thread_id)
);
app.agent_navigation.mark_closed(thread_id);
assert_eq!(app.mcp_inventory_request_thread_id(Some(thread_id)), None);
}
#[test]
fn build_feedback_upload_params_includes_thread_id_and_rollout_path() {
let thread_id = ThreadId::new();
+8 -4
View File
@@ -678,11 +678,15 @@ impl App {
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
}
}
AppEvent::FetchMcpInventory { detail } => {
self.fetch_mcp_inventory(app_server, detail);
AppEvent::FetchMcpInventory { detail, thread_id } => {
self.fetch_mcp_inventory(app_server, detail, thread_id);
}
AppEvent::McpInventoryLoaded { result, detail } => {
self.handle_mcp_inventory_result(result, detail);
AppEvent::McpInventoryLoaded {
result,
detail,
thread_id,
} => {
self.handle_mcp_inventory_result(result, detail, thread_id);
}
AppEvent::SkillsListLoaded { result } => {
self.handle_skills_list_result(
+16 -1
View File
@@ -137,7 +137,7 @@ async fn next_thread_settings_updated(
}
#[tokio::test]
async fn handle_mcp_inventory_result_clears_committed_loading_cell() {
async fn handle_mcp_inventory_result_respects_origin_thread() {
let mut app = make_test_app().await;
app.transcript_cells
.push(Arc::new(history_cell::new_mcp_inventory_loading(
@@ -153,9 +153,24 @@ async fn handle_mcp_inventory_result_clears_committed_loading_cell() {
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
}]),
McpServerStatusDetail::ToolsAndAuthOnly,
/*thread_id*/ None,
);
assert_eq!(app.transcript_cells.len(), 0);
app.active_thread_id = Some(ThreadId::new());
app.transcript_cells
.push(Arc::new(history_cell::new_mcp_inventory_loading(
/*animations_enabled*/ false,
)));
app.handle_mcp_inventory_result(
Ok(Vec::new()),
McpServerStatusDetail::ToolsAndAuthOnly,
Some(ThreadId::new()),
);
assert_eq!(app.transcript_cells.len(), 1);
}
#[test]
+2
View File
@@ -545,12 +545,14 @@ pub(crate) enum AppEvent {
/// Fetch MCP inventory via app-server RPCs and render it into history.
FetchMcpInventory {
detail: McpServerStatusDetail,
thread_id: Option<ThreadId>,
},
/// Result of fetching MCP inventory via app-server RPCs.
McpInventoryLoaded {
result: Result<Vec<McpServerStatus>, String>,
detail: McpServerStatusDetail,
thread_id: Option<ThreadId>,
},
/// Result of the startup skills refresh that runs after the first frame is scheduled.
+4 -2
View File
@@ -1468,8 +1468,10 @@ impl ChatWidget {
)));
self.bump_active_cell_revision();
self.request_redraw();
self.app_event_tx
.send(AppEvent::FetchMcpInventory { detail });
self.app_event_tx.send(AppEvent::FetchMcpInventory {
detail,
thread_id: self.thread_id(),
});
}
/// Remove the MCP loading spinner if it is still the active cell.
@@ -1747,6 +1747,8 @@ 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(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.dispatch_command(SlashCommand::Mcp);
@@ -1754,8 +1756,9 @@ async fn slash_mcp_requests_inventory_via_app_server() {
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchMcpInventory {
detail: McpServerStatusDetail::ToolsAndAuthOnly
})
detail: McpServerStatusDetail::ToolsAndAuthOnly,
thread_id: Some(actual_thread_id)
}) if actual_thread_id == thread_id
);
assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");
}
@@ -1763,6 +1766,8 @@ async fn slash_mcp_requests_inventory_via_app_server() {
#[tokio::test]
async fn slash_mcp_verbose_requests_full_inventory_via_app_server() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
submit_composer_text(&mut chat, "/mcp verbose");
@@ -1770,8 +1775,9 @@ async fn slash_mcp_verbose_requests_full_inventory_via_app_server() {
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchMcpInventory {
detail: McpServerStatusDetail::Full
})
detail: McpServerStatusDetail::Full,
thread_id: Some(actual_thread_id)
}) if actual_thread_id == thread_id
);
assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");
}