Skip to main content

mcpls_core/
lib.rs

1//! # mcpls-core
2//!
3//! Core library for MCP (Model Context Protocol) to LSP (Language Server Protocol) translation.
4//!
5//! This crate provides the fundamental building blocks for bridging AI agents with
6//! language servers, enabling semantic code intelligence through MCP tools.
7//!
8//! ## Architecture
9//!
10//! The library is organized into several modules:
11//!
12//! - [`lsp`] - LSP client implementation for communicating with language servers
13//! - [`mcp`] - MCP tool definitions and handlers
14//! - [`bridge`] - Translation layer between MCP and LSP protocols
15//! - [`config`] - Configuration types and loading
16//! - [`mod@error`] - Error types for the library
17//!
18//! ## Example
19//!
20//! ```rust,ignore
21//! use mcpls_core::{serve, serve_with, Transport, ServerConfig};
22//!
23//! #[tokio::main]
24//! async fn main() {
25//!     let config = ServerConfig::load().expect("failed to load config");
26//!     // Stdio (default):
27//!     let result = serve(config).await;
28//!     // HTTP (requires `transport-http` feature):
29//!     // let http = mcpls_core::HttpConfig::new("127.0.0.1:3000".parse().unwrap(), "/mcp");
30//!     // let result = serve_with(config, Transport::Http(http)).await;
31//!
32//!     // See `serve`/`serve_with`'s "Shutdown" docs: process::exit avoids a
33//!     // runtime-shutdown hang under the stdio transport.
34//!     std::process::exit(if result.is_ok() { 0 } else { 1 });
35//! }
36//! ```
37
38pub mod bridge;
39pub mod config;
40pub mod error;
41pub mod lsp;
42pub mod mcp;
43pub mod transport;
44mod util;
45
46use std::collections::{HashMap, HashSet};
47use std::path::{Component, PathBuf};
48use std::sync::Arc;
49use std::time::Duration;
50
51use bridge::resources::make_uri;
52use bridge::{NotificationCache, ResourceSubscriptions, Translator};
53pub use config::{ProjectConfigTrust, ServerConfig};
54use config::{ServerId, ToolRouter};
55pub use error::Error;
56use lsp::{LspNotification, LspServer, ServerInitConfig};
57use lsp_types::Uri;
58use rmcp::model::ResourceUpdatedNotificationParam;
59use tokio::sync::{Mutex, OnceCell};
60use tokio::task::{JoinHandle, JoinSet};
61use tracing::{debug, error, info, warn};
62#[cfg(feature = "transport-http")]
63pub use transport::HttpConfig;
64pub use transport::Transport;
65#[cfg(feature = "transport-http")]
66use transport::run_http;
67use transport::{ShutdownSignal, run_stdio};
68
69/// Whether `uri` falls within one of `workspace_roots`.
70///
71/// Used to reject diagnostics for out-of-workspace URIs before caching them:
72/// a misbehaving or compromised LSP server could otherwise publish
73/// diagnostics for an unbounded number of fabricated (often non-existent)
74/// URIs, defeating `MAX_DIAGNOSTIC_ENTRIES`'s FIFO cap by flushing every
75/// legitimate entry out of the cache before it (see #234). Deliberately does
76/// not canonicalize -- this runs per incoming notification, and LSP servers
77/// report already-resolved canonical paths, so a prefix check is enough to
78/// reject URIs a legitimate server would never publish for, without a
79/// filesystem syscall on every diagnostic.
80///
81/// # Preconditions
82///
83/// `workspace_roots` must itself already be canonical, or every diagnostic
84/// silently fails to match and gets dropped (a raw `[[lsp_servers]]`-derived
85/// or relative root will never `starts_with`-match a canonical LSP path).
86/// `serve_with` guarantees this by passing `workspace_roots_snapshot`, which
87/// is built via [`canonicalize_workspace_roots`] -- see that function's docs.
88///
89/// An empty `workspace_roots` (no workspace configured) allows any URI,
90/// matching `validate_path_against_roots`'s "no roots = no restriction"
91/// behavior.
92fn diagnostic_path_in_workspace(uri: &Uri, workspace_roots: &[PathBuf]) -> bool {
93    if workspace_roots.is_empty() {
94        return true;
95    }
96    let Some(path) = bridge::uri_to_path(uri) else {
97        return false;
98    };
99    // `Path::starts_with` compares components lexically and does not resolve
100    // `.`/`..`, so `/workspace/../etc/passwd` would otherwise pass the
101    // `/workspace` prefix check despite pointing outside it. A legitimate LSP
102    // server never publishes such a path (canonical paths never contain
103    // `.`/`..` components), so rejecting them outright costs nothing and
104    // closes the bypass for a server that deliberately crafts one.
105    if path
106        .components()
107        .any(|c| matches!(c, Component::CurDir | Component::ParentDir))
108    {
109        return false;
110    }
111    workspace_roots.iter().any(|root| path.starts_with(root))
112}
113
114/// `Arc`-backed state shared by every `diagnostics_pump` task spawned for one
115/// `serve_with` run, factored out of `diagnostics_pump`'s parameter list to
116/// keep it under clippy's argument-count lint. `Clone` is cheap (`Arc`
117/// clones only).
118#[derive(Clone)]
119pub(crate) struct PumpShared {
120    pub(crate) notification_cache: Arc<Mutex<NotificationCache>>,
121    pub(crate) subs: Arc<ResourceSubscriptions>,
122    pub(crate) peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
123    /// Used to reject diagnostics for out-of-workspace URIs; see
124    /// `diagnostic_path_in_workspace`.
125    pub(crate) workspace_roots: Arc<[PathBuf]>,
126}
127
128/// Background task that drains LSP notifications, writes them to the cache,
129/// and forwards `resources/updated` to the MCP peer when subscribed.
130///
131/// The task operates in two phases without explicit state:
132/// - **Phase A** (before peer is set): caches every notification, skips peer notify.
133/// - **Phase B** (after peer is set): additionally fires `notify_resource_updated`
134///   for subscribed `PublishDiagnostics` URIs.
135///
136/// The task exits when:
137/// - The LSP notification channel closes (`rx.recv()` returns `None`).
138/// - The cancellation watch fires (or the sender is dropped).
139/// - `notify_resource_updated` returns an error (peer disconnect / transport closed).
140///
141/// # Lock independence
142/// Cache writes acquire only `Arc<Mutex<NotificationCache>>`, a lock entirely
143/// separate from `translator`'s own internal locks (`Arc<Translator>` has no
144/// outer mutex; each field manages its own short-lived, independent lock).
145/// Neither an in-flight LSP round-trip (e.g. `textDocument/diagnostic`) nor
146/// any other translator-side work holds the notification-cache lock, so this
147/// pump is never blocked by tool-call activity: a `publishDiagnostics`
148/// notification arriving mid-request is cached immediately instead of being
149/// silently dropped. This matters because the LSP transport forwards
150/// notifications via `mpsc::Sender::try_send`, which drops on a full channel
151/// rather than blocking — a pump stalled behind someone else's lock would
152/// previously lose notifications under sustained push traffic.
153pub(crate) async fn diagnostics_pump(
154    server_id: ServerId,
155    mut rx: tokio::sync::mpsc::Receiver<LspNotification>,
156    mut cancel_rx: tokio::sync::watch::Receiver<bool>,
157    caches_diagnostics: bool,
158    shared: PumpShared,
159) {
160    let PumpShared {
161        notification_cache,
162        subs,
163        peer_cell,
164        workspace_roots,
165    } = shared;
166    loop {
167        tokio::select! {
168            // Exit when cancellation is requested or the sender is dropped.
169            result = cancel_rx.changed() => {
170                // Err means the sender was dropped; treat as cancellation.
171                if result.is_err() || *cancel_rx.borrow() {
172                    break;
173                }
174            }
175            msg = rx.recv() => {
176                let Some(notif) = msg else { break };
177                match notif {
178                    LspNotification::PublishDiagnostics(p) => {
179                        // Only the server the router resolves `Diagnostics` to for
180                        // this notification's language caches (and notifies
181                        // subscribers of) it -- see #174 §8. A server that was
182                        // never the diagnostics route, or lost it without a live
183                        // catch-all to rebind to, is not the authoritative source
184                        // for this language's diagnostics; skip publishing so it
185                        // doesn't overwrite (or spuriously notify about) another
186                        // server's cache entry.
187                        if !caches_diagnostics {
188                            continue;
189                        }
190                        if !diagnostic_path_in_workspace(&p.uri, &workspace_roots) {
191                            debug!(
192                                "dropping diagnostics for out-of-workspace URI: {}",
193                                p.uri.as_str()
194                            );
195                            continue;
196                        }
197                        {
198                            let mut cache = notification_cache.lock().await;
199                            cache.store_diagnostics(&server_id, &p.uri, p.version, p.diagnostics);
200                        }
201
202                        // Fast path: skip URI construction when nothing is subscribed.
203                        if subs.is_empty().await {
204                            continue;
205                        }
206
207                        // Notify only when peer is ready and URI is subscribed.
208                        let Some(peer) = peer_cell.get() else { continue };
209                        let Some(path) = bridge::uri_to_path(&p.uri) else { continue };
210                        let Ok(mcp_uri) = make_uri(&path) else { continue };
211
212                        if !subs.contains(&mcp_uri).await {
213                            continue;
214                        }
215
216                        if peer
217                            .notify_resource_updated(ResourceUpdatedNotificationParam::new(
218                                mcp_uri,
219                            ))
220                            .await
221                            .is_err()
222                        {
223                            // Peer disconnected; stop the pump.
224                            break;
225                        }
226                    }
227                    LspNotification::LogMessage(m) => {
228                        let mut cache = notification_cache.lock().await;
229                        cache.store_log(m.typ.into(), m.message);
230                    }
231                    LspNotification::ShowMessage(m) => {
232                        let mut cache = notification_cache.lock().await;
233                        cache.store_message(m.typ.into(), m.message);
234                    }
235                    LspNotification::Progress { .. } | LspNotification::Other { .. } => {}
236                }
237            }
238        }
239    }
240}
241
242/// Result of [`register_servers`]: everything the caller needs to start the
243/// per-server diagnostics pump tasks.
244pub(crate) struct RegisteredServers {
245    /// Notification receivers extracted from each server before registration.
246    pub(crate) receivers: HashMap<ServerId, tokio::sync::mpsc::Receiver<lsp::LspNotification>>,
247    /// Whether each server is the one the (rebound) router resolves
248    /// `ToolKind::Diagnostics` to for its language -- see #174 §8. Computed
249    /// here, right after the rebind, so it always reflects the post-rebind
250    /// router rather than a stale pre-rebind view.
251    pub(crate) diagnostics_flags: HashMap<ServerId, bool>,
252}
253
254/// Register initialized LSP servers with the translator, rebind the router to
255/// the set that actually registered, and extract notification receivers.
256///
257/// Takes ownership of the `ServerInitResult`, extracts `notification_rx` from
258/// each server before registration. Registration itself is a sequence of
259/// short, independently-locked map inserts (see `Translator`'s field docs),
260/// so no external synchronization is required here; the rebind that follows
261/// relies only on all of *this* function's inserts having completed, which
262/// the sequential code below guarantees.
263///
264/// `configs` supplies the `ServerInitConfig` each surviving server was
265/// spawned from, keyed by routing identity, so the translator can respawn it
266/// later if its process dies (see `Translator::respawn_if_dead`).
267pub(crate) fn register_servers(
268    mut result: lsp::ServerInitResult,
269    translator: &bridge::Translator,
270    configs: &HashMap<ServerId, ServerInitConfig>,
271) -> RegisteredServers {
272    let mut receivers = HashMap::new();
273    for (id, server) in &mut result.servers {
274        receivers.insert(id.clone(), server.take_notification_rx());
275    }
276
277    let registered: HashSet<ServerId> = result.servers.keys().cloned().collect();
278
279    let mut language_by_id = HashMap::new();
280    for (id, server) in result.servers {
281        let client = server.client().clone();
282        language_by_id.insert(id.clone(), client.language_id().to_string());
283        translator.register_client(id.clone(), client);
284        if let Some(config) = configs.get(&id) {
285            translator.register_server_config(id.clone(), config.clone());
286        } else {
287            // Would silently turn auto-respawn into a no-op for this server
288            // (surfacing as `Error::ServerUnavailable` instead of actually
289            // recovering) -- the keys are derived identically on both sides
290            // (`LspServerConfig::id()`), so this should never happen; warn
291            // rather than fail, since the server is otherwise usable.
292            warn!(
293                "No respawn config registered for LSP server '{id}'; auto-respawn on crash will be unavailable for it"
294            );
295        }
296        translator.register_server(id, server);
297    }
298
299    translator.rebind_router(&registered);
300
301    let diagnostics_flags = language_by_id
302        .into_iter()
303        .map(|(id, language)| {
304            let is_diagnostics_server = translator.is_diagnostics_route(&language, &id);
305            (id, is_diagnostics_server)
306        })
307        .collect();
308
309    RegisteredServers {
310        receivers,
311        diagnostics_flags,
312    }
313}
314
315/// Resolve workspace roots from config or current directory.
316///
317/// If no workspace roots are provided in the configuration, this function
318/// will use the current working directory, canonicalized for security.
319///
320/// # Returns
321///
322/// A vector of workspace root paths. If config roots are provided, they are
323/// returned as-is. Otherwise, returns the canonicalized current directory,
324/// falling back to relative "." if canonicalization fails.
325fn resolve_workspace_roots(config_roots: &[PathBuf]) -> Vec<PathBuf> {
326    if config_roots.is_empty() {
327        match std::env::current_dir() {
328            Ok(cwd) => {
329                // current_dir() always returns an absolute path
330                match cwd.canonicalize() {
331                    Ok(canonical) => {
332                        info!(
333                            "Using current directory as workspace root: {}",
334                            canonical.display()
335                        );
336                        vec![canonical]
337                    }
338                    Err(e) => {
339                        // Canonicalization can fail if directory was deleted or permissions changed
340                        // but cwd itself is still absolute
341                        warn!(
342                            "Failed to canonicalize current directory: {e}, using non-canonical path"
343                        );
344                        vec![cwd]
345                    }
346                }
347            }
348            Err(e) => {
349                // This is extremely rare - only happens if cwd was deleted or unlinked
350                // In this case, we have no choice but to use a relative path
351                warn!("Failed to get current directory: {e}, using fallback");
352                vec![PathBuf::from(".")]
353            }
354        }
355    } else {
356        config_roots.to_vec()
357    }
358}
359
360/// Canonicalize each workspace root, falling back to the original path for
361/// any root that fails to canonicalize (e.g. deleted after startup).
362///
363/// `resolve_workspace_roots` returns config-provided roots unmodified
364/// (relative paths, symlinks kept as-is); this normalizes them so a plain
365/// prefix comparison against an already-resolved LSP path (see
366/// `diagnostic_path_in_workspace`) works without a filesystem syscall on
367/// that hot per-notification path.
368///
369/// Uses [`dunce::canonicalize`] rather than [`Path::canonicalize`]: on
370/// Windows, the latter returns the `\\?\`-prefixed verbatim form (e.g.
371/// `\\?\C:\...`), which a URI-derived path from `Url::to_file_path` (never
372/// verbatim-prefixed) can never `starts_with`-match, silently dropping every
373/// diagnostic. `dunce::canonicalize` resolves symlinks identically but
374/// returns the ordinary `C:\...` form when the result doesn't require the
375/// verbatim syntax (i.e. essentially always, for realistic workspace paths).
376fn canonicalize_workspace_roots(roots: &[PathBuf]) -> Vec<PathBuf> {
377    roots
378        .iter()
379        .map(|root| dunce::canonicalize(root).unwrap_or_else(|_| root.clone()))
380        .collect()
381}
382
383/// Start the MCPLS server with the given configuration over stdio.
384///
385/// This is the backward-compatible entry point. It is equivalent to calling
386/// `serve_with(config, Transport::Stdio)`.
387///
388/// # Errors
389///
390/// Returns an error if:
391/// - All LSP servers fail to initialize
392/// - MCP server setup fails
393/// - Configuration is invalid
394///
395/// # Graceful Degradation
396///
397/// - **All servers succeed**: Service runs normally
398/// - **Partial success**: Logs warnings for failures, continues with available servers
399/// - **All servers fail**: Returns `Error::AllServersFailedToInit` with details
400///
401/// # Shutdown
402///
403/// See [`serve_with`]'s "Shutdown" section — this function uses
404/// [`Transport::Stdio`], so the same `std::process::exit` requirement
405/// applies to callers.
406pub async fn serve(config: ServerConfig) -> Result<(), Error> {
407    serve_with(config, Transport::Stdio).await
408}
409
410/// Start the MCPLS server with an explicit transport.
411///
412/// Performs all shared setup (workspace discovery, LSP spawning, translator
413/// initialization, diagnostic pump tasks) and then delegates to the
414/// appropriate transport runner.
415///
416/// # Errors
417///
418/// Returns an error if:
419/// - All LSP servers fail to initialize
420/// - The MCP server or transport fails to start
421/// - Configuration is invalid, including two applicable `[[lsp_servers]]`
422///   entries whose per-tool routing is ambiguous in this workspace (shared
423///   routing identity, two catch-alls, or the same tool claimed by both) --
424///   see `config::ToolRouter::from_configs`
425///
426/// # DNS rebinding protection (HTTP transport)
427///
428/// When using `Transport::Http`, the underlying rmcp service validates the
429/// inbound `Host` header against an allowlist that defaults to loopback
430/// addresses only (`localhost`, `127.0.0.1`, `::1`). Requests with any other
431/// `Host` value are rejected with `421 Misdirected Request`.
432///
433/// If you bind to a non-loopback address (e.g. `0.0.0.0:3000`) and expose the
434/// service through a reverse proxy, the proxy must forward `Host: localhost`
435/// (or another loopback alias) to the mcpls process. Direct non-loopback
436/// access is intentionally blocked to prevent DNS-rebinding attacks.
437///
438/// # Shutdown
439///
440/// [`Transport::Stdio`] is backed by `tokio::io::stdin()`, which internally
441/// parks an uncancellable blocking-pool thread in a raw `read()` syscall
442/// that only returns on more input or EOF. If your `main` uses
443/// `#[tokio::main]` and simply returns after awaiting this function, the
444/// macro-generated runtime-shutdown wrapper blocks waiting for that thread
445/// -- hanging indefinitely on `SIGTERM`/`SIGINT` as long as the MCP
446/// client's stdin write end is still open, since that never triggers EOF.
447/// Call `std::process::exit` right after this function resolves instead of
448/// returning normally from `main`, as in the example below (see mcpls's own
449/// `mcpls-cli` binary; tracked as #308). This does not apply to
450/// [`Transport::Http`], which never touches `tokio::io::stdin()`.
451///
452/// # Examples
453///
454/// ```rust,ignore
455/// use mcpls_core::{serve_with, Transport, ServerConfig};
456///
457/// #[tokio::main]
458/// async fn main() {
459///     let config = ServerConfig::load().expect("failed to load config");
460///     let exit_code = match serve_with(config, Transport::Stdio).await {
461///         Ok(()) => 0,
462///         Err(_) => 1,
463///     };
464///     // See "Shutdown" above: process::exit avoids a runtime-shutdown hang.
465///     std::process::exit(exit_code);
466/// }
467/// ```
468pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
469    info!("Starting MCPLS server...");
470
471    // Registered before any other startup work -- including
472    // `spawn_lsp_servers_background` below, which spawns LSP child processes
473    // concurrently on another worker thread -- so a `SIGTERM`/`SIGINT`
474    // arriving during config validation, workspace-root heuristics, or LSP
475    // spawning is caught rather than hitting the OS's default disposition
476    // (immediate termination, orphaning any LSP child mid-spawn; see #270)
477    // and skipping the `shutdown()` cleanup below entirely. See
478    // `ShutdownSignal`'s docs for why this must be a single instance carried
479    // through by value rather than re-registered later.
480    let shutdown_signal = ShutdownSignal::new();
481
482    // `ServerConfig::load`/`load_from` already validate the TOML-loading
483    // path; this covers the other one -- a caller building `ServerConfig`
484    // programmatically (e.g. a library embedder) previously hit no
485    // diagnosable error here, only silent clamping at accessor level (e.g.
486    // `LspClient::request_timeout`). `serve` delegates to this function, so
487    // one call site here covers both public entry points (`serve` and
488    // `serve_with`); note this does mean a config loaded via the CLI's
489    // `load_from` -> `serve` path is validated twice (harmless -- `validate`
490    // is a pure check with no side effects beyond a `tracing::warn!` for a
491    // non-fatal duplicate-name case, which will simply log twice).
492    //
493    // Considered wrapping this in a `Validated<ServerConfig>` marker type to
494    // make "already validated" a compile-time guarantee instead of a runtime
495    // check here; rejected as unnecessary ceremony for a pre-1.0 API (#282).
496    config.validate()?;
497
498    let project_config_ignored = config.project_config_ignored;
499    let workspace_roots = resolve_workspace_roots(&config.workspace.roots);
500    let extension_map = config.build_effective_extension_map();
501    let max_depth = Some(config.workspace.heuristics_max_depth);
502
503    let applicable_configs: Vec<ServerInitConfig> = config
504        .lsp_servers
505        .iter()
506        .filter_map(|lsp_config| {
507            let should_spawn = workspace_roots
508                .iter()
509                .any(|root| lsp_config.should_spawn(root, max_depth));
510
511            if !should_spawn {
512                info!(
513                    "Skipping LSP server '{}' ({}): no project markers found",
514                    lsp_config.language_id, lsp_config.command
515                );
516                return None;
517            }
518
519            Some(ServerInitConfig {
520                server_config: lsp_config.clone(),
521                workspace_roots: workspace_roots.clone(),
522                initialization_options: lsp_config.initialization_options.clone(),
523                position_encodings: config.workspace.position_encodings.clone(),
524                notification_tx: None,
525            })
526        })
527        .collect();
528
529    info!(
530        "Attempting to spawn {} applicable LSP server(s)...",
531        applicable_configs.len()
532    );
533
534    // Built over the applicable (post-heuristics) configs only: this is where
535    // #174's workspace-scoped routing rules (duplicate ServerId, conflicting
536    // `handles` claims) are enforced -- a startup error naming the
537    // conflicting `[[lsp_servers]]` entries, not a silent drop.
538    let router = ToolRouter::from_configs(applicable_configs.iter().map(|c| &c.server_config))?;
539
540    // Built here (rather than alongside `subscriptions`/`peer_cell` below) so
541    // it can be handed to the translator, which uses it to invalidate a
542    // respawned server's stale cached diagnostics -- see
543    // `Translator::with_notification_cache`. Independent of `translator`
544    // itself, which holds no outer lock: the pump only ever locks this
545    // cache, so it never contends with a request handler running an
546    // in-flight LSP round-trip.
547    let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
548
549    let mut translator = Translator::new()
550        .with_resource_limits(config.workspace.resource_limits())
551        .with_extensions(extension_map)
552        .with_router(router)
553        .with_notification_cache(Arc::clone(&notification_cache));
554    translator.set_workspace_roots(workspace_roots.clone());
555
556    // Mark applicable servers as "expected" so a tool call that arrives while
557    // its server is still initializing gets a clear "still initializing" error
558    // (instead of "no server configured"), telling the caller to wait and retry.
559    let expected_servers: HashSet<ServerId> = applicable_configs
560        .iter()
561        .map(|c| c.server_config.id())
562        .collect();
563    translator.set_expected_servers(expected_servers);
564
565    // Shared state, built BEFORE LSP initialization so the MCP server can answer
566    // `initialize` immediately. LSP servers (which can take minutes to initialize
567    // on a large solution, e.g. a 130-project Unity .sln via OmniSharp) are spawned
568    // in a background task and registered into this shared translator once ready.
569    // Blocking the MCP handshake on LSP init makes slow servers exceed the client's
570    // initialize-request timeout (Claude Code: ~60s) -> "Request timed out".
571    // Fixed for the server's lifetime: shared as a lock-free snapshot so
572    // cache-only handlers (e.g. `get_cached_diagnostics`, `read_resource`) can
573    // validate a path without locking `translator` below.
574    //
575    // Canonicalized once here rather than left as-is: `resolve_workspace_roots`
576    // returns config-provided roots unmodified (relative paths, symlinks kept),
577    // but `diagnostic_path_in_workspace` (fed by this snapshot) does a plain
578    // prefix check with no filesystem I/O on its hot per-notification path --
579    // that only matches correctly if both sides are already in the same
580    // (canonical) form, and LSP servers report already-resolved canonical
581    // paths. Comparing an un-canonicalized root against a canonical path would
582    // silently drop every diagnostic for a workspace configured with a
583    // relative or symlinked root. Falls back to the original root if
584    // canonicalization fails (e.g. deleted between startup and this point);
585    // `validate_path_against_roots`'s own per-call canonicalize is unaffected
586    // either way, since canonicalizing an already-canonical path is a no-op.
587    let workspace_roots_snapshot: Arc<[PathBuf]> =
588        Arc::from(canonicalize_workspace_roots(&workspace_roots));
589
590    let translator = Arc::new(translator);
591    let subscriptions = Arc::new(ResourceSubscriptions::new());
592    // Peer cell is populated after the MCP transport is established (Phase B).
593    let peer_cell = Arc::new(OnceCell::new());
594
595    // Cancellation for pump tasks: send `true` to request shutdown.
596    let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
597
598    let lsp_init_handle = if applicable_configs.is_empty() {
599        warn!("No applicable LSP servers configured — starting in protocol-only mode");
600        None
601    } else {
602        info!(
603            "Spawning {} LSP server(s) in the background...",
604            applicable_configs.len()
605        );
606        Some(spawn_lsp_servers_background(
607            applicable_configs,
608            Arc::clone(&translator),
609            Arc::clone(&notification_cache),
610            Arc::clone(&subscriptions),
611            Arc::clone(&peer_cell),
612            cancel_rx.clone(),
613            Arc::clone(&workspace_roots_snapshot),
614        ))
615    };
616
617    info!("Starting MCP server with rmcp...");
618    let mcp_server = mcp::McplsServer::new(
619        Arc::clone(&translator),
620        Arc::clone(&notification_cache),
621        Arc::clone(&workspace_roots_snapshot),
622        Arc::clone(&subscriptions),
623        project_config_ignored,
624    );
625    info!("MCPLS server initialized successfully");
626
627    let result = match transport {
628        Transport::Stdio => {
629            info!("Listening for MCP requests on stdio...");
630            run_stdio(mcp_server, &peer_cell, shutdown_signal).await
631        }
632        #[cfg(feature = "transport-http")]
633        Transport::Http(cfg) => run_http(mcp_server, cfg, shutdown_signal).await,
634    };
635
636    shutdown(&cancel_tx, &translator, lsp_init_handle).await;
637
638    info!("MCPLS server shutting down");
639    result
640}
641
642/// Bounds how long [`shutdown`] waits for the background LSP init task
643/// (see [`spawn_lsp_servers_background`]) to finish after cancellation is
644/// signaled. Deliberately shorter than [`Translator`]'s own per-server
645/// shutdown timeout: by the time `shutdown_servers` returns, every
646/// registered server's notification channel has closed, so the init task's
647/// diagnostics pumps should already be draining. This bound only matters
648/// for the rarer case where the init task is still mid-`initialize` (never
649/// registered anything for `shutdown_servers` to act on).
650const LSP_INIT_TASK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
651
652/// Awaits the background LSP init task's `JoinHandle` with a bounded
653/// `timeout`, logging a panic at `error` level (previously dropped
654/// silently, see #196) or an unresponsive task at `warn` level instead of
655/// letting either go unnoticed.
656///
657/// `timeout` is a parameter (rather than always
658/// [`LSP_INIT_TASK_SHUTDOWN_TIMEOUT`]) so tests can exercise the timeout
659/// branch without waiting out the real bound. Awaits `handle` by `&mut`
660/// (not by value): dropping an *owned* `JoinHandle` on timeout would only
661/// detach the task — it keeps running rather than stopping, contradicting
662/// the warning logged below. Retaining ownership lets `abort()` make that
663/// message true.
664///
665/// `abort()` only *requests* cancellation; the task's locals (which may own
666/// not-yet-registered `tokio::process::Child` handles for LSP servers
667/// [`spawn_lsp_servers_background`] is still spawning via `spawn_batch`,
668/// relying entirely on `kill_on_drop` to terminate them) are only actually
669/// dropped once the runtime polls the task to completion. `mcpls-cli`'s
670/// `main` calls `std::process::exit` right after `serve_with` returns (see
671/// #308), which skips the executor's own task teardown that used to do this
672/// polling implicitly — so this function awaits the aborted handle again,
673/// bounded, to drive that drop here instead of leaving it to chance.
674/// Otherwise a `SIGTERM` arriving mid-`spawn_batch` could orphan those LSP
675/// child processes, the exact failure mode #270 was filed to prevent.
676async fn await_lsp_init_handle(mut handle: JoinHandle<()>, timeout: Duration) {
677    match tokio::time::timeout(timeout, &mut handle).await {
678        Ok(Ok(())) => {}
679        Ok(Err(err)) => error!("Background LSP initialization task failed: {err}"),
680        Err(_) => {
681            warn!("Timed out waiting for background LSP initialization task to stop");
682            handle.abort();
683            let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
684        }
685    }
686}
687
688/// Post-transport shutdown sequence, run once the transport future
689/// (`run_stdio`/`run_http`) returns — whether that's because of a
690/// `SIGTERM`/`SIGINT`, stdio EOF, or (for HTTP) its own graceful shutdown.
691///
692/// Signals background pump tasks to exit, then gracefully shuts down every
693/// LSP server registered on `translator` (see
694/// [`Translator::shutdown_servers`] for what "gracefully" bounds and falls
695/// back to). Finally, if the background LSP init task (see
696/// [`spawn_lsp_servers_background`]) is still running, awaits it via
697/// [`await_lsp_init_handle`], giving its diagnostics pump tasks a chance to
698/// finish draining before `serve_with` returns. Extracted from
699/// [`serve_with`] so this sequence is exercised directly in tests without
700/// needing a full stdio/HTTP transport round trip.
701async fn shutdown(
702    cancel_tx: &tokio::sync::watch::Sender<bool>,
703    translator: &Translator,
704    lsp_init_handle: Option<JoinHandle<()>>,
705) {
706    let _ = cancel_tx.send(true);
707
708    info!("Shutting down LSP servers...");
709    translator.shutdown_servers().await;
710
711    if let Some(handle) = lsp_init_handle {
712        await_lsp_init_handle(handle, LSP_INIT_TASK_SHUTDOWN_TIMEOUT).await;
713    }
714}
715
716/// Spawn the applicable LSP servers in a background task and register them into
717/// the shared `translator` once ready.
718///
719/// This intentionally does NOT block the caller: `serve_with` starts the MCP
720/// server immediately so its `initialize` handshake returns before slow language
721/// servers (e.g. `OmniSharp` on a large Unity solution, which can take minutes to
722/// load) finish initializing. Tool calls that arrive before a server has
723/// registered return a `ServerInitializing` error telling the caller to wait and
724/// retry. If every server fails, the "expected servers" set is cleared so those
725/// calls fall back to a plain "no server configured" error instead.
726///
727/// Returns the task's `JoinHandle` so [`shutdown`] can await it: previously
728/// this handle was dropped, silently swallowing panics from
729/// `LspServer::spawn_batch`, `register_servers`, or a diagnostics pump task
730/// (see #196).
731fn spawn_lsp_servers_background(
732    applicable_configs: Vec<ServerInitConfig>,
733    translator: Arc<Translator>,
734    notification_cache: Arc<Mutex<NotificationCache>>,
735    subscriptions: Arc<ResourceSubscriptions>,
736    peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
737    cancel_rx: tokio::sync::watch::Receiver<bool>,
738    workspace_roots: Arc<[PathBuf]>,
739) -> JoinHandle<()> {
740    tokio::spawn(async move {
741        let configs_by_id: HashMap<ServerId, ServerInitConfig> = applicable_configs
742            .iter()
743            .map(|c| (c.server_config.id(), c.clone()))
744            .collect();
745        let result = LspServer::spawn_batch(&applicable_configs).await;
746
747        if result.all_failed() {
748            error!(
749                "All {} configured LSP server(s) failed to initialize",
750                result.failure_count()
751            );
752            for failure in &result.failures {
753                error!("Server initialization failed: {}", failure);
754            }
755            // No server will register: rebind against an empty registered
756            // set so every route drops (one rule, no special case -- see
757            // `ToolRouter::rebind_to_registered`), then stop reporting
758            // "still initializing". This path returns before
759            // `register_servers` ever runs, so it needs its own rebind call;
760            // skipping it would leave every route pointed at a dead server.
761            translator.rebind_router(&HashSet::new());
762            translator.clear_expected_servers();
763            return;
764        }
765
766        if result.partial_success() {
767            warn!(
768                "Partial server initialization: {} succeeded, {} failed",
769                result.server_count(),
770                result.failure_count()
771            );
772            for failure in &result.failures {
773                error!("Server initialization failed: {}", failure);
774            }
775        }
776
777        let server_count = result.server_count();
778        let registered = register_servers(result, &translator, &configs_by_id);
779        // Background initialization has completed; stop reporting "still
780        // initializing" (especially for servers that failed to spawn on
781        // partial success, which would otherwise return ServerInitializing
782        // forever instead of NoServerForLanguage/Tool).
783        translator.clear_expected_servers();
784        info!("Proceeding with {} LSP server(s)", server_count);
785
786        // Give each diagnostics-route server a fair share of the shared
787        // diagnostics cache budget now that the full set is known -- see
788        // `NotificationCache::set_diagnostics_route_count` (#266).
789        let diagnostics_route_count = registered
790            .diagnostics_flags
791            .values()
792            .filter(|&&is_route| is_route)
793            .count();
794        notification_cache
795            .lock()
796            .await
797            .set_diagnostics_route_count(diagnostics_route_count);
798
799        // Start diagnostics pump tasks now that servers are registered.
800        let pump_shared = PumpShared {
801            notification_cache,
802            subs: subscriptions,
803            peer_cell,
804            workspace_roots,
805        };
806        let mut pumps: JoinSet<()> = JoinSet::new();
807        for (id, rx) in registered.receivers {
808            let caches_diagnostics = registered
809                .diagnostics_flags
810                .get(&id)
811                .copied()
812                .unwrap_or(false);
813            pumps.spawn(diagnostics_pump(
814                id,
815                rx,
816                cancel_rx.clone(),
817                caches_diagnostics,
818                pump_shared.clone(),
819            ));
820        }
821        while pumps.join_next().await.is_some() {}
822    })
823}
824
825#[cfg(test)]
826#[allow(clippy::unwrap_used)]
827mod tests {
828    use bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE};
829
830    use super::*;
831
832    #[test]
833    fn test_diagnostic_path_in_workspace_empty_roots_allows_any_uri() {
834        let uri: Uri = "file:///anywhere/at/all.rs".parse().unwrap();
835        assert!(diagnostic_path_in_workspace(&uri, &[]));
836    }
837
838    #[test]
839    fn test_diagnostic_path_in_workspace_accepts_uri_under_root() {
840        // `Url::to_file_path` on Windows requires the URL's first path
841        // segment to be a drive letter; a Unix-style path with no drive
842        // letter fails to convert at all (`uri_to_path` returns `None`),
843        // trivially satisfying this assertion for the wrong reason. Use a
844        // drive-letter path so the test actually exercises the prefix check
845        // on every platform.
846        #[cfg(windows)]
847        let (root, uri_str) = (
848            PathBuf::from(r"C:\workspace\project"),
849            "file:///C:/workspace/project/src/main.rs",
850        );
851        #[cfg(not(windows))]
852        let (root, uri_str) = (
853            PathBuf::from("/workspace/project"),
854            "file:///workspace/project/src/main.rs",
855        );
856        let uri: Uri = uri_str.parse().unwrap();
857        assert!(diagnostic_path_in_workspace(&uri, &[root]));
858    }
859
860    #[test]
861    fn test_diagnostic_path_in_workspace_rejects_uri_outside_roots() {
862        #[cfg(windows)]
863        let (root, uri_str) = (
864            PathBuf::from(r"C:\workspace\project"),
865            "file:///C:/etc/passwd",
866        );
867        #[cfg(not(windows))]
868        let (root, uri_str) = (PathBuf::from("/workspace/project"), "file:///etc/passwd");
869        let uri: Uri = uri_str.parse().unwrap();
870        assert!(!diagnostic_path_in_workspace(&uri, &[root]));
871    }
872
873    #[test]
874    fn test_diagnostic_path_in_workspace_rejects_non_file_uri() {
875        let root = PathBuf::from("/workspace/project");
876        let uri: Uri = "untitled:Untitled-1".parse().unwrap();
877        assert!(!diagnostic_path_in_workspace(&uri, &[root]));
878    }
879
880    /// `Path::starts_with` is a lexical, component-wise comparison that does
881    /// not resolve `.`/`..` — without an explicit check, a URI like
882    /// `file:///workspace/project/../../etc/passwd` would lexically "start
883    /// with" `/workspace/project` despite pointing outside it.
884    #[test]
885    fn test_diagnostic_path_in_workspace_rejects_parent_dir_traversal() {
886        #[cfg(windows)]
887        let (root, uri_str) = (
888            PathBuf::from(r"C:\workspace\project"),
889            "file:///C:/workspace/project/../../etc/passwd",
890        );
891        #[cfg(not(windows))]
892        let (root, uri_str) = (
893            PathBuf::from("/workspace/project"),
894            "file:///workspace/project/../../etc/passwd",
895        );
896        let uri: Uri = uri_str.parse().unwrap();
897        assert!(!diagnostic_path_in_workspace(&uri, &[root]));
898    }
899
900    #[test]
901    fn test_canonicalize_workspace_roots_falls_back_on_nonexistent_path() {
902        let missing = PathBuf::from("/definitely/does/not/exist/anywhere");
903        let result = canonicalize_workspace_roots(std::slice::from_ref(&missing));
904        assert_eq!(result, vec![missing]);
905    }
906
907    /// #234 round-3 regression: a symlinked workspace root must canonicalize
908    /// to its real path, matching what LSP servers report in diagnostics --
909    /// otherwise `diagnostic_path_in_workspace`'s uncanonicalized prefix check
910    /// would silently drop every diagnostic for that workspace.
911    #[test]
912    #[cfg(unix)]
913    fn test_canonicalize_workspace_roots_resolves_symlink() {
914        use std::os::unix::fs::symlink;
915
916        use tempfile::TempDir;
917
918        let temp_dir = TempDir::new().unwrap();
919        let base = temp_dir.path().canonicalize().unwrap();
920        let real_dir = base.join("real");
921        std::fs::create_dir(&real_dir).unwrap();
922        let link_dir = base.join("link");
923        symlink(&real_dir, &link_dir).unwrap();
924
925        let result = canonicalize_workspace_roots(&[link_dir]);
926        assert_eq!(result, vec![real_dir]);
927    }
928
929    #[test]
930    fn test_resolve_workspace_roots_empty_config() {
931        let roots = resolve_workspace_roots(&[]);
932        assert_eq!(roots.len(), 1);
933        assert!(
934            roots[0].is_absolute(),
935            "Workspace root should be absolute path"
936        );
937    }
938
939    #[test]
940    fn test_resolve_workspace_roots_with_config() {
941        let config_roots = vec![PathBuf::from("/test/root")];
942        let roots = resolve_workspace_roots(&config_roots);
943        assert_eq!(roots, config_roots);
944    }
945
946    #[test]
947    fn test_resolve_workspace_roots_multiple_paths() {
948        let config_roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
949        let roots = resolve_workspace_roots(&config_roots);
950        assert_eq!(roots, config_roots);
951        assert_eq!(roots.len(), 2);
952    }
953
954    #[test]
955    fn test_resolve_workspace_roots_preserves_order() {
956        let config_roots = vec![
957            PathBuf::from("/workspace/alpha"),
958            PathBuf::from("/workspace/beta"),
959            PathBuf::from("/workspace/gamma"),
960        ];
961        let roots = resolve_workspace_roots(&config_roots);
962        assert_eq!(roots[0], PathBuf::from("/workspace/alpha"));
963        assert_eq!(roots[1], PathBuf::from("/workspace/beta"));
964        assert_eq!(roots[2], PathBuf::from("/workspace/gamma"));
965    }
966
967    #[test]
968    fn test_resolve_workspace_roots_single_path() {
969        let config_roots = vec![PathBuf::from("/single/workspace")];
970        let roots = resolve_workspace_roots(&config_roots);
971        assert_eq!(roots.len(), 1);
972        assert_eq!(roots[0], PathBuf::from("/single/workspace"));
973    }
974
975    #[test]
976    fn test_resolve_workspace_roots_empty_returns_cwd() {
977        let roots = resolve_workspace_roots(&[]);
978        assert!(
979            !roots.is_empty(),
980            "Should return at least one workspace root"
981        );
982    }
983
984    #[test]
985    fn test_resolve_workspace_roots_relative_paths() {
986        let config_roots = vec![
987            PathBuf::from("relative/path1"),
988            PathBuf::from("relative/path2"),
989        ];
990        let roots = resolve_workspace_roots(&config_roots);
991        assert_eq!(roots.len(), 2);
992        assert_eq!(roots[0], PathBuf::from("relative/path1"));
993        assert_eq!(roots[1], PathBuf::from("relative/path2"));
994    }
995
996    #[test]
997    fn test_resolve_workspace_roots_mixed_paths() {
998        let config_roots = vec![
999            PathBuf::from("/absolute/path"),
1000            PathBuf::from("relative/path"),
1001        ];
1002        let roots = resolve_workspace_roots(&config_roots);
1003        assert_eq!(roots.len(), 2);
1004        assert_eq!(roots[0], PathBuf::from("/absolute/path"));
1005        assert_eq!(roots[1], PathBuf::from("relative/path"));
1006    }
1007
1008    #[test]
1009    fn test_resolve_workspace_roots_with_dot_path() {
1010        let config_roots = vec![PathBuf::from(".")];
1011        let roots = resolve_workspace_roots(&config_roots);
1012        assert_eq!(roots, config_roots);
1013    }
1014
1015    #[test]
1016    fn test_resolve_workspace_roots_with_parent_path() {
1017        let config_roots = vec![PathBuf::from("..")];
1018        let roots = resolve_workspace_roots(&config_roots);
1019        assert_eq!(roots.len(), 1);
1020        assert_eq!(roots[0], PathBuf::from(".."));
1021    }
1022
1023    #[test]
1024    fn test_resolve_workspace_roots_unicode_paths() {
1025        let config_roots = vec![
1026            PathBuf::from("/workspace/テスト"),
1027            PathBuf::from("/workspace/тест"),
1028        ];
1029        let roots = resolve_workspace_roots(&config_roots);
1030        assert_eq!(roots.len(), 2);
1031        assert_eq!(roots[0], PathBuf::from("/workspace/テスト"));
1032        assert_eq!(roots[1], PathBuf::from("/workspace/тест"));
1033    }
1034
1035    #[test]
1036    fn test_resolve_workspace_roots_spaces_in_paths() {
1037        let config_roots = vec![
1038            PathBuf::from("/workspace/path with spaces"),
1039            PathBuf::from("/another path/workspace"),
1040        ];
1041        let roots = resolve_workspace_roots(&config_roots);
1042        assert_eq!(roots.len(), 2);
1043        assert_eq!(roots[0], PathBuf::from("/workspace/path with spaces"));
1044    }
1045
1046    // Tests for graceful degradation behavior
1047    mod graceful_degradation_tests {
1048        use super::*;
1049        use crate::error::ServerSpawnFailure;
1050        use crate::lsp::ServerInitResult;
1051
1052        #[test]
1053        fn test_all_servers_failed_error_handling() {
1054            let mut result = ServerInitResult::new();
1055            result.add_failure(ServerSpawnFailure {
1056                server_id: ServerId::from("rust"),
1057                language_id: "rust".to_string(),
1058                command: "rust-analyzer".to_string(),
1059                message: "not found".to_string(),
1060            });
1061            result.add_failure(ServerSpawnFailure {
1062                server_id: ServerId::from("python"),
1063                language_id: "python".to_string(),
1064                command: "pyright".to_string(),
1065                message: "not found".to_string(),
1066            });
1067
1068            assert!(result.all_failed());
1069            assert_eq!(result.failure_count(), 2);
1070            assert_eq!(result.server_count(), 0);
1071        }
1072
1073        #[test]
1074        fn test_partial_success_detection() {
1075            use std::collections::HashMap;
1076
1077            let mut result = ServerInitResult::new();
1078            // Simulate one success and one failure
1079            result.servers = HashMap::new(); // Would have a real server in production
1080            result.add_failure(ServerSpawnFailure {
1081                server_id: ServerId::from("python"),
1082                language_id: "python".to_string(),
1083                command: "pyright".to_string(),
1084                message: "not found".to_string(),
1085            });
1086
1087            // Without actual servers, we can verify the failure was recorded
1088            assert_eq!(result.failure_count(), 1);
1089            assert_eq!(result.server_count(), 0);
1090        }
1091
1092        #[test]
1093        fn test_all_servers_succeeded_detection() {
1094            use std::collections::HashMap;
1095
1096            let mut result = ServerInitResult::new();
1097            result.servers = HashMap::new(); // Would have real servers in production
1098
1099            assert_eq!(result.failure_count(), 0);
1100            assert!(!result.all_failed());
1101            assert!(!result.partial_success());
1102        }
1103
1104        #[test]
1105        fn test_all_servers_failed_to_init_error() {
1106            let failures = vec![
1107                ServerSpawnFailure {
1108                    server_id: ServerId::from("rust"),
1109                    language_id: "rust".to_string(),
1110                    command: "rust-analyzer".to_string(),
1111                    message: "command not found".to_string(),
1112                },
1113                ServerSpawnFailure {
1114                    server_id: ServerId::from("python"),
1115                    language_id: "python".to_string(),
1116                    command: "pyright".to_string(),
1117                    message: "permission denied".to_string(),
1118                },
1119            ];
1120
1121            let err = Error::AllServersFailedToInit { count: 2, failures };
1122
1123            assert!(err.to_string().contains("all LSP servers failed"));
1124            assert!(err.to_string().contains("2 configured"));
1125
1126            // Verify failures are preserved
1127            if let Error::AllServersFailedToInit { count, failures: f } = err {
1128                assert_eq!(count, 2);
1129                assert_eq!(f.len(), 2);
1130                assert_eq!(f[0].language_id, "rust");
1131                assert_eq!(f[1].language_id, "python");
1132            } else {
1133                panic!("Expected AllServersFailedToInit error");
1134            }
1135        }
1136
1137        #[test]
1138        fn test_graceful_degradation_with_empty_config() {
1139            let result = ServerInitResult::new();
1140
1141            // Empty config means no servers configured
1142            assert!(!result.all_failed());
1143            assert!(!result.partial_success());
1144            assert!(!result.has_servers());
1145            assert_eq!(result.server_count(), 0);
1146            assert_eq!(result.failure_count(), 0);
1147        }
1148
1149        #[test]
1150        fn test_server_spawn_failure_display() {
1151            let failure = ServerSpawnFailure {
1152                server_id: ServerId::from("typescript"),
1153                language_id: "typescript".to_string(),
1154                command: "tsserver".to_string(),
1155                message: "executable not found in PATH".to_string(),
1156            };
1157
1158            let display = failure.to_string();
1159            assert!(display.contains("typescript"));
1160            assert!(display.contains("tsserver"));
1161            assert!(display.contains("executable not found"));
1162        }
1163
1164        #[test]
1165        fn test_result_helpers_consistency() {
1166            let mut result = ServerInitResult::new();
1167
1168            // Initially empty
1169            assert!(!result.has_servers());
1170            assert!(!result.all_failed());
1171            assert!(!result.partial_success());
1172
1173            // Add a failure
1174            result.add_failure(ServerSpawnFailure {
1175                server_id: ServerId::from("go"),
1176                language_id: "go".to_string(),
1177                command: "gopls".to_string(),
1178                message: "error".to_string(),
1179            });
1180
1181            assert!(result.all_failed());
1182            assert!(!result.has_servers());
1183            assert!(!result.partial_success());
1184        }
1185
1186        #[tokio::test]
1187        async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
1188            use crate::config::{LspServerConfig, WorkspaceConfig};
1189
1190            // A configured server whose command cannot spawn used to make serve()
1191            // fail synchronously with NoServersAvailable / AllServersFailedToInit.
1192            // LSP initialization now runs in a background task so the MCP
1193            // `initialize` handshake is never blocked, which means the spawn
1194            // failure is handled in the background instead: serve() starts the MCP
1195            // server in degraded mode (mirroring `test_serve_starts_with_empty_config`)
1196            // rather than failing fast. Any error it surfaces must therefore be a
1197            // transport/MCP error from the closed test connection, NOT a fail-fast
1198            // server-availability error.
1199            let config = ServerConfig {
1200                workspace: WorkspaceConfig {
1201                    roots: vec![PathBuf::from("/tmp/test-workspace")],
1202                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1203                    language_extensions: vec![],
1204                    heuristics_max_depth: 10,
1205                    max_documents: DEFAULT_MAX_DOCUMENTS,
1206                    max_file_size: DEFAULT_MAX_FILE_SIZE,
1207                },
1208                lsp_servers: vec![LspServerConfig {
1209                    language_id: "rust".to_string(),
1210                    command: "nonexistent-command-that-will-fail-12345".to_string(),
1211                    args: vec![],
1212                    env: std::collections::HashMap::new(),
1213                    file_patterns: vec!["**/*.rs".to_string()],
1214                    initialization_options: None,
1215                    timeout_seconds: 10,
1216                    request_timeout_seconds: 10,
1217                    heuristics: None,
1218                    name: None,
1219                    handles: None,
1220                }],
1221                project_config_ignored: false,
1222            };
1223
1224            // serve() proceeds to run the MCP server and blocks on the stdio
1225            // transport until EOF; bound it so the test can't hang if stdin stays
1226            // open (e.g. under multi-threaded `cargo test`, where several serve()
1227            // tests share the process stdin).
1228            let outcome =
1229                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
1230
1231            match outcome {
1232                // Still serving after the deadline => it did not fail fast. Good.
1233                Err(_elapsed) => {}
1234                // Transport closed cleanly. Also fine.
1235                Ok(Ok(())) => {}
1236                // It returned an error: it must not be a fail-fast availability error.
1237                Ok(Err(err)) => assert!(
1238                    !matches!(err, Error::NoServersAvailable(_))
1239                        && !matches!(err, Error::AllServersFailedToInit { .. }),
1240                    "serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
1241                ),
1242            }
1243        }
1244
1245        #[tokio::test]
1246        async fn test_serve_starts_with_empty_config() {
1247            use crate::config::WorkspaceConfig;
1248
1249            // Server starts in protocol-only mode when no LSP servers are configured.
1250            // serve() blocks until the MCP transport closes, so it will error with a
1251            // connection/transport error — not NoServersAvailable.
1252            let config = ServerConfig {
1253                workspace: WorkspaceConfig {
1254                    roots: vec![PathBuf::from("/tmp/test-workspace")],
1255                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1256                    language_extensions: vec![],
1257                    heuristics_max_depth: 10,
1258                    max_documents: DEFAULT_MAX_DOCUMENTS,
1259                    max_file_size: DEFAULT_MAX_FILE_SIZE,
1260                },
1261                lsp_servers: vec![],
1262                project_config_ignored: false,
1263            };
1264
1265            let result = serve(config).await;
1266
1267            // serve() may succeed or fail with a transport error, but must NOT
1268            // return NoServersAvailable when the config simply has no servers.
1269            if let Err(ref err) = result {
1270                assert!(
1271                    !matches!(err, Error::NoServersAvailable(_)),
1272                    "serve() must not return NoServersAvailable for empty lsp_servers config"
1273                );
1274            }
1275        }
1276
1277        /// #282: a `ServerConfig` built programmatically (not via `load`/
1278        /// `load_from`, which already run `validate()`) previously skipped
1279        /// validation entirely, so `serve`/`serve_with` never rejected it —
1280        /// misconfiguration only surfaced later as silent accessor-level
1281        /// clamping. `serve` delegates straight to `serve_with`, so
1282        /// exercising it here also covers `serve_with`'s own `validate()`
1283        /// call. `validate()` runs before any LSP spawn or transport setup,
1284        /// so this returns immediately without needing a timeout guard.
1285        #[tokio::test]
1286        async fn test_serve_rejects_invalid_caller_supplied_config() {
1287            use crate::config::{LspServerConfig, WorkspaceConfig};
1288
1289            let config = ServerConfig {
1290                workspace: WorkspaceConfig {
1291                    roots: vec![PathBuf::from("/tmp/test-workspace")],
1292                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1293                    language_extensions: vec![],
1294                    heuristics_max_depth: 10,
1295                    max_documents: DEFAULT_MAX_DOCUMENTS,
1296                    max_file_size: DEFAULT_MAX_FILE_SIZE,
1297                },
1298                lsp_servers: vec![LspServerConfig {
1299                    language_id: "rust".to_string(),
1300                    command: String::new(),
1301                    args: vec![],
1302                    env: std::collections::HashMap::new(),
1303                    file_patterns: vec!["**/*.rs".to_string()],
1304                    initialization_options: None,
1305                    timeout_seconds: 10,
1306                    request_timeout_seconds: 10,
1307                    heuristics: None,
1308                    name: None,
1309                    handles: None,
1310                }],
1311                project_config_ignored: false,
1312            };
1313
1314            // `validate()` runs before any spawn/transport work and should
1315            // return immediately; bound it anyway so a regression that lets
1316            // an invalid config reach the stdio transport fails fast with a
1317            // clear timeout instead of hanging nextest for the default 120s
1318            // (mirroring the guard on `test_serve_degrades_when_all_servers_fail_to_spawn`).
1319            let outcome =
1320                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
1321
1322            match outcome {
1323                Err(elapsed) => panic!(
1324                    "serve() must reject the invalid config immediately, not hang until \
1325                     timeout: {elapsed}"
1326                ),
1327                Ok(result) => assert!(
1328                    matches!(result, Err(Error::InvalidConfig(_))),
1329                    "serve() must reject a caller-supplied config with an empty `command` via \
1330                     Error::InvalidConfig, matching the load_from path; got: {result:?}"
1331                ),
1332            }
1333        }
1334
1335        /// #241: `serve_with`'s post-transport shutdown sequence must drain
1336        /// registered LSP servers rather than orphaning them. Exercises
1337        /// `shutdown()` directly (the exact code `serve_with` runs after its
1338        /// transport future returns) against a `Translator` with a real,
1339        /// registered `LspServer` — `serve_with` itself can't be driven
1340        /// through this path in a portable unit test, since it only
1341        /// registers a server after a successful LSP `initialize` handshake,
1342        /// which requires a real language server binary.
1343        #[tokio::test]
1344        async fn test_shutdown_drains_registered_lsp_server() {
1345            let translator = Translator::new();
1346            translator.register_server("fake-server", crate::lsp::fake_lsp_server());
1347            assert_eq!(translator.registered_server_count(), 1);
1348
1349            let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
1350
1351            let result = tokio::time::timeout(
1352                std::time::Duration::from_secs(20),
1353                super::super::shutdown(&cancel_tx, &translator, None),
1354            )
1355            .await;
1356
1357            assert!(
1358                result.is_ok(),
1359                "shutdown must not hang against a non-responsive mock LSP server"
1360            );
1361            assert_eq!(
1362                translator.registered_server_count(),
1363                0,
1364                "shutdown must drain every registered LSP server"
1365            );
1366            assert!(
1367                *cancel_rx.borrow(),
1368                "shutdown must signal background pump tasks to exit"
1369            );
1370        }
1371
1372        /// #196: `shutdown` must await the background LSP init task's
1373        /// `JoinHandle` (rather than leaving it detached) so a panic inside
1374        /// it surfaces as an `error!` log instead of being silently dropped.
1375        #[tokio::test]
1376        async fn test_shutdown_awaits_background_init_task() {
1377            use std::sync::atomic::{AtomicBool, Ordering};
1378
1379            let translator = Translator::new();
1380            let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false);
1381
1382            let completed = Arc::new(AtomicBool::new(false));
1383            let completed_clone = Arc::clone(&completed);
1384            let handle = tokio::spawn(async move {
1385                completed_clone.store(true, Ordering::SeqCst);
1386            });
1387
1388            let result = tokio::time::timeout(
1389                std::time::Duration::from_secs(5),
1390                super::super::shutdown(&cancel_tx, &translator, Some(handle)),
1391            )
1392            .await;
1393
1394            assert!(result.is_ok(), "shutdown must not hang on a live handle");
1395            assert!(
1396                completed.load(Ordering::SeqCst),
1397                "shutdown must await the background init task before returning"
1398            );
1399        }
1400
1401        /// A timed-out background init task must actually be stopped
1402        /// (`JoinHandle::abort`), not merely detached: awaiting the handle
1403        /// *by value* inside `tokio::time::timeout` would drop only the
1404        /// `JoinHandle` on timeout, which detaches the task without
1405        /// cancelling it — it keeps running (and its future is never
1406        /// dropped) despite the "timed out waiting ... to stop" log.
1407        ///
1408        /// Tests `await_lsp_init_handle` directly with a millisecond-scale
1409        /// `timeout` (rather than going through `shutdown` with the real
1410        /// multi-second `LSP_INIT_TASK_SHUTDOWN_TIMEOUT`) so this stays
1411        /// fast. A `completed`-style flag set at the end of the task
1412        /// couldn't tell "aborted" from "merely detached" apart here either
1413        /// way, since the task hasn't finished its (deliberately long)
1414        /// sleep yet in both cases — so this uses a `Drop`-signaling guard
1415        /// held across the `.await` instead: `abort()` drops the task's
1416        /// future promptly (well inside the grace period below), while a
1417        /// detached-but-still-running task would only drop it once its
1418        /// sleep actually finishes.
1419        #[tokio::test]
1420        async fn test_await_lsp_init_handle_aborts_on_timeout() {
1421            use std::sync::atomic::{AtomicBool, Ordering};
1422
1423            struct DropFlag(Arc<AtomicBool>);
1424            impl Drop for DropFlag {
1425                fn drop(&mut self) {
1426                    self.0.store(true, Ordering::SeqCst);
1427                }
1428            }
1429
1430            let future_dropped = Arc::new(AtomicBool::new(false));
1431            let guard = DropFlag(Arc::clone(&future_dropped));
1432            let handle = tokio::spawn(async move {
1433                let _guard = guard;
1434                // Far longer than the timeout below, so it only elapses if
1435                // the task is genuinely aborted rather than left running.
1436                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1437            });
1438
1439            super::super::await_lsp_init_handle(handle, std::time::Duration::from_millis(20)).await;
1440
1441            // Give the just-aborted task's cancellation a moment to land.
1442            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1443            assert!(
1444                future_dropped.load(Ordering::SeqCst),
1445                "timed-out background init task's future must be dropped via abort(), \
1446                 not left running detached until its own sleep completes"
1447            );
1448        }
1449
1450        /// #196: a panicking background init task must not hang or crash
1451        /// `shutdown`, and the panic must actually be logged (not merely
1452        /// swallowed while `shutdown` happens not to hang for other
1453        /// reasons) — asserted via a captured `tracing` event rather than
1454        /// just checking completion.
1455        #[tokio::test]
1456        async fn test_await_lsp_init_handle_logs_panic() {
1457            use tracing_subscriber::layer::SubscriberExt as _;
1458
1459            let handle = tokio::spawn(async {
1460                panic!("simulated background LSP init panic");
1461            });
1462
1463            let captured = CapturedMessages::default();
1464            let subscriber = tracing_subscriber::registry().with(captured.clone());
1465            let guard = tracing::subscriber::set_default(subscriber);
1466
1467            super::super::await_lsp_init_handle(handle, std::time::Duration::from_secs(5)).await;
1468
1469            drop(guard);
1470
1471            let messages = captured.0.lock().unwrap().clone();
1472            assert!(
1473                messages
1474                    .iter()
1475                    .any(|m| m.contains("Background LSP initialization task failed")),
1476                "expected an error! log for the panicking background init task, got: {messages:?}"
1477            );
1478        }
1479
1480        /// Captures `tracing` events emitted while a closure runs. Mirrors
1481        /// `transport::tests::http_tests::CapturedMessages` — duplicated
1482        /// rather than shared since this crate has no common test-support
1483        /// module and the two live in separate, non-`pub` test submodules.
1484        #[derive(Clone, Default)]
1485        struct CapturedMessages(Arc<std::sync::Mutex<Vec<String>>>);
1486
1487        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedMessages {
1488            fn on_event(
1489                &self,
1490                event: &tracing::Event<'_>,
1491                _ctx: tracing_subscriber::layer::Context<'_, S>,
1492            ) {
1493                struct MessageVisitor(String);
1494                impl tracing::field::Visit for MessageVisitor {
1495                    fn record_debug(
1496                        &mut self,
1497                        field: &tracing::field::Field,
1498                        value: &dyn std::fmt::Debug,
1499                    ) {
1500                        if field.name() == "message" {
1501                            self.0 = format!("{value:?}");
1502                        }
1503                    }
1504                }
1505                let mut visitor = MessageVisitor(String::new());
1506                event.record(&mut visitor);
1507                self.0.lock().unwrap().push(visitor.0);
1508            }
1509        }
1510    }
1511
1512    // ------------------------------------------------------------------
1513    // diagnostics_pump unit tests
1514    // ------------------------------------------------------------------
1515
1516    #[allow(clippy::unwrap_used, clippy::expect_used)]
1517    mod pump_tests {
1518        use lsp_types::{PublishDiagnosticsParams, Uri};
1519        use tokio::sync::{mpsc, watch};
1520
1521        use super::*;
1522
1523        fn make_cache() -> Arc<Mutex<NotificationCache>> {
1524            Arc::new(Mutex::new(NotificationCache::new()))
1525        }
1526
1527        fn make_subs() -> Arc<ResourceSubscriptions> {
1528            Arc::new(ResourceSubscriptions::new())
1529        }
1530
1531        type PeerCell = Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>;
1532
1533        fn make_peer_cell() -> PeerCell {
1534            Arc::new(OnceCell::new())
1535        }
1536
1537        /// Empty workspace roots: `diagnostic_path_in_workspace` allows any
1538        /// URI in this mode, matching `validate_path_against_roots`, so these
1539        /// pump-mechanics tests don't need to construct real workspace paths.
1540        fn no_workspace_roots() -> Arc<[PathBuf]> {
1541            Arc::from([])
1542        }
1543
1544        /// `PublishDiagnostics` is cached even when the peer is not yet connected.
1545        #[tokio::test]
1546        async fn test_pump_caches_before_peer_set() {
1547            let cache = make_cache();
1548            let subs = make_subs();
1549            let peer_cell = make_peer_cell();
1550            let (tx, rx) = mpsc::channel(8);
1551            // Keep _cancel_tx alive: dropping it causes cancel_rx.changed() to return Err,
1552            // which makes the pump exit before processing any messages.
1553            let (_cancel_tx, cancel_rx) = watch::channel(false);
1554
1555            let c = Arc::clone(&cache);
1556            tokio::spawn(diagnostics_pump(
1557                ServerId::from("rust"),
1558                rx,
1559                cancel_rx,
1560                true,
1561                PumpShared {
1562                    notification_cache: c,
1563                    subs: Arc::clone(&subs),
1564                    peer_cell: Arc::clone(&peer_cell),
1565                    workspace_roots: no_workspace_roots(),
1566                },
1567            ));
1568
1569            let uri: Uri = "file:///test/main.rs".parse().unwrap();
1570            tx.send(LspNotification::PublishDiagnostics(
1571                PublishDiagnosticsParams {
1572                    uri: uri.clone(),
1573                    diagnostics: vec![],
1574                    version: None,
1575                },
1576            ))
1577            .await
1578            .unwrap();
1579            drop(tx);
1580
1581            // Poll until the pump processes the message or we time out.
1582            let cached = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1583                loop {
1584                    tokio::task::yield_now().await;
1585                    let found = {
1586                        let guard = cache.lock().await;
1587                        guard.get_diagnostics(uri.as_str()).is_some()
1588                    };
1589                    if found {
1590                        return true;
1591                    }
1592                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1593                }
1594            })
1595            .await
1596            .expect("pump did not cache diagnostics within 5 s");
1597            assert!(cached, "diagnostics should be cached before peer is set");
1598        }
1599
1600        /// #234 (S1 hardening): diagnostics for URIs outside the configured
1601        /// workspace roots must be dropped rather than cached, closing the
1602        /// vector where a misbehaving server floods the FIFO-bounded cache
1603        /// with fabricated URIs to evict every legitimate entry.
1604        #[tokio::test]
1605        async fn test_pump_drops_diagnostics_outside_workspace_roots() {
1606            let cache = make_cache();
1607            let subs = make_subs();
1608            let peer_cell = make_peer_cell();
1609            let (tx, rx) = mpsc::channel(8);
1610            let (_cancel_tx, cancel_rx) = watch::channel(false);
1611
1612            // See `test_diagnostic_path_in_workspace_accepts_uri_under_root`
1613            // for why Windows needs a drive-letter path here.
1614            #[cfg(windows)]
1615            let (workspace_root, outside_uri_str, inside_uri_str) = (
1616                PathBuf::from(r"C:\workspace"),
1617                "file:///C:/etc/passwd",
1618                "file:///C:/workspace/src/main.rs",
1619            );
1620            #[cfg(not(windows))]
1621            let (workspace_root, outside_uri_str, inside_uri_str) = (
1622                PathBuf::from("/workspace"),
1623                "file:///etc/passwd",
1624                "file:///workspace/src/main.rs",
1625            );
1626            let workspace_roots: Arc<[PathBuf]> = Arc::from([workspace_root]);
1627
1628            tokio::spawn(diagnostics_pump(
1629                ServerId::from("rust"),
1630                rx,
1631                cancel_rx,
1632                true,
1633                PumpShared {
1634                    notification_cache: Arc::clone(&cache),
1635                    subs: Arc::clone(&subs),
1636                    peer_cell: Arc::clone(&peer_cell),
1637                    workspace_roots,
1638                },
1639            ));
1640
1641            let outside_uri: Uri = outside_uri_str.parse().unwrap();
1642            let inside_uri: Uri = inside_uri_str.parse().unwrap();
1643
1644            tx.send(LspNotification::PublishDiagnostics(
1645                PublishDiagnosticsParams {
1646                    uri: outside_uri.clone(),
1647                    diagnostics: vec![],
1648                    version: None,
1649                },
1650            ))
1651            .await
1652            .unwrap();
1653            tx.send(LspNotification::PublishDiagnostics(
1654                PublishDiagnosticsParams {
1655                    uri: inside_uri.clone(),
1656                    diagnostics: vec![],
1657                    version: None,
1658                },
1659            ))
1660            .await
1661            .unwrap();
1662            drop(tx);
1663
1664            // Poll until the (later-sent) in-workspace sentinel is cached --
1665            // proves the pump already processed the earlier out-of-workspace
1666            // message too, since the channel preserves send order.
1667            tokio::time::timeout(std::time::Duration::from_secs(5), async {
1668                loop {
1669                    {
1670                        let guard = cache.lock().await;
1671                        if guard.get_diagnostics(inside_uri.as_str()).is_some() {
1672                            return;
1673                        }
1674                    }
1675                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1676                }
1677            })
1678            .await
1679            .expect("pump did not cache in-workspace diagnostics within 5 s");
1680
1681            let found_outside = cache
1682                .lock()
1683                .await
1684                .get_diagnostics(outside_uri.as_str())
1685                .is_some();
1686            assert!(
1687                !found_outside,
1688                "diagnostics for a URI outside workspace roots must not be cached"
1689            );
1690        }
1691
1692        /// Pump exits cleanly when the cancel watch sends `true`.
1693        #[tokio::test]
1694        async fn test_pump_exits_on_cancel() {
1695            let cache = make_cache();
1696            let subs = make_subs();
1697            let peer_cell = make_peer_cell();
1698            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
1699            let (cancel_tx, cancel_rx) = watch::channel(false);
1700
1701            let handle = tokio::spawn(diagnostics_pump(
1702                ServerId::from("rust"),
1703                rx,
1704                cancel_rx,
1705                true,
1706                PumpShared {
1707                    notification_cache: cache,
1708                    subs,
1709                    peer_cell,
1710                    workspace_roots: no_workspace_roots(),
1711                },
1712            ));
1713
1714            cancel_tx.send(true).unwrap();
1715            // Pump must finish within a short time after cancellation.
1716            tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1717                .await
1718                .expect("pump did not exit within timeout")
1719                .unwrap();
1720        }
1721
1722        /// Pump exits when the cancel sender is dropped (Err branch).
1723        #[tokio::test]
1724        async fn test_pump_exits_when_cancel_sender_dropped() {
1725            let cache = make_cache();
1726            let subs = make_subs();
1727            let peer_cell = make_peer_cell();
1728            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
1729            let (cancel_tx, cancel_rx) = watch::channel(false);
1730
1731            let handle = tokio::spawn(diagnostics_pump(
1732                ServerId::from("rust"),
1733                rx,
1734                cancel_rx,
1735                true,
1736                PumpShared {
1737                    notification_cache: cache,
1738                    subs,
1739                    peer_cell,
1740                    workspace_roots: no_workspace_roots(),
1741                },
1742            ));
1743
1744            drop(cancel_tx); // triggers Err in cancel_rx.changed()
1745            tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1746                .await
1747                .expect("pump did not exit within timeout")
1748                .unwrap();
1749        }
1750
1751        /// Regression test for #104: the pump must cache a notification promptly
1752        /// even while another task holds the translator lock for far longer than
1753        /// any acceptable pump latency. Before the `NotificationCache` split, the
1754        /// pump locked `Arc<Mutex<Translator>>` to cache diagnostics, so it would
1755        /// have stalled here until the holder released the lock.
1756        #[tokio::test]
1757        async fn test_pump_makes_progress_while_translator_lock_held() {
1758            let translator = Arc::new(Mutex::new(Translator::new()));
1759            let cache = make_cache();
1760            let subs = make_subs();
1761            let peer_cell = make_peer_cell();
1762            let (tx, rx) = mpsc::channel(8);
1763            let (_cancel_tx, cancel_rx) = watch::channel(false);
1764
1765            // Simulate a slow in-flight MCP request (e.g. `pull_diagnostics`)
1766            // holding the translator lock across an LSP round-trip.
1767            let lock_acquired = Arc::new(tokio::sync::Notify::new());
1768            let notify = Arc::clone(&lock_acquired);
1769            let holder = tokio::spawn(async move {
1770                let _guard = translator.lock().await;
1771                notify.notify_one();
1772                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1773            });
1774            lock_acquired.notified().await;
1775
1776            tokio::spawn(diagnostics_pump(
1777                ServerId::from("rust"),
1778                rx,
1779                cancel_rx,
1780                true,
1781                PumpShared {
1782                    notification_cache: Arc::clone(&cache),
1783                    subs,
1784                    peer_cell,
1785                    workspace_roots: no_workspace_roots(),
1786                },
1787            ));
1788
1789            let uri: Uri = "file:///test/locked.rs".parse().unwrap();
1790            tx.send(LspNotification::PublishDiagnostics(
1791                PublishDiagnosticsParams {
1792                    uri: uri.clone(),
1793                    diagnostics: vec![],
1794                    version: None,
1795                },
1796            ))
1797            .await
1798            .unwrap();
1799            drop(tx);
1800
1801            // Well within the 2 s translator lock hold: a translator-locking
1802            // pump would still be blocked at this point.
1803            tokio::time::timeout(std::time::Duration::from_millis(500), async {
1804                loop {
1805                    {
1806                        let guard = cache.lock().await;
1807                        if guard.get_diagnostics(uri.as_str()).is_some() {
1808                            return;
1809                        }
1810                    }
1811                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1812                }
1813            })
1814            .await
1815            .expect("pump stalled behind translator lock");
1816
1817            holder.await.unwrap();
1818        }
1819    }
1820}