fix: restore MCP startup progress messages in TUI (fixes #7827) (#7828)

## Problem

The introduction of `notify_sandbox_state_change()` in #7112 caused a
regression where the blocking call in `Session::new()` waits for all MCP
servers to fully initialize before returning. This prevents the TUI
event loop from starting, resulting in `McpStartupUpdateEvent` messages
being emitted but never consumed or displayed. As a result, the app
appears to hang during startup, and users do not see the expected
"Booting MCP server: {name}" status line.

Issue: [#7827](https://github.com/openai/codex/issues/7827)

## Solution
This change moves sandbox state notification into each MCP server's
background initialization task. The notification is sent immediately
after the server transitions to the Ready state. This approach:
- Avoids blocking `Session::new()`, allowing the TUI event loop to start
promptly.
- Ensures each MCP server receives its sandbox state before handling any
tool calls.
- Restores the display of "Booting MCP server" status lines during
startup.

## Key Changes
- Added `ManagedClient::notify_sandbox_state()` method.
- Passed sandbox_state to `McpConnectionManager::initialize()`.
- Sends sandbox state notification in the background task after the
server reaches Ready status.
- Removed blocking notify_sandbox_state_change() methods.
- Added a chatwidget snapshot test for the "Booting MCP server" status
line.

## Regression Details

Regression was bisected to #7112, which introduced the blocking
behavior.

---------

Co-authored-by: Michael Bolin <bolinfest@gmail.com>
Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
Ivan Murashko 2025-12-12 22:07:03 +00:00 committed by GitHub
parent 54feceea46
commit c978b6e222
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 109 additions and 31 deletions

View file

@ -700,6 +700,14 @@ impl Session {
for event in events {
sess.send_event_raw(event).await;
}
// Construct sandbox_state before initialize() so it can be sent to each
// MCP server immediately after it becomes ready (avoiding blocking).
let sandbox_state = SandboxState {
sandbox_policy: session_configuration.sandbox_policy.clone(),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: session_configuration.cwd.clone(),
};
sess.services
.mcp_connection_manager
.write()
@ -710,25 +718,10 @@ impl Session {
auth_statuses.clone(),
tx_event.clone(),
sess.services.mcp_startup_cancellation_token.clone(),
sandbox_state,
)
.await;
let sandbox_state = SandboxState {
sandbox_policy: session_configuration.sandbox_policy.clone(),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: session_configuration.cwd.clone(),
};
if let Err(e) = sess
.services
.mcp_connection_manager
.read()
.await
.notify_sandbox_state_change(&sandbox_state)
.await
{
tracing::error!("Failed to notify sandbox state change: {e}");
}
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
sess.record_initial_history(initial_history).await;
@ -840,14 +833,34 @@ impl Session {
sub_id: String,
updates: SessionSettingsUpdate,
) -> Arc<TurnContext> {
let session_configuration = {
let (session_configuration, sandbox_policy_changed) = {
let mut state = self.state.lock().await;
let session_configuration = state.session_configuration.clone().apply(&updates);
let sandbox_policy_changed =
state.session_configuration.sandbox_policy != session_configuration.sandbox_policy;
state.session_configuration = session_configuration.clone();
session_configuration
(session_configuration, sandbox_policy_changed)
};
let per_turn_config = Self::build_per_turn_config(&session_configuration);
if sandbox_policy_changed {
let sandbox_state = SandboxState {
sandbox_policy: per_turn_config.sandbox_policy.clone(),
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: per_turn_config.cwd.clone(),
};
if let Err(e) = self
.services
.mcp_connection_manager
.read()
.await
.notify_sandbox_state_change(&sandbox_state)
.await
{
warn!("Failed to notify sandbox state change to MCP servers: {e:#}");
}
}
let model_family = self
.services
.models_manager

View file

@ -1,14 +1,18 @@
pub mod auth;
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use async_channel::unbounded;
use codex_protocol::protocol::McpListToolsResponseEvent;
use codex_protocol::protocol::SandboxPolicy;
use mcp_types::Tool as McpTool;
use tokio_util::sync::CancellationToken;
use crate::config::Config;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::SandboxState;
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
@ -34,6 +38,13 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
drop(rx_event);
let cancel_token = CancellationToken::new();
// Use ReadOnly sandbox policy for MCP snapshot collection (safest default)
let sandbox_state = SandboxState {
sandbox_policy: SandboxPolicy::ReadOnly,
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
};
mcp_connection_manager
.initialize(
config.mcp_servers.clone(),
@ -41,6 +52,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
auth_status_entries.clone(),
tx_event,
cancel_token.clone(),
sandbox_state,
)
.await;

View file

@ -182,6 +182,21 @@ struct ManagedClient {
server_supports_sandbox_state_capability: bool,
}
impl ManagedClient {
async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
if !self.server_supports_sandbox_state_capability {
return Ok(());
}
self.client
.send_custom_notification(
MCP_SANDBOX_STATE_NOTIFICATION,
Some(serde_json::to_value(sandbox_state)?),
)
.await
}
}
#[derive(Clone)]
struct AsyncManagedClient {
client: Shared<BoxFuture<'static, Result<ManagedClient, StartupOutcomeError>>>,
@ -231,17 +246,7 @@ impl AsyncManagedClient {
async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
let managed = self.client().await?;
if !managed.server_supports_sandbox_state_capability {
return Ok(());
}
managed
.client
.send_custom_notification(
MCP_SANDBOX_STATE_NOTIFICATION,
Some(serde_json::to_value(sandbox_state)?),
)
.await
managed.notify_sandbox_state_change(sandbox_state).await
}
}
@ -274,6 +279,7 @@ impl McpConnectionManager {
auth_entries: HashMap<String, McpAuthStatusEntry>,
tx_event: Sender<Event>,
cancel_token: CancellationToken,
initial_sandbox_state: SandboxState,
) {
if cancel_token.is_cancelled() {
return;
@ -302,13 +308,25 @@ impl McpConnectionManager {
clients.insert(server_name.clone(), async_managed_client.clone());
let tx_event = tx_event.clone();
let auth_entry = auth_entries.get(&server_name).cloned();
let sandbox_state = initial_sandbox_state.clone();
join_set.spawn(async move {
let outcome = async_managed_client.client().await;
if cancel_token.is_cancelled() {
return (server_name, Err(StartupOutcomeError::Cancelled));
}
let status = match &outcome {
Ok(_) => McpStartupStatus::Ready,
Ok(_) => {
// Send sandbox state notification immediately after Ready
if let Err(e) = async_managed_client
.notify_sandbox_state_change(&sandbox_state)
.await
{
warn!(
"Failed to notify sandbox state to MCP server {server_name}: {e:#}",
);
}
McpStartupStatus::Ready
}
Err(error) => {
let error_str = mcp_init_error_display(
server_name.as_str(),

View file

@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"• Booting MCP server: alpha (0s • esc to interrupt) "
" "
" "
" Ask Codex to do anything "
" "
" 100% context left · ? for shortcuts "

View file

@ -27,6 +27,8 @@ use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpStartupStatus;
use codex_core::protocol::McpStartupUpdateEvent;
use codex_core::protocol::Op;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
@ -2417,6 +2419,28 @@ fn status_widget_active_snapshot() {
assert_snapshot!("status_widget_active", terminal.backend());
}
#[test]
fn mcp_startup_header_booting_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
chat.show_welcome_banner = false;
chat.handle_codex_event(Event {
id: "mcp-1".into(),
msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
server: "alpha".into(),
status: McpStartupStatus::Starting,
}),
});
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chat widget");
assert_snapshot!("mcp_startup_header_booting", terminal.backend());
}
#[test]
fn background_event_updates_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);