diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 96b24f932..dac3a4fdf 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2234,6 +2234,12 @@ }, "status": { "$ref": "#/definitions/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0c29f4a09..1873ed317 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11462,6 +11462,12 @@ }, "status": { "$ref": "#/definitions/v2/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b378a54d1..38c74b185 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7964,6 +7964,12 @@ }, "status": { "$ref": "#/definitions/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json index b0e2cd5a0..87dab90cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json @@ -23,6 +23,12 @@ }, "status": { "$ref": "#/definitions/McpServerStartupState" + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts index 42f5881c5..b5c0a9058 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index ae61f12b2..37197a223 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -235,6 +235,7 @@ pub enum McpServerStartupState { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct McpServerStatusUpdatedNotification { + pub thread_id: Option, pub name: String, pub status: McpServerStartupState, pub error: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 4a683be99..d064795f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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 { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d918f70b7..a06adf03e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0cec31b6f..547d0666e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 5954342b9..6366c6fbc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -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!( diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index ea9fce90f..800560d52 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -35,6 +35,7 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option 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(¬ification); + + 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(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::AppScoped); + } + #[test] fn thread_settings_updated_notifications_route_to_threads() { let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index dea630876..268dbd305 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -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 = 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 => {} } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2e3e1b8c9..ac70930a3 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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::("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::("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] diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index a4e514a43..5f7a58da1 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -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"), + ); + } } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 7e9f91bef..099c81fb7 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -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 { diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index 05d3def32..32839b437 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 1ddbe40d5..9b77da037 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -6,12 +6,6 @@ impl ChatWidget { notification: ServerNotification, replay_kind: Option, ) { - 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)); diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index ba862a529..8b0d5ca47 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -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::(); + 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::(); + 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",