Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f549049
Surface MCP startup failures in tui_app_server
etraut-openai Mar 27, 2026
916a160
codex: address PR review feedback (#16041)
etraut-openai Mar 27, 2026
04d8195
codex: address PR review feedback (#16041)
etraut-openai Mar 27, 2026
a055cdd
codex: address PR review feedback (#16041)
etraut-openai Mar 27, 2026
5fe6436
Merge branch 'main' of github.com:openai/codex into etraut/tuiappserv…
etraut-openai Mar 28, 2026
ab30524
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
6ae5dcb
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
5249448
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
0915e75
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
13ecd08
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
dedb278
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
f940073
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
a5d2cc4
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
e524e32
Merge origin/main into etraut/tuiappserver-mcp-regression
etraut-openai Mar 28, 2026
c101820
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
5c5381f
codex: address PR review feedback (#16041)
etraut-openai Mar 28, 2026
26277b4
Merge branch 'main' into etraut/tuiappserver-mcp-regression
etraut-openai Mar 28, 2026
7a9f162
codex: address PR review feedback (#16041)
etraut-openai Mar 29, 2026
6207cc4
codex: address PR review feedback (#16041)
etraut-openai Mar 29, 2026
5c9ea7b
Merge branch 'main' into etraut/tuiappserver-mcp-regression
etraut-openai Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions codex-rs/tui/src/app/app_server_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ use codex_protocol::protocol::TurnStartedEvent;
use std::time::Duration;

impl App {
fn refresh_mcp_startup_expected_servers_from_config(&mut self) {
let enabled_config_mcp_servers: Vec<String> = self
.chat_widget
.config_ref()
.mcp_servers
.get()
.iter()
.filter_map(|(name, server)| server.enabled.then_some(name.clone()))
.collect();
self.chat_widget
.set_mcp_startup_expected_servers(enabled_config_mcp_servers);
}

pub(super) async fn handle_app_server_event(
&mut self,
app_server_client: &AppServerSession,
Expand All @@ -119,6 +132,8 @@ impl App {
skipped,
"app-server event consumer lagged; dropping ignored events"
);
self.refresh_mcp_startup_expected_servers_from_config();
self.chat_widget.finish_mcp_startup_after_lag();
}
AppServerEvent::ServerNotification(notification) => {
self.handle_server_notification_event(app_server_client, notification)
Expand Down Expand Up @@ -146,6 +161,9 @@ impl App {
self.pending_app_server_requests
.resolve_notification(&notification.request_id);
}
ServerNotification::McpServerStatusUpdated(_) => {
self.refresh_mcp_startup_expected_servers_from_config();
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget.on_rate_limit_snapshot(Some(
app_server_rate_limit_snapshot_to_core(notification.rate_limits.clone()),
Expand Down
206 changes: 190 additions & 16 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
Expand Down Expand Up @@ -795,6 +797,16 @@ pub(crate) struct ChatWidget {
/// bottom pane is treated as "running" while this is populated, even if no agent turn is
/// currently executing.
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
/// Expected MCP servers for the current startup round, seeded from enabled local config.
mcp_startup_expected_servers: Option<HashSet<String>>,
/// After startup settles, ignore stale updates until enough notifications confirm a new round.
mcp_startup_ignore_updates_until_next_start: bool,
/// A lag signal for the next round means terminal-only updates are enough to settle it.
mcp_startup_allow_terminal_only_next_round: bool,
/// Buffers post-settle MCP startup updates until they cover a full fresh round.
mcp_startup_pending_next_round: HashMap<String, McpStartupStatus>,
/// Tracks whether the buffered next round has seen any `Starting` update yet.
mcp_startup_pending_next_round_saw_starting: bool,
connectors_cache: ConnectorsCacheState,
connectors_partial_snapshot: Option<ConnectorsSnapshot>,
connectors_prefetch_in_flight: bool,
Expand Down Expand Up @@ -2780,15 +2792,87 @@ impl ChatWidget {
self.request_redraw();
}

#[cfg(test)]
fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) {
let mut status = self.mcp_startup_status.take().unwrap_or_default();
if let McpStartupStatus::Failed { error } = &ev.status {
self.on_warning(error);
fn update_mcp_startup_status(
&mut self,
server: String,
status: McpStartupStatus,
complete_when_settled: bool,
) {
let mut activated_pending_round = false;
let startup_status = if self.mcp_startup_ignore_updates_until_next_start {
if matches!(status, McpStartupStatus::Starting)
&& !self.mcp_startup_pending_next_round_saw_starting
{
self.mcp_startup_pending_next_round.clear();
self.mcp_startup_allow_terminal_only_next_round = false;
}
self.mcp_startup_pending_next_round_saw_starting |=
matches!(status, McpStartupStatus::Starting);
self.mcp_startup_pending_next_round.insert(server, status);
let Some(expected_servers) = &self.mcp_startup_expected_servers else {
return;
};
let saw_full_round = expected_servers.is_empty()
|| expected_servers
.iter()
.all(|name| self.mcp_startup_pending_next_round.contains_key(name));
let saw_starting = self
.mcp_startup_pending_next_round
.values()
.any(|state| matches!(state, McpStartupStatus::Starting));
if !(saw_full_round
&& (saw_starting || self.mcp_startup_allow_terminal_only_next_round))
{
return;
}
self.mcp_startup_ignore_updates_until_next_start = false;
self.mcp_startup_allow_terminal_only_next_round = false;
self.mcp_startup_pending_next_round_saw_starting = false;
activated_pending_round = true;
std::mem::take(&mut self.mcp_startup_pending_next_round)
} else {
let mut startup_status = self.mcp_startup_status.take().unwrap_or_default();
if let McpStartupStatus::Failed { error } = &status {
self.on_warning(error);
}
startup_status.insert(server, status);
startup_status
};
if activated_pending_round {
for state in startup_status.values() {
if let McpStartupStatus::Failed { error } = state {
self.on_warning(error);
}
}
}
status.insert(ev.server, ev.status);
self.mcp_startup_status = Some(status);
self.mcp_startup_status = Some(startup_status);
self.update_task_running_state();
if complete_when_settled
&& let Some(current) = &self.mcp_startup_status
&& let Some(expected_servers) = &self.mcp_startup_expected_servers
&& !current.is_empty()
&& expected_servers
.iter()
.all(|name| current.contains_key(name))
&& current
.values()
.all(|state| !matches!(state, McpStartupStatus::Starting))
{
let mut failed = Vec::new();
let mut cancelled = Vec::new();
for (name, state) in current {
match state {
McpStartupStatus::Ready => {}
McpStartupStatus::Failed { .. } => failed.push(name.clone()),
McpStartupStatus::Cancelled => cancelled.push(name.clone()),
McpStartupStatus::Starting => {}
}
}
failed.sort();
cancelled.sort();
self.finish_mcp_startup(failed, cancelled);
return;
}
if let Some(current) = &self.mcp_startup_status {
let total = current.len();
let mut starting: Vec<_> = current
Expand Down Expand Up @@ -2827,29 +2911,112 @@ impl ChatWidget {
self.request_redraw();
}

pub(crate) fn set_mcp_startup_expected_servers<I>(&mut self, server_names: I)
where
I: IntoIterator<Item = String>,
{
self.mcp_startup_expected_servers = Some(server_names.into_iter().collect());
}

#[cfg(test)]
fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) {
let mut parts = Vec::new();
if !ev.failed.is_empty() {
let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect();
parts.push(format!("failed: {}", failed_servers.join(", ")));
}
if !ev.cancelled.is_empty() {
fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) {
self.update_mcp_startup_status(ev.server, ev.status, /*complete_when_settled*/ false);
}

fn finish_mcp_startup(&mut self, failed: Vec<String>, cancelled: Vec<String>) {
if !cancelled.is_empty() {
self.on_warning(format!(
"MCP startup interrupted. The following servers were not initialized: {}",
ev.cancelled.join(", ")
cancelled.join(", ")
));
}
let mut parts = Vec::new();
if !failed.is_empty() {
parts.push(format!("failed: {}", failed.join(", ")));
}
if !parts.is_empty() {
self.on_warning(format!("MCP startup incomplete ({})", parts.join("; ")));
}

self.mcp_startup_status = None;
self.mcp_startup_ignore_updates_until_next_start = true;
self.mcp_startup_allow_terminal_only_next_round = false;
self.mcp_startup_pending_next_round.clear();
self.mcp_startup_pending_next_round_saw_starting = false;
self.update_task_running_state();
self.maybe_send_next_queued_input();
self.request_redraw();
}

pub(crate) fn finish_mcp_startup_after_lag(&mut self) {
if self.mcp_startup_ignore_updates_until_next_start {
self.mcp_startup_pending_next_round.clear();
self.mcp_startup_pending_next_round_saw_starting = false;
self.mcp_startup_allow_terminal_only_next_round = true;
}

let Some(current) = &self.mcp_startup_status else {
return;
};

let mut failed = Vec::new();
let mut cancelled = Vec::new();

if let Some(expected_servers) = &self.mcp_startup_expected_servers
&& !expected_servers.is_empty()
{
for name in expected_servers {
match current.get(name) {
Some(McpStartupStatus::Ready) => {}
Some(McpStartupStatus::Failed { .. }) => failed.push(name.clone()),
Some(McpStartupStatus::Cancelled | McpStartupStatus::Starting) | None => {
cancelled.push(name.clone());
}
}
}
} else {
for (name, state) in current {
match state {
McpStartupStatus::Ready => {}
McpStartupStatus::Failed { .. } => failed.push(name.clone()),
McpStartupStatus::Cancelled | McpStartupStatus::Starting => {
cancelled.push(name.clone());
}
}
}
}

failed.sort();
failed.dedup();
cancelled.sort();
cancelled.dedup();
self.finish_mcp_startup(failed, cancelled);
}

#[cfg(test)]
fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) {
let failed = ev.failed.into_iter().map(|f| f.server).collect();
self.finish_mcp_startup(failed, ev.cancelled);
}

fn on_mcp_server_status_updated(&mut self, notification: McpServerStatusUpdatedNotification) {
let status = match notification.status {
McpServerStartupState::Starting => McpStartupStatus::Starting,
McpServerStartupState::Ready => McpStartupStatus::Ready,
McpServerStartupState::Failed => McpStartupStatus::Failed {
error: notification.error.unwrap_or_else(|| {
format!("MCP client for `{}` failed to start", notification.name)
}),
},
McpServerStartupState::Cancelled => McpStartupStatus::Cancelled,
};
self.update_mcp_startup_status(
notification.name,
status,
/*complete_when_settled*/ true,
);
}

/// Handle a turn aborted due to user interrupt (Esc).
/// When there are queued user messages, restore them into the composer
/// separated by newlines rather than auto‑submitting the next one.
Expand Down Expand Up @@ -4552,6 +4719,11 @@ impl ChatWidget {
agent_turn_running: false,
mcp_startup_status: None,
pending_turn_copyable_output: None,
mcp_startup_expected_servers: None,
mcp_startup_ignore_updates_until_next_start: false,
mcp_startup_allow_terminal_only_next_round: false,
mcp_startup_pending_next_round: HashMap::new(),
mcp_startup_pending_next_round_saw_starting: false,
connectors_cache: ConnectorsCacheState::default(),
connectors_partial_snapshot: None,
connectors_prefetch_in_flight: false,
Expand Down Expand Up @@ -6318,6 +6490,9 @@ impl ChatWidget {
.map(|details| format!("{}: {details}", notification.summary))
.unwrap_or(notification.summary),
),
ServerNotification::McpServerStatusUpdated(notification) => {
self.on_mcp_server_status_updated(notification)
}
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
self.on_guardian_review_notification(
notification.target_item_id,
Expand Down Expand Up @@ -6401,7 +6576,6 @@ impl ChatWidget {
| ServerNotification::RawResponseItemCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerStatusUpdated(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::FsChanged(_)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 11761
expression: term.backend().vt100().screen().contents()
---



⚠ MCP client for `alpha` failed to start: handshake failed
⚠ MCP startup incomplete (failed: alpha)


› Ask Codex to do anything

gpt-5.3-codex default · 100% left · /tmp/project
Loading
Loading