From c5f9ac0200c5df8efaf1789a49ecceee3d00f1b7 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 26 May 2026 18:16:03 -0700 Subject: [PATCH 1/9] Expose MCP server initialize metadata in status --- .../src/protocol/v2/mcp.rs | 4 ++++ .../src/request_processors/mcp_processor.rs | 2 ++ .../tests/suite/v2/mcp_server_status.rs | 16 +++++++++++++++ codex-rs/codex-mcp/src/connection_manager.rs | 12 +++++++++++ codex-rs/codex-mcp/src/mcp/mod.rs | 7 ++++++- codex-rs/codex-mcp/src/rmcp_client.rs | 19 ++++++++++++++++++ codex-rs/protocol/src/mcp.rs | 20 +++++++++++++++++++ 7 files changed, 79 insertions(+), 1 deletion(-) 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 5f92e8305995..cee28df2b7ba 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -2,6 +2,7 @@ use super::shared::v2_enum_from_core; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource as McpResource; pub use codex_protocol::mcp::ResourceContent as McpResourceContent; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; @@ -53,6 +54,9 @@ pub enum McpServerStatusDetail { #[ts(export_to = "v2/")] pub struct McpServerStatus { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub server_info: Option, pub tools: std::collections::HashMap, pub resources: Vec, pub resource_templates: Vec, 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 22d5a4326a48..ae62e2e78556 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -276,6 +276,7 @@ impl McpRequestProcessor { .await; let McpServerStatusSnapshot { + server_infos, tools_by_server, resources, resource_templates, @@ -315,6 +316,7 @@ impl McpRequestProcessor { .iter() .map(|name| McpServerStatus { name: name.clone(), + server_info: server_infos.get(name).cloned(), tools: tools_by_server.get(name).cloned().unwrap_or_default(), resources: resources.get(name).cloned().unwrap_or_default(), resource_templates: resource_templates.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 bc9839b5303d..f9e2a1d3d8bd 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 @@ -20,6 +20,7 @@ use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; +use rmcp::model::Implementation; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; @@ -99,6 +100,13 @@ url = "{mcp_server_url}/mcp" .map(|tool| tool.name.as_str()), Some("look-up.raw") ); + assert_eq!( + status + .server_info + .as_ref() + .and_then(|info| info.title.as_deref()), + Some("Lookup Server") + ); mcp_server_handle.abort(); let _ = mcp_server_handle.await; @@ -207,6 +215,14 @@ impl ServerHandler for McpStatusServer { fn get_info(&self) -> ServerInfo { ServerInfo { capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "lookup-server".to_string(), + title: Some("Lookup Server".to_string()), + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + }, ..ServerInfo::default() } } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 91ed8b1b64d7..31475434867d 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -44,6 +44,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; @@ -394,6 +395,17 @@ impl McpConnectionManager { normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names) } + pub async fn list_server_infos(&self) -> HashMap { + let mut server_infos = HashMap::new(); + for (server_name, client) in &self.clients { + let Ok(managed_client) = client.client().await else { + continue; + }; + server_infos.insert(server_name.clone(), managed_client.server_info); + } + server_infos + } + /// Force-refresh codex apps tools by bypassing the in-process cache. /// /// On success, the refreshed tools replace the cache contents and the diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 407ece427eb9..092d5e1c4cd5 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -25,6 +25,7 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; @@ -314,6 +315,7 @@ pub async fn read_mcp_resource( #[derive(Debug, Clone)] pub struct McpServerStatusSnapshot { + pub server_infos: HashMap, pub tools_by_server: HashMap>, pub resources: HashMap>, pub resource_templates: HashMap>, @@ -333,6 +335,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { + server_infos: HashMap::new(), tools_by_server: HashMap::new(), resources: HashMap::new(), resource_templates: HashMap::new(), @@ -588,7 +591,8 @@ async fn collect_mcp_server_status_snapshot_from_manager( server_names: Vec, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { - let (tools, resources, resource_templates) = tokio::join!( + let (server_infos, tools, resources, resource_templates) = tokio::join!( + mcp_connection_manager.list_server_infos(), mcp_connection_manager.list_all_tools(), async { if detail.include_resources() { @@ -620,6 +624,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } McpServerStatusSnapshot { + server_infos, tools_by_server, resources: convert_mcp_resources(resources), resource_templates: convert_mcp_resource_templates(resource_templates), diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index c75895842b2b..874343da1c7e 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -47,6 +47,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::HttpClient; use codex_exec_server::ReqwestHttpClient; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::protocol::Event; use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; @@ -85,6 +86,7 @@ const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[ #[derive(Clone)] pub(crate) struct ManagedClient { pub(crate) client: Arc, + pub(crate) server_info: McpServerInfo, pub(crate) tools: Vec, pub(crate) tool_filter: ToolFilter, pub(crate) tool_timeout: Option, @@ -535,6 +537,7 @@ async fn start_server_task( let managed = ManagedClient { client: Arc::clone(&client), + server_info: mcp_server_info_from_implementation(initialize_result.server_info), tools, tool_timeout: Some(tool_timeout), tool_filter, @@ -546,6 +549,22 @@ async fn start_server_task( Ok(managed) } +fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { + McpServerInfo { + name: server_info.name, + title: server_info.title, + version: server_info.version, + description: server_info.description, + icons: server_info.icons.map(|icons| { + icons + .into_iter() + .filter_map(|icon| serde_json::to_value(icon).ok()) + .collect() + }), + website_url: server_info.website_url, + } +} + struct StartServerTaskParams { startup_timeout: Option, // TODO: cancel_token should handle this. tool_timeout: Duration, diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index f6e69743b924..3eefd317050f 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -26,6 +26,26 @@ impl std::fmt::Display for RequestId { } } +/// Presentation metadata advertised by an initialized MCP server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpServerInfo { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub icons: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub website_url: Option, +} + /// Definition for a tool the client can call. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] From dfeeabb656d3574eeaca2862e7e4deb45edad8ee Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 26 May 2026 22:13:41 -0700 Subject: [PATCH 2/9] Avoid blocking MCP status on cached app startup --- .../codex_app_server_protocol.schemas.json | 51 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 51 ++++++++ .../json/v2/ListMcpServerStatusResponse.json | 51 ++++++++ .../schema/typescript/McpServerInfo.ts | 9 ++ .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/McpServerStatus.ts | 3 +- .../src/protocol/v2/mcp.rs | 2 - codex-rs/app-server/README.md | 2 +- codex-rs/codex-mcp/src/codex_apps.rs | 24 +++- codex-rs/codex-mcp/src/connection_manager.rs | 23 +++- .../codex-mcp/src/connection_manager_tests.rs | 114 ++++++++++++++++-- codex-rs/codex-mcp/src/mcp/mod.rs | 4 +- codex-rs/codex-mcp/src/rmcp_client.rs | 15 ++- codex-rs/tui/src/app/background_requests.rs | 2 + codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/history_cell/tests.rs | 2 + 16 files changed, 328 insertions(+), 27 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts 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 4c6d5a6eeee7..d41da603381c 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 @@ -10940,6 +10940,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -11050,6 +11091,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "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 e74323745ece..f342f521d723 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 @@ -7469,6 +7469,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -7579,6 +7620,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "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 fc181c2702e5..0dc2f5e2e4b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -10,6 +10,47 @@ ], "type": "string" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerStatus": { "properties": { "authStatus": { @@ -30,6 +71,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" diff --git a/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts new file mode 100644 index 000000000000..6bbfbf6a0883 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Presentation metadata advertised by an initialized MCP server. + */ +export type McpServerInfo = { name: string, title?: string, version: string, description?: string, icons?: Array, websiteUrl?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 8be75af546fd..458d2e43b9a7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -42,6 +42,7 @@ export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; +export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; 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 430494e2687d..d2e99ce96fd5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts @@ -1,9 +1,10 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpServerInfo } from "../McpServerInfo"; import type { Resource } from "../Resource"; import type { ResourceTemplate } from "../ResourceTemplate"; import type { Tool } from "../Tool"; import type { McpAuthStatus } from "./McpAuthStatus"; -export type McpServerStatus = { name: string, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; +export type McpServerStatus = { name: string, serverInfo: McpServerInfo | null, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; 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 cee28df2b7ba..318c21fb0a20 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -54,8 +54,6 @@ pub enum McpServerStatusDetail { #[ts(export_to = "v2/")] pub struct McpServerStatus { pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] pub server_info: Option, pub tools: std::collections::HashMap, pub resources: Vec, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index db76e363ece7..1bb26442db59 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -216,7 +216,7 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, and nullable presentation metadata advertised by initialized servers, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index eb88ffa6b1bf..31e51b6d5fba 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -13,6 +13,7 @@ use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; use codex_login::CodexAuth; +use codex_protocol::mcp::McpServerInfo; use codex_utils_plugins::mcp_connector::is_connector_id_allowed; use codex_utils_plugins::mcp_connector::sanitize_name; use serde::Deserialize; @@ -54,7 +55,10 @@ impl CodexAppsToolsCacheContext { } pub(crate) enum CachedCodexAppsToolsLoad { - Hit(Vec), + Hit { + tools: Vec, + server_info: Option, + }, Missing, Invalid, } @@ -136,6 +140,7 @@ pub(crate) fn normalize_codex_apps_callable_namespace( pub(crate) fn write_cached_codex_apps_tools_if_needed( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, + server_info: &McpServerInfo, tools: &[ToolInfo], ) { if server_name != CODEX_APPS_MCP_SERVER_NAME { @@ -144,7 +149,7 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); - write_cached_codex_apps_tools(cache_context, tools); + write_cached_codex_apps_tools(cache_context, server_info, tools); emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), @@ -156,7 +161,7 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, -) -> Option> { +) -> Option<(Vec, Option)> { if server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } @@ -164,7 +169,7 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( let cache_context = cache_context?; match load_cached_codex_apps_tools(cache_context) { - CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Hit { tools, server_info } => Some((tools, server_info)), CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } } @@ -174,7 +179,7 @@ pub(crate) fn read_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> Option> { match load_cached_codex_apps_tools(cache_context) { - CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Hit { tools, .. } => Some(tools), CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } } @@ -197,11 +202,15 @@ pub(crate) fn load_cached_codex_apps_tools( if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { return CachedCodexAppsToolsLoad::Invalid; } - CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) + CachedCodexAppsToolsLoad::Hit { + tools: filter_disallowed_codex_apps_tools(cache.tools), + server_info: cache.server_info, + } } pub(crate) fn write_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, + server_info: &McpServerInfo, tools: &[ToolInfo], ) { let cache_path = cache_context.cache_path(); @@ -213,6 +222,7 @@ pub(crate) fn write_cached_codex_apps_tools( let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + server_info: Some(server_info.clone()), tools, }) else { return; @@ -234,6 +244,8 @@ pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec, tools: Vec, } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 31475434867d..32190ca191e8 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; @@ -395,13 +396,26 @@ impl McpConnectionManager { normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names) } - pub async fn list_server_infos(&self) -> HashMap { + /// Returns presentation metadata without waiting for uncached clients still initializing. + pub async fn list_available_server_infos(&self) -> HashMap { let mut server_infos = HashMap::new(); for (server_name, client) in &self.clients { - let Ok(managed_client) = client.client().await else { + if !client.startup_complete.load(Ordering::Acquire) { + if let Some(server_info) = client.startup_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } continue; - }; - server_infos.insert(server_name.clone(), managed_client.server_info); + } + match client.client().await { + Ok(managed_client) => { + server_infos.insert(server_name.clone(), managed_client.server_info); + } + Err(_) => { + if let Some(server_info) = client.startup_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } + } + } } server_infos } @@ -441,6 +455,7 @@ impl McpConnectionManager { write_cached_codex_apps_tools_if_needed( CODEX_APPS_MCP_SERVER_NAME, managed_client.codex_apps_tools_cache_context.as_ref(), + &managed_client.server_info, &tools, ); emit_duration( diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 86eb703e7a7f..253910890d96 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -20,6 +20,7 @@ use codex_config::Constrained; use codex_config::McpServerConfig; use codex_exec_server::EnvironmentManager; use codex_protocol::ToolName; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::McpAuthStatus; @@ -88,6 +89,17 @@ fn create_codex_apps_tools_cache_context( } } +fn create_test_server_info(title: &str) -> McpServerInfo { + McpServerInfo { + name: "codex-apps".to_string(), + title: Some(title.to_string()), + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + } +} + fn model_tool_names(tools: &[ToolInfo]) -> HashSet { tools .iter() @@ -549,13 +561,14 @@ fn codex_apps_tools_cache_is_overwritten_by_last_write() { ); let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools(&cache_context, &tools_gateway_1); + write_cached_codex_apps_tools(&cache_context, &server_info, &tools_gateway_1); let cached_gateway_1 = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for first write"); assert_eq!(cached_gateway_1[0].callable_name, "one"); - write_cached_codex_apps_tools(&cache_context, &tools_gateway_2); + write_cached_codex_apps_tools(&cache_context, &server_info, &tools_gateway_2); let cached_gateway_2 = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for second write"); assert_eq!(cached_gateway_2[0].callable_name, "two"); @@ -576,9 +589,10 @@ fn codex_apps_tools_cache_is_scoped_per_user() { ); let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1); - write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2); + write_cached_codex_apps_tools(&cache_context_user_1, &server_info, &tools_user_1); + write_cached_codex_apps_tools(&cache_context_user_2, &server_info, &tools_user_2); let read_user_1 = read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one"); @@ -616,8 +630,9 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() { Some("Calendar"), ), ]; + let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools(&cache_context, &tools); + write_cached_codex_apps_tools(&cache_context, &server_info, &tools); let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); assert_eq!(cached.len(), 1); @@ -676,17 +691,50 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { CODEX_APPS_MCP_SERVER_NAME, "calendar_search", )]; - write_cached_codex_apps_tools(&cache_context, &cached_tools); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools(&cache_context, &server_info, &cached_tools); let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ); - let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); + let (startup_tools, startup_server_info) = + startup_snapshot.expect("expected startup snapshot to load from cache"); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(startup_server_info, Some(server_info)); +} + +#[test] +fn startup_cached_codex_apps_tools_loads_cache_without_server_info() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + let (startup_tools, startup_server_info) = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("legacy startup snapshot should remain available"); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(startup_server_info, None); } #[tokio::test] @@ -710,6 +758,7 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { AsyncManagedClient { client: pending_client, startup_snapshot: Some(startup_tools), + startup_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -728,6 +777,43 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { assert_eq!(tool.callable_name, "calendar_create_event"); } +#[tokio::test] +async fn list_available_server_infos_uses_cache_while_client_is_pending() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + let server_info = create_test_server_info("Codex Apps"); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(Vec::new()), + startup_server_info: Some(server_info.clone()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let timeout_result = tokio::time::timeout( + Duration::from_millis(10), + manager.list_available_server_infos(), + ) + .await; + let server_infos = timeout_result.expect("server info lookup should not block on startup"); + assert_eq!( + server_infos.get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); +} + #[tokio::test] async fn list_all_tools_accepts_canonical_namespaced_tool_names() { let startup_tools = vec![create_test_tool("rmcp", "echo")]; @@ -746,6 +832,7 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() { AsyncManagedClient { client: pending_client, startup_snapshot: Some(startup_tools), + startup_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -788,6 +875,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { AsyncManagedClient { client: pending_client, startup_snapshot: Some(startup_tools), + startup_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -829,6 +917,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( AsyncManagedClient { client: pending_client, startup_snapshot: None, + startup_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -857,6 +946,7 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( AsyncManagedClient { client: pending_client, startup_snapshot: Some(Vec::new()), + startup_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -875,6 +965,7 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", )]; + let server_info = create_test_server_info("Codex Apps"); let failed_client = futures::future::ready::>(Err( StartupOutcomeError::Failed { error: "startup failed".to_string(), @@ -895,6 +986,7 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { AsyncManagedClient { client: failed_client, startup_snapshot: Some(startup_tools), + startup_server_info: Some(server_info.clone()), startup_complete, tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -911,6 +1003,13 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { .expect("tool from startup cache"); assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(tool.callable_name, "calendar_create_event"); + assert_eq!( + manager + .list_available_server_infos() + .await + .get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); } #[tokio::test] @@ -942,6 +1041,7 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { AsyncManagedClient { client: pending_client, startup_snapshot: Some(startup_tools), + startup_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 092d5e1c4cd5..6d386feb1582 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -591,8 +591,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( server_names: Vec, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { - let (server_infos, tools, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_server_infos(), + let (tools, resources, resource_templates) = tokio::join!( mcp_connection_manager.list_all_tools(), async { if detail.include_resources() { @@ -609,6 +608,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } }, ); + let server_infos = mcp_connection_manager.list_available_server_infos().await; let mut tools_by_server = HashMap::>::new(); for tool_info in tools { diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 874343da1c7e..3aa25dfa166f 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -99,7 +99,7 @@ impl ManagedClient { fn listed_tools(&self) -> Vec { let total_start = Instant::now(); if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref() - && let CachedCodexAppsToolsLoad::Hit(tools) = + && let CachedCodexAppsToolsLoad::Hit { tools, .. } = load_cached_codex_apps_tools(cache_context) { emit_duration( @@ -126,6 +126,7 @@ impl ManagedClient { pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, pub(crate) startup_snapshot: Option>, + pub(crate) startup_server_info: Option, pub(crate) startup_complete: Arc, pub(crate) tool_plugin_provenance: Arc, pub(crate) cancel_token: CancellationToken, @@ -155,8 +156,11 @@ impl AsyncManagedClient { let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( &server_name, codex_apps_tools_cache_context.as_ref(), - ) - .map(|tools| filter_tools(tools, &tool_filter)); + ); + let (startup_snapshot, startup_server_info) = startup_snapshot + .map_or((None, None), |(tools, server_info)| { + (Some(filter_tools(tools, &tool_filter)), server_info) + }); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); @@ -219,6 +223,7 @@ impl AsyncManagedClient { Self { client, startup_snapshot, + startup_server_info, startup_complete, tool_plugin_provenance, cancel_token, @@ -521,9 +526,11 @@ async fn start_server_task( fetch_start.elapsed(), &[], ); + let server_info = mcp_server_info_from_implementation(initialize_result.server_info); write_cached_codex_apps_tools_if_needed( &server_name, codex_apps_tools_cache_context.as_ref(), + &server_info, &tools, ); if server_name == CODEX_APPS_MCP_SERVER_NAME { @@ -537,7 +544,7 @@ async fn start_server_task( let managed = ManagedClient { client: Arc::clone(&client), - server_info: mcp_server_info_from_implementation(initialize_result.server_info), + server_info, tools, tool_timeout: Some(tool_timeout), tool_filter, diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index f81f8b071ebb..32961570b5d0 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -1061,6 +1061,7 @@ mod tests { let statuses = vec![ McpServerStatus { name: "docs".to_string(), + server_info: None, tools: HashMap::from([( "list".to_string(), Tool { @@ -1080,6 +1081,7 @@ mod tests { }, McpServerStatus { name: "disabled".to_string(), + server_info: None, tools: HashMap::new(), resources: Vec::new(), resource_templates: Vec::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c2138ba31aee..0e49877b497d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -147,6 +147,7 @@ async fn handle_mcp_inventory_result_respects_origin_thread() { app.handle_mcp_inventory_result( Ok(vec![McpServerStatus { name: "docs".to_string(), + server_info: None, tools: HashMap::new(), resources: Vec::new(), resource_templates: Vec::new(), diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 7f846d39fb2b..388da22268fc 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -806,6 +806,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 { name: "plugin_docs".to_string(), + server_info: None, tools: HashMap::from([( "lookup".to_string(), Tool { @@ -835,6 +836,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() { fn mcp_tools_output_from_statuses_renders_verbose_inventory() { let statuses = vec![McpServerStatus { name: "plugin_docs".to_string(), + server_info: None, tools: HashMap::from([( "lookup".to_string(), Tool { From 86889e7b24203afd3ed4ceefcc0b05c4064e8400 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 26 May 2026 22:17:47 -0700 Subject: [PATCH 3/9] Test nullable MCP server status metadata --- .../src/protocol/v2/tests.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 8f6824f397b4..2ebc33226d4c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1923,6 +1923,36 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { ); } +#[test] +fn mcp_server_status_serializes_absent_server_info_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "not-ready".to_string(), + server_info: None, + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "not-ready", + "serverInfo": null, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { From 24baf47281dd89f56a2363587a7118f340d9b271 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 26 May 2026 22:44:58 -0700 Subject: [PATCH 4/9] Keep MCP server metadata outside tools cache --- codex-rs/codex-mcp/src/codex_apps.rs | 80 +++++++++++++---- .../codex-mcp/src/connection_manager_tests.rs | 85 ++++++++++++++++--- codex-rs/codex-mcp/src/rmcp_client.rs | 12 +-- 3 files changed, 141 insertions(+), 36 deletions(-) diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 31e51b6d5fba..5906496276f5 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -46,19 +46,24 @@ pub(crate) struct CodexAppsToolsCacheContext { impl CodexAppsToolsCacheContext { pub(crate) fn cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR) + } + + pub(crate) fn server_info_cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR) + } + + fn cache_path_in(&self, cache_dir: &str) -> PathBuf { let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); let user_key_hash = sha1_hex(&user_key_json); self.codex_home - .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(cache_dir) .join(format!("{user_key_hash}.json")) } } pub(crate) enum CachedCodexAppsToolsLoad { - Hit { - tools: Vec, - server_info: Option, - }, + Hit(Vec), Missing, Invalid, } @@ -149,7 +154,8 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); - write_cached_codex_apps_tools(cache_context, server_info, tools); + write_cached_codex_apps_tools(cache_context, tools); + write_cached_codex_apps_server_info(cache_context, server_info); emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), @@ -161,7 +167,7 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, -) -> Option<(Vec, Option)> { +) -> Option> { if server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } @@ -169,17 +175,28 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( let cache_context = cache_context?; match load_cached_codex_apps_tools(cache_context) { - CachedCodexAppsToolsLoad::Hit { tools, server_info } => Some((tools, server_info)), + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } } +pub(crate) fn load_startup_cached_codex_apps_server_info( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + load_cached_codex_apps_server_info(cache_context?) +} + #[cfg(test)] pub(crate) fn read_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> Option> { match load_cached_codex_apps_tools(cache_context) { - CachedCodexAppsToolsLoad::Hit { tools, .. } => Some(tools), + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } } @@ -202,15 +219,11 @@ pub(crate) fn load_cached_codex_apps_tools( if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { return CachedCodexAppsToolsLoad::Invalid; } - CachedCodexAppsToolsLoad::Hit { - tools: filter_disallowed_codex_apps_tools(cache.tools), - server_info: cache.server_info, - } + CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) } pub(crate) fn write_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, - server_info: &McpServerInfo, tools: &[ToolInfo], ) { let cache_path = cache_context.cache_path(); @@ -222,7 +235,6 @@ pub(crate) fn write_cached_codex_apps_tools( let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, - server_info: Some(server_info.clone()), tools, }) else { return; @@ -230,6 +242,34 @@ pub(crate) fn write_cached_codex_apps_tools( let _ = std::fs::write(cache_path, bytes); } +pub(crate) fn load_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, +) -> Option { + let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?; + let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?; + (cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION) + .then_some(cache.server_info) +} + +fn write_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, + server_info: &McpServerInfo, +) { + let cache_path = cache_context.server_info_cache_path(); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache { + schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION, + server_info: server_info.clone(), + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { tools .into_iter() @@ -244,12 +284,18 @@ pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec, tools: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsServerInfoDiskCache { + schema_version: u8, + server_info: McpServerInfo, +} + const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; +const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; +const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 253910890d96..1fa9adede8af 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1,9 +1,11 @@ use super::*; use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION; use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::read_cached_codex_apps_tools; use crate::codex_apps::write_cached_codex_apps_tools; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -561,14 +563,13 @@ fn codex_apps_tools_cache_is_overwritten_by_last_write() { ); let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools(&cache_context, &server_info, &tools_gateway_1); + write_cached_codex_apps_tools(&cache_context, &tools_gateway_1); let cached_gateway_1 = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for first write"); assert_eq!(cached_gateway_1[0].callable_name, "one"); - write_cached_codex_apps_tools(&cache_context, &server_info, &tools_gateway_2); + write_cached_codex_apps_tools(&cache_context, &tools_gateway_2); let cached_gateway_2 = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for second write"); assert_eq!(cached_gateway_2[0].callable_name, "two"); @@ -589,10 +590,9 @@ fn codex_apps_tools_cache_is_scoped_per_user() { ); let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools(&cache_context_user_1, &server_info, &tools_user_1); - write_cached_codex_apps_tools(&cache_context_user_2, &server_info, &tools_user_2); + write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1); + write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2); let read_user_1 = read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one"); @@ -630,9 +630,8 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() { Some("Calendar"), ), ]; - let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools(&cache_context, &server_info, &tools); + write_cached_codex_apps_tools(&cache_context, &tools); let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); assert_eq!(cached.len(), 1); @@ -692,14 +691,22 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { "calendar_search", )]; let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools(&cache_context, &server_info, &cached_tools); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &cached_tools, + ); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("expected startup snapshot to load from cache"); + let startup_server_info = load_startup_cached_codex_apps_server_info( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ); - let (startup_tools, startup_server_info) = - startup_snapshot.expect("expected startup snapshot to load from cache"); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); @@ -708,7 +715,7 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { } #[test] -fn startup_cached_codex_apps_tools_loads_cache_without_server_info() { +fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { let codex_home = tempdir().expect("tempdir"); let cache_context = create_codex_apps_tools_cache_context( codex_home.path().to_path_buf(), @@ -726,17 +733,67 @@ fn startup_cached_codex_apps_tools_loads_cache_without_server_info() { .expect("serialize"); std::fs::write(cache_path, bytes).expect("write"); - let (startup_tools, startup_server_info) = load_startup_cached_codex_apps_tools_snapshot( + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ) .expect("legacy startup snapshot should remain available"); + let startup_server_info = load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].callable_name, "calendar_search"); assert_eq!(startup_server_info, None); } +#[test] +fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &[create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )], + ); + + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION - 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write legacy tools cache"); + + assert_eq!( + load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ), + Some(server_info) + ); + assert!( + load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .is_none() + ); +} + #[tokio::test] async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { let startup_tools = vec![create_test_tool( diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 3aa25dfa166f..898831947147 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -20,6 +20,7 @@ use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::filter_disallowed_codex_apps_tools; use crate::codex_apps::load_cached_codex_apps_tools; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; @@ -99,7 +100,7 @@ impl ManagedClient { fn listed_tools(&self) -> Vec { let total_start = Instant::now(); if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref() - && let CachedCodexAppsToolsLoad::Hit { tools, .. } = + && let CachedCodexAppsToolsLoad::Hit(tools) = load_cached_codex_apps_tools(cache_context) { emit_duration( @@ -157,10 +158,11 @@ impl AsyncManagedClient { &server_name, codex_apps_tools_cache_context.as_ref(), ); - let (startup_snapshot, startup_server_info) = startup_snapshot - .map_or((None, None), |(tools, server_info)| { - (Some(filter_tools(tools, &tool_filter)), server_info) - }); + let startup_snapshot = startup_snapshot.map(|tools| filter_tools(tools, &tool_filter)); + let startup_server_info = load_startup_cached_codex_apps_server_info( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); From a3a29f9fbe7a6c69dbd4c34d597c0ca0633a3ae3 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 27 May 2026 09:38:19 -0700 Subject: [PATCH 5/9] Name cached MCP startup state explicitly --- codex-rs/codex-mcp/src/connection_manager.rs | 4 +- .../codex-mcp/src/connection_manager_tests.rs | 48 +++++++++---------- codex-rs/codex-mcp/src/rmcp_client.rs | 26 +++++----- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 32190ca191e8..98e24fbc2960 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -401,7 +401,7 @@ impl McpConnectionManager { let mut server_infos = HashMap::new(); for (server_name, client) in &self.clients { if !client.startup_complete.load(Ordering::Acquire) { - if let Some(server_info) = client.startup_server_info.clone() { + if let Some(server_info) = client.cached_server_info.clone() { server_infos.insert(server_name.clone(), server_info); } continue; @@ -411,7 +411,7 @@ impl McpConnectionManager { server_infos.insert(server_name.clone(), managed_client.server_info); } Err(_) => { - if let Some(server_info) = client.startup_server_info.clone() { + if let Some(server_info) = client.cached_server_info.clone() { server_infos.insert(server_name.clone(), server_info); } } diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 1fa9adede8af..b0fa9fc51942 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -703,7 +703,7 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { Some(&cache_context), ) .expect("expected startup snapshot to load from cache"); - let startup_server_info = load_startup_cached_codex_apps_server_info( + let cached_server_info = load_startup_cached_codex_apps_server_info( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ); @@ -711,7 +711,7 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(startup_tools[0].callable_name, "calendar_search"); - assert_eq!(startup_server_info, Some(server_info)); + assert_eq!(cached_server_info, Some(server_info)); } #[test] @@ -738,14 +738,14 @@ fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { Some(&cache_context), ) .expect("legacy startup snapshot should remain available"); - let startup_server_info = load_startup_cached_codex_apps_server_info( + let cached_server_info = load_startup_cached_codex_apps_server_info( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].callable_name, "calendar_search"); - assert_eq!(startup_server_info, None); + assert_eq!(cached_server_info, None); } #[test] @@ -795,7 +795,7 @@ fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { +async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", @@ -814,8 +814,8 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), - startup_server_info: None, + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -851,8 +851,8 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(Vec::new()), - startup_server_info: Some(server_info.clone()), + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: Some(server_info.clone()), startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -888,8 +888,8 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), - startup_server_info: None, + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -931,8 +931,8 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), - startup_server_info: None, + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -958,7 +958,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { } #[tokio::test] -async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { +async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_snapshot() { let pending_client = futures::future::pending::>() .boxed() .shared(); @@ -973,8 +973,8 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: None, - startup_server_info: None, + cached_tool_info_snapshot: None, + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -987,7 +987,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( } #[tokio::test] -async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { +async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() { let pending_client = futures::future::pending::>() .boxed() .shared(); @@ -1002,8 +1002,8 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(Vec::new()), - startup_server_info: None, + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -1017,7 +1017,7 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { +async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", @@ -1042,8 +1042,8 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: failed_client, - startup_snapshot: Some(startup_tools), - startup_server_info: Some(server_info.clone()), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: Some(server_info.clone()), startup_complete, tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -1097,8 +1097,8 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { server_name.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), - startup_server_info: None, + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 898831947147..5d768c0b0fa0 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -126,8 +126,8 @@ impl ManagedClient { #[derive(Clone)] pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, - pub(crate) startup_snapshot: Option>, - pub(crate) startup_server_info: Option, + pub(crate) cached_tool_info_snapshot: Option>, + pub(crate) cached_server_info: Option, pub(crate) startup_complete: Arc, pub(crate) tool_plugin_provenance: Arc, pub(crate) cancel_token: CancellationToken, @@ -154,12 +154,13 @@ impl AsyncManagedClient { .configured_config() .map(ToolFilter::from_config) .unwrap_or_default(); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot( &server_name, codex_apps_tools_cache_context.as_ref(), ); - let startup_snapshot = startup_snapshot.map(|tools| filter_tools(tools, &tool_filter)); - let startup_server_info = load_startup_cached_codex_apps_server_info( + let cached_tool_info_snapshot = + cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter)); + let cached_server_info = load_startup_cached_codex_apps_server_info( &server_name, codex_apps_tools_cache_context.as_ref(), ); @@ -215,7 +216,7 @@ impl AsyncManagedClient { outcome }; let client = fut.boxed().shared(); - if startup_snapshot.is_some() { + if cached_tool_info_snapshot.is_some() { let startup_task = client.clone(); tokio::spawn(async move { let _ = startup_task.await; @@ -224,8 +225,8 @@ impl AsyncManagedClient { Self { client, - startup_snapshot, - startup_server_info, + cached_tool_info_snapshot, + cached_server_info, startup_complete, tool_plugin_provenance, cancel_token, @@ -247,9 +248,9 @@ impl AsyncManagedClient { } } - fn startup_snapshot_while_initializing(&self) -> Option> { + fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { if !self.startup_complete.load(Ordering::Acquire) { - return self.startup_snapshot.clone(); + return self.cached_tool_info_snapshot.clone(); } None } @@ -307,12 +308,13 @@ impl AsyncManagedClient { }; // Keep cache payloads raw; plugin provenance is resolved per-session at read time. - let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() + { Some(startup_tools) } else { match self.client().await { Ok(client) => Some(client.listed_tools()), - Err(_) => self.startup_snapshot.clone(), + Err(_) => self.cached_tool_info_snapshot.clone(), } }; tools.map(annotate_tools) From 464cec212538b119664b62506c5e371b3efc63f2 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 27 May 2026 09:42:55 -0700 Subject: [PATCH 6/9] Name Codex Apps tools cache path explicitly --- codex-rs/codex-mcp/src/codex_apps.rs | 6 +++--- codex-rs/codex-mcp/src/connection_manager_tests.rs | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 5906496276f5..6e8891994914 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -45,7 +45,7 @@ pub(crate) struct CodexAppsToolsCacheContext { } impl CodexAppsToolsCacheContext { - pub(crate) fn cache_path(&self) -> PathBuf { + pub(crate) fn tools_cache_path(&self) -> PathBuf { self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR) } @@ -204,7 +204,7 @@ pub(crate) fn read_cached_codex_apps_tools( pub(crate) fn load_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> CachedCodexAppsToolsLoad { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); let bytes = match std::fs::read(cache_path) { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -226,7 +226,7 @@ pub(crate) fn write_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo], ) { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() && std::fs::create_dir_all(parent).is_err() { diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index b0fa9fc51942..80b7fe7eb84a 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -602,8 +602,8 @@ fn codex_apps_tools_cache_is_scoped_per_user() { assert_eq!(read_user_1[0].callable_name, "one"); assert_eq!(read_user_2[0].callable_name, "two"); assert_ne!( - cache_context_user_1.cache_path(), - cache_context_user_2.cache_path(), + cache_context_user_1.tools_cache_path(), + cache_context_user_2.tools_cache_path(), "each user should get an isolated cache file" ); } @@ -647,7 +647,7 @@ fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -669,7 +669,7 @@ fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -722,7 +722,7 @@ fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -767,7 +767,7 @@ fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { )], ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } From 1a52310bb7606a4d6670e22a966b60c2ec94f9ed Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 27 May 2026 10:44:15 -0700 Subject: [PATCH 7/9] Cleanup --- .../schema/typescript/McpServerInfo.ts | 2 +- .../src/protocol/v2/tests.rs | 45 +++++++++++++++++++ codex-rs/app-server/README.md | 2 +- codex-rs/codex-mcp/src/codex_apps.rs | 4 +- codex-rs/codex-mcp/src/connection_manager.rs | 1 + codex-rs/protocol/src/mcp.rs | 8 ---- 6 files changed, 50 insertions(+), 12 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts index 6bbfbf6a0883..a3f6b0e14281 100644 --- a/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts @@ -6,4 +6,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Presentation metadata advertised by an initialized MCP server. */ -export type McpServerInfo = { name: string, title?: string, version: string, description?: string, icons?: Array, websiteUrl?: string, }; +export type McpServerInfo = { name: string, title: string | null, version: string, description: string | null, icons: Array | null, websiteUrl: string | null, }; 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 2ebc33226d4c..079bac5f9c0e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -11,6 +11,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; @@ -1953,6 +1954,50 @@ fn mcp_server_status_serializes_absent_server_info_as_null() { ); } +#[test] +fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "initialized".to_string(), + server_info: Some(McpServerInfo { + name: "lookup-server".to_string(), + title: None, + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + }), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "initialized", + "serverInfo": { + "name": "lookup-server", + "title": null, + "version": "1.0.0", + "description": null, + "icons": null, + "websiteUrl": null, + }, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1bb26442db59..d7eb14d73c51 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -216,7 +216,7 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, and nullable presentation metadata advertised by initialized servers, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 6e8891994914..a084588df0a5 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -21,8 +21,6 @@ use serde::Serialize; use sha1::Digest; use sha1::Sha1; -pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexAppsToolsCacheKey { pub(crate) account_id: Option, @@ -294,6 +292,8 @@ struct CodexAppsServerInfoDiskCache { } const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; + const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 98e24fbc2960..81d510fd58bf 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -397,6 +397,7 @@ impl McpConnectionManager { } /// Returns presentation metadata without waiting for uncached clients still initializing. + /// Cached values will be used if available and the server is still starting up. pub async fn list_available_server_infos(&self) -> HashMap { let mut server_infos = HashMap::new(); for (server_name, client) in &self.clients { diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index 3eefd317050f..a1916d424c99 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -31,18 +31,10 @@ impl std::fmt::Display for RequestId { #[serde(rename_all = "camelCase")] pub struct McpServerInfo { pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] pub title: Option, pub version: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] pub icons: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] pub website_url: Option, } From 59453b2886fc4b13f573d35e683bd989a5ce3ef5 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 27 May 2026 14:42:16 -0700 Subject: [PATCH 8/9] Log Codex Apps server info cache write failures --- codex-rs/codex-mcp/src/codex_apps.rs | 33 ++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index a084588df0a5..3196a143e8e3 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -12,6 +12,7 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; +use anyhow::Context; use codex_login::CodexAuth; use codex_protocol::mcp::McpServerInfo; use codex_utils_plugins::mcp_connector::is_connector_id_allowed; @@ -153,7 +154,9 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); write_cached_codex_apps_tools(cache_context, tools); - write_cached_codex_apps_server_info(cache_context, server_info); + if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) { + tracing::warn!("failed to write Codex Apps server info cache: {err:#}"); + } emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), @@ -252,20 +255,28 @@ pub(crate) fn load_cached_codex_apps_server_info( fn write_cached_codex_apps_server_info( cache_context: &CodexAppsToolsCacheContext, server_info: &McpServerInfo, -) { +) -> anyhow::Result<()> { let cache_path = cache_context.server_info_cache_path(); - if let Some(parent) = cache_path.parent() - && std::fs::create_dir_all(parent).is_err() - { - return; + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create Codex Apps server info cache directory `{}`", + parent.display() + ) + })?; } - let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache { + let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache { schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION, server_info: server_info.clone(), - }) else { - return; - }; - let _ = std::fs::write(cache_path, bytes); + }) + .context("failed to serialize Codex Apps server info cache")?; + std::fs::write(&cache_path, bytes).with_context(|| { + format!( + "failed to write Codex Apps server info cache `{}`", + cache_path.display() + ) + })?; + Ok(()) } pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { From d87533cba012bbfec545b707514a341e52222747 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 27 May 2026 17:04:46 -0700 Subject: [PATCH 9/9] Fix table fallback after hyperlink line migration --- .../src/markdown_render/table_key_value.rs | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/codex-rs/tui/src/markdown_render/table_key_value.rs b/codex-rs/tui/src/markdown_render/table_key_value.rs index 9e506a0d4598..3c4c390d289d 100644 --- a/codex-rs/tui/src/markdown_render/table_key_value.rs +++ b/codex-rs/tui/src/markdown_render/table_key_value.rs @@ -4,7 +4,10 @@ use super::TABLE_BODY_SEPARATOR_CHAR; use super::TableCell; use super::TableColumnKind; use super::TableColumnMetrics; +use crate::render::line_utils::line_to_static; use crate::render::line_utils::push_owned_lines; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::remap_wrapped_line; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use ratatui::style::Style; @@ -99,7 +102,7 @@ pub(super) fn render_records( available_width: Option, label_style: Style, separator_style: Style, -) -> Vec> { +) -> Vec { let label_width = headers .iter() .map(|header| header.plain_text().width()) @@ -135,10 +138,10 @@ pub(super) fn render_records( } if row_index + 1 < rows.len() { let width = available_width.unwrap_or_else(|| widest_line_width(&out)); - out.push(Line::from(Span::styled( + out.push(HyperlinkLine::new(Line::from(Span::styled( TABLE_BODY_SEPARATOR_CHAR.to_string().repeat(width), separator_style, - ))); + )))); } } @@ -146,7 +149,7 @@ pub(super) fn render_records( } fn render_aligned_field( - out: &mut Vec>, + out: &mut Vec, header: &TableCell, value: &TableCell, label_width: usize, @@ -170,13 +173,20 @@ fn render_aligned_field( } else { spans.push(Span::raw(" ".repeat(value_indent))); } - spans.extend(value_line.spans); - out.push(Line::from(spans)); + spans.extend(value_line.line.spans); + let mut line = HyperlinkLine::new(Line::from(spans)); + line.hyperlinks + .extend(value_line.hyperlinks.into_iter().map(|mut hyperlink| { + hyperlink.columns = + hyperlink.columns.start + value_indent..hyperlink.columns.end + value_indent; + hyperlink + })); + out.push(line); } } fn render_stacked_field( - out: &mut Vec>, + out: &mut Vec, header: &TableCell, value: &TableCell, available_width: Option, @@ -194,7 +204,7 @@ fn render_stacked_field( for label_line in wrapped_labels { let mut spans = vec![Span::raw(" ".repeat(FIELD_LEADING_PADDING))]; spans.extend(label_line.spans); - out.push(Line::from(spans)); + out.push(HyperlinkLine::new(Line::from(spans))); } let value_width = available_width @@ -202,27 +212,37 @@ fn render_stacked_field( .unwrap_or_else(|| cell_width(value).max(1)); for value_line in wrap_cell(value, value_width) { let mut spans = vec![Span::raw(" ".repeat(STACKED_VALUE_INDENT))]; - spans.extend(value_line.spans); - out.push(Line::from(spans)); + spans.extend(value_line.line.spans); + let mut line = HyperlinkLine::new(Line::from(spans)); + line.hyperlinks + .extend(value_line.hyperlinks.into_iter().map(|mut hyperlink| { + hyperlink.columns = hyperlink.columns.start + STACKED_VALUE_INDENT + ..hyperlink.columns.end + STACKED_VALUE_INDENT; + hyperlink + })); + out.push(line); } } -fn wrap_cell(cell: &TableCell, width: usize) -> Vec> { +fn wrap_cell(cell: &TableCell, width: usize) -> Vec { if cell.lines.is_empty() { - return vec![Line::default()]; + return vec![HyperlinkLine::new(Line::default())]; } let mut wrapped = Vec::new(); for source_line in &cell.lines { - let rendered = word_wrap_line(source_line, RtOptions::new(width.max(1))); + let rendered = word_wrap_line(&source_line.line, RtOptions::new(width.max(1))) + .into_iter() + .map(|line| line_to_static(&line)) + .collect::>(); if rendered.is_empty() { - wrapped.push(Line::default()); + wrapped.push(HyperlinkLine::new(Line::default())); } else { - push_owned_lines(&rendered, &mut wrapped); + wrapped.extend(remap_wrapped_line(source_line, rendered)); } } if wrapped.is_empty() { - wrapped.push(Line::default()); + wrapped.push(HyperlinkLine::new(Line::default())); } wrapped } @@ -231,7 +251,8 @@ fn cell_width(cell: &TableCell) -> usize { cell.lines .iter() .map(|line| { - line.spans + line.line + .spans .iter() .map(|span| span.content.width()) .sum::() @@ -240,11 +261,12 @@ fn cell_width(cell: &TableCell) -> usize { .unwrap_or(0) } -fn widest_line_width(lines: &[Line<'_>]) -> usize { +fn widest_line_width(lines: &[HyperlinkLine]) -> usize { lines .iter() .map(|line| { - line.spans + line.line + .spans .iter() .map(|span| span.content.width()) .sum::()