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() -> Result<(), mcpls_core::Error> {
25//!     let config = ServerConfig::load()?;
26//!     // Stdio (default):
27//!     serve(config).await
28//!     // HTTP (requires `transport-http` feature):
29//!     // serve_with(config, Transport::Http(mcpls_core::HttpConfig {
30//!     //     bind: "127.0.0.1:3000".parse().unwrap(),
31//!     //     path: "/mcp".to_string(),
32//!     // })).await
33//! }
34//! ```
35
36pub mod bridge;
37pub mod config;
38pub mod error;
39pub mod lsp;
40pub mod mcp;
41pub mod transport;
42
43use std::collections::{HashMap, HashSet};
44use std::path::PathBuf;
45use std::sync::Arc;
46
47use bridge::resources::make_uri;
48use bridge::{NotificationCache, ResourceSubscriptions, Translator};
49pub use config::{ProjectConfigTrust, ServerConfig};
50use config::{ServerId, ToolRouter};
51pub use error::Error;
52use lsp::{LspNotification, LspServer, ServerInitConfig};
53use rmcp::model::ResourceUpdatedNotificationParam;
54use tokio::sync::{Mutex, OnceCell};
55use tokio::task::JoinSet;
56use tracing::{error, info, warn};
57#[cfg(feature = "transport-http")]
58pub use transport::HttpConfig;
59pub use transport::Transport;
60#[cfg(feature = "transport-http")]
61use transport::run_http;
62use transport::run_stdio;
63
64/// Background task that drains LSP notifications, writes them to the cache,
65/// and forwards `resources/updated` to the MCP peer when subscribed.
66///
67/// The task operates in two phases without explicit state:
68/// - **Phase A** (before peer is set): caches every notification, skips peer notify.
69/// - **Phase B** (after peer is set): additionally fires `notify_resource_updated`
70///   for subscribed `PublishDiagnostics` URIs.
71///
72/// The task exits when:
73/// - The LSP notification channel closes (`rx.recv()` returns `None`).
74/// - The cancellation watch fires (or the sender is dropped).
75/// - `notify_resource_updated` returns an error (peer disconnect / transport closed).
76///
77/// # Lock independence
78/// Cache writes acquire only `Arc<Mutex<NotificationCache>>`, a lock entirely
79/// separate from `translator`'s own internal locks (`Arc<Translator>` has no
80/// outer mutex; each field manages its own short-lived, independent lock).
81/// Neither an in-flight LSP round-trip (e.g. `textDocument/diagnostic`) nor
82/// any other translator-side work holds the notification-cache lock, so this
83/// pump is never blocked by tool-call activity: a `publishDiagnostics`
84/// notification arriving mid-request is cached immediately instead of being
85/// silently dropped. This matters because the LSP transport forwards
86/// notifications via `mpsc::Sender::try_send`, which drops on a full channel
87/// rather than blocking — a pump stalled behind someone else's lock would
88/// previously lose notifications under sustained push traffic.
89pub(crate) async fn diagnostics_pump(
90    _server_id: String,
91    mut rx: tokio::sync::mpsc::Receiver<LspNotification>,
92    notification_cache: Arc<Mutex<NotificationCache>>,
93    subs: Arc<ResourceSubscriptions>,
94    peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
95    mut cancel_rx: tokio::sync::watch::Receiver<bool>,
96    caches_diagnostics: bool,
97) {
98    loop {
99        tokio::select! {
100            // Exit when cancellation is requested or the sender is dropped.
101            result = cancel_rx.changed() => {
102                // Err means the sender was dropped; treat as cancellation.
103                if result.is_err() || *cancel_rx.borrow() {
104                    break;
105                }
106            }
107            msg = rx.recv() => {
108                let Some(notif) = msg else { break };
109                match notif {
110                    LspNotification::PublishDiagnostics(p) => {
111                        // Only the server the router resolves `Diagnostics` to for
112                        // this notification's language caches (and notifies
113                        // subscribers of) it -- see #174 §8. A server that was
114                        // never the diagnostics route, or lost it without a live
115                        // catch-all to rebind to, is not the authoritative source
116                        // for this language's diagnostics; skip publishing so it
117                        // doesn't overwrite (or spuriously notify about) another
118                        // server's cache entry.
119                        if !caches_diagnostics {
120                            continue;
121                        }
122                        {
123                            let mut cache = notification_cache.lock().await;
124                            cache.store_diagnostics(&p.uri, p.version, p.diagnostics);
125                        }
126
127                        // Fast path: skip URI construction when nothing is subscribed.
128                        if subs.is_empty().await {
129                            continue;
130                        }
131
132                        // Notify only when peer is ready and URI is subscribed.
133                        let Some(peer) = peer_cell.get() else { continue };
134                        let Some(path) = bridge::uri_to_path(&p.uri) else { continue };
135                        let Ok(mcp_uri) = make_uri(&path) else { continue };
136
137                        if !subs.contains(&mcp_uri).await {
138                            continue;
139                        }
140
141                        if peer
142                            .notify_resource_updated(ResourceUpdatedNotificationParam::new(
143                                mcp_uri,
144                            ))
145                            .await
146                            .is_err()
147                        {
148                            // Peer disconnected; stop the pump.
149                            break;
150                        }
151                    }
152                    LspNotification::LogMessage(m) => {
153                        let mut cache = notification_cache.lock().await;
154                        cache.store_log(m.typ.into(), m.message);
155                    }
156                    LspNotification::ShowMessage(m) => {
157                        let mut cache = notification_cache.lock().await;
158                        cache.store_message(m.typ.into(), m.message);
159                    }
160                    LspNotification::Progress { .. } | LspNotification::Other { .. } => {}
161                }
162            }
163        }
164    }
165}
166
167/// Result of [`register_servers`]: everything the caller needs to start the
168/// per-server diagnostics pump tasks.
169pub(crate) struct RegisteredServers {
170    /// Notification receivers extracted from each server before registration.
171    pub(crate) receivers: HashMap<ServerId, tokio::sync::mpsc::Receiver<lsp::LspNotification>>,
172    /// Whether each server is the one the (rebound) router resolves
173    /// `ToolKind::Diagnostics` to for its language -- see #174 §8. Computed
174    /// here, right after the rebind, so it always reflects the post-rebind
175    /// router rather than a stale pre-rebind view.
176    pub(crate) diagnostics_flags: HashMap<ServerId, bool>,
177}
178
179/// Register initialized LSP servers with the translator, rebind the router to
180/// the set that actually registered, and extract notification receivers.
181///
182/// Takes ownership of the `ServerInitResult`, extracts `notification_rx` from
183/// each server before registration. Registration itself is a sequence of
184/// short, independently-locked map inserts (see `Translator`'s field docs),
185/// so no external synchronization is required here; the rebind that follows
186/// relies only on all of *this* function's inserts having completed, which
187/// the sequential code below guarantees.
188pub(crate) fn register_servers(
189    mut result: lsp::ServerInitResult,
190    translator: &bridge::Translator,
191) -> RegisteredServers {
192    let mut receivers = HashMap::new();
193    for (id, server) in &mut result.servers {
194        receivers.insert(id.clone(), server.take_notification_rx());
195    }
196
197    let registered: HashSet<ServerId> = result.servers.keys().cloned().collect();
198
199    let mut language_by_id = HashMap::new();
200    for (id, server) in result.servers {
201        let client = server.client().clone();
202        language_by_id.insert(id.clone(), client.language_id().to_string());
203        translator.register_client(id.clone(), client);
204        translator.register_server(id, server);
205    }
206
207    translator.rebind_router(&registered);
208
209    let diagnostics_flags = language_by_id
210        .into_iter()
211        .map(|(id, language)| {
212            let is_diagnostics_server = translator.is_diagnostics_route(&language, &id);
213            (id, is_diagnostics_server)
214        })
215        .collect();
216
217    RegisteredServers {
218        receivers,
219        diagnostics_flags,
220    }
221}
222
223/// Resolve workspace roots from config or current directory.
224///
225/// If no workspace roots are provided in the configuration, this function
226/// will use the current working directory, canonicalized for security.
227///
228/// # Returns
229///
230/// A vector of workspace root paths. If config roots are provided, they are
231/// returned as-is. Otherwise, returns the canonicalized current directory,
232/// falling back to relative "." if canonicalization fails.
233fn resolve_workspace_roots(config_roots: &[PathBuf]) -> Vec<PathBuf> {
234    if config_roots.is_empty() {
235        match std::env::current_dir() {
236            Ok(cwd) => {
237                // current_dir() always returns an absolute path
238                match cwd.canonicalize() {
239                    Ok(canonical) => {
240                        info!(
241                            "Using current directory as workspace root: {}",
242                            canonical.display()
243                        );
244                        vec![canonical]
245                    }
246                    Err(e) => {
247                        // Canonicalization can fail if directory was deleted or permissions changed
248                        // but cwd itself is still absolute
249                        warn!(
250                            "Failed to canonicalize current directory: {e}, using non-canonical path"
251                        );
252                        vec![cwd]
253                    }
254                }
255            }
256            Err(e) => {
257                // This is extremely rare - only happens if cwd was deleted or unlinked
258                // In this case, we have no choice but to use a relative path
259                warn!("Failed to get current directory: {e}, using fallback");
260                vec![PathBuf::from(".")]
261            }
262        }
263    } else {
264        config_roots.to_vec()
265    }
266}
267
268/// Start the MCPLS server with the given configuration over stdio.
269///
270/// This is the backward-compatible entry point. It is equivalent to calling
271/// `serve_with(config, Transport::Stdio)`.
272///
273/// # Errors
274///
275/// Returns an error if:
276/// - All LSP servers fail to initialize
277/// - MCP server setup fails
278/// - Configuration is invalid
279///
280/// # Graceful Degradation
281///
282/// - **All servers succeed**: Service runs normally
283/// - **Partial success**: Logs warnings for failures, continues with available servers
284/// - **All servers fail**: Returns `Error::AllServersFailedToInit` with details
285pub async fn serve(config: ServerConfig) -> Result<(), Error> {
286    serve_with(config, Transport::Stdio).await
287}
288
289/// Start the MCPLS server with an explicit transport.
290///
291/// Performs all shared setup (workspace discovery, LSP spawning, translator
292/// initialization, diagnostic pump tasks) and then delegates to the
293/// appropriate transport runner.
294///
295/// # Errors
296///
297/// Returns an error if:
298/// - All LSP servers fail to initialize
299/// - The MCP server or transport fails to start
300/// - Configuration is invalid, including two applicable `[[lsp_servers]]`
301///   entries whose per-tool routing is ambiguous in this workspace (shared
302///   routing identity, two catch-alls, or the same tool claimed by both) --
303///   see `config::ToolRouter::from_configs`
304///
305/// # DNS rebinding protection (HTTP transport)
306///
307/// When using `Transport::Http`, the underlying rmcp service validates the
308/// inbound `Host` header against an allowlist that defaults to loopback
309/// addresses only (`localhost`, `127.0.0.1`, `::1`). Requests with any other
310/// `Host` value are rejected with `421 Misdirected Request`.
311///
312/// If you bind to a non-loopback address (e.g. `0.0.0.0:3000`) and expose the
313/// service through a reverse proxy, the proxy must forward `Host: localhost`
314/// (or another loopback alias) to the mcpls process. Direct non-loopback
315/// access is intentionally blocked to prevent DNS-rebinding attacks.
316///
317/// # Examples
318///
319/// ```rust,ignore
320/// use mcpls_core::{serve_with, Transport, ServerConfig};
321///
322/// #[tokio::main]
323/// async fn main() -> Result<(), mcpls_core::Error> {
324///     let config = ServerConfig::load()?;
325///     serve_with(config, Transport::Stdio).await
326/// }
327/// ```
328pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
329    info!("Starting MCPLS server...");
330
331    let workspace_roots = resolve_workspace_roots(&config.workspace.roots);
332    let extension_map = config.build_effective_extension_map();
333    let max_depth = Some(config.workspace.heuristics_max_depth);
334
335    let applicable_configs: Vec<ServerInitConfig> = config
336        .lsp_servers
337        .iter()
338        .filter_map(|lsp_config| {
339            let should_spawn = workspace_roots
340                .iter()
341                .any(|root| lsp_config.should_spawn(root, max_depth));
342
343            if !should_spawn {
344                info!(
345                    "Skipping LSP server '{}' ({}): no project markers found",
346                    lsp_config.language_id, lsp_config.command
347                );
348                return None;
349            }
350
351            Some(ServerInitConfig {
352                server_config: lsp_config.clone(),
353                workspace_roots: workspace_roots.clone(),
354                initialization_options: lsp_config.initialization_options.clone(),
355                notification_tx: None,
356            })
357        })
358        .collect();
359
360    info!(
361        "Attempting to spawn {} applicable LSP server(s)...",
362        applicable_configs.len()
363    );
364
365    // Built over the applicable (post-heuristics) configs only: this is where
366    // #174's workspace-scoped routing rules (duplicate ServerId, conflicting
367    // `handles` claims) are enforced -- a startup error naming the
368    // conflicting `[[lsp_servers]]` entries, not a silent drop.
369    let router = ToolRouter::from_configs(applicable_configs.iter().map(|c| &c.server_config))?;
370
371    let mut translator = Translator::new()
372        .with_extensions(extension_map)
373        .with_router(router);
374    translator.set_workspace_roots(workspace_roots.clone());
375
376    // Mark applicable servers as "expected" so a tool call that arrives while
377    // its server is still initializing gets a clear "still initializing" error
378    // (instead of "no server configured"), telling the caller to wait and retry.
379    let expected_servers: HashSet<ServerId> = applicable_configs
380        .iter()
381        .map(|c| c.server_config.id())
382        .collect();
383    translator.set_expected_servers(expected_servers);
384
385    // Shared state, built BEFORE LSP initialization so the MCP server can answer
386    // `initialize` immediately. LSP servers (which can take minutes to initialize
387    // on a large solution, e.g. a 130-project Unity .sln via OmniSharp) are spawned
388    // in a background task and registered into this shared translator once ready.
389    // Blocking the MCP handshake on LSP init makes slow servers exceed the client's
390    // initialize-request timeout (Claude Code: ~60s) -> "Request timed out".
391    // Fixed for the server's lifetime: shared as a lock-free snapshot so
392    // cache-only handlers (e.g. `get_cached_diagnostics`, `read_resource`) can
393    // validate a path without locking `translator` below.
394    let workspace_roots_snapshot: Arc<[PathBuf]> = Arc::from(workspace_roots.clone());
395
396    let translator = Arc::new(translator);
397    // Independent of `translator`, which itself holds no outer lock: the pump
398    // only ever locks this cache, so it never contends with a request handler
399    // running an in-flight LSP round-trip.
400    let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
401    let subscriptions = Arc::new(ResourceSubscriptions::new());
402    // Peer cell is populated after the MCP transport is established (Phase B).
403    let peer_cell = Arc::new(OnceCell::new());
404
405    // Cancellation for pump tasks: send `true` to request shutdown.
406    let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
407
408    if applicable_configs.is_empty() {
409        warn!("No applicable LSP servers configured — starting in protocol-only mode");
410    } else {
411        info!(
412            "Spawning {} LSP server(s) in the background...",
413            applicable_configs.len()
414        );
415        spawn_lsp_servers_background(
416            applicable_configs,
417            Arc::clone(&translator),
418            Arc::clone(&notification_cache),
419            Arc::clone(&subscriptions),
420            Arc::clone(&peer_cell),
421            cancel_rx.clone(),
422        );
423    }
424
425    info!("Starting MCP server with rmcp...");
426    let mcp_server = mcp::McplsServer::new(
427        Arc::clone(&translator),
428        Arc::clone(&notification_cache),
429        Arc::clone(&workspace_roots_snapshot),
430        Arc::clone(&subscriptions),
431    );
432    info!("MCPLS server initialized successfully");
433
434    let result = match transport {
435        Transport::Stdio => {
436            info!("Listening for MCP requests on stdio...");
437            run_stdio(mcp_server, &peer_cell).await
438        }
439        #[cfg(feature = "transport-http")]
440        Transport::Http(cfg) => run_http(mcp_server, cfg).await,
441    };
442
443    // Signal background pump tasks to exit.
444    let _ = cancel_tx.send(true);
445
446    info!("MCPLS server shutting down");
447    result
448}
449
450/// Spawn the applicable LSP servers in a background task and register them into
451/// the shared `translator` once ready.
452///
453/// This intentionally does NOT block the caller: `serve_with` starts the MCP
454/// server immediately so its `initialize` handshake returns before slow language
455/// servers (e.g. `OmniSharp` on a large Unity solution, which can take minutes to
456/// load) finish initializing. Tool calls that arrive before a server has
457/// registered return a `ServerInitializing` error telling the caller to wait and
458/// retry. If every server fails, the "expected servers" set is cleared so those
459/// calls fall back to a plain "no server configured" error instead.
460fn spawn_lsp_servers_background(
461    applicable_configs: Vec<ServerInitConfig>,
462    translator: Arc<Translator>,
463    notification_cache: Arc<Mutex<NotificationCache>>,
464    subscriptions: Arc<ResourceSubscriptions>,
465    peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
466    cancel_rx: tokio::sync::watch::Receiver<bool>,
467) {
468    tokio::spawn(async move {
469        let result = LspServer::spawn_batch(&applicable_configs).await;
470
471        if result.all_failed() {
472            error!(
473                "All {} configured LSP server(s) failed to initialize",
474                result.failure_count()
475            );
476            for failure in &result.failures {
477                error!("Server initialization failed: {}", failure);
478            }
479            // No server will register: rebind against an empty registered
480            // set so every route drops (one rule, no special case -- see
481            // `ToolRouter::rebind_to_registered`), then stop reporting
482            // "still initializing". This path returns before
483            // `register_servers` ever runs, so it needs its own rebind call;
484            // skipping it would leave every route pointed at a dead server.
485            translator.rebind_router(&HashSet::new());
486            translator.clear_expected_servers();
487            return;
488        }
489
490        if result.partial_success() {
491            warn!(
492                "Partial server initialization: {} succeeded, {} failed",
493                result.server_count(),
494                result.failure_count()
495            );
496            for failure in &result.failures {
497                error!("Server initialization failed: {}", failure);
498            }
499        }
500
501        let server_count = result.server_count();
502        let registered = register_servers(result, &translator);
503        // Background initialization has completed; stop reporting "still
504        // initializing" (especially for servers that failed to spawn on
505        // partial success, which would otherwise return ServerInitializing
506        // forever instead of NoServerForLanguage/Tool).
507        translator.clear_expected_servers();
508        info!("Proceeding with {} LSP server(s)", server_count);
509
510        // Start diagnostics pump tasks now that servers are registered.
511        let mut pumps: JoinSet<()> = JoinSet::new();
512        for (id, rx) in registered.receivers {
513            let caches_diagnostics = registered
514                .diagnostics_flags
515                .get(&id)
516                .copied()
517                .unwrap_or(false);
518            pumps.spawn(diagnostics_pump(
519                id.to_string(),
520                rx,
521                Arc::clone(&notification_cache),
522                Arc::clone(&subscriptions),
523                Arc::clone(&peer_cell),
524                cancel_rx.clone(),
525                caches_diagnostics,
526            ));
527        }
528        while pumps.join_next().await.is_some() {}
529    });
530}
531
532#[cfg(test)]
533#[allow(clippy::unwrap_used)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_resolve_workspace_roots_empty_config() {
539        let roots = resolve_workspace_roots(&[]);
540        assert_eq!(roots.len(), 1);
541        assert!(
542            roots[0].is_absolute(),
543            "Workspace root should be absolute path"
544        );
545    }
546
547    #[test]
548    fn test_resolve_workspace_roots_with_config() {
549        let config_roots = vec![PathBuf::from("/test/root")];
550        let roots = resolve_workspace_roots(&config_roots);
551        assert_eq!(roots, config_roots);
552    }
553
554    #[test]
555    fn test_resolve_workspace_roots_multiple_paths() {
556        let config_roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
557        let roots = resolve_workspace_roots(&config_roots);
558        assert_eq!(roots, config_roots);
559        assert_eq!(roots.len(), 2);
560    }
561
562    #[test]
563    fn test_resolve_workspace_roots_preserves_order() {
564        let config_roots = vec![
565            PathBuf::from("/workspace/alpha"),
566            PathBuf::from("/workspace/beta"),
567            PathBuf::from("/workspace/gamma"),
568        ];
569        let roots = resolve_workspace_roots(&config_roots);
570        assert_eq!(roots[0], PathBuf::from("/workspace/alpha"));
571        assert_eq!(roots[1], PathBuf::from("/workspace/beta"));
572        assert_eq!(roots[2], PathBuf::from("/workspace/gamma"));
573    }
574
575    #[test]
576    fn test_resolve_workspace_roots_single_path() {
577        let config_roots = vec![PathBuf::from("/single/workspace")];
578        let roots = resolve_workspace_roots(&config_roots);
579        assert_eq!(roots.len(), 1);
580        assert_eq!(roots[0], PathBuf::from("/single/workspace"));
581    }
582
583    #[test]
584    fn test_resolve_workspace_roots_empty_returns_cwd() {
585        let roots = resolve_workspace_roots(&[]);
586        assert!(
587            !roots.is_empty(),
588            "Should return at least one workspace root"
589        );
590    }
591
592    #[test]
593    fn test_resolve_workspace_roots_relative_paths() {
594        let config_roots = vec![
595            PathBuf::from("relative/path1"),
596            PathBuf::from("relative/path2"),
597        ];
598        let roots = resolve_workspace_roots(&config_roots);
599        assert_eq!(roots.len(), 2);
600        assert_eq!(roots[0], PathBuf::from("relative/path1"));
601        assert_eq!(roots[1], PathBuf::from("relative/path2"));
602    }
603
604    #[test]
605    fn test_resolve_workspace_roots_mixed_paths() {
606        let config_roots = vec![
607            PathBuf::from("/absolute/path"),
608            PathBuf::from("relative/path"),
609        ];
610        let roots = resolve_workspace_roots(&config_roots);
611        assert_eq!(roots.len(), 2);
612        assert_eq!(roots[0], PathBuf::from("/absolute/path"));
613        assert_eq!(roots[1], PathBuf::from("relative/path"));
614    }
615
616    #[test]
617    fn test_resolve_workspace_roots_with_dot_path() {
618        let config_roots = vec![PathBuf::from(".")];
619        let roots = resolve_workspace_roots(&config_roots);
620        assert_eq!(roots, config_roots);
621    }
622
623    #[test]
624    fn test_resolve_workspace_roots_with_parent_path() {
625        let config_roots = vec![PathBuf::from("..")];
626        let roots = resolve_workspace_roots(&config_roots);
627        assert_eq!(roots.len(), 1);
628        assert_eq!(roots[0], PathBuf::from(".."));
629    }
630
631    #[test]
632    fn test_resolve_workspace_roots_unicode_paths() {
633        let config_roots = vec![
634            PathBuf::from("/workspace/テスト"),
635            PathBuf::from("/workspace/тест"),
636        ];
637        let roots = resolve_workspace_roots(&config_roots);
638        assert_eq!(roots.len(), 2);
639        assert_eq!(roots[0], PathBuf::from("/workspace/テスト"));
640        assert_eq!(roots[1], PathBuf::from("/workspace/тест"));
641    }
642
643    #[test]
644    fn test_resolve_workspace_roots_spaces_in_paths() {
645        let config_roots = vec![
646            PathBuf::from("/workspace/path with spaces"),
647            PathBuf::from("/another path/workspace"),
648        ];
649        let roots = resolve_workspace_roots(&config_roots);
650        assert_eq!(roots.len(), 2);
651        assert_eq!(roots[0], PathBuf::from("/workspace/path with spaces"));
652    }
653
654    // Tests for graceful degradation behavior
655    mod graceful_degradation_tests {
656        use super::*;
657        use crate::error::ServerSpawnFailure;
658        use crate::lsp::ServerInitResult;
659
660        #[test]
661        fn test_all_servers_failed_error_handling() {
662            let mut result = ServerInitResult::new();
663            result.add_failure(ServerSpawnFailure {
664                server_id: ServerId::from("rust"),
665                language_id: "rust".to_string(),
666                command: "rust-analyzer".to_string(),
667                message: "not found".to_string(),
668            });
669            result.add_failure(ServerSpawnFailure {
670                server_id: ServerId::from("python"),
671                language_id: "python".to_string(),
672                command: "pyright".to_string(),
673                message: "not found".to_string(),
674            });
675
676            assert!(result.all_failed());
677            assert_eq!(result.failure_count(), 2);
678            assert_eq!(result.server_count(), 0);
679        }
680
681        #[test]
682        fn test_partial_success_detection() {
683            use std::collections::HashMap;
684
685            let mut result = ServerInitResult::new();
686            // Simulate one success and one failure
687            result.servers = HashMap::new(); // Would have a real server in production
688            result.add_failure(ServerSpawnFailure {
689                server_id: ServerId::from("python"),
690                language_id: "python".to_string(),
691                command: "pyright".to_string(),
692                message: "not found".to_string(),
693            });
694
695            // Without actual servers, we can verify the failure was recorded
696            assert_eq!(result.failure_count(), 1);
697            assert_eq!(result.server_count(), 0);
698        }
699
700        #[test]
701        fn test_all_servers_succeeded_detection() {
702            use std::collections::HashMap;
703
704            let mut result = ServerInitResult::new();
705            result.servers = HashMap::new(); // Would have real servers in production
706
707            assert_eq!(result.failure_count(), 0);
708            assert!(!result.all_failed());
709            assert!(!result.partial_success());
710        }
711
712        #[test]
713        fn test_all_servers_failed_to_init_error() {
714            let failures = vec![
715                ServerSpawnFailure {
716                    server_id: ServerId::from("rust"),
717                    language_id: "rust".to_string(),
718                    command: "rust-analyzer".to_string(),
719                    message: "command not found".to_string(),
720                },
721                ServerSpawnFailure {
722                    server_id: ServerId::from("python"),
723                    language_id: "python".to_string(),
724                    command: "pyright".to_string(),
725                    message: "permission denied".to_string(),
726                },
727            ];
728
729            let err = Error::AllServersFailedToInit { count: 2, failures };
730
731            assert!(err.to_string().contains("all LSP servers failed"));
732            assert!(err.to_string().contains("2 configured"));
733
734            // Verify failures are preserved
735            if let Error::AllServersFailedToInit { count, failures: f } = err {
736                assert_eq!(count, 2);
737                assert_eq!(f.len(), 2);
738                assert_eq!(f[0].language_id, "rust");
739                assert_eq!(f[1].language_id, "python");
740            } else {
741                panic!("Expected AllServersFailedToInit error");
742            }
743        }
744
745        #[test]
746        fn test_graceful_degradation_with_empty_config() {
747            let result = ServerInitResult::new();
748
749            // Empty config means no servers configured
750            assert!(!result.all_failed());
751            assert!(!result.partial_success());
752            assert!(!result.has_servers());
753            assert_eq!(result.server_count(), 0);
754            assert_eq!(result.failure_count(), 0);
755        }
756
757        #[test]
758        fn test_server_spawn_failure_display() {
759            let failure = ServerSpawnFailure {
760                server_id: ServerId::from("typescript"),
761                language_id: "typescript".to_string(),
762                command: "tsserver".to_string(),
763                message: "executable not found in PATH".to_string(),
764            };
765
766            let display = failure.to_string();
767            assert!(display.contains("typescript"));
768            assert!(display.contains("tsserver"));
769            assert!(display.contains("executable not found"));
770        }
771
772        #[test]
773        fn test_result_helpers_consistency() {
774            let mut result = ServerInitResult::new();
775
776            // Initially empty
777            assert!(!result.has_servers());
778            assert!(!result.all_failed());
779            assert!(!result.partial_success());
780
781            // Add a failure
782            result.add_failure(ServerSpawnFailure {
783                server_id: ServerId::from("go"),
784                language_id: "go".to_string(),
785                command: "gopls".to_string(),
786                message: "error".to_string(),
787            });
788
789            assert!(result.all_failed());
790            assert!(!result.has_servers());
791            assert!(!result.partial_success());
792        }
793
794        #[tokio::test]
795        async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
796            use crate::config::{LspServerConfig, WorkspaceConfig};
797
798            // A configured server whose command cannot spawn used to make serve()
799            // fail synchronously with NoServersAvailable / AllServersFailedToInit.
800            // LSP initialization now runs in a background task so the MCP
801            // `initialize` handshake is never blocked, which means the spawn
802            // failure is handled in the background instead: serve() starts the MCP
803            // server in degraded mode (mirroring `test_serve_starts_with_empty_config`)
804            // rather than failing fast. Any error it surfaces must therefore be a
805            // transport/MCP error from the closed test connection, NOT a fail-fast
806            // server-availability error.
807            let config = ServerConfig {
808                workspace: WorkspaceConfig {
809                    roots: vec![PathBuf::from("/tmp/test-workspace")],
810                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
811                    language_extensions: vec![],
812                    heuristics_max_depth: 10,
813                },
814                lsp_servers: vec![LspServerConfig {
815                    language_id: "rust".to_string(),
816                    command: "nonexistent-command-that-will-fail-12345".to_string(),
817                    args: vec![],
818                    env: std::collections::HashMap::new(),
819                    file_patterns: vec!["**/*.rs".to_string()],
820                    initialization_options: None,
821                    timeout_seconds: 10,
822                    heuristics: None,
823                    name: None,
824                    handles: None,
825                }],
826            };
827
828            // serve() proceeds to run the MCP server and blocks on the stdio
829            // transport until EOF; bound it so the test can't hang if stdin stays
830            // open (e.g. under multi-threaded `cargo test`, where several serve()
831            // tests share the process stdin).
832            let outcome =
833                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
834
835            match outcome {
836                // Still serving after the deadline => it did not fail fast. Good.
837                Err(_elapsed) => {}
838                // Transport closed cleanly. Also fine.
839                Ok(Ok(())) => {}
840                // It returned an error: it must not be a fail-fast availability error.
841                Ok(Err(err)) => assert!(
842                    !matches!(err, Error::NoServersAvailable(_))
843                        && !matches!(err, Error::AllServersFailedToInit { .. }),
844                    "serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
845                ),
846            }
847        }
848
849        #[tokio::test]
850        async fn test_serve_starts_with_empty_config() {
851            use crate::config::WorkspaceConfig;
852
853            // Server starts in protocol-only mode when no LSP servers are configured.
854            // serve() blocks until the MCP transport closes, so it will error with a
855            // connection/transport error — not NoServersAvailable.
856            let config = ServerConfig {
857                workspace: WorkspaceConfig {
858                    roots: vec![PathBuf::from("/tmp/test-workspace")],
859                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
860                    language_extensions: vec![],
861                    heuristics_max_depth: 10,
862                },
863                lsp_servers: vec![],
864            };
865
866            let result = serve(config).await;
867
868            // serve() may succeed or fail with a transport error, but must NOT
869            // return NoServersAvailable when the config simply has no servers.
870            if let Err(ref err) = result {
871                assert!(
872                    !matches!(err, Error::NoServersAvailable(_)),
873                    "serve() must not return NoServersAvailable for empty lsp_servers config"
874                );
875            }
876        }
877    }
878
879    // ------------------------------------------------------------------
880    // diagnostics_pump unit tests
881    // ------------------------------------------------------------------
882
883    #[allow(clippy::unwrap_used, clippy::expect_used)]
884    mod pump_tests {
885        use lsp_types::{PublishDiagnosticsParams, Uri};
886        use tokio::sync::{mpsc, watch};
887
888        use super::*;
889
890        fn make_cache() -> Arc<Mutex<NotificationCache>> {
891            Arc::new(Mutex::new(NotificationCache::new()))
892        }
893
894        fn make_subs() -> Arc<ResourceSubscriptions> {
895            Arc::new(ResourceSubscriptions::new())
896        }
897
898        type PeerCell = Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>;
899
900        fn make_peer_cell() -> PeerCell {
901            Arc::new(OnceCell::new())
902        }
903
904        /// `PublishDiagnostics` is cached even when the peer is not yet connected.
905        #[tokio::test]
906        async fn test_pump_caches_before_peer_set() {
907            let cache = make_cache();
908            let subs = make_subs();
909            let peer_cell = make_peer_cell();
910            let (tx, rx) = mpsc::channel(8);
911            // Keep _cancel_tx alive: dropping it causes cancel_rx.changed() to return Err,
912            // which makes the pump exit before processing any messages.
913            let (_cancel_tx, cancel_rx) = watch::channel(false);
914
915            let c = Arc::clone(&cache);
916            tokio::spawn(diagnostics_pump(
917                "rust".to_string(),
918                rx,
919                c,
920                Arc::clone(&subs),
921                Arc::clone(&peer_cell),
922                cancel_rx,
923                true,
924            ));
925
926            let uri: Uri = "file:///test/main.rs".parse().unwrap();
927            tx.send(LspNotification::PublishDiagnostics(
928                PublishDiagnosticsParams {
929                    uri: uri.clone(),
930                    diagnostics: vec![],
931                    version: None,
932                },
933            ))
934            .await
935            .unwrap();
936            drop(tx);
937
938            // Poll until the pump processes the message or we time out.
939            let cached = tokio::time::timeout(std::time::Duration::from_secs(5), async {
940                loop {
941                    tokio::task::yield_now().await;
942                    let found = {
943                        let guard = cache.lock().await;
944                        guard.get_diagnostics(uri.as_str()).is_some()
945                    };
946                    if found {
947                        return true;
948                    }
949                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
950                }
951            })
952            .await
953            .expect("pump did not cache diagnostics within 5 s");
954            assert!(cached, "diagnostics should be cached before peer is set");
955        }
956
957        /// Pump exits cleanly when the cancel watch sends `true`.
958        #[tokio::test]
959        async fn test_pump_exits_on_cancel() {
960            let cache = make_cache();
961            let subs = make_subs();
962            let peer_cell = make_peer_cell();
963            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
964            let (cancel_tx, cancel_rx) = watch::channel(false);
965
966            let handle = tokio::spawn(diagnostics_pump(
967                "rust".to_string(),
968                rx,
969                cache,
970                subs,
971                peer_cell,
972                cancel_rx,
973                true,
974            ));
975
976            cancel_tx.send(true).unwrap();
977            // Pump must finish within a short time after cancellation.
978            tokio::time::timeout(std::time::Duration::from_millis(200), handle)
979                .await
980                .expect("pump did not exit within timeout")
981                .unwrap();
982        }
983
984        /// Pump exits when the cancel sender is dropped (Err branch).
985        #[tokio::test]
986        async fn test_pump_exits_when_cancel_sender_dropped() {
987            let cache = make_cache();
988            let subs = make_subs();
989            let peer_cell = make_peer_cell();
990            let (_tx, rx) = mpsc::channel::<LspNotification>(8);
991            let (cancel_tx, cancel_rx) = watch::channel(false);
992
993            let handle = tokio::spawn(diagnostics_pump(
994                "rust".to_string(),
995                rx,
996                cache,
997                subs,
998                peer_cell,
999                cancel_rx,
1000                true,
1001            ));
1002
1003            drop(cancel_tx); // triggers Err in cancel_rx.changed()
1004            tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1005                .await
1006                .expect("pump did not exit within timeout")
1007                .unwrap();
1008        }
1009
1010        /// Regression test for #104: the pump must cache a notification promptly
1011        /// even while another task holds the translator lock for far longer than
1012        /// any acceptable pump latency. Before the `NotificationCache` split, the
1013        /// pump locked `Arc<Mutex<Translator>>` to cache diagnostics, so it would
1014        /// have stalled here until the holder released the lock.
1015        #[tokio::test]
1016        async fn test_pump_makes_progress_while_translator_lock_held() {
1017            let translator = Arc::new(Mutex::new(Translator::new()));
1018            let cache = make_cache();
1019            let subs = make_subs();
1020            let peer_cell = make_peer_cell();
1021            let (tx, rx) = mpsc::channel(8);
1022            let (_cancel_tx, cancel_rx) = watch::channel(false);
1023
1024            // Simulate a slow in-flight MCP request (e.g. `pull_diagnostics`)
1025            // holding the translator lock across an LSP round-trip.
1026            let lock_acquired = Arc::new(tokio::sync::Notify::new());
1027            let notify = Arc::clone(&lock_acquired);
1028            let holder = tokio::spawn(async move {
1029                let _guard = translator.lock().await;
1030                notify.notify_one();
1031                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1032            });
1033            lock_acquired.notified().await;
1034
1035            tokio::spawn(diagnostics_pump(
1036                "rust".to_string(),
1037                rx,
1038                Arc::clone(&cache),
1039                subs,
1040                peer_cell,
1041                cancel_rx,
1042                true,
1043            ));
1044
1045            let uri: Uri = "file:///test/locked.rs".parse().unwrap();
1046            tx.send(LspNotification::PublishDiagnostics(
1047                PublishDiagnosticsParams {
1048                    uri: uri.clone(),
1049                    diagnostics: vec![],
1050                    version: None,
1051                },
1052            ))
1053            .await
1054            .unwrap();
1055            drop(tx);
1056
1057            // Well within the 2 s translator lock hold: a translator-locking
1058            // pump would still be blocked at this point.
1059            tokio::time::timeout(std::time::Duration::from_millis(500), async {
1060                loop {
1061                    {
1062                        let guard = cache.lock().await;
1063                        if guard.get_diagnostics(uri.as_str()).is_some() {
1064                            return;
1065                        }
1066                    }
1067                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1068                }
1069            })
1070            .await
1071            .expect("pump stalled behind translator lock");
1072
1073            holder.await.unwrap();
1074        }
1075    }
1076}