From faa2de33403b88adbbbd608d5bc7be7213f90112 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 30 Jul 2026 17:08:28 +0200 Subject: [PATCH 1/4] feat(cli, mcp): Show timer while waiting on slow MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP servers boot in the background while a query starts, but a query that needs an MCP tool before startup finishes used to wait silently with no feedback. Once the wait passes 4 seconds, the CLI now prints a single updating line naming the pending servers, e.g. `⏱ Starting MCP server bookworm… 4.3s`, so a slow server doesn't look like a hang. The line is cleared once every server has started or a required one fails. The indicator is configurable under `[style.mcp_startup]`: `show` disables it, `delay_secs` controls how long a server can take before the line appears (default 4s, set to 0 to show immediately), and `interval_ms` controls the update rate. Each MCP server also gets its own configurable startup timeout via `startup_timeout_secs` (default 60s), replacing the previous hardcoded 1-minute timeout. Servers that legitimately take longer, such as ones that compile from source on spawn, can now raise this per server. The resulting `InitializeTimeout` error reports the configured deadline and includes the captured stderr tail, which usually points at the actual problem. Closes: #845 Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/query.rs | 94 ++++++++++++++- crates/jp_cli/src/cmd/query_tests.rs | 114 ++++++++++++++++++ crates/jp_cli/src/ctx.rs | 11 +- crates/jp_config/src/providers/mcp.rs | 20 +++ crates/jp_config/src/providers/mcp_tests.rs | 20 +++ .../jp_config__tests__app_config_fields.snap | 3 + ...ig__tests__partial_app_config_default.snap | 5 + ...ts__partial_app_config_default_values.snap | 11 ++ ...s__partial_app_config_empty_serialize.snap | 5 + crates/jp_config/src/style.rs | 13 ++ crates/jp_config/src/style/mcp_startup.rs | 90 ++++++++++++++ .../jp_config/src/style/mcp_startup_tests.rs | 32 +++++ ...ompletion_stream__conversation_stream.snap | 5 + ...tool_soft_forces__conversation_stream.snap | 5 + ...image_attachment__conversation_stream.snap | 5 + ...urn_conversation__conversation_stream.snap | 5 + ...daptive_thinking__conversation_stream.snap | 5 + ...s_4_6_max_effort__conversation_stream.snap | 5 + ...edacted_thinking__conversation_stream.snap | 5 + ...request_chaining__conversation_stream.snap | 5 + ...tructured_output__conversation_stream.snap | 5 + ...t_tool_call_auto__conversation_stream.snap | 5 + ...ol_call_function__conversation_stream.snap | 5 + ...l_call_reasoning__conversation_stream.snap | 5 + ...red_no_reasoning__conversation_stream.snap | 5 + ...quired_reasoning__conversation_stream.snap | 5 + ...tool_call_stream__conversation_stream.snap | 5 + ...ompletion_stream__conversation_stream.snap | 5 + ...urn_conversation__conversation_stream.snap | 5 + ...tructured_output__conversation_stream.snap | 5 + ...t_tool_call_auto__conversation_stream.snap | 5 + ...ol_call_function__conversation_stream.snap | 5 + ...l_call_reasoning__conversation_stream.snap | 5 + ...red_no_reasoning__conversation_stream.snap | 5 + ...quired_reasoning__conversation_stream.snap | 5 + ...tool_call_stream__conversation_stream.snap | 5 + ...uto_omits_effort__conversation_stream.snap | 5 + ...l_off_sends_none__conversation_stream.snap | 5 + ...ompletion_stream__conversation_stream.snap | 5 + ...mini_3_reasoning__conversation_stream.snap | 5 + ...image_attachment__conversation_stream.snap | 5 + ...urn_conversation__conversation_stream.snap | 5 + ...tructured_output__conversation_stream.snap | 5 + ...t_tool_call_auto__conversation_stream.snap | 5 + ...ol_call_function__conversation_stream.snap | 5 + ...l_call_reasoning__conversation_stream.snap | 5 + ...red_no_reasoning__conversation_stream.snap | 5 + ...quired_reasoning__conversation_stream.snap | 5 + ...tool_call_stream__conversation_stream.snap | 5 + ...d_thinking_level__conversation_stream.snap | 5 + ...ompletion_stream__conversation_stream.snap | 5 + ...image_attachment__conversation_stream.snap | 5 + ...urn_conversation__conversation_stream.snap | 5 + ...tructured_output__conversation_stream.snap | 5 + ...t_tool_call_auto__conversation_stream.snap | 5 + ...ol_call_function__conversation_stream.snap | 5 + ...l_call_reasoning__conversation_stream.snap | 5 + ...red_no_reasoning__conversation_stream.snap | 5 + ...quired_reasoning__conversation_stream.snap | 5 + ...tool_call_stream__conversation_stream.snap | 5 + ...ompletion_stream__conversation_stream.snap | 5 + ...image_attachment__conversation_stream.snap | 5 + ...urn_conversation__conversation_stream.snap | 5 + ...tructured_output__conversation_stream.snap | 5 + ...t_tool_call_auto__conversation_stream.snap | 5 + ...ol_call_function__conversation_stream.snap | 5 + ...l_call_reasoning__conversation_stream.snap | 5 + ...red_no_reasoning__conversation_stream.snap | 5 + ...quired_reasoning__conversation_stream.snap | 5 + ...tool_call_stream__conversation_stream.snap | 5 + ...ompletion_stream__conversation_stream.snap | 5 + ..._explicit_optout__conversation_stream.snap | 5 + ...explicit_caching__conversation_stream.snap | 5 + ...read_after_write__conversation_stream.snap | 5 + ...image_attachment__conversation_stream.snap | 5 + ...urn_conversation__conversation_stream.snap | 5 + ...nsupported_model__conversation_stream.snap | 5 + ...tructured_output__conversation_stream.snap | 5 + ...t_tool_call_auto__conversation_stream.snap | 5 + ...ol_call_function__conversation_stream.snap | 5 + ...l_call_reasoning__conversation_stream.snap | 5 + ...red_no_reasoning__conversation_stream.snap | 5 + ...quired_reasoning__conversation_stream.snap | 5 + ...tool_call_stream__conversation_stream.snap | 5 + ...r_event_metadata__conversation_stream.snap | 5 + ...r_event_metadata__conversation_stream.snap | 5 + ...r_event_metadata__conversation_stream.snap | 5 + ...ompletion_stream__conversation_stream.snap | 5 + ...image_attachment__conversation_stream.snap | 5 + ...urn_conversation__conversation_stream.snap | 5 + ...tructured_output__conversation_stream.snap | 5 + ...t_tool_call_auto__conversation_stream.snap | 5 + ...ol_call_function__conversation_stream.snap | 5 + ...l_call_reasoning__conversation_stream.snap | 5 + ...red_no_reasoning__conversation_stream.snap | 5 + ...quired_reasoning__conversation_stream.snap | 5 + ...tool_call_stream__conversation_stream.snap | 5 + ...r_event_metadata__conversation_stream.snap | 5 + crates/jp_mcp/src/client.rs | 71 ++++++++--- crates/jp_mcp/src/client_tests.rs | 78 +++++++++++- crates/jp_mcp/src/error.rs | 11 +- crates/jp_mcp/src/lib.rs | 2 +- 102 files changed, 973 insertions(+), 37 deletions(-) create mode 100644 crates/jp_config/src/style/mcp_startup.rs create mode 100644 crates/jp_config/src/style/mcp_startup_tests.rs diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 995d661f8..9a050174f 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -81,7 +81,7 @@ use jp_config::{ }, fs::{expand_tilde, load_partial}, model::parameters::{PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig}, - style::reasoning::ReasoningDisplayConfig, + style::{mcp_startup::McpStartupConfig, reasoning::ReasoningDisplayConfig}, }; use jp_conversation::{ Conversation, ConversationEvent, ConversationId, ConversationStream, @@ -97,6 +97,7 @@ use jp_llm::{ tool_definitions, }, }; +use jp_mcp::{StartupSet, id::McpServerId}; use jp_printer::Printer; use jp_storage::backend::Projection; use jp_task::task::TitleGeneratorTask; @@ -131,6 +132,7 @@ use crate::{ parser::AttachmentUrlOrPath, render::TurnView, signals::SignalRouter, + timer::spawn_line_timer, }; type BoxedResult = std::result::Result>; @@ -403,7 +405,7 @@ impl Query { self.apply_pre_query_compaction(&lock, &cfg).await?; } - let mut mcp_servers_handle = ctx.configure_active_mcp_servers().await?; + let mcp_servers_handle = ctx.configure_active_mcp_servers().await?; let conv_title = lock.metadata().title.clone(); @@ -570,10 +572,15 @@ impl Query { } } - // Wait for all MCP servers to finish loading. - while let Some(result) = mcp_servers_handle.join_next().await { - result??; - } + // Wait for all MCP servers to finish loading, showing a timer line + // when the wait takes long enough to be noticeable. + await_mcp_servers( + mcp_servers_handle, + cfg.style.mcp_startup.clone(), + ctx.printer.clone(), + ctx.term.is_tty, + ) + .await?; let forced_tool = cfg.assistant.tool_choice.function_name(); let tools = @@ -1102,6 +1109,81 @@ impl Query { } } +/// Wait for background MCP server startups to complete. +/// +/// Shows a single aggregate timer line on stderr once the wait exceeds the +/// configured delay, updating the listed server names as startups finish. +/// Servers that finish within the delay never trigger the line. +/// +/// Returns the first startup error, after clearing the timer line so the error +/// renders on a clean row. +async fn await_mcp_servers( + mut startup: StartupSet, + config: McpStartupConfig, + printer: Arc, + is_tty: bool, +) -> std::result::Result<(), cmd::Error> { + if startup.joins.is_empty() { + return Ok(()); + } + + let timer = spawn_line_timer( + printer, + config.show && is_tty, + Duration::from_secs(config.delay_secs.into()), + Duration::from_millis(config.interval_ms.into()), + |secs, status| { + format!( + "\r\x1b[K⏱ Starting {}… {secs:.1}s", + status.unwrap_or("MCP servers") + ) + }, + ); + if let Some(timer) = &timer { + timer.set_status(mcp_startup_status(&startup.pending)); + } + + let result = loop { + match startup.joins.join_next().await { + None => break Ok(()), + Some(Err(error)) => break Err(error.into()), + Some(Ok(Err(error))) => break Err(error.into()), + Some(Ok(Ok(id))) => { + startup.pending.retain(|pending| pending != &id); + if let Some(timer) = &timer + && !startup.pending.is_empty() + { + timer.set_status(mcp_startup_status(&startup.pending)); + } + } + } + }; + + if let Some(timer) = timer { + timer.finish().await; + } + + result +} + +/// Render the pending-server fragment for the MCP startup timer line. +/// +/// One server renders as `MCP server bookworm`; several render as `2 MCP +/// servers (bookworm, grizzly)`. +fn mcp_startup_status(pending: &[McpServerId]) -> String { + match pending { + [id] => format!("MCP server {id}"), + ids => format!( + "{} MCP servers ({})", + ids.len(), + ids.iter() + .map(McpServerId::as_str) + .collect::>() + .join(", ") + ), + } +} + /// Return the most recent assistant message text in the stream. /// /// Walks the stream in reverse and returns the first `ChatResponse::Message` it diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index 33940e54a..f7d96f881 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -1357,3 +1357,117 @@ fn pending_trim_default_is_noop() { "a default PendingStreamTrim must not mutate the stream" ); } + +#[test] +fn mcp_startup_status_single_server() { + assert_eq!( + mcp_startup_status(&[McpServerId::new("bookworm")]), + "MCP server bookworm" + ); +} + +#[test] +fn mcp_startup_status_multiple_servers() { + assert_eq!( + mcp_startup_status(&[McpServerId::new("bookworm"), McpServerId::new("grizzly")]), + "2 MCP servers (bookworm, grizzly)" + ); +} + +/// Timer settings that render immediately, so tests don't wait out a delay. +fn immediate_mcp_startup_config() -> McpStartupConfig { + McpStartupConfig { + show: true, + delay_secs: 0, + interval_ms: 10, + } +} + +#[tokio::test] +async fn await_mcp_servers_drains_all_startups() { + let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); + + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async { Ok(McpServerId::new("bookworm")) }); + joins.spawn(async { Ok(McpServerId::new("grizzly")) }); + let startup = StartupSet { + joins, + pending: vec![McpServerId::new("bookworm"), McpServerId::new("grizzly")], + }; + + await_mcp_servers( + startup, + immediate_mcp_startup_config(), + Arc::new(printer), + false, + ) + .await + .expect("all startups succeed"); +} + +#[tokio::test] +async fn await_mcp_servers_propagates_startup_error() { + let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); + + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async { Err(jp_mcp::Error::UnknownServer(McpServerId::new("bookworm"))) }); + let startup = StartupSet { + joins, + pending: vec![McpServerId::new("bookworm")], + }; + + let error = await_mcp_servers( + startup, + immediate_mcp_startup_config(), + Arc::new(printer), + false, + ) + .await + .expect_err("a failed required server must fail the wait"); + + assert_eq!(error.message.as_deref(), Some("MCP error")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn await_mcp_servers_shows_and_clears_timer_line() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer); + + // Hold the startup window open until the test releases it, so the timer + // is guaranteed to tick while the server is still "starting". + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async move { + release_rx.await.ok(); + Ok(McpServerId::new("bookworm")) + }); + let startup = StartupSet { + joins, + pending: vec![McpServerId::new("bookworm")], + }; + + let wait = tokio::spawn(await_mcp_servers( + startup, + immediate_mcp_startup_config(), + printer.clone(), + true, + )); + + // Let a few ticks land before releasing the startup. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + release_tx.send(()).expect("wait task is still running"); + wait.await + .expect("task did not panic") + .expect("startup succeeds"); + printer.flush(); + + let chrome = err.lock(); + assert!( + chrome.contains("⏱ Starting MCP server bookworm…"), + "timer line should name the pending server.\nChrome:\n{chrome}" + ); + assert!( + chrome.ends_with("\r\x1b[K"), + "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" + ); +} diff --git a/crates/jp_cli/src/ctx.rs b/crates/jp_cli/src/ctx.rs index 7bae3b635..9f76e4e74 100644 --- a/crates/jp_cli/src/ctx.rs +++ b/crates/jp_cli/src/ctx.rs @@ -8,15 +8,12 @@ use std::{ use camino::Utf8Path; use chrono::{DateTime, Utc}; use jp_config::{AppConfig, PartialAppConfig, conversation::tool::ToolSource}; -use jp_mcp::id::McpServerId; +use jp_mcp::{StartupSet, id::McpServerId}; use jp_printer::Printer; use jp_storage::backend::FsStorageBackend; use jp_task::TaskHandler; use jp_workspace::{Workspace, session::Session}; -use tokio::{ - runtime::{Handle, Runtime}, - task::JoinSet, -}; +use tokio::runtime::{Handle, Runtime}; use crate::{Globals, Result, signals::SignalRouter}; @@ -172,9 +169,7 @@ impl Ctx { /// Activate and deactivate MCP servers based on the active conversation /// context. - pub(crate) async fn configure_active_mcp_servers( - &mut self, - ) -> Result>> { + pub(crate) async fn configure_active_mcp_servers(&mut self) -> Result { let mut server_ids = HashSet::new(); for (_name, cfg) in self.config.conversation.tools.iter() { diff --git a/crates/jp_config/src/providers/mcp.rs b/crates/jp_config/src/providers/mcp.rs index 7ebe720ce..57a4efa55 100644 --- a/crates/jp_config/src/providers/mcp.rs +++ b/crates/jp_config/src/providers/mcp.rs @@ -37,6 +37,10 @@ impl PartialConfigDelta for PartialMcpProviderConfig { variables: delta_opt_vec(prev.variables.as_ref(), next.variables), checksum: delta_opt_partial(prev.checksum.as_ref(), next.checksum), optional: delta_opt(prev.optional.as_ref(), next.optional), + startup_timeout_secs: delta_opt( + prev.startup_timeout_secs.as_ref(), + next.startup_timeout_secs, + ), }), } } @@ -100,6 +104,17 @@ pub struct StdioConfig { /// server failed to start. #[setting(default)] pub optional: bool, + + /// Timeout in seconds for the server to start and complete the MCP + /// handshake. + /// + /// Defaults to `60`. + /// The window covers everything from spawning the command to the server + /// answering the MCP `initialize` request. + /// Increase it for servers that do expensive work on startup, such as + /// compiling from source. + #[setting(default = 60)] + pub startup_timeout_secs: u32, } impl AssignKeyValue for PartialStdioConfig { @@ -111,6 +126,7 @@ impl AssignKeyValue for PartialStdioConfig { _ if kv.p("env") => kv.try_some_vec_of_strings(&mut self.variables)?, _ if kv.p("binary_checksum") => self.checksum.assign(kv)?, "optional" => self.optional = kv.try_some_bool()?, + "startup_timeout_secs" => self.startup_timeout_secs = kv.try_some_u32()?, _ => return missing_key(&kv), } @@ -128,6 +144,10 @@ impl ToPartial for StdioConfig { variables: partial_opt(&self.variables, defaults.variables), checksum: partial_opt_config(self.checksum.as_ref(), defaults.checksum), optional: partial_opt(&self.optional, defaults.optional), + startup_timeout_secs: partial_opt( + &self.startup_timeout_secs, + defaults.startup_timeout_secs, + ), } } } diff --git a/crates/jp_config/src/providers/mcp_tests.rs b/crates/jp_config/src/providers/mcp_tests.rs index 2f5d517cd..284a30c32 100644 --- a/crates/jp_config/src/providers/mcp_tests.rs +++ b/crates/jp_config/src/providers/mcp_tests.rs @@ -1,3 +1,4 @@ +use schematic::PartialConfig as _; use test_log::test; use super::*; @@ -11,6 +12,7 @@ fn stdio_optional_defaults_to_false() { variables: vec![], checksum: None, optional: bool::default(), + startup_timeout_secs: 60, }; assert!(!config.optional); @@ -24,6 +26,7 @@ fn mcp_provider_optional_reports_stdio_flag() { variables: vec![], checksum: None, optional: false, + startup_timeout_secs: 60, }); assert!(!required.optional()); @@ -33,10 +36,27 @@ fn mcp_provider_optional_reports_stdio_flag() { variables: vec![], checksum: None, optional: true, + startup_timeout_secs: 60, }); assert!(optional.optional()); } +#[test] +fn startup_timeout_defaults_to_60_seconds() { + let p = PartialStdioConfig::default_values(&()).unwrap().unwrap(); + assert_eq!(p.startup_timeout_secs, Some(60)); +} + +#[test] +fn assign_startup_timeout_via_cli() { + let mut p = PartialStdioConfig::default(); + assert_eq!(p.startup_timeout_secs, None); + + let kv = KvAssignment::try_from_cli("startup_timeout_secs", "300").unwrap(); + p.assign(kv).unwrap(); + assert_eq!(p.startup_timeout_secs, Some(300)); +} + #[test] fn assign_optional_flag_via_cli() { let mut p = PartialStdioConfig::default(); diff --git a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap index f123dbc39..b879c750f 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap @@ -25,6 +25,9 @@ expression: "AppConfig::fields()" "style.reasoning.display", "style.reasoning.extend_across_tool_calls", "style.reasoning.summary_model", + "style.mcp_startup.delay_secs", + "style.mcp_startup.interval_ms", + "style.mcp_startup.show", "style.markdown.hr_style", "style.markdown.table_max_column_width", "style.markdown.theme", diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap index 3d86a796e..a94f2c7cd 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap @@ -105,6 +105,11 @@ PartialAppConfig { theme: None, hr_style: None, }, + mcp_startup: PartialMcpStartupConfig { + show: None, + delay_secs: None, + interval_ms: None, + }, reasoning: PartialReasoningConfig { display: None, summary_model: None, diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap index d3b2b0082..61b1220e0 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap @@ -203,6 +203,17 @@ Ok( ), hr_style: None, }, + mcp_startup: PartialMcpStartupConfig { + show: Some( + true, + ), + delay_secs: Some( + 4, + ), + interval_ms: Some( + 100, + ), + }, reasoning: PartialReasoningConfig { display: None, summary_model: None, diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap index 7a5dae787..e8d361b31 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap @@ -105,6 +105,11 @@ PartialAppConfig { theme: None, hr_style: None, }, + mcp_startup: PartialMcpStartupConfig { + show: None, + delay_secs: None, + interval_ms: None, + }, reasoning: PartialReasoningConfig { display: None, summary_model: None, diff --git a/crates/jp_config/src/style.rs b/crates/jp_config/src/style.rs index a2ce2ac89..d67acc8a8 100644 --- a/crates/jp_config/src/style.rs +++ b/crates/jp_config/src/style.rs @@ -4,6 +4,7 @@ pub mod code; pub mod inline_code; pub mod lock_wait; pub mod markdown; +pub mod mcp_startup; pub mod reasoning; pub mod streaming; pub mod tool_call; @@ -24,6 +25,7 @@ use crate::{ inline_code::{InlineCodeConfig, PartialInlineCodeConfig}, lock_wait::{LockWaitConfig, PartialLockWaitConfig}, markdown::{MarkdownConfig, PartialMarkdownConfig}, + mcp_startup::{McpStartupConfig, PartialMcpStartupConfig}, reasoning::{PartialReasoningConfig, ReasoningConfig}, streaming::{PartialStreamingConfig, StreamingConfig}, tool_call::{PartialToolCallConfig, ToolCallConfig}, @@ -53,6 +55,13 @@ pub struct StyleConfig { #[setting(nested)] pub markdown: MarkdownConfig, + /// MCP server startup indicator. + /// + /// Configures the timer shown while waiting for MCP servers that are still + /// starting when a query needs them. + #[setting(nested)] + pub mcp_startup: McpStartupConfig, + /// Reasoning content style. /// /// Configures how the assistant's reasoning process (thinking) is @@ -93,6 +102,7 @@ impl AssignKeyValue for PartialStyleConfig { _ if kv.p("code") => self.code.assign(kv)?, _ if kv.p("inline_code") => self.inline_code.assign(kv)?, _ if kv.p("markdown") => self.markdown.assign(kv)?, + _ if kv.p("mcp_startup") => self.mcp_startup.assign(kv)?, _ if kv.p("reasoning") => self.reasoning.assign(kv)?, _ if kv.p("lock_wait") => self.lock_wait.assign(kv)?, _ if kv.p("streaming") => self.streaming.assign(kv)?, @@ -111,6 +121,7 @@ impl PartialConfigDelta for PartialStyleConfig { code: self.code.delta(next.code), inline_code: self.inline_code.delta(next.inline_code), markdown: self.markdown.delta(next.markdown), + mcp_startup: self.mcp_startup.delta(next.mcp_startup), reasoning: self.reasoning.delta(next.reasoning), lock_wait: self.lock_wait.delta(next.lock_wait), streaming: self.streaming.delta(next.streaming), @@ -126,6 +137,7 @@ impl FillDefaults for PartialStyleConfig { code: self.code.fill_from(defaults.code), inline_code: self.inline_code.fill_from(defaults.inline_code), markdown: self.markdown.fill_from(defaults.markdown), + mcp_startup: self.mcp_startup.fill_from(defaults.mcp_startup), reasoning: self.reasoning.fill_from(defaults.reasoning), lock_wait: self.lock_wait.fill_from(defaults.lock_wait), streaming: self.streaming.fill_from(defaults.streaming), @@ -141,6 +153,7 @@ impl ToPartial for StyleConfig { code: self.code.to_partial(), inline_code: self.inline_code.to_partial(), markdown: self.markdown.to_partial(), + mcp_startup: self.mcp_startup.to_partial(), reasoning: self.reasoning.to_partial(), lock_wait: self.lock_wait.to_partial(), streaming: self.streaming.to_partial(), diff --git a/crates/jp_config/src/style/mcp_startup.rs b/crates/jp_config/src/style/mcp_startup.rs new file mode 100644 index 000000000..530b86512 --- /dev/null +++ b/crates/jp_config/src/style/mcp_startup.rs @@ -0,0 +1,90 @@ +//! MCP server startup progress indicator configuration. + +use schematic::Config; + +use crate::{ + assignment::{AssignKeyValue, AssignResult, KvAssignment, missing_key}, + delta::{PartialConfigDelta, delta_opt}, + fill::FillDefaults, + partial::{ToPartial, partial_opt}, +}; + +/// Progress indicator shown while MCP servers are starting. +/// +/// Enabled MCP servers boot in the background when a query starts. +/// When one or more of them are still starting by the time the query needs +/// them, the CLI waits and shows a timer listing the pending servers. +/// +/// ```toml +/// [style.mcp_startup] +/// delay_secs = 4 +/// ``` +#[derive(Debug, Clone, PartialEq, Config)] +#[config(rename_all = "snake_case")] +pub struct McpStartupConfig { + /// Whether to show the startup indicator. + #[setting(default = true)] + pub show: bool, + + /// Delay in seconds before showing the startup indicator. + /// + /// Servers that finish starting within this period never trigger the + /// indicator. + /// Set to 0 to show the indicator immediately. + #[setting(default = 4)] + pub delay_secs: u32, + + /// Interval in milliseconds between timer updates. + #[setting(default = 100)] + pub interval_ms: u32, +} + +impl AssignKeyValue for PartialMcpStartupConfig { + fn assign(&mut self, kv: KvAssignment) -> AssignResult { + match kv.key_string().as_str() { + "" => kv.try_merge_object(self)?, + "show" => self.show = kv.try_some_bool()?, + "delay_secs" => self.delay_secs = kv.try_some_u32()?, + "interval_ms" => self.interval_ms = kv.try_some_u32()?, + _ => return missing_key(&kv), + } + + Ok(()) + } +} + +impl PartialConfigDelta for PartialMcpStartupConfig { + fn delta(&self, next: Self) -> Self { + Self { + show: delta_opt(self.show.as_ref(), next.show), + delay_secs: delta_opt(self.delay_secs.as_ref(), next.delay_secs), + interval_ms: delta_opt(self.interval_ms.as_ref(), next.interval_ms), + } + } +} + +impl FillDefaults for PartialMcpStartupConfig { + fn fill_from(self, defaults: Self) -> Self { + Self { + show: self.show.or(defaults.show), + delay_secs: self.delay_secs.or(defaults.delay_secs), + interval_ms: self.interval_ms.or(defaults.interval_ms), + } + } +} + +impl ToPartial for McpStartupConfig { + fn to_partial(&self) -> Self::Partial { + let defaults = Self::Partial::default(); + + Self::Partial { + show: partial_opt(&self.show, defaults.show), + delay_secs: partial_opt(&self.delay_secs, defaults.delay_secs), + interval_ms: partial_opt(&self.interval_ms, defaults.interval_ms), + } + } +} + +#[cfg(test)] +#[path = "mcp_startup_tests.rs"] +mod tests; diff --git a/crates/jp_config/src/style/mcp_startup_tests.rs b/crates/jp_config/src/style/mcp_startup_tests.rs new file mode 100644 index 000000000..f526be145 --- /dev/null +++ b/crates/jp_config/src/style/mcp_startup_tests.rs @@ -0,0 +1,32 @@ +use schematic::PartialConfig as _; +use test_log::test; + +use super::*; + +#[test] +fn defaults_show_after_four_seconds() { + let p = PartialMcpStartupConfig::default_values(&()) + .unwrap() + .unwrap(); + + assert_eq!(p.show, Some(true)); + assert_eq!(p.delay_secs, Some(4)); + assert_eq!(p.interval_ms, Some(100)); +} + +#[test] +fn assign_fields_via_cli() { + let mut p = PartialMcpStartupConfig::default(); + + let kv = KvAssignment::try_from_cli("show", "false").unwrap(); + p.assign(kv).unwrap(); + assert_eq!(p.show, Some(false)); + + let kv = KvAssignment::try_from_cli("delay_secs", "0").unwrap(); + p.assign(kv).unwrap(); + assert_eq!(p.delay_secs, Some(0)); + + let kv = KvAssignment::try_from_cli("interval_ms", "250").unwrap(); + p.assign(kv).unwrap(); + assert_eq!(p.interval_ms, Some(250)); +} diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap index f982c177e..2e0108cc0 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap index 33b8e6d15..35adb93ae 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap index 86019fd7c..22b64608a 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap index 8145d87e0..ab258e9a9 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap index 21a37d720..3c7f11b42 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap index d7af195b2..63ce95a79 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap index deb659e73..69d3de998 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap index f7c9ce848..1edd9996c 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap @@ -104,6 +104,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap index 7d215e97d..ad904ed07 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap index 66268450e..bdf970269 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap index 7f21e8231..8bcfa4c93 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap index d9fe27526..fd5629327 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap index 3ea618f3a..25dcf5af0 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap index c167ff1db..c2ba57c97 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap index ebcd1f991..ca530799f 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap index 946fca8b0..0531b42d3 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap index 4e443ae3e..17da7d956 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap index 621abb44f..2f7a5f0fc 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap index 39754b735..4fcf90a3b 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap index ad8f8f982..472490d1e 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap index 3a43383e7..3440e0399 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap index 0298c446f..45787d343 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap index 88ed7dcb8..43b88424e 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap index 2731458a2..a33c4cdd9 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap index c5a801732..b84957d25 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap index e6e434240..fbe91e33b 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap index b7573c835..ce63c9fc8 100644 --- a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap index 68dbba025..f12ea3445 100644 --- a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap index c71a5e6fe..138472de5 100644 --- a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap index cf0082b2d..0b0a60f81 100644 --- a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap index d7c4bdc1b..5b3634221 100644 --- a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap index cbe83e653..b0dd2fff5 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap index 2ab1be2c2..ecd00f8d2 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap index 6296de24e..c64174900 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap index 43fa99c7c..daa217b8a 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap index e5d3cf7fe..afb7fda66 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap index 5b5a1f734..4dcce82c2 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap index bfb999ae2..834d2c39c 100644 --- a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap index 1ea674ba8..43f937460 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap index 7b94ace25..d8c66a925 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap index 046b7f930..2477c69be 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap index accd0aff5..1a0b8c5d5 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap index ed25220cf..1ebae2a00 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap index 2cfe43fcc..f8bc51d2c 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap index fe65e74d2..f9e64995a 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap index 5edfa1ea4..51f67f3bf 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap index d83d732d5..5d1f1b61c 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap index f1c85a1b4..b33b3a851 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap index 50bbb7472..535b66f67 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap index 9bf3c44dc..9883fd181 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap index e2e8e8ed7..667a6be09 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap index 4651b8af2..28d7e2c85 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap index 063655e8a..3db76ff28 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap index 723c1a172..b70e99e32 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap index d29a9df92..49ac21f65 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap index 5fa599d4d..97bbe2418 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap index 2fdd8b5ec..6c3d1e47f 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap index 01a022aca..739c1a2c0 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap index ca81b0c3c..076b56dd5 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap index c9d2b1d9f..176cb2ebc 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap index f7f34cd7a..5317cff76 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap @@ -104,6 +104,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap index 9530ff5af..27b0e85f5 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap index 580aefa8e..f4160e26c 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap index 52975aeba..65c7cf1c2 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap index 3fabe9b6f..134ea4ed7 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap index 63612fd67..4faad3c57 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap index 59706744b..257b79020 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap index 715f4343e..e456cf64e 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap index f70c44abe..4672960ba 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap index 31e273cf2..6dd8b1eb3 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap index b8a0ec801..ab8d45ecd 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap index 1a22fee15..0acd739df 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap index b29793a38..e12846e54 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap index 92f75ec58..c99fccb97 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap index 16ed7e035..021c931f9 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap index 5a911405e..9a31ff2cd 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap index d821617f0..d1a0598b7 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap index f563f2924..083cb1b71 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap index 12b4ab10f..0bcd002b0 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap index 1dff10cad..8f4157f5c 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap index bc5164fc5..903b68a7e 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap index 4d0184b7e..745486cd2 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap index e6cb6dcf2..0acf0b0bc 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap index 73c549d5d..414892243 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap index af20aabef..05de0c9f5 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap @@ -99,6 +99,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap index 7501f44c1..c250560bb 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap @@ -102,6 +102,11 @@ expression: v "theme": "gruvbox-dark", "hr_style": "line" }, + "mcp_startup": { + "show": true, + "delay_secs": 4, + "interval_ms": 100 + }, "reasoning": { "display": "full", "background": 236, diff --git a/crates/jp_mcp/src/client.rs b/crates/jp_mcp/src/client.rs index d6ce8dd34..de82e8f4f 100644 --- a/crates/jp_mcp/src/client.rs +++ b/crates/jp_mcp/src/client.rs @@ -34,6 +34,26 @@ use crate::{ id::{McpServerId, McpToolId}, }; +/// A batch of MCP servers starting in the background. +/// +/// Returned by [`Client::run_services`]. +/// Await the tasks in [`joins`] to learn when each server finishes starting; +/// [`pending`] names the servers the batch is starting, so callers can report +/// which ones are still booting. +/// +/// [`joins`]: Self::joins +/// [`pending`]: Self::pending +pub struct StartupSet { + /// One task per starting server. + /// Each resolves to the id of the server it started (also for `optional` + /// servers that failed and were skipped), or to the startup error for a + /// required server. + pub joins: JoinSet>, + + /// Ids of the servers being started, sorted by name. + pub pending: Vec, +} + /// Outcome of attempting to start an MCP server. enum SpawnOutcome { /// Server started successfully. @@ -166,7 +186,7 @@ impl Client { &mut self, server_ids: HashSet, handle: Handle, - ) -> Result>> { + ) -> Result { let mut clients = self.services.write().await; let servers_to_stop: Vec<_> = clients .keys() @@ -181,7 +201,8 @@ impl Client { } let _guard = handle.enter(); - let mut joins = JoinSet::>::new(); + let mut joins = JoinSet::>::new(); + let mut pending = Vec::new(); for server_id in server_ids { // Determine which servers to start (in configs but not currently // active) @@ -190,6 +211,7 @@ impl Client { } trace!(id = %server_id, "Starting MCP server."); + pending.push(server_id.clone()); joins.spawn({ let servers = self.servers.clone(); @@ -206,12 +228,16 @@ impl Client { } SpawnOutcome::OptionalFailed => {} } - Ok(()) + Ok(server_id) } }); } - Ok(joins) + // `server_ids` is a set, so spawn order is nondeterministic; sort for + // a stable presentation order. + pending.sort_by(|a, b| a.as_str().cmp(b.as_str())); + + Ok(StartupSet { joins, pending }) } /// Check whether a server has an active running service. @@ -329,17 +355,32 @@ impl Client { spawn_stderr_forwarder(stderr, id.clone(), Arc::clone(&stderr_tail)); } - // Create a timeout for the connection - let timeout = Duration::from_mins(1); - - // Serve the client with timeout - let client = tokio::time::timeout(timeout, async { ().serve(child_process).await }) - .await? - .map_err(|error| Error::InitializeError { - cmd: cmd_display, - error: error.to_string(), - stderr: render_stderr_tail(&stderr_tail), - })?; + // Give the server time to start and answer the MCP + // `initialize` handshake. Configurable per server because a + // server that builds from source on spawn can legitimately + // take minutes. + let timeout = Duration::from_secs(config.startup_timeout_secs.into()); + + // Serve the client with timeout. Both failure paths attach + // the captured stderr tail: it usually names the actual + // problem (build output, missing dependency, lock contention). + let client = + match tokio::time::timeout(timeout, async { ().serve(child_process).await }) + .await + { + Ok(result) => result.map_err(|error| Error::InitializeError { + cmd: cmd_display, + error: error.to_string(), + stderr: render_stderr_tail(&stderr_tail), + })?, + Err(_) => { + return Err(Error::InitializeTimeout { + cmd: cmd_display, + timeout_secs: config.startup_timeout_secs, + stderr: render_stderr_tail(&stderr_tail), + }); + } + }; Ok(client) } diff --git a/crates/jp_mcp/src/client_tests.rs b/crates/jp_mcp/src/client_tests.rs index ebce3c0e2..30675d654 100644 --- a/crates/jp_mcp/src/client_tests.rs +++ b/crates/jp_mcp/src/client_tests.rs @@ -18,6 +18,7 @@ fn stdio_config(command: &str, optional: bool) -> McpProviderConfig { variables: vec![], checksum: None, optional, + startup_timeout_secs: 60, }) } @@ -102,6 +103,68 @@ fn initialize_error_display_omits_stderr_section_when_empty() { ); } +#[test] +fn initialize_timeout_display_includes_timeout_command_and_stderr() { + let error = Error::InitializeTimeout { + cmd: "just serve-bookworm".to_owned(), + timeout_secs: 60, + stderr: "\nstderr:\n Compiling bookworm v0.1.0".to_owned(), + }; + + assert_eq!( + error.to_string(), + "Server initialization timed out after 60s: just serve-bookworm\nstderr:\n Compiling \ + bookworm v0.1.0" + ); +} + +#[test] +fn initialize_timeout_display_omits_stderr_section_when_empty() { + let error = Error::InitializeTimeout { + cmd: "just".to_owned(), + timeout_secs: 5, + stderr: String::new(), + }; + + assert_eq!( + error.to_string(), + "Server initialization timed out after 5s: just" + ); +} + +// A child that writes to stderr and then hangs without ever speaking MCP +// forces the startup-timeout path; the resulting error must carry the +// configured deadline and the captured stderr tail. +#[cfg(unix)] +#[tokio::test] +async fn startup_timeout_attaches_stderr_tail() { + let config = McpProviderConfig::Stdio(StdioConfig { + command: PathBuf::from("sh"), + arguments: vec!["-c".to_owned(), "echo compiling >&2; sleep 30".to_owned()], + variables: vec![], + checksum: None, + optional: false, + startup_timeout_secs: 1, + }); + + let error = Client::create_client(&McpServerId::new("slow"), &config) + .await + .expect_err("a server that never completes the handshake must time out"); + + match error { + Error::InitializeTimeout { + cmd, + timeout_secs, + stderr, + } => { + assert_eq!(cmd, "sh -c echo compiling >&2; sleep 30"); + assert_eq!(timeout_secs, 1); + assert_eq!(stderr, "\nstderr:\n compiling"); + } + other => panic!("expected InitializeTimeout, got: {other:?}"), + } +} + // Use a binary path that cannot exist on any sane system. This drives // `create_client` into a `CannotSpawnProcess` error path without depending on // any specific environment behavior. @@ -116,15 +179,18 @@ async fn optional_server_failure_is_tolerated() { let mut client = Client::new(providers); let server_id = McpServerId::new(&server_name); - let mut joins = client + let mut startup = client .run_services(HashSet::from([server_id.clone()]), Handle::current()) .await .expect("run_services should not fail for optional servers"); - while let Some(joined) = joins.join_next().await { - joined + assert_eq!(startup.pending, vec![server_id.clone()]); + + while let Some(joined) = startup.joins.join_next().await { + let completed = joined .expect("task did not panic") .expect("optional failure is swallowed inside the task"); + assert_eq!(completed, server_id); } assert!( @@ -142,13 +208,15 @@ async fn required_server_failure_propagates() { let mut client = Client::new(providers); let server_id = McpServerId::new(&server_name); - let mut joins = client + let mut startup = client .run_services(HashSet::from([server_id.clone()]), Handle::current()) .await .expect("run_services itself returns Ok; per-task results carry the error"); + assert_eq!(startup.pending, vec![server_id.clone()]); + let mut saw_error = false; - while let Some(joined) = joins.join_next().await { + while let Some(joined) = startup.joins.join_next().await { let task_result = joined.expect("task did not panic"); if task_result.is_err() { saw_error = true; diff --git a/crates/jp_mcp/src/error.rs b/crates/jp_mcp/src/error.rs index 9ca18c6ad..3f7766350 100644 --- a/crates/jp_mcp/src/error.rs +++ b/crates/jp_mcp/src/error.rs @@ -9,8 +9,15 @@ pub enum Error { #[error("Service error: {0}")] Service(#[from] rmcp::ServiceError), - #[error("Timeout error: {0}")] - Timeout(#[from] tokio::time::error::Elapsed), + #[error("Server initialization timed out after {timeout_secs}s: {cmd}{stderr}")] + InitializeTimeout { + cmd: String, + timeout_secs: u32, + /// Pre-rendered tail of the server's stderr, including a leading + /// newline and `stderr:` header when non-empty. + /// Empty when the server produced no stderr output before the deadline. + stderr: String, + }, #[error("Unknown tool: {0}")] UnknownTool(String), diff --git a/crates/jp_mcp/src/lib.rs b/crates/jp_mcp/src/lib.rs index f5f21ef40..5d1c7c756 100644 --- a/crates/jp_mcp/src/lib.rs +++ b/crates/jp_mcp/src/lib.rs @@ -2,6 +2,6 @@ mod client; pub mod error; pub mod id; -pub use client::Client; +pub use client::{Client, StartupSet}; pub use error::Error; pub use rmcp::model::{CallToolResult, Content, RawContent, ResourceContents, Tool}; From fd60e58c59111f7adfbe723c0fa2b06420cd83d3 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 30 Jul 2026 19:21:21 +0200 Subject: [PATCH 2/4] review feedback Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/query.rs | 22 ++++++-- crates/jp_cli/src/cmd/query_tests.rs | 69 +++++++++++++++++++++++ crates/jp_config/src/providers/mcp.rs | 1 + crates/jp_config/src/style/mcp_startup.rs | 5 ++ crates/jp_mcp/src/client.rs | 37 ++++++------ crates/jp_mcp/src/client_tests.rs | 26 +++++++++ 6 files changed, 139 insertions(+), 21 deletions(-) diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 9a050174f..99ed26f41 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -101,6 +101,7 @@ use jp_mcp::{StartupSet, id::McpServerId}; use jp_printer::Printer; use jp_storage::backend::Projection; use jp_task::task::TitleGeneratorTask; +use jp_term::width::truncate_to_width; use jp_workspace::{ConversationHandle, ConversationLock, Workspace}; use minijinja::{Environment, UndefinedBehavior}; use tool::{TerminalExecutorSource, ToolCoordinator}; @@ -579,6 +580,7 @@ impl Query { cfg.style.mcp_startup.clone(), ctx.printer.clone(), ctx.term.is_tty, + ctx.term.width, ) .await?; @@ -1117,11 +1119,17 @@ impl Query { /// /// Returns the first startup error, after clearing the timer line so the error /// renders on a clean row. +/// +/// `width` bounds the rendered line to the terminal so a long server list wraps +/// no further than one row, keeping the timer's single-row clear on finish +/// sufficient. +/// `None` leaves the line unbounded (unknown width). async fn await_mcp_servers( mut startup: StartupSet, config: McpStartupConfig, printer: Arc, is_tty: bool, + width: Option, ) -> std::result::Result<(), cmd::Error> { if startup.joins.is_empty() { return Ok(()); @@ -1132,11 +1140,15 @@ async fn await_mcp_servers( config.show && is_tty, Duration::from_secs(config.delay_secs.into()), Duration::from_millis(config.interval_ms.into()), - |secs, status| { - format!( - "\r\x1b[K⏱ Starting {}… {secs:.1}s", - status.unwrap_or("MCP servers") - ) + move |secs, status| { + let line = format!("⏱ Starting {}… {secs:.1}s", status.unwrap_or("MCP servers")); + // Truncate the visible text only; the `\r\x1b[K` control prefix + // must stay outside the width budget. + let line = match width { + Some(w) => truncate_to_width(&line, w.into()), + None => line, + }; + format!("\r\x1b[K{line}") }, ); if let Some(timer) = &timer { diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index f7d96f881..6cf164bce 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -1400,6 +1400,7 @@ async fn await_mcp_servers_drains_all_startups() { immediate_mcp_startup_config(), Arc::new(printer), false, + None, ) .await .expect("all startups succeed"); @@ -1421,6 +1422,7 @@ async fn await_mcp_servers_propagates_startup_error() { immediate_mcp_startup_config(), Arc::new(printer), false, + None, ) .await .expect_err("a failed required server must fail the wait"); @@ -1451,6 +1453,7 @@ async fn await_mcp_servers_shows_and_clears_timer_line() { immediate_mcp_startup_config(), printer.clone(), true, + None, )); // Let a few ticks land before releasing the startup. @@ -1471,3 +1474,69 @@ async fn await_mcp_servers_shows_and_clears_timer_line() { "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" ); } + +/// Drives the aggregate redraw: two servers start, one finishes while the other +/// is still pending, then the second finishes. +/// The line must go from both names, to the survivor alone, to cleared. +#[tokio::test(flavor = "multi_thread")] +async fn await_mcp_servers_redraws_as_servers_finish() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer); + + // Two independently-released tasks: releasing `bookworm` first makes + // `grizzly` the deterministic survivor of the mid-drain redraw. + let (bookworm_tx, bookworm_rx) = tokio::sync::oneshot::channel::<()>(); + let (grizzly_tx, grizzly_rx) = tokio::sync::oneshot::channel::<()>(); + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async move { + bookworm_rx.await.ok(); + Ok(McpServerId::new("bookworm")) + }); + joins.spawn(async move { + grizzly_rx.await.ok(); + Ok(McpServerId::new("grizzly")) + }); + let startup = StartupSet { + joins, + pending: vec![McpServerId::new("bookworm"), McpServerId::new("grizzly")], + }; + + let wait = tokio::spawn(await_mcp_servers( + startup, + immediate_mcp_startup_config(), + printer.clone(), + true, + None, + )); + + // Let the two-server frame render, then release `bookworm` and let the + // survivor-only frame render before releasing `grizzly`. + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + bookworm_tx.send(()).expect("wait task is still running"); + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + grizzly_tx.send(()).expect("wait task is still running"); + wait.await + .expect("task did not panic") + .expect("all startups succeed"); + printer.flush(); + + let chrome = err.lock(); + let both = chrome + .find("2 MCP servers (bookworm, grizzly)") + .expect("the aggregate two-server frame must render first"); + let survivor = chrome + .find("MCP server grizzly…") + .expect("the survivor-only frame must render after bookworm finishes"); + assert!( + both < survivor, + "the two-server frame must precede the survivor-only frame.\nChrome:\n{chrome}" + ); + assert!( + !chrome.contains("MCP server bookworm…"), + "bookworm was never the sole pending server; it must not render alone.\nChrome:\n{chrome}" + ); + assert!( + chrome.ends_with("\r\x1b[K"), + "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" + ); +} diff --git a/crates/jp_config/src/providers/mcp.rs b/crates/jp_config/src/providers/mcp.rs index 57a4efa55..e686b5fc0 100644 --- a/crates/jp_config/src/providers/mcp.rs +++ b/crates/jp_config/src/providers/mcp.rs @@ -113,6 +113,7 @@ pub struct StdioConfig { /// answering the MCP `initialize` request. /// Increase it for servers that do expensive work on startup, such as /// compiling from source. + /// Set to 0 to wait indefinitely (no timeout). #[setting(default = 60)] pub startup_timeout_secs: u32, } diff --git a/crates/jp_config/src/style/mcp_startup.rs b/crates/jp_config/src/style/mcp_startup.rs index 530b86512..7f26d48a7 100644 --- a/crates/jp_config/src/style/mcp_startup.rs +++ b/crates/jp_config/src/style/mcp_startup.rs @@ -23,11 +23,14 @@ use crate::{ #[config(rename_all = "snake_case")] pub struct McpStartupConfig { /// Whether to show the startup indicator. + /// + /// Defaults to `true`. #[setting(default = true)] pub show: bool, /// Delay in seconds before showing the startup indicator. /// + /// Defaults to `4`. /// Servers that finish starting within this period never trigger the /// indicator. /// Set to 0 to show the indicator immediately. @@ -35,6 +38,8 @@ pub struct McpStartupConfig { pub delay_secs: u32, /// Interval in milliseconds between timer updates. + /// + /// Defaults to `100`. #[setting(default = 100)] pub interval_ms: u32, } diff --git a/crates/jp_mcp/src/client.rs b/crates/jp_mcp/src/client.rs index de82e8f4f..1b4ca9928 100644 --- a/crates/jp_mcp/src/client.rs +++ b/crates/jp_mcp/src/client.rs @@ -358,21 +358,25 @@ impl Client { // Give the server time to start and answer the MCP // `initialize` handshake. Configurable per server because a // server that builds from source on spawn can legitimately - // take minutes. - let timeout = Duration::from_secs(config.startup_timeout_secs.into()); - - // Serve the client with timeout. Both failure paths attach - // the captured stderr tail: it usually names the actual - // problem (build output, missing dependency, lock contention). - let client = - match tokio::time::timeout(timeout, async { ().serve(child_process).await }) - .await - { - Ok(result) => result.map_err(|error| Error::InitializeError { - cmd: cmd_display, - error: error.to_string(), - stderr: render_stderr_tail(&stderr_tail), - })?, + // take minutes; `0` disables the timeout entirely. + let serve = async { ().serve(child_process).await }; + + // An initialization failure attaches the captured stderr + // tail: it usually names the actual problem (build output, + // missing dependency, lock contention). + let init_error = + |error: rmcp::service::ClientInitializeError| Error::InitializeError { + cmd: cmd_display.clone(), + error: error.to_string(), + stderr: render_stderr_tail(&stderr_tail), + }; + + let client = if config.startup_timeout_secs == 0 { + serve.await.map_err(init_error)? + } else { + let timeout = Duration::from_secs(config.startup_timeout_secs.into()); + match tokio::time::timeout(timeout, serve).await { + Ok(result) => result.map_err(init_error)?, Err(_) => { return Err(Error::InitializeTimeout { cmd: cmd_display, @@ -380,7 +384,8 @@ impl Client { stderr: render_stderr_tail(&stderr_tail), }); } - }; + } + }; Ok(client) } diff --git a/crates/jp_mcp/src/client_tests.rs b/crates/jp_mcp/src/client_tests.rs index 30675d654..028712391 100644 --- a/crates/jp_mcp/src/client_tests.rs +++ b/crates/jp_mcp/src/client_tests.rs @@ -165,6 +165,32 @@ async fn startup_timeout_attaches_stderr_tail() { } } +// `startup_timeout_secs = 0` disables the timeout: startup must run through the +// un-timed serve path. A child that exits immediately without speaking MCP +// fails that path quickly, so the error is `InitializeError`, never the +// `InitializeTimeout` that a zero-duration `tokio::time::timeout` would raise. +#[cfg(unix)] +#[tokio::test] +async fn zero_startup_timeout_disables_timeout() { + let config = McpProviderConfig::Stdio(StdioConfig { + command: PathBuf::from("sh"), + arguments: vec!["-c".to_owned(), "exit 0".to_owned()], + variables: vec![], + checksum: None, + optional: false, + startup_timeout_secs: 0, + }); + + let error = Client::create_client(&McpServerId::new("instant"), &config) + .await + .expect_err("a child that exits without a handshake fails initialization"); + + assert!( + matches!(error, Error::InitializeError { .. }), + "zero timeout must take the un-timed serve path, got: {error:?}" + ); +} + // Use a binary path that cannot exist on any sane system. This drives // `create_client` into a `CannotSpawnProcess` error path without depending on // any specific environment behavior. From d682dd9ebbb93de35e72d0f3c6c89684d91f7b8b Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 30 Jul 2026 23:12:32 +0200 Subject: [PATCH 3/4] review feedback Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/query.rs | 28 ++++++++-- crates/jp_cli/src/cmd/query_tests.rs | 82 ++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 99ed26f41..f65e82456 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -101,7 +101,7 @@ use jp_mcp::{StartupSet, id::McpServerId}; use jp_printer::Printer; use jp_storage::backend::Projection; use jp_task::task::TitleGeneratorTask; -use jp_term::width::truncate_to_width; +use jp_term::width::{display_width, truncate_to_width}; use jp_workspace::{ConversationHandle, ConversationLock, Workspace}; use minijinja::{Environment, UndefinedBehavior}; use tool::{TerminalExecutorSource, ToolCoordinator}; @@ -1141,12 +1141,28 @@ async fn await_mcp_servers( Duration::from_secs(config.delay_secs.into()), Duration::from_millis(config.interval_ms.into()), move |secs, status| { - let line = format!("⏱ Starting {}… {secs:.1}s", status.unwrap_or("MCP servers")); - // Truncate the visible text only; the `\r\x1b[K` control prefix - // must stay outside the width budget. + let status = status.unwrap_or("MCP servers"); + let full = format!("⏱ Starting {status}… {secs:.1}s"); + // Truncate only the server-list fragment, reserving the prefix and + // the elapsed-time suffix, so the timer keeps ticking even when a + // long list overflows. The `\r\x1b[K` control prefix stays outside + // the width budget. let line = match width { - Some(w) => truncate_to_width(&line, w.into()), - None => line, + Some(w) if display_width(&full) > usize::from(w) => { + let w = usize::from(w); + let prefix = "⏱ Starting "; + let suffix = format!(" {secs:.1}s"); + let reserved = display_width(prefix) + display_width(&suffix); + if w <= reserved { + // Too narrow even for the prefix and timer: keep the + // bounded timer alone so elapsed time still moves. + truncate_to_width(&format!("⏱ {secs:.1}s"), w) + } else { + let status = truncate_to_width(status, w - reserved); + format!("{prefix}{status}{suffix}") + } + } + _ => full, }; format!("\r\x1b[K{line}") }, diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index 6cf164bce..4a09e3b0e 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -18,7 +18,8 @@ use jp_llm::{ provider::mock::MockProvider, tool::{InvocationContext, builtin::BuiltinExecutors, executor::ExecutorSource}, }; -use jp_printer::{OutputFormat, Printer}; +use jp_printer::{OutputFormat, Printer, SharedBuffer}; +use jp_term::width::display_width; use jp_workspace::{ConversationHandle, Workspace}; use relative_path::RelativePathBuf; use serde_json::Value; @@ -1475,6 +1476,21 @@ async fn await_mcp_servers_shows_and_clears_timer_line() { ); } +/// Poll `err` until `needle` appears, failing after a hard timeout. +/// +/// Synchronizes on the rendered output instead of a fixed sleep: the timer +/// writes frames from its own task, so tests wait for the frame to land rather +/// than guessing how long that takes. +async fn wait_for_frame(err: &SharedBuffer, needle: &str) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !err.lock().contains(needle) { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("frame {needle:?} never rendered")); +} + /// Drives the aggregate redraw: two servers start, one finishes while the other /// is still pending, then the second finishes. /// The line must go from both names, to the survivor alone, to cleared. @@ -1509,11 +1525,12 @@ async fn await_mcp_servers_redraws_as_servers_finish() { None, )); - // Let the two-server frame render, then release `bookworm` and let the - // survivor-only frame render before releasing `grizzly`. - tokio::time::sleep(std::time::Duration::from_millis(60)).await; + // Advance on the rendered frames, not the clock: wait until each frame is + // actually in the buffer before releasing the next server, so a slow timer + // task can't make the release outrun the redraw it's supposed to observe. + wait_for_frame(&err, "2 MCP servers (bookworm, grizzly)").await; bookworm_tx.send(()).expect("wait task is still running"); - tokio::time::sleep(std::time::Duration::from_millis(60)).await; + wait_for_frame(&err, "MCP server grizzly…").await; grizzly_tx.send(()).expect("wait task is still running"); wait.await .expect("task did not panic") @@ -1540,3 +1557,58 @@ async fn await_mcp_servers_redraws_as_servers_finish() { "finishing the wait must leave the line cleared.\nChrome:\n{chrome}" ); } + +/// A server name long enough to force truncation must not cost the elapsed-time +/// suffix: the whole point of the line is the moving timer, so truncation has +/// to fall on the server list, not the `Ns` tail. +#[tokio::test(flavor = "multi_thread")] +async fn await_mcp_servers_timer_suffix_survives_truncation() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer); + + let long = "bookworm-with-a-very-long-descriptive-server-name"; + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let mut joins = tokio::task::JoinSet::new(); + joins.spawn(async move { + release_rx.await.ok(); + Ok(McpServerId::new(long)) + }); + let startup = StartupSet { + joins, + pending: vec![McpServerId::new(long)], + }; + + // A width far below the full line forces the status fragment to truncate. + let width = 30u16; + let wait = tokio::spawn(await_mcp_servers( + startup, + immediate_mcp_startup_config(), + printer.clone(), + true, + Some(width), + )); + + // The suffix is only reachable if truncation spared it: under whole-line + // truncation the trailing ` 0.0s` is the first thing cut, so this wait + // would time out instead. + wait_for_frame(&err, "0.0s").await; + release_tx.send(()).expect("wait task is still running"); + wait.await + .expect("task did not panic") + .expect("startup succeeds"); + printer.flush(); + + let chrome = err.lock(); + assert!( + chrome.contains('…'), + "the long server name must actually truncate.\nChrome:\n{chrome}" + ); + // Every rendered frame (\r-separated) must fit the declared width once its + // control bytes are stripped, so the line never wraps past one row. + for frame in chrome.split('\r').filter(|f| !f.is_empty()) { + assert!( + display_width(frame) <= usize::from(width), + "frame exceeds width {width}: {frame:?}\nChrome:\n{chrome}" + ); + } +} From 9e0dae7a41d8e4bff8cf8ac08fd05062caae56bf Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 31 Jul 2026 15:09:01 +0200 Subject: [PATCH 4/4] review feedback Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/query.rs | 57 ++++++++++--------- crates/jp_cli/src/cmd/query_tests.rs | 83 ++++++++++++---------------- 2 files changed, 65 insertions(+), 75 deletions(-) diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index f65e82456..a6f81d274 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -1140,32 +1140,7 @@ async fn await_mcp_servers( config.show && is_tty, Duration::from_secs(config.delay_secs.into()), Duration::from_millis(config.interval_ms.into()), - move |secs, status| { - let status = status.unwrap_or("MCP servers"); - let full = format!("⏱ Starting {status}… {secs:.1}s"); - // Truncate only the server-list fragment, reserving the prefix and - // the elapsed-time suffix, so the timer keeps ticking even when a - // long list overflows. The `\r\x1b[K` control prefix stays outside - // the width budget. - let line = match width { - Some(w) if display_width(&full) > usize::from(w) => { - let w = usize::from(w); - let prefix = "⏱ Starting "; - let suffix = format!(" {secs:.1}s"); - let reserved = display_width(prefix) + display_width(&suffix); - if w <= reserved { - // Too narrow even for the prefix and timer: keep the - // bounded timer alone so elapsed time still moves. - truncate_to_width(&format!("⏱ {secs:.1}s"), w) - } else { - let status = truncate_to_width(status, w - reserved); - format!("{prefix}{status}{suffix}") - } - } - _ => full, - }; - format!("\r\x1b[K{line}") - }, + move |secs, status| mcp_startup_line(secs, status, width), ); if let Some(timer) = &timer { timer.set_status(mcp_startup_status(&startup.pending)); @@ -1194,6 +1169,36 @@ async fn await_mcp_servers( result } +/// Render the MCP startup timer line for `secs` elapsed and `status`, bounding +/// the visible text to `width` columns when known. +/// +/// Truncation falls on the server-list fragment only: the ` ⏱ Starting ` +/// prefix and the ` {secs:.1}s ` timer suffix are always preserved, so the +/// elapsed time keeps moving even when a long list overflows. +/// A terminal too narrow for even the prefix and suffix falls back to a bounded +/// `⏱ {secs:.1}s`. +/// The leading `\r\x1b[K` control prefix stays outside the width budget. +fn mcp_startup_line(secs: f64, status: Option<&str>, width: Option) -> String { + let status = status.unwrap_or("MCP servers"); + let full = format!("⏱ Starting {status}… {secs:.1}s"); + let line = match width { + Some(w) if display_width(&full) > usize::from(w) => { + let w = usize::from(w); + let prefix = "⏱ Starting "; + let suffix = format!(" {secs:.1}s"); + let reserved = display_width(prefix) + display_width(&suffix); + if w <= reserved { + truncate_to_width(&format!("⏱ {secs:.1}s"), w) + } else { + let status = truncate_to_width(status, w - reserved); + format!("{prefix}{status}{suffix}") + } + } + _ => full, + }; + format!("\r\x1b[K{line}") +} + /// Render the pending-server fragment for the MCP startup timer line. /// /// One server renders as `MCP server bookworm`; several render as `2 MCP diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index 4a09e3b0e..6c386ce98 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -1558,57 +1558,42 @@ async fn await_mcp_servers_redraws_as_servers_finish() { ); } -/// A server name long enough to force truncation must not cost the elapsed-time -/// suffix: the whole point of the line is the moving timer, so truncation has -/// to fall on the server list, not the `Ns` tail. -#[tokio::test(flavor = "multi_thread")] -async fn await_mcp_servers_timer_suffix_survives_truncation() { - let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); - let printer = Arc::new(printer); +#[test] +fn mcp_startup_line_renders_full_when_it_fits() { + assert_eq!( + mcp_startup_line(4.2, Some("MCP server bookworm"), Some(80)), + "\r\x1b[K⏱ Starting MCP server bookworm… 4.2s" + ); + // Unknown width leaves the line unbounded. + assert_eq!( + mcp_startup_line(4.2, Some("MCP server bookworm"), None), + "\r\x1b[K⏱ Starting MCP server bookworm… 4.2s" + ); +} - let long = "bookworm-with-a-very-long-descriptive-server-name"; - let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); - let mut joins = tokio::task::JoinSet::new(); - joins.spawn(async move { - release_rx.await.ok(); - Ok(McpServerId::new(long)) - }); - let startup = StartupSet { - joins, - pending: vec![McpServerId::new(long)], - }; +// A long server list forced to truncate must keep the elapsed-time suffix: the +// whole point of the line is the moving timer, so truncation has to fall on the +// server list, not the `Ns` tail. Testing the pure formatter at a fixed `secs` +// pins the invariant without depending on when the timer task first ticks. +#[test] +fn mcp_startup_line_truncation_preserves_timer_suffix() { + let long = "MCP server bookworm-with-a-very-long-descriptive-server-name"; + let line = mcp_startup_line(12.3, Some(long), Some(30)); - // A width far below the full line forces the status fragment to truncate. - let width = 30u16; - let wait = tokio::spawn(await_mcp_servers( - startup, - immediate_mcp_startup_config(), - printer.clone(), - true, - Some(width), - )); + assert!(line.ends_with(" 12.3s"), "suffix must survive: {line:?}"); + assert!(line.contains('…'), "server list must truncate: {line:?}"); + // The visible text (control prefix stripped) must fit the declared width. + let visible = line.strip_prefix("\r\x1b[K").expect("control prefix"); + assert!(display_width(visible) <= 30, "must fit width: {line:?}"); +} - // The suffix is only reachable if truncation spared it: under whole-line - // truncation the trailing ` 0.0s` is the first thing cut, so this wait - // would time out instead. - wait_for_frame(&err, "0.0s").await; - release_tx.send(()).expect("wait task is still running"); - wait.await - .expect("task did not panic") - .expect("startup succeeds"); - printer.flush(); +// A terminal too narrow for even the prefix and suffix still keeps a moving +// timer rather than a static stub. +#[test] +fn mcp_startup_line_ultra_narrow_keeps_bounded_timer() { + let line = mcp_startup_line(7.0, Some("MCP server bookworm"), Some(6)); - let chrome = err.lock(); - assert!( - chrome.contains('…'), - "the long server name must actually truncate.\nChrome:\n{chrome}" - ); - // Every rendered frame (\r-separated) must fit the declared width once its - // control bytes are stripped, so the line never wraps past one row. - for frame in chrome.split('\r').filter(|f| !f.is_empty()) { - assert!( - display_width(frame) <= usize::from(width), - "frame exceeds width {width}: {frame:?}\nChrome:\n{chrome}" - ); - } + let visible = line.strip_prefix("\r\x1b[K").expect("control prefix"); + assert!(display_width(visible) <= 6, "must fit width: {line:?}"); + assert!(visible.contains("7.0s"), "timer must survive: {line:?}"); }