diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index c4e6641192..dbac6c0f66 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1733,6 +1733,13 @@ "null" ] }, + "serverName": { + "description": "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": [ "string", 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 ecb6d4a185..dc578ec14b 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 @@ -13938,6 +13938,13 @@ "null" ] }, + "serverName": { + "description": "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": [ "string", 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 f7127cfb0f..f1d0fc3817 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 @@ -9970,6 +9970,13 @@ "null" ] }, + "serverName": { + "description": "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": [ "string", 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 19dad86adf..437b60b64c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json @@ -37,6 +37,13 @@ "null" ] }, + "serverName": { + "description": "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index fc37d8d959..e576ede54a 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index b7b292406e..6de68dd2e7 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ 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 2296c73621..5af67cc097 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,8 @@ limit?: number | null, * Controls how much MCP inventory data to fetch for each server. * Defaults to `Full` when omitted. */ -detail?: McpServerStatusDetail | null, threadId?: string | null, }; +detail?: McpServerStatusDetail | null, threadId?: string | null, +/** + * Limit discovery to one server. With a thread ID, reuse that thread's MCP connection. + */ +serverName?: 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 55d6c8e86c..be16f57fd4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -60,6 +60,9 @@ pub struct ListMcpServerStatusParams { pub detail: Option, #[ts(optional = nullable)] pub thread_id: Option, + /// Limit discovery to one server. With a thread ID, reuse that thread's MCP connection. + #[ts(optional = nullable)] + pub server_name: 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 8baf3ee042..45ea19f54e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -313,6 +313,12 @@ This is the server's advertised MCP capabilities object, including its `extensio map. It is null when the connection has not initialized successfully; capabilities are never inferred from tools or copied from a shared catalog cache. +Pass `serverName` to discover only that server. With `threadId`, the request +reuses the thread's current MCP connection and tool catalog after any pending +runtime refresh; discovery then waits only for that server. Without `threadId`, +discovery creates a connection for the selected server. An unknown name returns an empty page. +Omitting `serverName` preserves full-inventory discovery. + # MCP OAuth login `mcpServer/oauth/login` only returns HTTP(S) authorization URLs. Authorization 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 5f2f773fe4..120e891863 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -1,6 +1,5 @@ use super::thread_input::ensure_direct_input_allowed; use super::*; -use codex_core::McpManager; use codex_mcp::McpServerSource; use codex_mcp::ReadResourceRequestParams; use codex_mcp::resolve_oauth_callback; @@ -273,59 +272,23 @@ impl McpRequestProcessor { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); - let (config, thread) = match params.thread_id.as_deref() { - Some(thread_id) => { - let (_, thread) = self.load_thread(thread_id).await?; - let thread_config = thread.config().await; - let config = self - .config_manager - .load_latest_config_with_session_layers( - &thread_config.config_layer_stack, - &thread_config.cwd, - ) - .await - .map_err(|err| internal_error(format!("failed to reload config: {err}")))?; - (config, Some(thread)) - } - None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None), + let thread = match params.thread_id.as_deref() { + Some(thread_id) => Some(self.load_thread(thread_id).await?.1), + None => None, }; - let mcp_manager = self.thread_manager.mcp_manager(); - let auth = self.auth_manager.auth().await; - let environment_manager = self.thread_manager.environment_manager(); - + let processor = self.clone(); tokio::spawn(async move { - let (mcp_config, runtime_context) = match thread.as_ref() { - Some(thread) => thread.runtime_mcp_config_and_context(&config).await, - None => { - let mcp_config = mcp_manager.runtime_config(&config).await; - let runtime_context = - McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()); - (mcp_config, runtime_context) - } - }; - - let result = Self::list_mcp_server_status_response( - request.request_id.to_string(), - params, - mcp_config, - auth, - runtime_context, - mcp_manager, - thread, - ) - .await; + let result = processor + .list_mcp_server_status_response(params, thread) + .await; outgoing.send_result(request, result).await; }); Ok(()) } async fn list_mcp_server_status_response( - request_id: String, + &self, params: ListMcpServerStatusParams, - mcp_config: codex_mcp::McpConfig, - auth: Option, - runtime_context: McpRuntimeContext, - mcp_manager: Arc, thread: Option>, ) -> Result { let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { @@ -333,16 +296,53 @@ impl McpRequestProcessor { McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, }; - let snapshot = collect_mcp_server_status_snapshot_with_detail( - &mcp_config, - auth.as_ref(), - request_id, - runtime_context, - mcp_manager.codex_apps_tools_cache(), - mcp_manager.tool_catalog_cache(), - detail, - ) - .await; + let (mcp_config, snapshot) = if let (Some(thread), Some(server)) = + (thread.as_ref(), params.server_name.as_deref()) + { + thread + .mcp_server_status_snapshot(server, detail) + .await + .map_err(|error| { + internal_error(format!("failed to read MCP server status: {error:#}")) + })? + } else { + let config = match thread.as_ref() { + Some(thread) => { + let thread_config = thread.config().await; + self.config_manager + .load_latest_config_with_session_layers( + &thread_config.config_layer_stack, + &thread_config.cwd, + ) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))? + } + None => self.load_latest_config(/*fallback_cwd*/ None).await?, + }; + let mcp_manager = self.thread_manager.mcp_manager(); + let auth = self.auth_manager.auth().await; + let (mcp_config, runtime_context) = match thread.as_ref() { + Some(thread) => thread.runtime_mcp_config_and_context(&config).await, + None => ( + mcp_manager.runtime_config(&config).await, + McpRuntimeContext::new( + self.thread_manager.environment_manager(), + config.cwd.to_path_buf(), + ), + ), + }; + let snapshot = collect_mcp_server_status_snapshot_with_detail( + &mcp_config, + auth.as_ref(), + runtime_context, + mcp_manager.codex_apps_tools_cache(), + mcp_manager.tool_catalog_cache(), + detail, + params.server_name.as_deref(), + ) + .await; + (Arc::new(mcp_config), snapshot) + }; let runtime_statuses = match thread { Some(thread) => thread.mcp_connection_statuses(&mcp_config).await, @@ -368,6 +368,12 @@ impl McpRequestProcessor { ); server_names.sort(); server_names.dedup(); + server_names.retain(|name| { + params + .server_name + .as_deref() + .is_none_or(|server| name == server) + }); let total = server_names.len(); let limit = params.limit.unwrap_or(total as u32).max(1) as usize; diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index c7df4e6b17..314aeb5d88 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -1552,6 +1552,7 @@ async fn mcp_server_statuses( ) -> Result> { let request_id = app_server .send_list_mcp_server_status_request(ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: None, 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 b9bf786e94..9bb9243f2b 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 @@ -72,6 +72,103 @@ use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +/// Selected status reads return the thread's tools without repeating MCP discovery. +#[tokio::test] +async fn mcp_server_status_list_reuses_thread_connection() -> Result<()> { + let server = MockServer::start().await; + let tool = json!({ + "name": "open_widget", + "title": "Open widget", + "inputSchema": { "type": "object" }, + "_meta": { "ui": { "resourceUri": "ui://widget.html" } } + }); + let response_tool = tool.clone(); + Mock::given(method("POST")) + .and(path("/mcp")) + .respond_with(move |request: &wiremock::Request| { + let request: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + let result = match request["method"].as_str() { + Some("initialize") => json!({ + "protocolVersion": "2025-11-25", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "widget", "version": "1.0.0" } + }), + Some("tools/list") => json!({ "tools": [response_tool.clone()] }), + Some("resources/list") => json!({ "resources": [] }), + Some("resources/templates/list") => json!({ "resourceTemplates": [] }), + _ => return ResponseTemplate::new(202), + }; + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": request["id"], "result": result + })) + }) + .mount(&server) + .await; + let codex_home = TempDir::new()?; + mock_responses_config(&server.uri()) + .with_extra_config(&format!( + "[mcp_servers.widget]\nurl = \"{}/mcp\"", + server.uri() + )) + .write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let thread = app.start_thread(ThreadStartParams::default()).await?.thread; + timeout( + DEFAULT_READ_TIMEOUT, + app.read_stream_until_matching_notification("widget MCP ready", |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification.params.as_ref().is_some_and(|params| { + params["threadId"] == thread.id + && params["name"] == "widget" + && params["status"] == "ready" + }) + }), + ) + .await??; + + for detail in [ + McpServerStatusDetail::ToolsAndAuthOnly, + McpServerStatusDetail::Full, + ] { + let response: ListMcpServerStatusResponse = app + .request(|request_id| ClientRequest::McpServerStatusList { + request_id, + params: ListMcpServerStatusParams { + thread_id: Some(thread.id.clone()), + server_name: Some("widget".to_string()), + detail: Some(detail), + cursor: None, + limit: None, + }, + }) + .await?; + assert_eq!(response.data.len(), 1); + assert_eq!(response.data[0].name, "widget"); + assert_eq!( + response.data[0].tools, + std::collections::HashMap::from([( + "open_widget".to_string(), + serde_json::from_value(tool.clone())? + )]) + ); + assert_eq!(response.next_cursor, None); + } + let discovery_methods: Vec<_> = server + .received_requests() + .await + .unwrap() + .iter() + .filter_map(|request| serde_json::from_slice::(&request.body).ok()) + .filter_map(|request| request["method"].as_str().map(str::to_owned)) + .filter(|method| matches!(method.as_str(), "initialize" | "tools/list")) + .collect(); + assert_eq!(discovery_methods, ["initialize", "tools/list"]); + Ok(()) +} + #[test_case(false, None, None, None, None, true; "legacy callback")] #[test_case( false, @@ -555,6 +652,7 @@ async fn oauth_login_automatically_selects_callback_specific_cimd_without_metada .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::Full), @@ -644,6 +742,7 @@ async fn mcp_server_status_list_returns_raw_server_and_tool_names(plugin: bool) .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: None, @@ -750,6 +849,7 @@ MCP_TEST_PID_FILE = {} .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -763,6 +863,7 @@ MCP_TEST_PID_FILE = {} std::fs::remove_file(&barrier_file)?; let second_request_id = mcp .send_list_mcp_server_status_request(ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -826,6 +927,7 @@ url = "{mcp_server_url}/mcp" .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -839,6 +941,7 @@ url = "{mcp_server_url}/mcp" .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -894,6 +997,7 @@ async fn mcp_server_status_list_reports_thread_runtime_connections() -> Result<( .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -932,6 +1036,7 @@ async fn mcp_server_status_list_reports_thread_runtime_connections() -> Result<( .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -1015,6 +1120,7 @@ async fn mcp_server_status_list_reports_disconnected_stdio_transport() -> Result .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -1062,6 +1168,7 @@ async fn mcp_server_status_retains_capabilities_when_tool_discovery_fails() -> R .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail, @@ -1216,6 +1323,7 @@ async fn mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls() let request_id = mcp .send_list_mcp_server_status_request(ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), @@ -1270,6 +1378,7 @@ url = "{underscore_server_url}/mcp" .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: None, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 827d228623..99ffcd08c5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -2466,6 +2466,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let request_id = mcp .send_list_mcp_server_status_request(ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: None, diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index aaa4d288fa..985684648a 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -1160,6 +1160,7 @@ async fn wait_for_selected_mcp_server( loop { let request_id = app_server .send_list_mcp_server_status_request(ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index b905b3ee09..7c5cd7e7e7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -1651,6 +1651,7 @@ required = true .request(|request_id| ClientRequest::McpServerStatusList { request_id, params: ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 764c2bde86..ad4fbf4828 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -1049,9 +1049,12 @@ impl McpConnectionSet { /// Returns presentation metadata from the current connection. /// Codex Apps metadata may come from its existing cache; regular MCP server information is /// connection-specific, so pending regular clients are awaited. - pub(crate) async fn list_available_server_infos(&self) -> HashMap { + pub(crate) async fn list_available_server_infos( + &self, + include_server: impl Fn(&str) -> bool, + ) -> HashMap { let mut server_infos = HashMap::new(); - for (server_name, view) in &self.servers { + for (server_name, view) in self.servers.iter().filter(|(name, _)| include_server(name)) { let client = &view.connection.client; if !client.startup_complete.load(Ordering::Acquire) && let Some(server_info) = client.cached_server_info.clone() diff --git a/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs b/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs index 8d8a9f84b5..d6040dcdf5 100644 --- a/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs +++ b/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs @@ -179,49 +179,57 @@ impl McpConnectionSet { /// Returns all tools with model-visible names normalized. pub async fn list_all_tools(&self) -> Vec { - self.list_tools_with_errors().await.0 + Box::pin(self.list_tools_with_errors(|_| true)).await.0 } #[instrument(level = "trace", skip_all, fields(mcp_server_count = self.servers.len()))] - pub(crate) async fn list_tools_with_errors(&self) -> (Vec, HashMap) { + pub(crate) async fn list_tools_with_errors( + &self, + include_server: impl Fn(&str) -> bool, + ) -> (Vec, HashMap) { let mut tools = Vec::new(); let mut errors = HashMap::new(); let mut available_server_count = 0; let mut unavailable_server_count = 0; - let server_results = join_all(self.servers.iter().map(|(server_name, view)| async move { - view.connection.client.reconnect_failed_startup().await; - let has_cached_tools = view.connection.client.has_cached_tools(); - let startup_complete = view - .connection - .client - .startup_complete - .load(Ordering::Acquire); - let server_tools = view - .listed_tools(&self.tool_plugin_context) - .instrument(trace_span!( - "list_tools_for_server", - server_name = %server_name, - has_cached_tools, - startup_complete - )) - .await; - let result = match server_tools { - Ok(server_tools) => Ok(server_tools - .into_iter() - .map(|tool| Self::with_server_metadata(tool, &view.metadata)) - .collect::>()), - Err(error) => { - trace!( - server_name = %server_name, - has_cached_tools, - startup_complete, - "MCP server tools unavailable while building tool list" - ); - Err(error) - } - }; - (server_name, result) - })) + let server_results = join_all( + self.servers + .iter() + .filter(|(name, _)| include_server(name)) + .map(|(server_name, view)| async move { + view.connection.client.reconnect_failed_startup().await; + let has_cached_tools = view.connection.client.has_cached_tools(); + let startup_complete = view + .connection + .client + .startup_complete + .load(Ordering::Acquire); + let server_tools = view + .listed_tools(&self.tool_plugin_context) + .instrument(trace_span!( + "list_tools_for_server", + server_name = %server_name, + has_cached_tools, + startup_complete + )) + .await; + let result = match server_tools { + Ok(server_tools) => Ok(server_tools + .into_iter() + .map(|tool| Self::with_server_metadata(tool, &view.metadata)) + .collect::>()), + Err(error) => { + trace!( + server_name = %server_name, + has_cached_tools, + startup_complete, + "MCP server tools unavailable while building tool list" + ); + Err(error) + } + }; + (server_name, result) + }), + ) .await; for (server_name, server_tools) in server_results { match server_tools { diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index cbf368936c..015240507b 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -2718,7 +2718,7 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { let timeout_result = tokio::time::timeout( Duration::from_millis(10), - manager.list_available_server_infos(), + manager.list_available_server_infos(|_| true), ) .await; let server_infos = timeout_result.expect("server info lookup should not block on startup"); @@ -4024,7 +4024,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_when_client_startup_fails() assert_eq!(tool.callable_name, "calendar_create_event"); assert_eq!( manager - .list_available_server_infos() + .list_available_server_infos(|_| true) .await .get(CODEX_APPS_MCP_SERVER_NAME), Some(&server_info) diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 7dfd637c23..a97f61cfae 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -497,13 +497,14 @@ pub struct McpServerStatusSnapshot { pub async fn collect_mcp_server_status_snapshot_with_detail( config: &McpConfig, auth: Option<&CodexAuth>, - submit_id: String, runtime_context: McpRuntimeContext, codex_apps_tools_cache: ConnectorRuntimeManager, tool_catalog_cache: crate::McpToolCatalogCache, detail: McpSnapshotDetail, + server_name: Option<&str>, ) -> McpServerStatusSnapshot { - let mcp_servers = effective_mcp_servers(config, auth); + let mut mcp_servers = effective_mcp_servers(config, auth); + mcp_servers.retain(|name, _| server_name.is_none_or(|selected| name == selected)); if mcp_servers.is_empty() { return McpServerStatusSnapshot { server_infos: HashMap::new(), @@ -539,7 +540,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( plugins_available: false, ready_selected_capability_roots: Vec::new(), mcp_servers, - submit_id, + submit_id: String::new(), tx_event: None, startup_cancellation_token: cancel_token.clone(), runtime_context, @@ -788,21 +789,29 @@ fn convert_mcp_resource_templates( .collect::>() } -async fn collect_mcp_server_status_snapshot_from_manager( +pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( mcp_connection_manager: &McpConnectionSet, auth_status_entries: HashMap, server_names: Vec, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { + let selected_servers: HashSet<&str> = server_names.iter().map(String::as_str).collect(); + let include_server = |name: &str| selected_servers.contains(name); let ((server_infos, (tools, tools_errors)), resources, resource_templates) = tokio::join!( async { - let server_infos = mcp_connection_manager.list_available_server_infos().await; - let tools = mcp_connection_manager.list_tools_with_errors().await; + let server_infos = mcp_connection_manager + .list_available_server_infos(include_server) + .await; + let tools = mcp_connection_manager + .list_tools_with_errors(include_server) + .await; (server_infos, tools) }, async { if detail.include_resources() { - mcp_connection_manager.list_all_resources(|_| true).await + mcp_connection_manager + .list_all_resources(include_server) + .await } else { HashMap::new() } @@ -810,7 +819,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( async { if detail.include_resources() { mcp_connection_manager - .list_all_resource_templates(|_| true) + .list_all_resource_templates(include_server) .await } else { HashMap::new() diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index c0f670ef09..5abfa51dc4 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -5,6 +5,8 @@ //! [`crate::rmcp_client`] and connection-set behavior lives in //! [`crate::connection_manager`]. +mod status; + use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; diff --git a/codex-rs/codex-mcp/src/runtime/status.rs b/codex-rs/codex-mcp/src/runtime/status.rs new file mode 100644 index 0000000000..1cab528193 --- /dev/null +++ b/codex-rs/codex-mcp/src/runtime/status.rs @@ -0,0 +1,53 @@ +//! Read selected-server metadata from one published thread runtime. + +use std::sync::Arc; + +use super::McpRuntime; +use super::McpRuntimeContext; +use crate::McpConfig; +use crate::McpServerStatusSnapshot; +use crate::McpSnapshotDetail; +use crate::mcp::collect_mcp_server_status_snapshot_from_manager; +use crate::mcp::compute_auth_statuses; +use crate::mcp::effective_mcp_servers; + +impl McpRuntime { + /// Reuses the selected connection and catalog without starting unrelated servers. + /// Configuration and metadata belong to the same captured runtime publication. + pub async fn server_status_snapshot( + &self, + server: &str, + detail: McpSnapshotDetail, + runtime_context: &McpRuntimeContext, + ) -> anyhow::Result<(Arc, McpServerStatusSnapshot)> { + let current = self.current.load_full(); + let runtime_context = runtime_context.clone().with_selected_environments( + Arc::clone(¤t.environment_selections), + current.ready_environments.clone(), + ); + let config = Arc::clone( + current + .config + .as_ref() + .ok_or_else(|| anyhow::anyhow!("MCP runtime is not configured"))?, + ); + let mut servers = effective_mcp_servers(&config, current.auth.as_ref()); + servers.retain(|name, _| name == server); + let auth_statuses = compute_auth_statuses( + servers.iter(), + config.mcp_oauth_credentials_store_mode, + config.auth_keyring_backend_kind, + current.auth.as_ref(), + &runtime_context, + ) + .await; + let snapshot = collect_mcp_server_status_snapshot_from_manager( + ¤t.connections, + auth_statuses, + servers.into_keys().collect(), + detail, + ) + .await; + Ok((config, snapshot)) + } +} diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 83e0d3da64..7fd17e3016 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1034,6 +1034,24 @@ impl CodexThread { Ok(serde_json::to_value(result)?) } + /// Inspects one server through this thread's current MCP connection. + pub async fn mcp_server_status_snapshot( + &self, + server: &str, + detail: codex_mcp::McpSnapshotDetail, + ) -> anyhow::Result<( + Arc, + codex_mcp::McpServerStatusSnapshot, + )> { + self.session.refresh_mcp_if_dirty().await; + let runtime_context = self.session.current_mcp_runtime_context().await; + self.session + .services + .mcp_runtime + .server_status_snapshot(server, detail, &runtime_context) + .await + } + /// Reads an app resource using the current authority of its originating tool call. pub async fn read_mcp_resource_for_call( &self, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 7b2bd83c23..765fe5eda6 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -1,5 +1,6 @@ use super::mcp_refresh::McpRefreshInvalidationGuard; use super::*; +use crate::environment_selection::TurnEnvironmentSnapshot; use crate::environment_selection::combine_selected_capability_roots; use codex_exec_server::ExecutorCapabilityDiscoveryCache; use codex_exec_server::ExecutorCapabilityDiscoverySnapshot; @@ -147,19 +148,33 @@ impl Session { .project_selected_environment_mcp_servers(config, &environments, mcp_projection) .await .config; + let runtime_context = self.mcp_runtime_context(&environments, &host_fallback_cwd); + (mcp_config, runtime_context) + } + + pub(crate) async fn current_mcp_runtime_context(&self) -> McpRuntimeContext { + let host_fallback_cwd = self.state.lock().await.session_configuration.cwd().clone(); + let environments = self.services.turn_environments.snapshot().await; + self.mcp_runtime_context(&environments, &host_fallback_cwd) + } + + fn mcp_runtime_context( + &self, + environments: &TurnEnvironmentSnapshot, + host_fallback_cwd: &std::path::Path, + ) -> McpRuntimeContext { let local_process_cwd = environments .local_environment_cwd() .map(|cwd| cwd.to_path_buf()) .unwrap_or_else(|| host_fallback_cwd.to_path_buf()); - let runtime_context = McpRuntimeContext::new( + McpRuntimeContext::new( self.services.turn_environments.environment_manager(), local_process_cwd, ) .with_selected_environments( - environment_selections.into(), + environments.all_selections().into(), environments.ready_environment_handles(), - ); - (mcp_config, runtime_context) + ) } pub(crate) async fn runtime_mcp_servers( diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 2219ee0e1c..8b29753b3c 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -768,6 +768,7 @@ pub(super) async fn fetch_all_mcp_server_statuses( limit: Some(100), detail: Some(detail), thread_id: thread_id.clone(), + server_name: None, }, }) .await diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap index d23bcc64e9..e1ce4af0ff 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap @@ -26,7 +26,7 @@ working=true, response rows=1, 80x6 ? for shortcuts working=false, response rows=1, 80x12 -Show a response. +› Show a response. • Done. └ Tip: Try /help. @@ -40,7 +40,7 @@ Show a response. ? for shortcuts working=false, response rows=30, 80x12 -Show a response. +› Show a response. • Done. └ Tip: Try /help. @@ -65,7 +65,7 @@ working=false, response rows=1, 80x9 ? for shortcuts working=false, response rows=1, 80x10 -Show a response. +› Show a response. • Done. [completion time] @@ -77,7 +77,7 @@ Show a response. ? for shortcuts working=false, response rows=1, 80x11 -Show a response. +› Show a response. • Done. └ Tip: Try /help. diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 14e28c36fa..352535757a 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1123,6 +1123,7 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() .request_typed(ClientRequest::McpServerStatusList { request_id: AppServerRequestId::String("tui-tool-inventory".to_string()), params: codex_app_server_protocol::ListMcpServerStatusParams { + server_name: None, cursor: None, limit: None, detail: Some(codex_app_server_protocol::McpServerStatusDetail::ToolsAndAuthOnly), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 7cef53c5a4..005fdd26b4 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -8469,6 +8469,13 @@ class ListMcpServerStatusParams(BaseModel): int | None, Field(description="Optional page size; defaults to a server-defined value.", ge=0), ] = None + server_name: Annotated[ + str | None, + Field( + alias="serverName", + description="Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + ), + ] = None thread_id: Annotated[str | None, Field(alias="threadId")] = None