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