diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index cfc2f492b3..22d09bba13 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -424,6 +424,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/codex-mcp/src/catalog_tests.rs b/codex-rs/codex-mcp/src/catalog_tests.rs index 4c5d1ed0cd..905e683ae9 100644 --- a/codex-rs/codex-mcp/src/catalog_tests.rs +++ b/codex-rs/codex-mcp/src/catalog_tests.rs @@ -41,6 +41,7 @@ fn server(url: &str) -> McpServerConfig { environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: true, + startup_readiness: Default::default(), supports_parallel_tool_calls: true, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index dda2edd914..764c2bde86 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -62,6 +62,7 @@ use anyhow::Result; use anyhow::anyhow; use anyhow::bail; use codex_config::McpServerTransportConfig; +use codex_config::McpStartupReadiness; use codex_diagnostics::Gauge; use codex_diagnostics::GaugeGuard; use codex_protocol::mcp::CallToolResult; @@ -161,6 +162,7 @@ impl Drop for McpServerConnection { struct McpServerView { connection: Arc, protocol_mode: crate::McpProtocolMode, + startup_readiness: McpStartupReadiness, metadata: McpServerMetadata, tool_filter: ToolFilter, tool_timeout: Option, @@ -168,6 +170,28 @@ struct McpServerView { } impl McpServerView { + fn allows_cached_startup(&self) -> bool { + self.startup_readiness == McpStartupReadiness::Catalog + || self.connection.startup_is_dormant() + } + + fn cached_startup_tools(&self, fallback: Option>) -> Option> { + self.connection + .client + .cached_tools_or(fallback) + .filter(|tools| self.accepts_cached_tools(tools)) + } + + fn accepts_cached_tools(&self, tools: &[ToolInfo]) -> bool { + self.connection.client.is_codex_apps_mcp_server + || match self.startup_readiness { + McpStartupReadiness::Connection => !tools.is_empty(), + McpStartupReadiness::Catalog => tools.iter().any(|tool| { + self.tool_filter.allows(&tool.tool.name) && tool_is_model_visible(tool) + }), + } + } + async fn listed_tools( &self, tool_plugin_context: &ToolPluginContext, @@ -496,6 +520,7 @@ impl McpConnectionSet { McpServerView { connection, protocol_mode, + startup_readiness: configured_config.startup_readiness, metadata, tool_filter: configured_tool_filter, tool_timeout: configured_tool_timeout, @@ -633,6 +658,7 @@ impl McpConnectionSet { _diagnostics_guard: LIVE_CONNECTIONS.track(), }), protocol_mode, + startup_readiness: configured_config.startup_readiness, metadata, tool_filter: configured_tool_filter, tool_timeout: configured_tool_timeout, diff --git a/codex-rs/codex-mcp/src/connection_manager/required.rs b/codex-rs/codex-mcp/src/connection_manager/required.rs index 2ba8cb371d..6a2f267f62 100644 --- a/codex-rs/codex-mcp/src/connection_manager/required.rs +++ b/codex-rs/codex-mcp/src/connection_manager/required.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use anyhow::Result; use anyhow::anyhow; use codex_protocol::protocol::McpStartupFailure; @@ -8,12 +10,13 @@ use super::McpConnectionSet; use crate::rmcp_client::StartupOutcomeError; impl McpConnectionSet { - /// Waits for every required server and reports their startup failures together. + /// Waits for required servers unless their readiness policy accepts a cached catalog. /// /// The manager must already be reachable through [`crate::McpRuntime`] so /// startup-time elicitation can resolve while validation waits. pub(crate) async fn validate_required_servers(&self) -> Result<()> { let failures = async { + self.record_startup_readiness("required_startup", &[], &HashSet::new()); let mut failures = Vec::new(); for server_name in &self.required_servers { let Some(view) = self.servers.get(server_name) else { @@ -23,7 +26,8 @@ impl McpConnectionSet { }); continue; }; - if view.connection.startup_is_dormant() && view.connection.client.has_cached_tools() + if view.allows_cached_startup() + && view.cached_startup_tools(/*fallback*/ None).is_some() { continue; } 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 58b810366c..bce7aeb3cf 100644 --- a/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs +++ b/codex-rs/codex-mcp/src/connection_manager/tool_catalog.rs @@ -7,6 +7,7 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use codex_config::McpStartupReadiness; use codex_connectors::ConnectorRuntimeFetchSource; use futures::future::join_all; use tracing::Instrument; @@ -59,14 +60,58 @@ pub fn tool_is_model_visible(tool: &ToolInfo) -> bool { .any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY)) } -/// Catalog identity within one published connection set, including dormant servers. +/// Catalog identity within one published connection set, including cached declarations. #[derive(PartialEq, Eq)] pub(crate) enum BindingCatalogRevision { Ready(ClientToolCatalogRevision), - Dormant(u64), + Cached(u64), } impl McpConnectionSet { + /// Observes opted-in servers at startup gates without waiting or cloning catalogs. + pub(crate) fn record_startup_readiness( + &self, + phase: &'static str, + required_servers: &[String], + required_plugins: &HashSet, + ) { + if !tracing::enabled!(tracing::Level::INFO) { + return; + } + for (server_name, view) in &self.servers { + if view.startup_readiness != McpStartupReadiness::Catalog { + continue; + } + let client = &view.connection.client; + let startup_pending = !client.startup_complete.load(Ordering::Acquire); + let cache_eligible = startup_pending + && client + .tool_catalog_cache_context + .as_ref() + .is_some_and(|cache| { + cache + .current_revision_if(|tools| view.accepts_cached_tools(tools)) + .is_some() + }); + let explicitly_required = required_servers.contains(server_name) + || (self.is_selected_plugin_mcp_server(server_name) + && self + .plugin_id_for_mcp_server_name(server_name) + .is_some_and(|plugin_id| required_plugins.contains(plugin_id))); + tracing::info!( + event.name = "mcp.startup_readiness", + server_name = %server_name, + phase, + startup_pending, + startup_readiness = "catalog", + cache_eligible, + required = self.required_servers.binary_search(server_name).is_ok(), + explicitly_required, + "MCP startup readiness observed" + ); + } + } + pub(crate) async fn stable_catalog_revisions( &self, required_servers: &[String], @@ -74,15 +119,17 @@ impl McpConnectionSet { ) -> Option> { let mut revisions = HashMap::new(); for (server_name, view) in &self.servers { - if !view + let startup_complete = view .connection .client .startup_complete - .load(Ordering::Acquire) + .load(Ordering::Acquire); + if !startup_complete + || (view.startup_readiness == McpStartupReadiness::Catalog + && view.connection.client.ready_client().is_none()) { - // A cached catalog can remain stable without starting its server. - // Explicit requirements must still start the server during binding capture. - if !view.connection.startup_is_dormant() + // Explicit requirements still wait for a live connection during capture. + if !view.allows_cached_startup() || view.connection.client.cancel_token.is_cancelled() || required_servers .iter() @@ -99,10 +146,10 @@ impl McpConnectionSet { .client .tool_catalog_cache_context .as_ref()? - .current_revision()?; + .current_revision_if(|tools| view.accepts_cached_tools(tools))?; revisions.insert( server_name.clone(), - BindingCatalogRevision::Dormant(revision), + BindingCatalogRevision::Cached(revision), ); continue; } @@ -222,12 +269,10 @@ impl McpConnectionSet { { let required = self.required_servers.binary_search(server_name).is_ok(); // Keep the catalog that lets us skip startup even if it expires during the wait. - let cached_tools = view.connection.client.cached_tools().filter(|tools| { - view.connection.client.is_codex_apps_mcp_server || !tools.is_empty() - }); + let cached_tools = view.cached_startup_tools(/*fallback*/ None); let has_cached_tools = cached_tools.is_some(); let must_wait_for_startup = (required - && (!view.connection.startup_is_dormant() || !has_cached_tools)) + && (!view.allows_cached_startup() || !has_cached_tools)) || required_servers .iter() .any(|required| required == server_name) @@ -237,6 +282,7 @@ impl McpConnectionSet { .is_some_and(|plugin_id| required_plugins.contains(plugin_id))) || (server_name == CODEX_APPS_MCP_SERVER_NAME && !has_cached_tools); if !must_wait_for_startup && has_cached_tools { + trace!(server_name = %server_name, "using cached MCP catalog without waiting for startup"); return (server_name, view, cached_tools); } if !must_wait_for_startup && optional_mcp_startup_grace.is_zero() { @@ -283,14 +329,30 @@ impl McpConnectionSet { })) .await; let server_results = join_all(server_snapshots.into_iter().map(|(server_name, view, cached_tools)| async move { - let (client, server_tools) = if !view + let startup_pending = !view .connection .client .startup_complete - .load(Ordering::Acquire) + .load(Ordering::Acquire); + let accepts_cached_catalog = view.startup_readiness == McpStartupReadiness::Catalog; + let cached_tools = if startup_pending + || (accepts_cached_catalog && view.connection.client.ready_client().is_none()) { - (None, view.connection.client.cached_tools_or(cached_tools)?) + view.cached_startup_tools(cached_tools) } else { + None + }; + let (client, server_tools) = if let Some(cached_tools) = cached_tools { + (None, cached_tools) + } else { + // A server may opt out of caching after the first pass. Required catalog + // readiness must then wait for discovery rather than silently omit its tools. + if startup_pending + && !(accepts_cached_catalog + && self.required_servers.binary_search(server_name).is_ok()) + { + return None; + } view.connection.client.reconnect_failed_startup().await; let Ok(mut client) = view.connection.client().await else { trace!(server_name = %server_name, "omitting MCP server without an exact ready client"); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 2fb322548f..bf5129a3b9 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -136,6 +136,7 @@ impl McpConnectionSet { McpServerView { tool_filter: ToolFilter::default(), protocol_mode: crate::McpProtocolMode::Legacy, + startup_readiness: Default::default(), connection: Arc::new(McpServerConnection { identity: None, client, @@ -4668,6 +4669,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -4696,6 +4698,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -4807,6 +4810,7 @@ fn mcp_init_error_display_prompts_for_github_pat() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -4967,6 +4971,7 @@ fn mcp_init_error_display_reports_generic_errors() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -5046,6 +5051,7 @@ fn reusable_server_config(url: &str) -> McpServerConfig { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -5110,6 +5116,7 @@ async fn manager_with_reusable_ready_server( "docs".to_string(), McpServerView { protocol_mode: crate::McpProtocolMode::Legacy, + startup_readiness: Default::default(), connection: Arc::new(McpServerConnection { identity: Some(reusable_server_identity("docs", config, runtime_context)), client: create_ready_async_managed_client(tools).await, @@ -5535,6 +5542,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a "docs".to_string(), McpServerView { protocol_mode: crate::McpProtocolMode::Legacy, + startup_readiness: Default::default(), connection: Arc::new(McpServerConnection { identity: Some(reusable_server_identity("docs", &config, &runtime_context)), client: AsyncManagedClient { diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index a65da6e697..2d17c4eded 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -655,6 +655,7 @@ fn mcp_server_config_for_url( environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index b7d0a18af9..8e37e386ab 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -592,6 +592,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -620,6 +621,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/codex-mcp/src/plugin_config_tests.rs b/codex-rs/codex-mcp/src/plugin_config_tests.rs index 7d4c166f85..1e08ffddde 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -535,6 +535,7 @@ fn stdio_server( environment_id: environment_id.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -571,6 +572,7 @@ fn declared_placement_preserves_local_plugin_normalization() { environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -891,6 +893,7 @@ fn local_environment_placement_preserves_http_env_references() { environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index f23bb7db5e..2e0f9c85af 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -417,12 +417,13 @@ impl McpRuntime { required_servers: &[String], required_plugins: &HashSet, ) -> Option> { - Self::binding_from_published_runtime( - self.current.load_full(), + let current = self.current.load_full(); + current.connections.record_startup_readiness( + "model_binding", required_servers, required_plugins, - ) - .await + ); + Self::binding_from_published_runtime(current, required_servers, required_plugins).await } async fn binding_from_published_runtime( @@ -948,6 +949,7 @@ mod tests { environment_id: environment_id.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/codex-mcp/src/tool_catalog_cache.rs b/codex-rs/codex-mcp/src/tool_catalog_cache.rs index 72d723b981..c08c203b60 100644 --- a/codex-rs/codex-mcp/src/tool_catalog_cache.rs +++ b/codex-rs/codex-mcp/src/tool_catalog_cache.rs @@ -116,18 +116,22 @@ impl Default for ToolCatalogCacheEntry { impl McpToolCatalogCacheContext { pub(crate) fn has_tools(&self) -> bool { - self.current_revision().is_some() + self.current_revision_if(|_| true).is_some() } - /// Identifies the current usable catalog without cloning its tool definitions. - /// The entry is fixed for a connection; accepted publications advance its revision. - pub(crate) fn current_revision(&self) -> Option { + /// Checks eligibility and reads the revision under one lock without cloning tools. + /// The predicate borrows the current catalog while the cache entry is locked. + pub(crate) fn current_revision_if( + &self, + accepts_tools: impl FnOnce(&[ToolInfo]) -> bool, + ) -> Option { let state = lock_unpoisoned(&self.entry.state); let snapshot = state.snapshot.as_ref()?; (!state.disabled_by_server && !snapshot.tools.is_empty() - && snapshot.published_at.elapsed() <= TOOL_CATALOG_CACHE_TTL) - .then_some(state.last_accepted_generation) + && snapshot.published_at.elapsed() <= TOOL_CATALOG_CACHE_TTL + && accepts_tools(&snapshot.tools)) + .then_some(state.last_accepted_generation) } pub(crate) fn optional_startup_deadline( diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index fac2652e47..34ce7687d7 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -176,6 +176,7 @@ pub use mcp_types::McpServerEnvVar; pub use mcp_types::McpServerOAuthConfig; pub use mcp_types::McpServerToolConfig; pub use mcp_types::McpServerTransportConfig; +pub use mcp_types::McpStartupReadiness; pub use mcp_types::RawMcpServerConfig; pub use merge::ShellEnvironmentPolicyFilterRepresentation; pub use merge::is_structured_feature_path; diff --git a/codex-rs/config/src/mcp_requirements_tests.rs b/codex-rs/config/src/mcp_requirements_tests.rs index 8ce0c546e2..692e82ea32 100644 --- a/codex-rs/config/src/mcp_requirements_tests.rs +++ b/codex-rs/config/src/mcp_requirements_tests.rs @@ -17,6 +17,7 @@ fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig { environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index a1135c6af1..48ae33e37c 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -213,10 +213,20 @@ pub enum McpServerAuth { EmaAuth, } -impl McpServerAuth { - fn is_default(&self) -> bool { - self == &Self::default() - } +/// Readiness needed before startup can expose this server's tools to the model. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum McpStartupReadiness { + /// Use the live connection to determine startup readiness. + #[default] + Connection, + /// Allow a valid cached tool catalog while the live connection starts. + /// Tool execution still requires the current connection. + Catalog, +} + +fn is_default(value: &T) -> bool { + value == &T::default() } #[derive(Serialize, Debug, Clone, PartialEq)] @@ -225,7 +235,7 @@ pub struct McpServerConfig { pub transport: McpServerTransportConfig, /// Authentication flow, including an explicit no-fallback EMA mode. - #[serde(default, skip_serializing_if = "McpServerAuth::is_default")] + #[serde(default, skip_serializing_if = "is_default")] pub auth: McpServerAuth, /// Effective environment id for where Codex should start this MCP server. @@ -236,9 +246,15 @@ pub struct McpServerConfig { pub enabled: bool, /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize. + /// With `startup_readiness = "catalog"`, a valid cached catalog can satisfy startup; + /// connection failures are then reported when a tool is invoked. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub required: bool, + /// Whether startup requires a live connection or can use a valid cached tool catalog. + #[serde(default, skip_serializing_if = "is_default")] + pub startup_readiness: McpStartupReadiness, + /// When `true`, every tool from this server is advertised as safe for parallel tool calls. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub supports_parallel_tool_calls: bool, @@ -391,6 +407,9 @@ pub struct RawMcpServerConfig { pub enabled: Option, #[serde(default)] pub required: Option, + /// Whether startup requires a live connection or can use a valid cached tool catalog. + #[serde(default)] + pub startup_readiness: Option, #[serde(default)] pub supports_parallel_tool_calls: Option, #[serde(default)] @@ -437,6 +456,7 @@ impl TryFrom for McpServerConfig { tool_timeout_sec, enabled, required, + startup_readiness, supports_parallel_tool_calls, omit_tools_from, default_tools_approval_mode, @@ -556,6 +576,7 @@ impl TryFrom for McpServerConfig { tool_timeout_sec, enabled: enabled.unwrap_or_else(default_enabled), required: required.unwrap_or_default(), + startup_readiness: startup_readiness.unwrap_or_default(), supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(), omit_tools_from, disabled_reason: None, diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index 613e4fdcdb..dae588492d 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -640,6 +640,7 @@ fn deserialize_ignores_unknown_server_fields() { environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 9d5c4064b3..8a8a17148d 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -13,6 +13,7 @@ pub use crate::mcp_types::McpServerEnvVar; pub use crate::mcp_types::McpServerOAuthConfig; pub use crate::mcp_types::McpServerToolConfig; pub use crate::mcp_types::McpServerTransportConfig; +pub use crate::mcp_types::McpStartupReadiness; pub use crate::mcp_types::RawMcpServerConfig; pub use crate::shell_environment_policy::ShellEnvironmentPolicyToml; pub use codex_protocol::config_types::AltScreenMode; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index d01629b65b..6942e39142 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -981,6 +981,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -1083,6 +1084,7 @@ enabled = true environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -2186,6 +2188,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() { environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -2525,6 +2528,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -2780,6 +2784,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8aa2114281..978588fc7e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2393,6 +2393,25 @@ }, "type": "object" }, + "McpStartupReadiness": { + "description": "Readiness needed before startup can expose this server's tools to the model.", + "oneOf": [ + { + "description": "Use the live connection to determine startup readiness.", + "enum": [ + "connection" + ], + "type": "string" + }, + { + "description": "Allow a valid cached tool catalog while the live connection starts. Tool execution still requires the current connection.", + "enum": [ + "catalog" + ], + "type": "string" + } + ] + }, "MemoriesToml": { "additionalProperties": false, "description": "Memories settings loaded from config.toml.", @@ -3659,6 +3678,15 @@ }, "type": "array" }, + "startup_readiness": { + "allOf": [ + { + "$ref": "#/definitions/McpStartupReadiness" + } + ], + "default": null, + "description": "Whether startup requires a live connection or can use a valid cached tool catalog." + }, "startup_timeout_ms": { "default": null, "format": "uint64", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7c8891f2d1..22e50998d8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -141,6 +141,7 @@ fn stdio_mcp_with_args(command: &str, args: &[&str]) -> McpServerConfig { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -169,6 +170,7 @@ fn http_mcp(url: &str) -> McpServerConfig { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -6889,6 +6891,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { environment_id: "remote".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7324,6 +7327,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7402,6 +7406,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7465,6 +7470,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7519,6 +7525,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7576,6 +7583,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7650,6 +7658,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7735,6 +7744,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7773,6 +7783,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7847,6 +7858,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7875,6 +7887,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -7965,6 +7978,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -8017,6 +8031,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: true, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -8069,6 +8084,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -8126,6 +8142,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 79b5e1ab99..4bb2328a33 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -5,6 +5,7 @@ use codex_config::types::McpServerConfig; use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_config::types::McpStartupReadiness; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; use toml_edit::Array as TomlArray; @@ -115,6 +116,10 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result {} + McpStartupReadiness::Catalog => entry["startup_readiness"] = value("catalog"), + } if config.supports_parallel_tool_calls { entry["supports_parallel_tool_calls"] = value(true); } diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 24a7658e1f..1cedb9e37b 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -1080,6 +1080,7 @@ fn blocking_replace_mcp_servers_round_trips() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: true, omit_tools_from: None, disabled_reason: None, @@ -1113,6 +1114,7 @@ fn blocking_replace_mcp_servers_round_trips() { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -1205,6 +1207,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides(output_token_ environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -1281,6 +1284,7 @@ foo = { command = "cmd" } environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -1333,6 +1337,7 @@ foo = { command = "cmd" } # keep me environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -1384,6 +1389,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -1436,6 +1442,7 @@ foo = { command = "cmd" } environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 8b080bc483..8883930ba5 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -416,6 +416,7 @@ fn mcp_dependency_to_server_config( environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -455,6 +456,7 @@ fn mcp_dependency_to_server_config( environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 196d18007f..7b2bd83c23 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -337,7 +337,11 @@ impl Session { self.mcp_refresh.invalidate(); } - #[tracing::instrument(name = "mcp.runtime.resolve_for_step", skip_all)] + #[tracing::instrument( + name = "mcp.runtime.resolve_for_step", + skip_all, + fields(turn.id = %turn_context.sub_id) + )] pub(crate) async fn mcp_runtime_for_step( self: &Arc, turn_context: &TurnContext, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 55108a36a0..28278d838f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9676,6 +9676,7 @@ async fn step_context_keeps_its_mcp_runtime_for_tools() -> anyhow::Result<()> { environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index a8745323cb..97dae6549b 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -732,6 +732,7 @@ async fn run_code_mode_turn_with_rmcp_config( environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index 41d1e06be5..490bee483e 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -291,6 +291,7 @@ fn insert_rmcp_test_server( environment_id, enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs index 6e95b930f2..e383f468d2 100644 --- a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -45,6 +45,7 @@ async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() -> anyh environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs index bf110f45c9..b026db5ee0 100644 --- a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs +++ b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs @@ -135,6 +135,7 @@ async fn local_mcp_startup_and_refresh_use_configured_http_client() -> Result<() environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 91d3e59c1d..0505243a0c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -377,6 +377,7 @@ fn insert_mcp_server( environment_id: options.environment_id, enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: options.supports_parallel_tool_calls, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 4d9b2482cf..26a40eda14 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1326,6 +1326,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> { environment_id, enabled: true, required: false, + startup_readiness: Default::default(), disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, @@ -1459,6 +1460,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< environment_id, enabled: true, required: false, + startup_readiness: Default::default(), disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, @@ -1608,6 +1610,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti environment_id, enabled: true, required: false, + startup_readiness: Default::default(), disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 42d752d722..0eb28ded4c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -882,6 +882,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 6758e64910..3308e3a0c9 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -772,6 +772,7 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 4fee8dc355..4880985820 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -413,6 +413,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -516,6 +517,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { environment_id: "local".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, diff --git a/codex-rs/ext/mcp/src/plugin/provider_tests.rs b/codex-rs/ext/mcp/src/plugin/provider_tests.rs index ce6aac6878..36adbbaead 100644 --- a/codex-rs/ext/mcp/src/plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/plugin/provider_tests.rs @@ -195,6 +195,7 @@ async fn reads_declared_config_only_through_executor_file_system() { environment_id: "executor-test".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -223,6 +224,7 @@ async fn reads_declared_config_only_through_executor_file_system() { environment_id: "executor-test".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, @@ -281,6 +283,7 @@ async fn reads_manifest_object_config_without_executor_file_system_access() { environment_id: "executor-test".to_string(), enabled: true, required: false, + startup_readiness: Default::default(), supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None,