fix(tui): scope MCP startup status by thread (#26639)

## Why

MCP startup failures from spawned subagents were rendered as global
notifications, so a child thread's failure could pollute the visible
parent transcript. Routing the notification to the child exposed two
related replay problems: session refresh could discard the buffered
event, and a newly created child `ChatWidget` did not know the expected
MCP server set, which could leave its startup spinner running after
every server had settled.

MCP startup diagnostics should remain visible in the thread that owns
the startup without affecting other transcripts. The protocol also needs
to support a future app-scoped MCP lifecycle where startup is not owned
by any thread.

## Reported Behavior

The [originating Slack
report](https://openai.slack.com/archives/C08JZTV654K/p1780604538859939)
called out that using subagents could turn MCP startup failures into a
wall of yellow CLI warnings because repeated failures were not
deduplicated. The intended behavior is for those diagnostics to remain
visible once in the thread that owns the startup, without polluting the
parent transcript.

## What Changed

- add nullable `threadId` ownership to `mcpServer/startupStatus/updated`
- populate it from the app-server conversation ID for the current
thread-scoped lifecycle and regenerate the protocol schema and
TypeScript artifacts
- treat a missing or null `threadId` as app-scoped without injecting it
into the active chat transcript
- route and buffer thread-owned MCP startup notifications by thread in
the TUI
- preserve buffered MCP startup events across child session refresh
- seed expected MCP servers before replaying a thread snapshot so
startup reaches its terminal state
- suppress an identical repeated failure warning for the same server
within one startup round

The owning thread still renders the detailed failure and final `MCP
startup incomplete (...)` summary.

## How to Test

1. Configure an optional MCP server named `smoke` that exits during
initialization.
2. Launch the TUI with multi-agent support enabled.
3. Confirm the main thread's own startup failure renders one detailed
`smoke` warning and one incomplete-startup summary.
4. Spawn exactly one subagent.
5. Confirm the parent transcript does not receive the subagent's MCP
startup failure.
6. Switch to the subagent thread and confirm it contains exactly one
detailed `smoke` failure and one incomplete-startup summary.
7. Confirm the subagent's MCP startup spinner disappears and the thread
remains usable.
8. Switch between the parent and subagent and confirm the warnings
neither move nor duplicate.

Targeted tests:

- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server
thread_start_emits_mcp_server_status_updated_notifications`
- `just test -p codex-tui mcp_startup`

The parent/child behavior and spinner completion were also exercised
manually in tmux. `just argument-comment-lint` was attempted but blocked
by an unrelated local Bazel LLVM empty-glob failure; touched Rust
callsites were inspected manually.
This commit is contained in:
Felipe Coury
2026-06-07 23:12:05 -04:00
committed by GitHub
Unverified
parent e648ec771f
commit 5a440c03f2
18 changed files with 363 additions and 21 deletions
@@ -2234,6 +2234,12 @@
},
"status": {
"$ref": "#/definitions/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -11462,6 +11462,12 @@
},
"status": {
"$ref": "#/definitions/v2/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -7964,6 +7964,12 @@
},
"status": {
"$ref": "#/definitions/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -23,6 +23,12 @@
},
"status": {
"$ref": "#/definitions/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { McpServerStartupState } from "./McpServerStartupState";
export type McpServerStatusUpdatedNotification = { name: string, status: McpServerStartupState, error: string | null, };
export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, };
@@ -235,6 +235,7 @@ pub enum McpServerStartupState {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpServerStatusUpdatedNotification {
pub thread_id: Option<String>,
pub name: String,
pub status: McpServerStartupState,
pub error: Option<String>,
@@ -2040,6 +2040,33 @@ fn mcp_server_status_serializes_absent_server_info_as_null() {
);
}
#[test]
fn mcp_server_status_updated_accepts_missing_thread_id() {
let notification: McpServerStatusUpdatedNotification = serde_json::from_value(json!({
"name": "optional_broken",
"status": "failed",
"error": "handshake failed",
}))
.expect("notification without threadId should deserialize");
let expected = McpServerStatusUpdatedNotification {
thread_id: None,
name: "optional_broken".to_string(),
status: McpServerStartupState::Failed,
error: Some("handshake failed".to_string()),
};
assert_eq!(notification, expected);
assert_eq!(
serde_json::to_value(notification).expect("notification should serialize"),
json!({
"threadId": null,
"name": "optional_broken",
"status": "failed",
"error": "handshake failed",
})
);
}
#[test]
fn mcp_server_status_serializes_absent_server_info_metadata_as_null() {
let response = ListMcpServerStatusResponse {
+2 -2
View File
@@ -1233,7 +1233,7 @@ Because audio is intentionally separate from `ThreadItem`, clients can opt out o
### MCP server startup events
- `mcpServer/startupStatus/updated``{ name, status, error }` when app-server observes an MCP server startup transition. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`.
- `mcpServer/startupStatus/updated``{ threadId, name, status, error }` when app-server observes an MCP server startup transition. `threadId` identifies the owning thread when startup is thread-scoped and is `null` when startup is app-scoped. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`.
### Turn events
@@ -1785,7 +1785,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`.
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`.
### 1) Check auth state
@@ -212,6 +212,7 @@ pub(crate) async fn apply_bespoke_event_handling(
}
};
let notification = McpServerStatusUpdatedNotification {
thread_id: Some(conversation_id.to_string()),
name: update.server,
status,
error,
@@ -708,7 +708,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<
.send_thread_start_request(ThreadStartParams::default())
.await?;
let _: ThreadStartResponse = to_response(
let start_response: ThreadStartResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
@@ -745,6 +745,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<
assert_eq!(
starting,
McpServerStatusUpdatedNotification {
thread_id: Some(start_response.thread.id.clone()),
name: "optional_broken".to_string(),
status: McpServerStartupState::Starting,
error: None,
@@ -777,6 +778,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<
let ServerNotification::McpServerStatusUpdated(failed) = failed else {
anyhow::bail!("unexpected notification variant");
};
assert_eq!(failed.thread_id, Some(start_response.thread.id));
assert_eq!(failed.name, "optional_broken");
assert_eq!(failed.status, McpServerStartupState::Failed);
assert!(
@@ -35,6 +35,7 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option<Thread
pub(super) enum ServerNotificationThreadTarget {
Thread(ThreadId),
InvalidThreadId(String),
AppScoped,
Global,
}
@@ -147,8 +148,13 @@ pub(super) fn server_notification_thread_target(
}
ServerNotification::Warning(notification) => notification.thread_id.as_deref(),
ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()),
ServerNotification::McpServerStatusUpdated(notification) => {
match notification.thread_id.as_deref() {
Some(thread_id) => Some(thread_id),
None => return ServerNotificationThreadTarget::AppScoped,
}
}
ServerNotification::SkillsChanged(_)
| ServerNotification::McpServerStatusUpdated(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
@@ -184,6 +190,8 @@ mod tests {
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::GuardianWarningNotification;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
@@ -259,6 +267,37 @@ mod tests {
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn mcp_startup_notifications_route_to_threads() {
let thread_id = ThreadId::new();
let notification =
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some(thread_id.to_string()),
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn mcp_startup_notifications_without_threads_are_app_scoped() {
let notification =
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: None,
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::AppScoped);
}
#[test]
fn thread_settings_updated_notifications_route_to_threads() {
let thread_id = ThreadId::new();
+8 -3
View File
@@ -15,10 +15,9 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
impl App {
fn refresh_mcp_startup_expected_servers_from_config(&mut self) {
pub(super) fn refresh_mcp_startup_expected_servers_from_config(&mut self) {
let enabled_config_mcp_servers: Vec<String> = self
.chat_widget
.config_ref()
.config
.mcp_servers
.get()
.iter()
@@ -141,6 +140,12 @@ impl App {
);
return;
}
ServerNotificationThreadTarget::AppScoped => {
tracing::debug!(
"ignoring app-scoped MCP startup notification without a TUI app-level target"
);
return;
}
ServerNotificationThreadTarget::Global => {}
}
+177 -6
View File
@@ -3353,24 +3353,33 @@ async fn side_thread_snapshot_skips_session_header_preamble() {
}
#[tokio::test]
async fn side_thread_ignores_global_mcp_startup_notifications() {
async fn primary_thread_ignores_child_mcp_startup_notifications() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let sentry_config = toml::from_str::<toml::Value>("command = 'true'")
.expect("test MCP config should parse")
.try_into()
.expect("test MCP config should deserialize");
app.config
.mcp_servers
.set(std::collections::HashMap::from([(
"sentry".to_string(),
sentry_config,
)]))
.expect("test MCP servers should accept any configuration");
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
let child_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.sync_side_thread_ui();
app.active_thread_id = Some(parent_thread_id);
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some(child_thread_id.to_string()),
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
@@ -3380,6 +3389,168 @@ async fn side_thread_ignores_global_mcp_startup_notifications() {
.await;
assert!(app_event_rx.try_recv().is_err());
let mut child_snapshot = app
.thread_event_channels
.get(&child_thread_id)
.expect("child thread channel should be created")
.store
.lock()
.await
.snapshot();
assert!(
matches!(
child_snapshot.events.as_slice(),
[ThreadBufferedEvent::Notification(
ServerNotification::McpServerStatusUpdated(_)
)]
),
"child MCP startup notification should be buffered for the child thread"
);
app.apply_refreshed_snapshot_thread(
child_thread_id,
AppServerStartedThread {
session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")),
turns: Vec::new(),
},
&mut child_snapshot,
)
.await;
app.replay_thread_snapshot(child_snapshot, /*resume_restored_queue*/ false);
let mut rendered_cells = Vec::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
}
}
let rendered = rendered_cells.join("\n");
assert_eq!(app.chat_widget.thread_id(), Some(child_thread_id));
assert_eq!(rendered.matches("sentry is not logged in").count(), 1);
assert_eq!(
rendered
.matches("MCP startup incomplete (failed: sentry)")
.count(),
1
);
}
#[tokio::test]
async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.primary_thread_id = Some(thread_id);
app.active_thread_id = Some(thread_id);
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: None,
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
}),
),
)
.await;
assert!(app_event_rx.try_recv().is_err());
assert_eq!(
app.chat_widget.active_cell_transcript_lines(/*width*/ 120),
None
);
}
#[tokio::test]
async fn active_side_thread_renders_live_mcp_startup_notifications() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let sentry_config = toml::from_str::<toml::Value>("command = 'true'")
.expect("test MCP config should parse")
.try_into()
.expect("test MCP config should deserialize");
app.config
.mcp_servers
.set(std::collections::HashMap::from([(
"sentry".to_string(),
sentry_config,
)]))
.expect("test MCP servers should accept any configuration");
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.ensure_thread_channel(side_thread_id);
app.activate_thread_channel(side_thread_id).await;
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: Some(test_thread_session(
side_thread_id,
test_path_buf("/tmp/side"),
)),
turns: Vec::new(),
events: Vec::new(),
input_state: None,
},
/*resume_restored_queue*/ false,
);
app.sync_side_thread_ui();
for status in [
McpServerStartupState::Starting,
McpServerStartupState::Failed,
] {
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some(side_thread_id.to_string()),
name: "sentry".to_string(),
status,
error: matches!(status, McpServerStartupState::Failed)
.then(|| "sentry is not logged in".to_string()),
}),
),
)
.await;
}
let mut active_thread_events = Vec::new();
let active_thread_rx = app
.active_thread_rx
.as_mut()
.expect("side thread receiver should be active");
while let Ok(event) = active_thread_rx.try_recv() {
active_thread_events.push(event);
}
for event in active_thread_events {
app.handle_thread_event_now(event);
}
let mut rendered_cells = Vec::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
}
}
let rendered = rendered_cells.join("\n");
assert!(app.chat_widget.side_conversation_active());
assert_eq!(rendered.matches("sentry is not logged in").count(), 1);
assert_eq!(
rendered
.matches("MCP startup incomplete (failed: sentry)")
.count(),
1
);
}
#[tokio::test]
+28
View File
@@ -50,6 +50,7 @@ impl ThreadEventStore {
ThreadBufferedEvent::Request(_)
| ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_))
| ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_))
| ThreadBufferedEvent::Notification(ServerNotification::McpServerStatusUpdated(_))
| ThreadBufferedEvent::FeedbackSubmission(_)
)
}
@@ -590,4 +591,31 @@ mod tests {
]
);
}
#[test]
fn thread_event_store_rebase_preserves_mcp_startup_notifications() {
let thread_id = ThreadId::new();
let notification = ServerNotification::McpServerStatusUpdated(
codex_app_server_protocol::McpServerStatusUpdatedNotification {
thread_id: Some(thread_id.to_string()),
name: "sentry".to_string(),
status: codex_app_server_protocol::McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
},
);
let mut store = ThreadEventStore::new(/*capacity*/ 8);
store.push_notification(notification.clone());
store.rebase_buffer_after_session_refresh();
let snapshot = store.snapshot();
let actual = match snapshot.events.as_slice() {
[ThreadBufferedEvent::Notification(actual)] => actual,
other => panic!("expected one buffered MCP notification, saw: {other:?}"),
};
assert_eq!(
serde_json::to_value(actual).expect("MCP notification should serialize"),
serde_json::to_value(notification).expect("MCP notification should serialize"),
);
}
}
+1
View File
@@ -1261,6 +1261,7 @@ impl App {
snapshot: ThreadEventSnapshot,
resume_restored_queue: bool,
) {
self.refresh_mcp_startup_expected_servers_from_config();
let should_buffer_replay = self.terminal_resize_reflow_enabled()
&& (!snapshot.turns.is_empty() || !snapshot.events.is_empty());
if should_buffer_replay {
+7 -1
View File
@@ -83,7 +83,13 @@ impl ChatWidget {
// per-server failures immediately.
let mut startup_status = self.mcp_startup_status.take().unwrap_or_default();
if let McpStartupStatus::Failed { error } = &status {
self.on_warning(error);
let already_reported = matches!(
startup_status.get(&server),
Some(McpStartupStatus::Failed { error: previous }) if previous == error
);
if !already_reported {
self.on_warning(error);
}
}
startup_status.insert(server, status);
startup_status
-6
View File
@@ -6,12 +6,6 @@ impl ChatWidget {
notification: ServerNotification,
replay_kind: Option<ReplayKind>,
) {
if self.active_side_conversation
&& replay_kind.is_none()
&& matches!(notification, ServerNotification::McpServerStatusUpdated(_))
{
return;
}
let from_replay = replay_kind.is_some();
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
@@ -4,6 +4,7 @@ use pretty_assertions::assert_eq;
fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartupState) {
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some("thread-1".to_string()),
name: name.to_string(),
status,
error: None,
@@ -15,6 +16,7 @@ fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartup
fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) {
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some("thread-1".to_string()),
name: name.to_string(),
status: McpServerStartupState::Failed,
error: Some(error.to_string()),
@@ -23,6 +25,42 @@ fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) {
);
}
#[tokio::test]
async fn mcp_startup_dedupes_same_round_duplicate_failure_warning() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
let failure_text = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_eq!(
failure_text,
"⚠ MCP client for `alpha` failed to start: handshake failed\n"
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
let summary_text = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_eq!(summary_text, "⚠ MCP startup incomplete (failed: alpha)\n");
}
#[tokio::test]
async fn mcp_startup_header_booting_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -110,6 +148,11 @@ async fn app_server_mcp_startup_failure_renders_warning_history() {
assert!(drain_insert_history(&mut rx).is_empty());
assert!(chat.bottom_pane.is_task_running());
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
notify_mcp_status_error(
&mut chat,
"alpha",