[codex] Make MCP connection startup fallible (#27261)

## Why

Required MCP server startup was enforced in `Session::new` after
`McpConnectionManager` had already created the clients. That split let
other manager construction paths bypass the same requirement and exposed
manager internals solely so the session could validate them. Keeping
required-server readiness in the constructor gives every caller one
consistent startup contract.

## What changed

- make `McpConnectionManager::new` return `anyhow::Result<Self>` and
fail when an enabled, required server cannot initialize
- pass the startup cancellation token into the constructor so
required-server waits remain cancellable
- propagate constructor failures through resource reads, connector
discovery, and MCP status collection
- preserve the active manager and cancellation token when a refreshed
replacement fails
- keep required-startup failure collection private and cover the
constructor error contract directly

## Validation

- updated the focused connection-manager test to assert the complete
required-server startup error
- local tests not run; relying on CI
This commit is contained in:
Ahmed Ibrahim authored and GitHub committed 2026-06-10 00:17:58 -07:00
1 parent 00a25e1e0c
commit a7b6baecc6
8 files changed
+129 -107

No files matched your search

+62 -31
View File
@@ -68,6 +68,7 @@ use serde_json::Value as JsonValue;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
use tracing::info_span;
use tracing::instrument;
use tracing::trace;
use tracing::trace_span;
@@ -105,6 +106,7 @@ pub fn tool_is_model_visible(tool: &ToolInfo) -> bool {
pub struct McpConnectionManager {
clients: HashMap<String, AsyncManagedClient>,
server_metadata: HashMap<String, McpServerMetadata>,
required_servers: Vec<String>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
host_owned_codex_apps_enabled: bool,
prefix_mcp_tool_names: bool,
@@ -113,7 +115,7 @@ pub struct McpConnectionManager {
}
impl McpConnectionManager {
#[allow(clippy::new_ret_no_self, clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub async fn new(
mcp_servers: &HashMap<String, EffectiveMcpServer>,
store_mode: OAuthCredentialsStoreMode,
@@ -121,6 +123,7 @@ impl McpConnectionManager {
approval_policy: &Constrained<AskForApproval>,
submit_id: String,
tx_event: Sender<Event>,
startup_cancellation_token: CancellationToken,
initial_permission_profile: PermissionProfile,
runtime_context: McpRuntimeContext,
codex_home: PathBuf,
@@ -131,8 +134,13 @@ impl McpConnectionManager {
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
) -> (Self, CancellationToken) {
let cancel_token = CancellationToken::new();
) -> Self {
let mut required_servers = mcp_servers
.iter()
.filter(|(_, server)| server.enabled() && server.required())
.map(|(name, _)| name.clone())
.collect::<Vec<_>>();
required_servers.sort();
let mut clients = HashMap::new();
let mut server_metadata = HashMap::new();
let mut join_set = JoinSet::new();
@@ -152,7 +160,7 @@ impl McpConnectionManager {
.filter(|(_, server)| server.enabled())
{
server_metadata.insert(server_name.clone(), McpServerMetadata::from(&server));
let cancel_token = cancel_token.child_token();
let cancel_token = startup_cancellation_token.child_token();
let _ = emit_update(
startup_submit_id.as_str(),
&tx_event,
@@ -237,11 +245,12 @@ impl McpConnectionManager {
let manager = Self {
clients,
server_metadata,
required_servers,
tool_plugin_provenance,
host_owned_codex_apps_enabled,
prefix_mcp_tool_names,
elicitation_requests: elicitation_requests.clone(),
startup_cancellation_token: cancel_token.clone(),
startup_cancellation_token: startup_cancellation_token.clone(),
};
tokio::spawn(async move {
let outcomes = join_set.join_all().await;
@@ -265,7 +274,53 @@ impl McpConnectionManager {
})
.await;
});
(manager, cancel_token)
manager
}
/// Waits for every required server and reports their startup failures together.
///
/// Callers must make the manager reachable to request handlers before awaiting this method,
/// because server initialization may require client elicitation.
pub async fn validate_required_servers(&self) -> Result<()> {
let failures = async {
let mut failures = Vec::new();
for server_name in &self.required_servers {
let Some(async_managed_client) = self.clients.get(server_name).cloned() else {
failures.push(McpStartupFailure {
server: server_name.clone(),
error: format!("required MCP server `{server_name}` was not initialized"),
});
continue;
};
match async_managed_client.client().await {
Ok(_) => {}
Err(error) => failures.push(McpStartupFailure {
server: server_name.clone(),
error: startup_outcome_error_message(error),
}),
}
}
failures
}
.instrument(info_span!(
"session_init.required_mcp_wait",
otel.name = "session_init.required_mcp_wait",
session_init.required_mcp_server_count = self.required_servers.len(),
))
.await;
if failures.is_empty() {
return Ok(());
}
let details = failures
.iter()
.map(|failure| format!("{}: {}", failure.server, failure.error))
.collect::<Vec<_>>()
.join("; ");
Err(anyhow!(
"required MCP servers failed to initialize: {details}"
))
}
pub fn new_uninitialized_with_permission_profile(
@@ -276,6 +331,7 @@ impl McpConnectionManager {
Self {
clients: HashMap::new(),
server_metadata: HashMap::new(),
required_servers: Vec::new(),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
host_owned_codex_apps_enabled: false,
prefix_mcp_tool_names,
@@ -374,31 +430,6 @@ impl McpConnectionManager {
}
}
pub async fn required_startup_failures(
&self,
required_servers: &[String],
) -> Vec<McpStartupFailure> {
let mut failures = Vec::new();
for server_name in required_servers {
let Some(async_managed_client) = self.clients.get(server_name).cloned() else {
failures.push(McpStartupFailure {
server: server_name.clone(),
error: format!("required MCP server `{server_name}` was not initialized"),
});
continue;
};
match async_managed_client.client().await {
Ok(_) => {}
Err(error) => failures.push(McpStartupFailure {
server: server_name.clone(),
error: startup_outcome_error_message(error),
}),
}
}
failures
}
/// Returns all tools with model-visible names normalized.
#[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))]
pub async fn list_all_tools(&self) -> Vec<ToolInfo> {