diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 995d661f8..a6f81d274 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,9 +97,11 @@ 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; +use jp_term::width::{display_width, truncate_to_width}; use jp_workspace::{ConversationHandle, ConversationLock, Workspace}; use minijinja::{Environment, UndefinedBehavior}; use tool::{TerminalExecutorSource, ToolCoordinator}; @@ -131,6 +133,7 @@ use crate::{ parser::AttachmentUrlOrPath, render::TurnView, signals::SignalRouter, + timer::spawn_line_timer, }; type BoxedResult = std::result::Result>; @@ -403,7 +406,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 +573,16 @@ 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, + ctx.term.width, + ) + .await?; let forced_tool = cfg.assistant.tool_choice.function_name(); let tools = @@ -1102,6 +1111,112 @@ 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. +/// +/// `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(()); + } + + let timer = spawn_line_timer( + printer, + config.show && is_tty, + Duration::from_secs(config.delay_secs.into()), + Duration::from_millis(config.interval_ms.into()), + move |secs, status| mcp_startup_line(secs, status, width), + ); + 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 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 +/// 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..6c386ce98 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; @@ -1357,3 +1358,242 @@ 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, + None, + ) + .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, + None, + ) + .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, + None, + )); + + // 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}" + ); +} + +/// 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. +#[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, + )); + + // 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"); + wait_for_frame(&err, "MCP server grizzly…").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}" + ); +} + +#[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" + ); +} + +// 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)); + + 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:?}"); +} + +// 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 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:?}"); +} 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..e686b5fc0 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,18 @@ 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. + /// Set to 0 to wait indefinitely (no timeout). + #[setting(default = 60)] + pub startup_timeout_secs: u32, } impl AssignKeyValue for PartialStdioConfig { @@ -111,6 +127,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 +145,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..7f26d48a7 --- /dev/null +++ b/crates/jp_config/src/style/mcp_startup.rs @@ -0,0 +1,95 @@ +//! 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. + /// + /// 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. + #[setting(default = 4)] + pub delay_secs: u32, + + /// Interval in milliseconds between timer updates. + /// + /// Defaults to `100`. + #[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..1b4ca9928 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,37 @@ 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, + // 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; `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, + 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..028712391 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,94 @@ 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:?}"), + } +} + +// `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. @@ -116,15 +205,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 +234,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};