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 5cbcf12f4f..30e8ac86b1 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 @@ -14687,6 +14687,9 @@ ], "description": "Current thread-runtime connection state; null when unavailable or the configuration changed." }, + "serverCapabilities": { + "description": "Capabilities advertised by the initialized MCP server; null when unavailable." + }, "serverInfo": { "anyOf": [ { 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 65c97c8d01..e74feb1665 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 @@ -10739,6 +10739,9 @@ ], "description": "Current thread-runtime connection state; null when unavailable or the configuration changed." }, + "serverCapabilities": { + "description": "Capabilities advertised by the initialized MCP server; null when unavailable." + }, "serverInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json index 919cd571d8..61c0dd6886 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -101,6 +101,9 @@ ], "description": "Current thread-runtime connection state; null when unavailable or the configuration changed." }, + "serverCapabilities": { + "description": "Capabilities advertised by the initialized MCP server; null when unavailable." + }, "serverInfo": { "anyOf": [ { 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 c9c953b5bf..8bc770f7df 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 9b4f81f65e..88f160cc9e 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/McpServerStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts index e5dcd5dcbe..f33da54a43 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts @@ -5,6 +5,7 @@ import type { McpServerInfo } from "../McpServerInfo"; import type { Resource } from "../Resource"; import type { ResourceTemplate } from "../ResourceTemplate"; import type { Tool } from "../Tool"; +import type { JsonValue } from "../serde_json/JsonValue"; import type { McpAuthStatus } from "./McpAuthStatus"; import type { McpServerConnectionStatus } from "./McpServerConnectionStatus"; @@ -12,7 +13,11 @@ export type McpServerStatus = { name: string, /** * Current thread-runtime connection state; null when unavailable or the configuration changed. */ -runtimeStatus: McpServerConnectionStatus | null, pluginId: string | null, serverInfo: McpServerInfo | null, tools: { [key in string]?: Tool }, +runtimeStatus: McpServerConnectionStatus | null, pluginId: string | null, serverInfo: McpServerInfo | null, +/** + * Capabilities advertised by the initialized MCP server; null when unavailable. + */ +serverCapabilities: JsonValue | null, tools: { [key in string]?: Tool }, /** * Tool discovery failed and no catalog was returned. * Null when a catalog is returned, including cached or empty catalogs. 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 25a61167b3..a4cc385e49 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -79,6 +79,8 @@ pub struct McpServerStatus { pub runtime_status: Option, pub plugin_id: Option, pub server_info: Option, + /// Capabilities advertised by the initialized MCP server; null when unavailable. + pub server_capabilities: Option, pub tools: std::collections::HashMap, /// Tool discovery failed and no catalog was returned. /// Null when a catalog is returned, including cached or empty catalogs. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index dd3035e988..9e535721ad 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2581,6 +2581,7 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { fn mcp_server_status_serializes_absent_server_info_as_null() { let response = ListMcpServerStatusResponse { data: vec![McpServerStatus { + server_capabilities: None, tools_error: None, name: "not-ready".to_string(), runtime_status: None, @@ -2602,6 +2603,7 @@ fn mcp_server_status_serializes_absent_server_info_as_null() { "runtimeStatus": null, "pluginId": null, "serverInfo": null, + "serverCapabilities": null, "tools": {}, "toolsError": null, "resources": [], @@ -2628,6 +2630,7 @@ fn mcp_server_status_accepts_older_inventory_without_runtime_status() { assert_eq!( status, McpServerStatus { + server_capabilities: None, tools_error: None, name: "older-server".to_string(), runtime_status: None, @@ -2700,6 +2703,7 @@ fn mcp_server_status_updated_serializes_failure_reason() { fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { let response = ListMcpServerStatusResponse { data: vec![McpServerStatus { + server_capabilities: None, tools_error: None, name: "initialized".to_string(), runtime_status: None, @@ -2727,6 +2731,7 @@ fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { "name": "initialized", "runtimeStatus": null, "pluginId": "lookup@test", + "serverCapabilities": null, "serverInfo": { "name": "lookup-server", "title": null, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4f670720cb..f73d136bd1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -174,3 +174,11 @@ Attachments record the resources currently associated with a thread, independent `thread/attachment/list` accepts one `threadId` and returns at most 100 attachments per page, ordered by creation time and attachment id. Continue with `nextCursor` and the same `threadId` until the cursor is `null`. Each thread can retain up to 100 attachments. Removing an attachment frees a slot for a new attachment. Attachment creation and deletion requests using the same thread ID are serialized across connections. The requesting client receives its response before the compact update is broadcast, and duplicate creates or absent deletes do not emit updates. Deleting the owning thread removes its attachments under the same lifecycle exclusion; queued attachment mutations then report that the thread was not found. + +# MCP server capabilities + +`mcpServerStatus/list` returns `serverCapabilities` for each initialized MCP server +in both `full` and `toolsAndAuthOnly` detail modes, including thread-scoped reads. +This is the server's advertised MCP capabilities object, including its `extensions` +map. It is null when the connection has not initialized successfully; capabilities +are never inferred from tools or copied from a shared catalog cache. 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 e9acd1cc18..c445ac24de 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -342,6 +342,7 @@ impl McpRequestProcessor { }; let McpServerStatusSnapshot { server_infos, + server_capabilities, tools_by_server, tools_errors, resources, @@ -396,6 +397,7 @@ impl McpRequestProcessor { }, ), server_info: server_infos.get(name).cloned(), + server_capabilities: server_capabilities.get(name).cloned(), tools: tools_by_server.get(name).cloned().unwrap_or_default(), tools_error: tools_errors.get(name).cloned(), resources: resources.get(name).cloned().unwrap_or_default(), 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 48ab1f2b99..8947a99c49 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 @@ -349,6 +349,7 @@ async fn oauth_login_automatically_selects_callback_specific_cimd_without_metada move || { Ok(McpStatusServer { tool_name: Arc::clone(&tool_name), + tools_error: None, }) }, Arc::new(LocalSessionManager::default()), @@ -527,7 +528,8 @@ async fn oauth_login_automatically_selects_callback_specific_cimd_without_metada #[tokio::test] async fn mcp_server_status_list_returns_raw_server_and_tool_names() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; - let (mcp_server_url, mcp_server_handle) = start_mcp_server("look-up.raw").await?; + let (mcp_server_url, mcp_server_handle) = + start_mcp_server("look-up.raw", /*tools_error*/ None).await?; let codex_home = TempDir::new()?; mock_responses_config(&server.uri()) .with_extra_config(&format!( @@ -568,6 +570,7 @@ async fn mcp_server_status_list_returns_raw_server_and_tool_names() -> Result<() .find(|status| status.name == "broken-server") .unwrap(); assert!(failed.tools.is_empty()); + assert_eq!(failed.server_capabilities, None); assert!( failed .tools_error @@ -579,6 +582,15 @@ async fn mcp_server_status_list_returns_raw_server_and_tool_names() -> Result<() .iter() .find(|status| status.name == "some-server") .unwrap(); + assert_eq!( + status + .server_capabilities + .as_ref() + .and_then(|caps| caps.get("extensions")), + Some(&json!({ + "openai/settings": { "readTool": "settings.read", "updateTool": "settings.update" } + })) + ); assert_eq!(status.tools_error, None); assert_eq!(status.name, "some-server"); assert_eq!(status.runtime_status, None); @@ -684,7 +696,8 @@ MCP_TEST_PID_FILE = {} #[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 (mcp_server_url, mcp_server_handle) = + start_mcp_server("project_lookup", /*tools_error*/ None).await?; let codex_home = TempDir::new()?; let workspace = TempDir::new()?; mock_responses_config(&server.uri()).write(codex_home.path())?; @@ -758,7 +771,8 @@ url = "{mcp_server_url}/mcp" #[tokio::test] async fn mcp_server_status_list_reports_thread_runtime_connections() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; - let (mcp_server_url, mcp_server_handle) = start_mcp_server("lookup").await?; + let (mcp_server_url, mcp_server_handle) = + start_mcp_server("lookup", /*tools_error*/ None).await?; let codex_home = TempDir::new()?; mock_responses_config(&server.uri()) .with_extra_config(&format!( @@ -811,7 +825,8 @@ async fn mcp_server_status_list_reports_thread_runtime_connections() -> Result<( ); // Inventory uses the latest config, but a same-name replacement is not the // connection that this thread started. Unchanged registrations retain status. - let (replacement_url, replacement_handle) = start_mcp_server("replacement_lookup").await?; + let (replacement_url, replacement_handle) = + start_mcp_server("replacement_lookup", /*tools_error*/ None).await?; mock_responses_config(&server.uri()) .with_extra_config(&format!( "[mcp_servers.connected]\nurl = \"{replacement_url}/mcp\"\n\ @@ -928,14 +943,81 @@ async fn mcp_server_status_list_reports_disconnected_stdio_transport() -> Result Ok(()) } +#[tokio::test] +async fn mcp_server_status_retains_capabilities_when_tool_discovery_fails() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (url, handle) = start_mcp_server( + "lookup", + Some(rmcp::ErrorData::internal_error( + "tool discovery unavailable", + /*data*/ None, + )), + ) + .await?; + let codex_home = TempDir::new()?; + mock_responses_config(&server.uri()) + .with_extra_config(&format!("[mcp_servers.degraded]\nurl = \"{url}/mcp\"")) + .write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + for detail in [None, Some(McpServerStatusDetail::ToolsAndAuthOnly)] { + let response: ListMcpServerStatusResponse = app + .request(|request_id| ClientRequest::McpServerStatusList { + request_id, + params: ListMcpServerStatusParams { + cursor: None, + limit: None, + detail, + thread_id: None, + }, + }) + .await?; + let status = response + .data + .iter() + .find(|status| status.name == "degraded") + .expect("configured server is included"); + assert!(status.tools.is_empty()); + assert!( + status + .tools_error + .as_ref() + .is_some_and(|error| error.contains("tool discovery unavailable")) + ); + assert_eq!( + status + .server_capabilities + .as_ref() + .and_then(|caps| caps.get("extensions")), + Some(&json!({ + "openai/settings": { "readTool": "settings.read", "updateTool": "settings.update" } + })) + ); + } + handle.abort(); + let _ = handle.await; + Ok(()) +} + #[derive(Clone)] struct McpStatusServer { tool_name: Arc, + tools_error: Option, } impl ServerHandler for McpStatusServer { fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_server_info( + let mut capabilities = ServerCapabilities::builder().enable_tools().build(); + capabilities.extensions = Some(BTreeMap::from([( + "openai/settings".to_string(), + serde_json::from_value( + json!({ "readTool": "settings.read", "updateTool": "settings.update" }), + ) + .expect("settings capability is a JSON object"), + )])); + ServerInfo::new(capabilities).with_server_info( Implementation::new("lookup-server", "1.0.0").with_title("Lookup Server"), ) } @@ -945,6 +1027,9 @@ impl ServerHandler for McpStatusServer { _request: Option, _context: rmcp::service::RequestContext, ) -> Result { + if let Some(error) = &self.tools_error { + return Err(error.clone()); + } let input_schema: JsonObject = serde_json::from_value(json!({ "type": "object", "additionalProperties": false @@ -1065,9 +1150,10 @@ async fn mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls() #[tokio::test] async fn mcp_server_status_list_keeps_tools_for_sanitized_name_collisions() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; - let (dash_server_url, dash_server_handle) = start_mcp_server("dash_lookup").await?; + let (dash_server_url, dash_server_handle) = + start_mcp_server("dash_lookup", /*tools_error*/ None).await?; let (underscore_server_url, underscore_server_handle) = - start_mcp_server("underscore_lookup").await?; + start_mcp_server("underscore_lookup", /*tools_error*/ None).await?; let codex_home = TempDir::new()?; mock_responses_config(&server.uri()) .with_extra_config(&format!( @@ -1128,7 +1214,10 @@ url = "{underscore_server_url}/mcp" Ok(()) } -async fn start_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { +async fn start_mcp_server( + tool_name: &str, + tools_error: Option, +) -> Result<(String, JoinHandle<()>)> { let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; let tool_name = Arc::new(tool_name.to_string()); @@ -1136,6 +1225,7 @@ async fn start_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { move || { Ok(McpStatusServer { tool_name: Arc::clone(&tool_name), + tools_error: tools_error.clone(), }) }, Arc::new(LocalSessionManager::default()), diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 12a29288e6..933fd60ad1 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -1000,6 +1000,22 @@ impl McpConnectionSet { Ok(call_tool_result_from_rmcp(result)) } + /// Capabilities belong to the initialized connection, never a shared tool cache. + pub(crate) fn list_available_server_capabilities(&self) -> HashMap { + self.servers + .iter() + .filter_map(|(name, view)| { + view.connection + .client + .server_capabilities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .map(|capabilities| (name.clone(), capabilities)) + }) + .collect() + } + /// 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. diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 875d8be2e4..721865c457 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -552,6 +552,7 @@ pub(crate) async fn create_ready_async_managed_client(tools: Vec) -> A .boxed() .shared(), is_codex_apps_mcp_server: false, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -717,6 +718,7 @@ fn create_gated_async_managed_client( AsyncManagedClient { client, is_codex_apps_mcp_server: false, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -784,6 +786,7 @@ pub(crate) async fn create_test_manager_with_ready_apps_client( .boxed() .shared(), is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: Some(create_test_server_info("Codex Apps")), codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, @@ -835,6 +838,7 @@ fn create_test_manager_with_failed_apps_startup( AsyncManagedClient { client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, @@ -2208,6 +2212,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_while_client_is_pending() { AsyncManagedClient { client: pending_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, @@ -2266,6 +2271,7 @@ async fn capture_binding_uses_the_ready_clients_own_tools() { AsyncManagedClient { client: futures::future::ready(Ok(ready_client)).boxed().shared(), is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, @@ -2629,6 +2635,7 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { AsyncManagedClient { client: pending_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: Some(server_info.clone()), codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -2725,6 +2732,7 @@ async fn capture_binding_exposes_cached_tools_before_startup() { AsyncManagedClient { client: pending_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, @@ -2825,6 +2833,7 @@ async fn capture_binding_skips_pending_optional_servers_after_configured_shared_ .boxed() .shared(), is_codex_apps_mcp_server: false, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -3076,6 +3085,7 @@ async fn capture_binding_shares_optional_startup_grace_across_connection_sets() .boxed() .shared(), is_codex_apps_mcp_server: false, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: Some(cache_context.clone()), @@ -3709,6 +3719,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_cached_tools() { AsyncManagedClient { client: pending_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -3766,6 +3777,7 @@ async fn shutdown_cancels_pending_tool_listing() { AsyncManagedClient { client: pending_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -3812,6 +3824,7 @@ async fn shutdown_continues_after_caller_is_aborted() { AsyncManagedClient { client: blocking_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -3864,6 +3877,7 @@ async fn list_all_tools_does_not_block_when_shared_codex_apps_cache_is_empty() { AsyncManagedClient { client: pending_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, @@ -3916,6 +3930,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_when_client_startup_fails() AsyncManagedClient { client: failed_client, is_codex_apps_mcp_server: true, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: Some(server_info.clone()), codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, @@ -5350,6 +5365,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a client: AsyncManagedClient { client: futures::future::ready(Ok(managed_client)).boxed().shared(), is_codex_apps_mcp_server: false, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, @@ -5915,6 +5931,7 @@ async fn reconciliation_replaces_closed_connections() -> anyhow::Result<()> { .boxed() .shared(), is_codex_apps_mcp_server: false, + server_capabilities: Arc::new(std::sync::Mutex::new(None)), cached_server_info: None, codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index c9cd8ceb6f..fae3a86974 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -467,6 +467,7 @@ pub async fn read_mcp_resource( #[derive(Debug, Clone)] pub struct McpServerStatusSnapshot { pub server_infos: HashMap, + pub server_capabilities: HashMap, pub tools_by_server: HashMap>, pub tools_errors: HashMap, pub resources: HashMap>, @@ -488,6 +489,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( if mcp_servers.is_empty() { return McpServerStatusSnapshot { server_infos: HashMap::new(), + server_capabilities: HashMap::new(), tools_by_server: HashMap::new(), tools_errors: HashMap::new(), resources: HashMap::new(), @@ -819,6 +821,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } McpServerStatusSnapshot { + server_capabilities: mcp_connection_manager.list_available_server_capabilities(), server_infos, tools_by_server, tools_errors, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 393218883c..8f801aadad 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -3,6 +3,7 @@ //! This module owns startup of individual RMCP clients: building the transport, //! initializing the server, listing raw tools, applying per-server tool filters, //! and exposing cached Codex Apps tools while a client is still connecting. +//! Initialization capabilities survive tool-discovery failures and reset on a new attempt. //! Higher-level aggregation and resource/tool APIs live in //! [`crate::connection_manager`]. @@ -302,10 +303,16 @@ struct ManagedClientStartup { catalog_item_limit: usize, cancel_token: CancellationToken, startup_complete: Arc, + server_capabilities: Arc>>, } impl ManagedClientStartup { fn start(&self) -> ManagedClientFuture { + // A new attempt must not expose capabilities from an earlier connection. + *self + .server_capabilities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; let Self { server_name, server, @@ -326,6 +333,7 @@ impl ManagedClientStartup { catalog_item_limit, cancel_token, startup_complete, + server_capabilities, } = self.clone(); let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; let startup_timeout = server @@ -381,6 +389,7 @@ impl ManagedClientStartup { client_mcp_extensions, auth_changes, catalog_item_limit, + server_capabilities, }, ) .await @@ -419,6 +428,8 @@ pub(crate) struct AsyncManagedClient { pub(crate) client: ManagedClientFuture, pub(crate) is_codex_apps_mcp_server: bool, pub(crate) cached_server_info: Option, + /// Retained after initialization even if subsequent tool discovery fails. + pub(crate) server_capabilities: Arc>>, pub(crate) codex_apps_tools_cache_context: Option>, pub(crate) tool_catalog_cache_context: Option, pub(crate) startup_complete: Arc, @@ -463,6 +474,7 @@ impl AsyncManagedClient { None }; let startup_complete = Arc::new(AtomicBool::new(false)); + let server_capabilities = Arc::new(StdMutex::new(None)); let startup = Arc::new(ManagedClientStartup { server_name, server, @@ -483,6 +495,7 @@ impl AsyncManagedClient { catalog_item_limit, cancel_token: cancel_token.clone(), startup_complete: Arc::clone(&startup_complete), + server_capabilities: Arc::clone(&server_capabilities), }); let client = startup.start(); let startup_reconnect = is_codex_apps_mcp_server.then(|| { @@ -500,6 +513,7 @@ impl AsyncManagedClient { client, is_codex_apps_mcp_server, cached_server_info, + server_capabilities, codex_apps_tools_cache_context, tool_catalog_cache_context, startup_complete, @@ -907,6 +921,7 @@ async fn start_server_task( client_mcp_extensions, auth_changes, catalog_item_limit, + server_capabilities, } = params; let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event, &client_mcp_extensions); @@ -934,6 +949,10 @@ async fn start_server_task( &initialize_result, ); let initialize_result = initialize_result.map_err(StartupOutcomeError::from)?; + *server_capabilities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(serde_json::json!(initialize_result.capabilities)); let auth_change_notifications = crate::auth_changes::start( Arc::clone(&client), @@ -1098,6 +1117,7 @@ fn mcp_server_info_from_implementation( } struct StartServerTaskParams { + server_capabilities: Arc>>, is_codex_apps_mcp_server: bool, startup_timeout: Option, // TODO: cancel_token should handle this. tx_event: Option>, diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index b48aa9c01b..311c8482b1 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -1591,6 +1591,7 @@ mod tests { fn mcp_inventory_maps_prefix_tool_names_by_server() { let statuses = vec![ McpServerStatus { + server_capabilities: None, tools_error: None, name: "docs".to_string(), runtime_status: None, @@ -1614,6 +1615,7 @@ mod tests { auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, }, McpServerStatus { + server_capabilities: None, tools_error: None, name: "disabled".to_string(), runtime_status: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0c3641b888..f8af487bbe 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -398,6 +398,7 @@ async fn handle_mcp_inventory_result_respects_origin_thread() { app.handle_mcp_inventory_result( Ok(vec![McpServerStatus { + server_capabilities: None, tools_error: None, name: "docs".to_string(), runtime_status: None, diff --git a/codex-rs/tui/src/history_cell/mcp_tests.rs b/codex-rs/tui/src/history_cell/mcp_tests.rs index cc360f4403..5b59cf06da 100644 --- a/codex-rs/tui/src/history_cell/mcp_tests.rs +++ b/codex-rs/tui/src/history_cell/mcp_tests.rs @@ -23,6 +23,7 @@ fn mcp_inventory_connection_states() { ] .into_iter() .map(|(name, runtime_status)| McpServerStatus { + server_capabilities: None, name: name.to_string(), runtime_status, plugin_id: None, diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 138c9b5b94..dfa7129ef9 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1050,6 +1050,7 @@ async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() { #[test] fn mcp_tools_output_from_statuses_renders_status_only_servers() { let statuses = vec![McpServerStatus { + server_capabilities: None, tools_error: None, name: "plugin_docs".to_string(), runtime_status: None, @@ -1083,6 +1084,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() { #[test] fn mcp_tools_output_from_statuses_renders_verbose_inventory() { let statuses = vec![McpServerStatus { + server_capabilities: None, tools_error: None, name: "plugin_docs".to_string(), runtime_status: None, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index f56bed8591..8d33a27c78 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -8340,6 +8340,13 @@ class McpServerStatus(BaseModel): description="Current thread-runtime connection state; null when unavailable or the configuration changed.", ), ] = None + server_capabilities: Annotated[ + Any | None, + Field( + alias="serverCapabilities", + description="Capabilities advertised by the initialized MCP server; null when unavailable.", + ), + ] = None server_info: Annotated[McpServerInfo | None, Field(alias="serverInfo")] = None tools: dict[str, Tool] tools_error: Annotated[