mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user