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 16358792de..71959264ba 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 @@ -15157,6 +15157,9 @@ } ] }, + "toolListMeta": { + "description": "Opaque `_meta` from the first page of the corresponding MCP tool catalog. Null when unavailable." + }, "tools": { "additionalProperties": { "$ref": "#/definitions/v2/Tool" 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 b2cadebfd1..14330ebf18 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 @@ -11149,6 +11149,9 @@ } ] }, + "toolListMeta": { + "description": "Opaque `_meta` from the first page of the corresponding MCP tool catalog. Null when unavailable." + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" 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 da96c50354..f75a3f22ac 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -121,6 +121,9 @@ } ] }, + "toolListMeta": { + "description": "Opaque `_meta` from the first page of the corresponding MCP tool catalog. Null when unavailable." + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" 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 38af43dca9..7287d2f1c3 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 50a4721a56..e8e507d122 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 0b7d5449d9..bb33cbe138 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts @@ -22,7 +22,11 @@ httpOrigin: string | null, serverInfo: McpServerInfo | null, /** * Capabilities advertised by the initialized MCP server; null when unavailable. */ -serverCapabilities: JsonValue | null, tools: { [key in string]?: Tool }, +serverCapabilities: JsonValue | null, +/** + * Opaque `_meta` from the first page of the corresponding MCP tool catalog. Null when unavailable. + */ +toolListMeta: 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 e1ed3b624b..824d770f50 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -87,6 +87,8 @@ pub struct McpServerStatus { pub server_info: Option, /// Capabilities advertised by the initialized MCP server; null when unavailable. pub server_capabilities: Option, + /// Opaque `_meta` from the first page of the corresponding MCP tool catalog. Null when unavailable. + pub tool_list_meta: 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 bd7eed55a0..8c44989d64 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2671,6 +2671,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 { + tool_list_meta: None, server_capabilities: None, tools_error: None, name: "not-ready".to_string(), @@ -2696,6 +2697,7 @@ fn mcp_server_status_serializes_absent_server_info_as_null() { "httpOrigin": null, "serverInfo": null, "serverCapabilities": null, + "toolListMeta": null, "tools": {}, "toolsError": null, "resources": [], @@ -2722,6 +2724,7 @@ fn mcp_server_status_accepts_older_inventory_without_runtime_status() { assert_eq!( status, McpServerStatus { + tool_list_meta: None, server_capabilities: None, tools_error: None, name: "older-server".to_string(), @@ -2796,6 +2799,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 { + tool_list_meta: None, server_capabilities: None, tools_error: None, name: "initialized".to_string(), @@ -2827,6 +2831,7 @@ fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { "pluginId": "lookup@test", "httpOrigin": null, "serverCapabilities": null, + "toolListMeta": null, "serverInfo": { "name": "lookup-server", "title": null, 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 e65e6d744e..1eb52d61c4 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -452,6 +452,7 @@ impl McpRequestProcessor { let McpServerStatusSnapshot { mut server_infos, mut server_capabilities, + mut tool_list_meta, mut tools_by_server, mut tools_errors, mut resources, @@ -525,6 +526,7 @@ impl McpRequestProcessor { }), server_info: server_infos.remove(name), server_capabilities: server_capabilities.remove(name), + tool_list_meta: tool_list_meta.remove(name).map(Arc::unwrap_or_clone), tools: tools_by_server.remove(name).unwrap_or_default(), tools_error: tools_errors.remove(name), resources: resources.remove(name).unwrap_or_default(), diff --git a/codex-rs/codex-mcp/src/binding_tests.rs b/codex-rs/codex-mcp/src/binding_tests.rs index 2d8d16cba4..363f8760e7 100644 --- a/codex-rs/codex-mcp/src/binding_tests.rs +++ b/codex-rs/codex-mcp/src/binding_tests.rs @@ -80,6 +80,7 @@ async fn test_step( ); let tool_catalog = Arc::new(ClientToolCatalog::new( vec![tool.clone()], + /*meta*/ None, /*updates*/ None, )); let managed_client = Arc::new(ManagedClient { @@ -313,8 +314,8 @@ async fn prepared_call_is_rejected_after_catalog_refresh() { step.tool_catalog .refresh( - || async { Ok((step.step.tools().to_vec(), ())) }, - |_, ()| {}, + || async { Ok((step.step.tools().to_vec(), None, ())) }, + |_, _, ()| {}, ) .await .expect("refresh tool catalog"); @@ -347,8 +348,8 @@ async fn stale_prepared_call_does_not_run_preparation() { .expect("step should prepare the advertised tool"); step.tool_catalog .refresh( - || async { Ok((step.step.tools().to_vec(), ())) }, - |_, ()| {}, + || async { Ok((step.step.tools().to_vec(), None, ())) }, + |_, _, ()| {}, ) .await .expect("refresh tool catalog"); @@ -394,8 +395,8 @@ async fn preparation_holds_catalog_authority_until_it_finishes() { preparation_started.notified().await; let refresh = step.tool_catalog.refresh( - || async { Ok((step.step.tools().to_vec(), ())) }, - |_, ()| {}, + || async { Ok((step.step.tools().to_vec(), None, ())) }, + |_, _, ()| {}, ); tokio::pin!(refresh); assert!( diff --git a/codex-rs/codex-mcp/src/client_tool_catalog.rs b/codex-rs/codex-mcp/src/client_tool_catalog.rs index 233d835256..1bb85a8213 100644 --- a/codex-rs/codex-mcp/src/client_tool_catalog.rs +++ b/codex-rs/codex-mcp/src/client_tool_catalog.rs @@ -59,7 +59,10 @@ struct CatalogState { /// Ordinary MCP clients own their tools; Apps clients observe the shared provider. enum CatalogSource { - Local(Arc<[ToolInfo]>), + Local { + tools: Arc<[ToolInfo]>, + meta: Option>, + }, Live { updates: ToolCatalogUpdates, tools_version: u64, @@ -69,12 +72,14 @@ enum CatalogSource { pub(crate) struct ToolCatalogSnapshot { pub(crate) revision: u64, pub(crate) tools: Arc<[ToolInfo]>, + pub(crate) meta: Option>, } impl ClientToolCatalog { /// Live clients read the provider; callers publish startup tools before subscribing. pub(crate) fn new( tools: impl Into>, + meta: Option>, updates: Option, ) -> Self { let source = match updates { @@ -88,7 +93,10 @@ impl ClientToolCatalog { tools_version, } } - None => CatalogSource::Local(tools.into()), + None => CatalogSource::Local { + tools: tools.into(), + meta, + }, }; Self { current: RwLock::new(CatalogState { @@ -112,8 +120,8 @@ impl ClientToolCatalog { loop { { let current = self.current.read().await; - let tools = match ¤t.source { - CatalogSource::Local(tools) => Some(Arc::clone(tools)), + let catalog = match ¤t.source { + CatalogSource::Local { tools, meta } => Some((Arc::clone(tools), meta.clone())), CatalogSource::Live { updates, .. } => { // Hold the watch borrow while checking its version so the tools and // revision come from the same publication. @@ -121,15 +129,18 @@ impl ClientToolCatalog { (!updates.has_changed().unwrap_or(false)).then(|| { snapshot .as_ref() - .map(|snapshot| snapshot.shared_tools()) + .map(|snapshot| { + (snapshot.shared_tools(), snapshot.shared_tool_list_meta()) + }) .unwrap_or_default() }) } }; - if let Some(tools) = tools { + if let Some((tools, meta)) = catalog { let snapshot = ToolCatalogSnapshot { revision: current.revision, tools, + meta, }; return (current, snapshot); } @@ -148,7 +159,7 @@ impl ClientToolCatalog { *tools_version = version; changed } - CatalogSource::Local(_) => false, + CatalogSource::Local { .. } => false, }; if changed { current.revision += 1; @@ -175,7 +186,7 @@ impl ClientToolCatalog { ) -> Result<()> where F: FnMut() -> Fut, - Fut: Future>>, + Fut: Future, Option>)>>, { if self.notifications_current(notifications) { return Ok(()); @@ -199,7 +210,11 @@ impl ClientToolCatalog { if notifications.current() != revision { continue; } - current.source = CatalogSource::Local(tools?.into()); + let (tools, meta) = tools?; + current.source = CatalogSource::Local { + tools: tools.into(), + meta, + }; current.revision += 1; self.listed_notification_revision .store(revision, Ordering::Release); @@ -225,16 +240,22 @@ impl ClientToolCatalog { pub(crate) async fn refresh(&self, fetch: F, publish: P) -> Result where F: FnOnce() -> Fut, - Fut: Future, C)>>, - P: FnOnce(&[ToolInfo], C) -> R, + Fut: Future, Option>, C)>>, + P: FnOnce(&[ToolInfo], Option>, C) -> R, { let _refresh = self.refresh_lock.lock().await; - let (tools, context) = fetch().await?; + let (tools, meta, context) = fetch().await?; let _calls = self.calls.write().await; let mut current = self.current.write().await; - let result = publish(&tools, context); + let result = publish(&tools, meta.clone(), context); match &mut current.source { - CatalogSource::Local(current_tools) => *current_tools = tools.into(), + CatalogSource::Local { + tools: current_tools, + meta: current_meta, + } => { + *current_tools = tools.into(); + *current_meta = meta; + } CatalogSource::Live { updates, tools_version, @@ -274,7 +295,7 @@ impl ClientToolCatalog { return None; } let current = match current.source { - CatalogSource::Local(_) => { + CatalogSource::Local { .. } => { drop(current); None } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 8d2c471252..3cdf966c07 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -239,23 +239,24 @@ impl McpServerView { } } - async fn listed_tools( + async fn listed_catalog( &self, server_name: &str, tool_plugin_context: &ToolPluginContext, - ) -> Result, StartupOutcomeError> { - let tools = if let Some(client) = self.connection.client.ready_client() { + ) -> Result<(Vec, Option>), StartupOutcomeError> { + let (tools, meta) = if let Some(client) = self.connection.client.ready_client() { self.refresh_notified_catalog(server_name, &client).await; - client.listed_tools().await + client.listed_catalog().await } else { - self.connection.client.listed_tools().await? + self.connection.client.listed_catalog().await? }; let tools = filter_tools(tools, &self.tool_filter); - Ok(if self.connection.client.is_codex_apps_mcp_server { + let tools = if self.connection.client.is_codex_apps_mcp_server { prepare_codex_apps_tools_for_model(tools, tool_plugin_context) } else { prepare_regular_mcp_tools_for_model(tools, tool_plugin_context) - }) + }; + Ok((tools, meta)) } } @@ -448,6 +449,10 @@ impl McpConnectionSet { &configured_config.auth, protocol_mode.preferred_protocol_version().as_str(), catalog_item_limit, + &client_elicitation_capability, + client_mcp_extensions + .iter() + .collect::>(), ]); scope.sort_all_objects(); codex_apps_tools_cache @@ -1097,9 +1102,13 @@ impl McpConnectionSet { } /// Capabilities belong to the initialized connection, never a shared tool cache. - pub(crate) fn list_available_server_capabilities(&self) -> HashMap { + pub(crate) fn list_available_server_capabilities( + &self, + include_server: impl Fn(&str) -> bool, + ) -> HashMap { self.servers .iter() + .filter(|(name, _)| include_server(name)) .filter_map(|(name, view)| { view.connection .client 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 711e747fba..07e149e527 100644 --- a/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs +++ b/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs @@ -202,6 +202,19 @@ impl McpConnectionSet { &self, include_server: impl Fn(&str) -> bool, ) -> (Vec, HashMap) { + let (tools, errors, _) = self.list_catalogs_with_errors(include_server).await; + (tools, errors) + } + + pub(crate) async fn list_catalogs_with_errors( + &self, + include_server: impl Fn(&str) -> bool, + ) -> ( + Vec, + HashMap, + HashMap>, + ) { + let mut metadata = HashMap::new(); let mut tools = Vec::new(); let mut errors = HashMap::new(); let mut available_server_count = 0; @@ -219,7 +232,7 @@ impl McpConnectionSet { .startup_complete .load(Ordering::Acquire); let server_tools = view - .listed_tools(server_name, &self.tool_plugin_context) + .listed_catalog(server_name, &self.tool_plugin_context) .instrument(trace_span!( "list_tools_for_server", server_name = %server_name, @@ -228,10 +241,13 @@ impl McpConnectionSet { )) .await; let result = match server_tools { - Ok(server_tools) => Ok(server_tools - .into_iter() - .map(|tool| Self::with_server_metadata(tool, &view.metadata)) - .collect::>()), + Ok((server_tools, meta)) => Ok(( + server_tools + .into_iter() + .map(|tool| Self::with_server_metadata(tool, &view.metadata)) + .collect::>(), + meta, + )), Err(error) => { trace!( server_name = %server_name, @@ -248,7 +264,10 @@ impl McpConnectionSet { .await; for (server_name, server_tools) in server_results { match server_tools { - Ok(server_tools) => { + Ok((server_tools, meta)) => { + if let Some(meta) = meta { + metadata.insert(server_name.clone(), meta); + } available_server_count += 1; tools.extend(server_tools); } @@ -269,7 +288,7 @@ impl McpConnectionSet { tool_count = tools.len(), "built MCP tool list" ); - (tools, errors) + (tools, errors, metadata) } #[instrument(level = "trace", skip_all)] @@ -706,7 +725,7 @@ impl McpConnectionSet { cache_context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh) }, ); - let client_tools = list_tools_for_client_uncached( + let (client_tools, meta) = list_tools_for_client_uncached( CODEX_APPS_MCP_SERVER_NAME, /*is_codex_apps_mcp_server*/ true, /*codex_apps_refresh_trigger*/ "explicit", @@ -721,9 +740,9 @@ impl McpConnectionSet { "failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'" ) })?; - Ok((client_tools, (fetch_ticket, list_start))) + Ok((client_tools, meta, (fetch_ticket, list_start))) }, - |client_tools, (fetch_ticket, list_start)| { + |client_tools, meta, (fetch_ticket, list_start)| { // Discovery can accept another scope's winner; executable catalogs // receive only the latest successful fetch from their own scope. let tools = match ( @@ -731,11 +750,14 @@ impl McpConnectionSet { fetch_ticket, ) { (Some(cache_context), Some(fetch_ticket)) => cache_context - .publish_if_newest_accepted( + .publish_runtime_if_newest_accepted( fetch_ticket, &managed_client.server_info, client_tools.to_vec(), - ), + meta, + ) + .tools() + .to_vec(), (None, None) => client_tools.to_vec(), _ => unreachable!("Codex Apps fetch ticket requires cache context"), }; diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 758e6419dc..b9118ecd93 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -217,10 +217,11 @@ fn create_codex_apps_tools_cache_context( } fn store_current_tools(cache_context: &ConnectorRuntimeContext, tools: Vec) { - let _ = cache_context.publish_if_newest_accepted( + let _ = cache_context.publish_runtime_if_newest_accepted( cache_context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), &create_test_server_info("Codex Apps"), tools, + /*tool_list_meta*/ None, ); } @@ -486,7 +487,9 @@ async fn create_test_managed_client(tools: Vec) -> ManagedClient { .expect("create in-process RMCP client"), ), server_info: create_test_server_info("Ready"), - tool_catalog: Arc::new(ClientToolCatalog::new(tools, /*updates*/ None)), + tool_catalog: Arc::new(ClientToolCatalog::new( + tools, /*meta*/ None, /*updates*/ None, + )), tool_timeout: None, server_instructions: None, server_supports_sandbox_state_meta_capability: false, @@ -794,7 +797,11 @@ pub(crate) async fn create_test_manager_with_ready_apps_client( _auth_change_notifications: None, client, server_info: create_test_server_info("Codex Apps"), - tool_catalog: Arc::new(ClientToolCatalog::new(vec![tool], /*updates*/ None)), + tool_catalog: Arc::new(ClientToolCatalog::new( + vec![tool], + /*meta*/ None, + /*updates*/ None, + )), tool_timeout: Some(Duration::from_secs(5)), server_instructions: None, server_supports_sandbox_state_meta_capability: false, @@ -2339,7 +2346,8 @@ async fn capture_binding_preserves_ready_client_catalog_and_shares_prepared_meta codex_home.path().to_path_buf(), Some("account-one"), Some("user-one"), - ); + ) + .with_live_scope("apps".to_string()); store_current_tools( &cache_context, vec![create_test_tool( @@ -2639,12 +2647,12 @@ async fn tool_catalog_cache_sanitizes_tools_and_tracks_environment_generation() let first_environment = resolve_environment(); let first_environment_weak = Arc::downgrade(&first_environment); let first_context = cache_context(&first_environment); - first_context.publish_if_newest(first_context.begin_fetch(), &[]); + first_context.publish_if_newest(first_context.begin_fetch(), &[], /*meta*/ None); assert!(!first_context.has_tools()); let mut tool = create_test_tool("docs", "search"); tool.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true)); - first_context.publish_if_newest(first_context.begin_fetch(), &[tool]); + first_context.publish_if_newest(first_context.begin_fetch(), &[tool], /*meta*/ None); assert_eq!( first_context.current_tools().expect("cached tools")[0] .tool @@ -2670,8 +2678,16 @@ async fn tool_catalog_cache_sanitizes_tools_and_tracks_environment_generation() let older = first_context.begin_fetch(); let newer = first_context.begin_fetch(); - first_context.publish_if_newest(newer, &[create_test_tool("docs", "new")]); - first_context.publish_if_newest(older, &[create_test_tool("docs", "old")]); + first_context.publish_if_newest( + newer, + &[create_test_tool("docs", "new")], + /*meta*/ None, + ); + first_context.publish_if_newest( + older, + &[create_test_tool("docs", "old")], + /*meta*/ None, + ); assert_eq!( first_context.current_tools().expect("cached tools")[0].callable_name, "new" @@ -3299,6 +3315,7 @@ async fn capture_binding_shares_optional_startup_grace_across_connection_sets() cache_context.publish_if_newest( cache_context.begin_fetch(), &[create_test_tool("pending", "cached_tool")], + /*meta*/ None, ); let deadline_after_publication = tokio::time::Instant::now() + Duration::from_secs(1); assert_eq!( @@ -3384,6 +3401,7 @@ async fn capture_binding_uses_cache_published_during_optional_startup() { cache_context.publish_if_newest( cache_context.begin_fetch(), &[create_test_tool("pending", "peer_tool")], + /*meta*/ None, ); tokio::time::advance(Duration::from_secs(/*secs*/ 2)).await; @@ -3433,6 +3451,7 @@ async fn capture_binding_retains_cached_tools_that_expire_while_waiting_for_anot cache_context.publish_if_newest( cache_context.begin_fetch(), &[create_test_tool("server_a", "cached_tool")], + /*meta*/ None, ); tokio::time::advance(Duration::from_secs(30 * 60 - 1)).await; @@ -3498,6 +3517,7 @@ async fn capture_binding_omits_cache_disabled_while_waiting_for_another_server() cache_context.publish_if_newest( cache_context.begin_fetch(), &[create_test_tool("cached", "cached_tool")], + /*meta*/ None, ); let (mut cached, _started, _release) = create_gated_async_managed_client( create_test_managed_client(vec![create_test_tool("cached", "live_tool")]).await, @@ -3837,6 +3857,47 @@ async fn list_all_tools_resolves_server_catalogs_concurrently() { ); } +#[tokio::test] +async fn catalog_listing_preserves_legacy_apps_tools_during_failed_startup() { + for startup_complete in [false, true] { + let mut manager = create_test_manager_with_failed_apps_startup( + vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "drive_search")], + Arc::new(|| futures::future::pending().boxed().shared()), + ); + let connection = Arc::get_mut( + &mut manager + .servers + .get_mut(CODEX_APPS_MCP_SERVER_NAME) + .expect("Apps server") + .connection, + ) + .expect("unshared connection"); + let context = connection + .client + .codex_apps_tools_cache_context + .take() + .expect("Apps cache") + .with_live_scope("endpoint".to_string()); + assert!(context.current_catalog_snapshot().is_none()); + connection.client.codex_apps_tools_cache_context = Some(context); + connection.client.startup_reconnect = None; + connection + .client + .startup_complete + .store(startup_complete, Ordering::Release); + + let (tools, errors, metadata) = manager.list_catalogs_with_errors(|_| true).await; + assert_eq!( + (model_tool_names(&tools), errors, metadata), + ( + HashSet::from([ToolName::namespaced("mcp__codex_apps", "drive_search")]), + HashMap::new(), + HashMap::new(), + ), + ); + } +} + #[tokio::test] async fn list_all_tools_blocks_while_client_is_pending_without_cached_tools() { let pending_client = futures::future::pending::>() @@ -5385,7 +5446,7 @@ async fn apps_catalog_broadcast_preserves_running_calls_and_rejects_stale_calls( let original = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "original")]; let updated = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "updated")]; store_current_tools(&context, original.clone()); - let catalog = ClientToolCatalog::new(original.clone(), context.subscribe()); + let catalog = ClientToolCatalog::new(original.clone(), /*meta*/ None, context.subscribe()); let snapshot = catalog.read(Arc::new).await; store_current_tools(&context, original.clone()); @@ -5417,7 +5478,7 @@ async fn apps_catalog_broadcast_preserves_running_calls_and_rejects_stale_calls( ); // A client whose startup finishes late adopts the already-published result. - let late = ClientToolCatalog::new(original, context.subscribe()); + let late = ClientToolCatalog::new(original, /*meta*/ None, context.subscribe()); assert_eq!(late.read(|catalog| catalog.tools.to_vec()).await, updated); Ok(()) } @@ -5436,7 +5497,7 @@ async fn apps_catalog_broadcast_restores_equivalent_calls() -> anyhow::Result<() create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "second"), ]; store_current_tools(&context, original.clone()); - let catalog = ClientToolCatalog::new(original.clone(), context.subscribe()); + let catalog = ClientToolCatalog::new(original.clone(), /*meta*/ None, context.subscribe()); let snapshot = catalog.read(Arc::new).await; let mut changed = original.clone(); @@ -5460,8 +5521,8 @@ async fn apps_catalog_broadcast_restores_equivalent_calls() -> anyhow::Result<() ); catalog .refresh( - || async { Ok((snapshot.tools.to_vec(), ())) }, - |tools, ()| store_current_tools(&context, tools.to_vec()), + || async { Ok((snapshot.tools.to_vec(), None, ())) }, + |tools, _, ()| store_current_tools(&context, tools.to_vec()), ) .await?; assert_eq!( @@ -5487,8 +5548,8 @@ fn idle_apps_clients_do_not_retain_replaced_tools() { let schema = Arc::downgrade(&tool.tool.input_schema); store_current_tools(&context, vec![tool]); let clients = [ - ClientToolCatalog::new(Vec::new(), context.subscribe()), - ClientToolCatalog::new(Vec::new(), context.subscribe()), + ClientToolCatalog::new(Vec::new(), /*meta*/ None, context.subscribe()), + ClientToolCatalog::new(Vec::new(), /*meta*/ None, context.subscribe()), ]; store_current_tools( &context, @@ -5511,20 +5572,24 @@ async fn apps_catalog_broadcast_survives_an_older_local_refresh() -> anyhow::Res ) .with_live_scope("apps".to_string()); store_current_tools(&context, Vec::new()); - let catalog = ClientToolCatalog::new(Vec::new(), context.subscribe()); + let catalog = ClientToolCatalog::new(Vec::new(), /*meta*/ None, context.subscribe()); let older_ticket = context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh); let newer = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newer")]; store_current_tools(&context, newer.clone()); assert_eq!(catalog.read(|catalog| catalog.tools.to_vec()).await, newer); catalog .refresh( - || async { Ok((Vec::new(), older_ticket)) }, - |tools, ticket| { - context.publish_if_newest_accepted( - ticket, - &create_test_server_info("Apps"), - tools.to_vec(), - ) + || async { Ok((Vec::new(), None, older_ticket)) }, + |tools, _, ticket| { + context + .publish_runtime_if_newest_accepted( + ticket, + &create_test_server_info("Apps"), + tools.to_vec(), + /*tool_list_meta*/ None, + ) + .tools() + .to_vec() }, ) .await?; @@ -5573,7 +5638,7 @@ async fn refreshed_catalog_follows_reused_client_without_mutating_old_bindings() let startup_tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "startup")]; client .tool_catalog - .refresh(|| async { Ok((startup_tools, ())) }, |_, ()| {}) + .refresh(|| async { Ok((startup_tools, None, ())) }, |_, _, ()| {}) .await?; let old_binding = capture_binding(&previous).await; let old_tools = serde_json::to_value(old_binding.tools())?; @@ -5660,7 +5725,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a }), ) .await?; - let initial_tools = list_tools_for_client_uncached( + let (initial_tools, initial_meta) = list_tools_for_client_uncached( "docs", /*is_codex_apps_mcp_server*/ false, /*codex_apps_refresh_trigger*/ "test", @@ -5674,7 +5739,11 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a _auth_change_notifications: None, client: Arc::clone(&client), server_info: create_test_server_info("Mutable tools"), - tool_catalog: Arc::new(ClientToolCatalog::new(initial_tools, /*updates*/ None)), + tool_catalog: Arc::new(ClientToolCatalog::new( + initial_tools, + initial_meta, + /*updates*/ None, + )), tool_timeout: None, server_instructions: initialize.instructions, server_supports_sandbox_state_meta_capability: false, @@ -5749,7 +5818,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a cache.track_tool_list_changes = true; let mut ticket = cache.begin_fetch(); ticket.tool_list_revision = Some(client.tool_list_revision()); - cache.publish_if_newest(ticket, old_step.tools()); + cache.publish_if_newest(ticket, old_step.tools(), /*meta*/ None); assert!(cache.has_tools()); // Reused connections must use the new view's timeout, not their startup timeout. config.startup_timeout_sec = Some(Duration::from_secs(1)); diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index ba791d514f..a8a323a5aa 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -495,6 +495,7 @@ pub async fn read_mcp_resource( pub struct McpServerStatusSnapshot { pub server_infos: HashMap, pub server_capabilities: HashMap, + pub tool_list_meta: HashMap>, pub tools_by_server: HashMap>, pub tools_errors: HashMap, pub resources: HashMap>, @@ -518,6 +519,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( return McpServerStatusSnapshot { server_infos: HashMap::new(), server_capabilities: HashMap::new(), + tool_list_meta: HashMap::new(), tools_by_server: HashMap::new(), tools_errors: HashMap::new(), resources: HashMap::new(), @@ -806,13 +808,13 @@ pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( ) -> 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!( + let ((server_infos, (tools, tools_errors, tool_list_meta)), resources, resource_templates) = tokio::join!( async { let server_infos = mcp_connection_manager .list_available_server_infos(include_server) .await; let tools = mcp_connection_manager - .list_tools_with_errors(include_server) + .list_catalogs_with_errors(include_server) .await; (server_infos, tools) }, @@ -836,6 +838,8 @@ pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( }, ); + let server_capabilities = + mcp_connection_manager.list_available_server_capabilities(include_server); let mut tools_by_server = HashMap::>::new(); for tool_info in tools { let raw_tool_name = tool_info.tool.name.to_string(); @@ -859,7 +863,8 @@ pub(crate) async fn collect_mcp_server_status_snapshot_from_manager( } McpServerStatusSnapshot { - server_capabilities: mcp_connection_manager.list_available_server_capabilities(), + server_capabilities, + tool_list_meta, 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 7e9f4964ff..5d512a9e2d 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -128,7 +128,7 @@ pub(crate) struct ManagedClient { } impl ManagedClient { - pub(crate) async fn listed_tools(&self) -> Vec { + pub(crate) async fn listed_catalog(&self) -> (Vec, Option>) { let total_start = Instant::now(); self.tool_catalog .read(|catalog| { @@ -137,17 +137,17 @@ impl ManagedClient { if catalog.revision == 0 && let Some(cache_context) = &self.codex_apps_tools_cache_context { - let tools = cache_context.current_tools(); + let tools = cache_context.current_catalog_snapshot(); emit_duration( MCP_TOOLS_LIST_DURATION_METRIC, total_start.elapsed(), &[("cache", if tools.is_some() { "hit" } else { "miss" })], ); if let Some(tools) = tools { - return tools; + return (tools.tools().to_vec(), tools.shared_tool_list_meta()); } } - catalog.tools.to_vec() + (catalog.tools.to_vec(), catalog.meta) }) .await } @@ -598,10 +598,6 @@ impl AsyncManagedClient { .is_some_and(McpToolCatalogCacheContext::has_tools) } - pub(crate) fn cached_tools(&self) -> Option> { - self.cached_tools_or(/*fallback*/ None) - } - pub(crate) fn cached_tools_or(&self, fallback: Option>) -> Option> { self.codex_apps_tools_cache_context .as_ref() @@ -613,16 +609,34 @@ impl AsyncManagedClient { }) } - pub(crate) async fn listed_tools(&self) -> Result, StartupOutcomeError> { - // Plugin provenance is resolved per-session rather than stored in shared cache payloads. + pub(crate) fn cached_catalog(&self) -> Option<(Vec, Option>)> { + self.codex_apps_tools_cache_context + .as_ref() + .and_then(|context| { + context + .current_catalog_snapshot() + .map(|snapshot| (snapshot.tools().to_vec(), snapshot.shared_tool_list_meta())) + // Older caches still supply tools without discovery-scoped metadata. + .or_else(|| context.current_tools().map(|tools| (tools, None))) + }) + .or_else(|| { + self.tool_catalog_cache_context + .as_ref() + .and_then(|cache| cache.current_catalog_or(/*fallback*/ None)) + }) + } + + pub(crate) async fn listed_catalog( + &self, + ) -> Result<(Vec, Option>), StartupOutcomeError> { if !self.startup_complete.load(Ordering::Acquire) - && let Some(startup_tools) = self.cached_tools() + && let Some(catalog) = self.cached_catalog() { - Ok(startup_tools) + Ok(catalog) } else { match self.client().await { - Ok(client) => Ok(client.listed_tools().await), - Err(error) if self.is_codex_apps_mcp_server => self.cached_tools().ok_or(error), + Ok(client) => Ok(client.listed_catalog().await), + Err(error) if self.is_codex_apps_mcp_server => self.cached_catalog().ok_or(error), Err(error) => Err(error), } } @@ -673,14 +687,23 @@ pub(crate) async fn list_tools_for_client_uncached( timeout: Option, catalog_item_limit: usize, server_instructions: Option<&str>, -) -> Result> { +) -> Result<(Vec, Option>)> { + let meta = StdMutex::new(None); let fetch_start = Instant::now(); let tools = collect_paginated_with_limit("tools/list", timeout, catalog_item_limit, |params| { let client = Arc::clone(client); + let meta = &meta; + let first_page = params.as_ref().is_none_or(|params| params.cursor.is_none()); async move { let response = client .list_tools_with_connector_ids(params, timeout) .await?; + if first_page { + *meta + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + response.meta.map(Arc::new); + } Ok((response.tools, response.next_cursor)) } }) @@ -708,7 +731,10 @@ pub(crate) async fn list_tools_for_client_uncached( &[], ); } - Ok(tools) + let meta = meta + .into_inner() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok((tools, meta)) } /// Filters disabled connectors, presents declared Codex Apps file parameters to the model as @@ -1023,7 +1049,7 @@ async fn start_server_task( let fetch_ticket = codex_apps_tools_cache_context .as_ref() .map(|context| context.begin_fetch(ConnectorRuntimeFetchSource::Startup)); - let tools = list_tools_for_client_uncached( + let (tools, meta) = list_tools_for_client_uncached( &server_name, is_codex_apps_mcp_server, /*codex_apps_refresh_trigger*/ "initial", @@ -1037,11 +1063,16 @@ async fn start_server_task( let client_tools: Arc<[ToolInfo]> = match (codex_apps_tools_cache_context.as_ref(), fetch_ticket) { (Some(context), Some(ticket)) if server_disables_tool_catalog_cache => { - context.publish_runtime_if_newest_accepted(ticket, &server_info, tools.clone()); + context.publish_runtime_if_newest_accepted( + ticket, + &server_info, + tools.clone(), + meta.clone(), + ); tools.into() } (Some(context), Some(ticket)) => context - .publish_runtime_if_newest_accepted(ticket, &server_info, tools) + .publish_runtime_if_newest_accepted(ticket, &server_info, tools, meta.clone()) .shared_tools(), (None, None) => tools.into(), _ => unreachable!("Codex Apps fetch ticket requires cache context"), @@ -1055,7 +1086,7 @@ async fn start_server_task( // Discovery can be session-specific, so do not cache later live catalogs. fetch_ticket.tool_list_revision = Some(client.tool_list_revision()); } - cache_context.publish_if_newest(fetch_ticket, &client_tools); + cache_context.publish_if_newest(fetch_ticket, &client_tools, meta.clone()); } if is_codex_apps_mcp_server || tool_catalog_cache_context.is_some() { emit_duration( @@ -1070,6 +1101,7 @@ async fn start_server_task( server_info, tool_catalog: Arc::new(ClientToolCatalog::new( client_tools, + meta, codex_apps_tools_cache_context .as_ref() .and_then(ConnectorRuntimeContext::subscribe), diff --git a/codex-rs/codex-mcp/src/server_tests.rs b/codex-rs/codex-mcp/src/server_tests.rs index 1cbe2f122e..4f539e6783 100644 --- a/codex-rs/codex-mcp/src/server_tests.rs +++ b/codex-rs/codex-mcp/src/server_tests.rs @@ -171,7 +171,7 @@ fn tool_catalog_cache_does_not_reuse_catalogs_across_credential_policies() { .expect("explicit authorization permits catalog caching") }; let host_context = context(&host_identity); - host_context.publish_if_newest(host_context.begin_fetch(), &[]); + host_context.publish_if_newest(host_context.begin_fetch(), &[], /*meta*/ None); assert_eq!(context(&host_identity).current_tools(), Some(Vec::new())); assert_eq!(context(&executor_identity).current_tools(), None); diff --git a/codex-rs/codex-mcp/src/tool_catalog_cache.rs b/codex-rs/codex-mcp/src/tool_catalog_cache.rs index 60989beb4b..8385633bd6 100644 --- a/codex-rs/codex-mcp/src/tool_catalog_cache.rs +++ b/codex-rs/codex-mcp/src/tool_catalog_cache.rs @@ -70,6 +70,7 @@ struct OptionalStartupDeadline { struct ToolCatalogSnapshot { tools: Vec, + meta: Option>, published_at: Instant, tool_list_revision: Option, } @@ -192,6 +193,14 @@ impl McpToolCatalogCacheContext { &self, fallback: Option>, ) -> Option> { + self.current_catalog_or(fallback.map(|tools| (tools, None))) + .map(|(tools, _)| tools) + } + + pub(crate) fn current_catalog_or( + &self, + fallback: Option<(Vec, Option>)>, + ) -> Option<(Vec, Option>)> { let state = lock_unpoisoned(&self.entry.state); if state.disabled_by_server || state @@ -205,7 +214,7 @@ impl McpToolCatalogCacheContext { .snapshot .as_ref() .filter(|snapshot| snapshot.published_at.elapsed() <= TOOL_CATALOG_CACHE_TTL) - .map(|snapshot| snapshot.tools.clone()) + .map(|snapshot| (snapshot.tools.clone(), snapshot.meta.clone())) .or(fallback) } @@ -226,7 +235,12 @@ impl McpToolCatalogCacheContext { state.snapshot = None; } - pub(crate) fn publish_if_newest(&self, ticket: McpToolCatalogFetchTicket, tools: &[ToolInfo]) { + pub(crate) fn publish_if_newest( + &self, + ticket: McpToolCatalogFetchTicket, + tools: &[ToolInfo], + meta: Option>, + ) { let mut state = lock_unpoisoned(&self.entry.state); if state.disabled_by_server || ticket.generation <= state.last_accepted_generation { return; @@ -242,6 +256,7 @@ impl McpToolCatalogCacheContext { state.optional_startup_deadline = None; state.snapshot = Some(ToolCatalogSnapshot { tools, + meta, published_at: Instant::now(), tool_list_revision: ticket.tool_list_revision, }); diff --git a/codex-rs/connectors/src/connector_runtime/mod.rs b/codex-rs/connectors/src/connector_runtime/mod.rs index 23858e82d3..c425b40a26 100644 --- a/codex-rs/connectors/src/connector_runtime/mod.rs +++ b/codex-rs/connectors/src/connector_runtime/mod.rs @@ -107,12 +107,18 @@ pub fn connector_runtime_cache_path(codex_home: &Path, auth: Option<&CodexAuth>) #[derive(Debug, Clone)] pub struct ConnectorRuntimeSnapshot { tools: Arc<[T]>, + tool_list_meta: Option>, + discovery_scope: Vec, refreshed_at: SystemTime, generation: u64, tools_version: u64, } impl ConnectorRuntimeSnapshot { + pub fn shared_tool_list_meta(&self) -> Option> { + self.tool_list_meta.as_ref().map(Arc::clone) + } + pub fn tools(&self) -> &[T] { &self.tools } @@ -258,6 +264,19 @@ impl ConnectorRuntimeContext { self.entry.current_snapshot.load_full() } + pub fn current_catalog_snapshot(&self) -> Option>> { + let catalog = self.live_catalog.as_ref()?; + catalog.updates.borrow().clone().or_else(|| { + let scope: Vec<_> = catalog + .scope + .iter() + .map(|part| persistence::sha1_hex(part)) + .collect(); + self.current_snapshot() + .filter(|snapshot| snapshot.discovery_scope.starts_with(&scope)) + }) + } + pub fn has_current_tools(&self) -> bool { self.current_snapshot().is_some() } @@ -298,18 +317,21 @@ impl ConnectorRuntimeContext { ticket: ConnectorRuntimeFetchTicket, server_info: &McpServerInfo, tools: Vec, + tool_list_meta: Option>, ) -> Arc> { match self.entry.disk_cache { ConnectorRuntimeDiskCache::Enabled => self.publish_runtime_if_newest_accepted_with( ticket, server_info, tools, + tool_list_meta, persist_codex_apps_cache, ), ConnectorRuntimeDiskCache::Disabled => self.publish_runtime_if_newest_accepted_with( ticket, server_info, tools, + tool_list_meta, |_, _, _| {}, ), } @@ -320,6 +342,7 @@ impl ConnectorRuntimeContext { ticket: ConnectorRuntimeFetchTicket, server_info: &McpServerInfo, tools: Vec, + tool_list_meta: Option>, persist: impl FnOnce(&ConnectorRuntimeContext, &McpServerInfo, &ConnectorRuntimeSnapshot), ) -> Arc> { let publish_start = Instant::now(); @@ -337,6 +360,18 @@ impl ConnectorRuntimeContext { _ => (tools.into(), ticket.generation), }; let snapshot = Arc::new(ConnectorRuntimeSnapshot { + tool_list_meta, + discovery_scope: self + .live_catalog + .as_ref() + .map(|catalog| { + catalog + .scope + .iter() + .map(|part| persistence::sha1_hex(part)) + .collect() + }) + .unwrap_or_default(), tools, tools_version, refreshed_at: SystemTime::now(), @@ -381,17 +416,6 @@ impl ConnectorRuntimeContext { ); snapshot } - - pub fn publish_if_newest_accepted( - &self, - ticket: ConnectorRuntimeFetchTicket, - server_info: &McpServerInfo, - tools: Vec, - ) -> Vec { - self.publish_runtime_if_newest_accepted(ticket, server_info, tools) - .tools - .to_vec() - } } #[derive(Debug, Clone, Copy)] diff --git a/codex-rs/connectors/src/connector_runtime/persistence.rs b/codex-rs/connectors/src/connector_runtime/persistence.rs index 8abed04a74..1111079215 100644 --- a/codex-rs/connectors/src/connector_runtime/persistence.rs +++ b/codex-rs/connectors/src/connector_runtime/persistence.rs @@ -62,6 +62,8 @@ pub(crate) fn load_cached_connector_runtime_for_identity { + #[serde(default, skip_serializing_if = "Option::is_none")] + tool_list_meta: Option, + #[serde(default)] + discovery_scope: Vec, schema_version: u8, tools: Arc<[T]>, } @@ -216,7 +224,7 @@ struct CodexAppsServerInfoDiskCache { server_info: McpServerInfo, } -fn sha1_hex(s: &str) -> String { +pub(super) fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); hasher.update(s.as_bytes()); let sha1 = hasher.finalize(); @@ -233,6 +241,8 @@ pub(crate) fn write_cached_codex_apps_tools_for_test( { let snapshot = ConnectorRuntimeSnapshot { tools: tools.into(), + tool_list_meta: None, + discovery_scope: Vec::new(), refreshed_at: SystemTime::now(), generation: 0, tools_version: 0, @@ -265,6 +275,8 @@ where { let snapshot = ConnectorRuntimeSnapshot { tools: tools.into(), + tool_list_meta: None, + discovery_scope: Vec::new(), refreshed_at: SystemTime::now(), generation: 0, tools_version: 0, diff --git a/codex-rs/connectors/src/connector_runtime/tests.rs b/codex-rs/connectors/src/connector_runtime/tests.rs index e4a4996dc3..183c5262c5 100644 --- a/codex-rs/connectors/src/connector_runtime/tests.rs +++ b/codex-rs/connectors/src/connector_runtime/tests.rs @@ -371,11 +371,25 @@ fn codex_apps_tools_cache_publishes_newest_shared_snapshot() { let newer_tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newer")]; let older_tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "older")]; - let published_tools = - cache_context_2.publish_if_newest_accepted(newer_ticket, &server_info, newer_tools); + let published_tools = cache_context_2 + .publish_runtime_if_newest_accepted( + newer_ticket, + &server_info, + newer_tools, + /*tool_list_meta*/ None, + ) + .tools() + .to_vec(); assert_eq!(cache_context_1.current_tools(), Some(published_tools)); - let current_tools = - cache_context_1.publish_if_newest_accepted(older_ticket, &server_info, older_tools); + let current_tools = cache_context_1 + .publish_runtime_if_newest_accepted( + older_ticket, + &server_info, + older_tools, + /*tool_list_meta*/ None, + ) + .tools() + .to_vec(); assert_eq!(current_tools[0].callable_name, "newer"); assert_eq!( @@ -403,11 +417,15 @@ fn codex_apps_tools_cache_keeps_live_publish_when_disk_persistence_fails() { }, ); let tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "live")]; - let published_tools = cache_context.publish_if_newest_accepted( - cache_context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), - &create_test_server_info("Codex Apps"), - tools.clone(), - ); + let published_tools = cache_context + .publish_runtime_if_newest_accepted( + cache_context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), + &create_test_server_info("Codex Apps"), + tools.clone(), + /*tool_list_meta*/ None, + ) + .tools() + .to_vec(); assert_eq!(published_tools, tools); assert_eq!(cache_context.current_tools(), Some(tools)); @@ -450,11 +468,15 @@ fn connector_runtime_without_cache_publishes_without_writing() { }, ); let tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "live")]; - let published_tools = context.publish_if_newest_accepted( - context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), - &create_test_server_info("Codex Apps"), - tools.clone(), - ); + let published_tools = context + .publish_runtime_if_newest_accepted( + context.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), + &create_test_server_info("Codex Apps"), + tools.clone(), + /*tool_list_meta*/ None, + ) + .tools() + .to_vec(); assert_eq!(published_tools, tools); assert_eq!(context.current_tools(), Some(tools)); @@ -514,15 +536,35 @@ fn live_catalogs_isolate_scopes_and_reject_older_refreshes() { let tools_a = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newer-a")]; let tools_b = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newest-b")]; - scope_b.publish_if_newest_accepted(newest_b, &server_info, tools_b.clone()); + scope_b + .publish_runtime_if_newest_accepted( + newest_b, + &server_info, + tools_b.clone(), + /*tool_list_meta*/ None, + ) + .tools() + .to_vec(); assert!(updates_a.borrow().is_none()); - let discovery = scope_a.publish_if_newest_accepted(newer_a, &server_info, tools_a.clone()); + let discovery = scope_a + .publish_runtime_if_newest_accepted( + newer_a, + &server_info, + tools_a.clone(), + /*tool_list_meta*/ None, + ) + .tools() + .to_vec(); assert_eq!(discovery, tools_b); - scope_a.publish_if_newest_accepted( - older_a, - &server_info, - vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale-a")], - ); + scope_a + .publish_runtime_if_newest_accepted( + older_a, + &server_info, + vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale-a")], + /*tool_list_meta*/ None, + ) + .tools() + .to_vec(); assert_eq!( updates_a.borrow().as_ref().expect("scope A tools").tools(), &tools_a @@ -552,6 +594,7 @@ fn contexts_for_different_identities_keep_isolated_snapshots() { context_a.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), &create_test_server_info("Codex Apps"), tools_a.clone(), + /*tool_list_meta*/ None, ); let older_ticket_a = context_a.begin_fetch(ConnectorRuntimeFetchSource::Startup); let context_b = manager.context( @@ -587,17 +630,20 @@ fn contexts_for_different_identities_keep_isolated_snapshots() { context_b.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), &create_test_server_info("Codex Apps"), tools_b.clone(), + /*tool_list_meta*/ None, ); let newer_tools_a = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newer-a")]; let newer_snapshot_a = same_context_a.publish_runtime_if_newest_accepted( same_context_a.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh), &create_test_server_info("Codex Apps"), newer_tools_a.clone(), + /*tool_list_meta*/ None, ); let stale_snapshot_a = context_a.publish_runtime_if_newest_accepted( older_ticket_a, &create_test_server_info("Codex Apps"), vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale-a")], + /*tool_list_meta*/ None, ); assert_eq!(snapshot_a.tools(), &tools_a); @@ -657,6 +703,7 @@ fn live_provider_reuses_equal_tools_only_within_its_scope() { context.begin_fetch(ConnectorRuntimeFetchSource::Startup), &create_test_server_info("Apps"), tools.clone(), + /*tool_list_meta*/ None, ) }; let original = publish(&first); @@ -746,6 +793,7 @@ fn accepted_generations_finish_persistence_in_order() { older_ticket, &create_test_server_info("Codex Apps"), vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "older")], + /*tool_list_meta*/ None, move |_, _, _| { older_persisting_tx .send(()) @@ -765,6 +813,7 @@ fn accepted_generations_finish_persistence_in_order() { newer_ticket, &create_test_server_info("Codex Apps"), vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newer")], + /*tool_list_meta*/ None, move |_, _, _| { newer_persisting_tx .send(()) @@ -807,6 +856,7 @@ fn personal_and_workspace_contexts_are_distinct_even_with_matching_ids() { personal_context.begin_fetch(ConnectorRuntimeFetchSource::Startup), &create_test_server_info("Codex Apps"), personal_tools.clone(), + /*tool_list_meta*/ None, ); let workspace_context = manager.context( @@ -823,6 +873,7 @@ fn personal_and_workspace_contexts_are_distinct_even_with_matching_ids() { workspace_context.begin_fetch(ConnectorRuntimeFetchSource::Startup), &create_test_server_info("Codex Apps"), workspace_tools.clone(), + /*tool_list_meta*/ None, ); assert_eq!(personal_context.current_tools(), Some(personal_tools)); @@ -848,6 +899,7 @@ fn live_publish_sets_timestamp_and_stale_publish_preserves_it() { current_ticket, &create_test_server_info("Codex Apps"), vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "current")], + /*tool_list_meta*/ None, ); let after = SystemTime::now(); @@ -858,6 +910,7 @@ fn live_publish_sets_timestamp_and_stale_publish_preserves_it() { stale_ticket, &create_test_server_info("Codex Apps"), vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale")], + /*tool_list_meta*/ None, ); assert!(Arc::ptr_eq(¤t, &stale)); assert_eq!(stale.refreshed_at(), current.refreshed_at()); diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 552a80bc62..321ed5d197 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -389,6 +389,7 @@ pub struct ToolWithConnectorId { } pub struct ListToolsWithConnectorIdResult { + pub meta: Option, pub next_cursor: Option, pub tools: Vec, } @@ -750,6 +751,7 @@ impl RmcpClient { }) .collect::>>()?; Ok(ListToolsWithConnectorIdResult { + meta: result.meta.map(|meta| serde_json::json!(meta)), next_cursor: result.next_cursor, tools, }) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index f99f7bee2d..13daf6a761 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -1599,6 +1599,7 @@ mod tests { let statuses = vec![ McpServerStatus { server_capabilities: None, + tool_list_meta: None, tools_error: None, name: "docs".to_string(), runtime_status: None, @@ -1624,6 +1625,7 @@ mod tests { }, McpServerStatus { server_capabilities: None, + tool_list_meta: 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 2b323a6720..dccb70d576 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -504,6 +504,7 @@ async fn handle_mcp_inventory_result_respects_origin_thread() { app.handle_mcp_inventory_result( Ok(vec![McpServerStatus { server_capabilities: None, + tool_list_meta: 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 23f1e1c9f5..97d1a5b10a 100644 --- a/codex-rs/tui/src/history_cell/mcp_tests.rs +++ b/codex-rs/tui/src/history_cell/mcp_tests.rs @@ -25,6 +25,7 @@ fn mcp_inventory_connection_states() { .into_iter() .map(|(name, runtime_status)| McpServerStatus { server_capabilities: None, + tool_list_meta: 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 1828bf7f37..8996d8e3a1 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1134,6 +1134,7 @@ async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() { fn mcp_tools_output_from_statuses_renders_status_only_servers() { let statuses = vec![McpServerStatus { server_capabilities: None, + tool_list_meta: None, tools_error: None, name: "plugin_docs".to_string(), runtime_status: None, @@ -1169,6 +1170,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() { fn mcp_tools_output_from_statuses_renders_verbose_inventory() { let statuses = vec![McpServerStatus { server_capabilities: None, + tool_list_meta: 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 b36a01b4b9..9395f0e817 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -8839,6 +8839,13 @@ class McpServerStatus(BaseModel): ), ] = None server_info: Annotated[McpServerInfo | None, Field(alias="serverInfo")] = None + tool_list_meta: Annotated[ + Any | None, + Field( + alias="toolListMeta", + description="Opaque `_meta` from the first page of the corresponding MCP tool catalog. Null when unavailable.", + ), + ] = None tools: dict[str, Tool] tools_error: Annotated[ str | None,