diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7595527c2..d2bec3d2e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1143,6 +1143,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a73840503..4c6d5a6ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10343,6 +10343,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "title": "ListMcpServerStatusParams", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ac565328f..e74323745 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6872,6 +6872,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "title": "ListMcpServerStatusParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json index 52149d9fb..19dad86ad 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json @@ -36,6 +36,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "title": "ListMcpServerStatusParams", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts index fa00b8ea0..2296c7362 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts @@ -16,4 +16,4 @@ limit?: number | null, * Controls how much MCP inventory data to fetch for each server. * Defaults to `Full` when omitted. */ -detail?: McpServerStatusDetail | null, }; +detail?: McpServerStatusDetail | null, threadId?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index 9fd938407..5f92e8305 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -36,6 +36,8 @@ pub struct ListMcpServerStatusParams { /// Defaults to `Full` when omitted. #[ts(optional = nullable)] pub detail: Option, + #[ts(optional = nullable)] + pub thread_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dcc295307..db76e363e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -216,7 +216,7 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 2d60ed135..22d5a4326 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -199,15 +199,25 @@ impl McpRequestProcessor { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let config = match params.thread_id.as_deref() { + Some(thread_id) => { + let (_, thread) = self.load_thread(thread_id).await?; + let thread_config = thread.config().await; + self.config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))? + } + None => self.load_latest_config(/*fallback_cwd*/ None).await?, + }; let mcp_config = config .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; let auth = self.auth_manager.auth().await; let environment_manager = self.thread_manager.environment_manager(); - // This threadless status path has no turn cwd or turn-selected - // environment. Use config cwd only as the local stdio fallback; named - // environment stdio MCPs must declare their own absolute cwd. + // This status path has no turn-selected environment. Use config cwd + // as the local stdio fallback; named environment stdio MCPs must + // declare their own absolute cwd. let runtime_context = McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf()); diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 44efec4ed..bc9839b53 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -14,6 +14,10 @@ use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; @@ -70,6 +74,7 @@ url = "{mcp_server_url}/mcp" cursor: None, limit: None, detail: None, + thread_id: None, }) .await?; let response = timeout( @@ -101,6 +106,98 @@ url = "{mcp_server_url}/mcp" Ok(()) } +#[tokio::test] +async fn mcp_server_status_list_uses_thread_project_local_config() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server("project_lookup").await?; + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + std::fs::create_dir_all(workspace.path().join(".git"))?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let thread_start_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?; + + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + format!( + r#" +[mcp_servers.project-server] +url = "{mcp_server_url}/mcp" +"# + ), + )?; + + let threadless_request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + thread_id: None, + }) + .await?; + let threadless_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(threadless_request_id)), + ) + .await??; + let threadless_response: ListMcpServerStatusResponse = to_response(threadless_response)?; + assert_eq!(threadless_response.data, Vec::new()); + + let thread_request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + thread_id: Some(thread.id), + }) + .await?; + let thread_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let thread_response: ListMcpServerStatusResponse = to_response(thread_response)?; + + assert_eq!(thread_response.next_cursor, None); + assert_eq!(thread_response.data.len(), 1); + let status = &thread_response.data[0]; + assert_eq!(status.name, "project-server"); + assert_eq!( + status.tools.keys().cloned().collect::>(), + BTreeSet::from(["project_lookup".to_string()]) + ); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + #[derive(Clone)] struct McpStatusServer { tool_name: Arc, @@ -241,6 +338,7 @@ url = "{mcp_server_url}/mcp" cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + thread_id: None, }) .await?; let response = timeout( @@ -305,6 +403,7 @@ url = "{underscore_server_url}/mcp" cursor: None, limit: None, detail: None, + thread_id: None, }) .await?; let response = timeout( diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 63772c9db..f81f8b071 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -28,17 +28,33 @@ impl App { &mut self, app_server: &AppServerSession, detail: McpServerStatusDetail, + thread_id: Option, ) { 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) -> Option { + 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, String>, detail: McpServerStatusDetail, + thread_id: Option, ) { + 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, ) -> Result> { 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) - #[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(); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index e150dbccd..f92806b16 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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( diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 04c8bbf9a..c2138ba31 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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] diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 17b749971..b37aee64e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, }, /// Result of fetching MCP inventory via app-server RPCs. McpInventoryLoaded { result: Result, String>, detail: McpServerStatusDetail, + thread_id: Option, }, /// Result of the startup skills refresh that runs after the first frame is scheduled. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b05d76a88..a063372c3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 74d965ae5..0346b9706 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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"); }