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());
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
38#![cfg_attr(
39    not(test),
40    warn(clippy::arithmetic_side_effects, clippy::indexing_slicing)
41)]
42#![cfg_attr(docsrs, feature(doc_cfg))]
43
44pub mod bridge;
45pub mod config;
46pub mod error;
47pub mod lsp;
48pub mod mcp;
49mod redaction;
50pub(crate) mod runtime;
51pub mod transport;
52mod util;
53
54#[cfg(test)]
55mod test_lsp;
56#[cfg(test)]
57mod test_support;
58
59use std::collections::HashSet;
60use std::sync::Arc;
61
62use bridge::{NotificationCache, Translator, WorkspaceRoots};
63pub use config::{
64    ProjectConfigStatus, ProjectConfigTrust, ServerAllowlist, ServerConfig, WorkspaceTrust,
65};
66use config::{ServerId, ToolRouter};
67pub use error::Error;
68use mcp::SubscriptionRegistry;
69use runtime::disk_sweep::{spawn_disk_sweep, stop_disk_sweep};
70use runtime::{StartPlan, plan_server_starts, shutdown, spawn_lsp_servers_background};
71use tokio::sync::Mutex;
72use tokio_util::sync::CancellationToken;
73use tracing::{info, warn};
74pub use transport::Transport;
75#[cfg(feature = "transport-http")]
76use transport::run_http;
77#[cfg(feature = "transport-http")]
78#[cfg_attr(docsrs, doc(cfg(feature = "transport-http")))]
79pub use transport::{
80    AllowedHost, AllowedOrigin, ConnectionLimit, HeaderReadTimeout, HttpConfig, HttpPath,
81    InvalidAllowedHost, InvalidAllowedOrigin, InvalidHttpPath, LeaseWindow, ListenLease,
82    ProbeDeadline, ProbeInterval, RequestBodyLimit, ResponseStreamDeadline, SessionLimit,
83    StreamLiveness, WriteStallTimeout,
84};
85use transport::{ShutdownSignal, run_stdio};
86pub use util::{escape_control, needs_control_escape};
87
88/// Start the MCPLS server with the given configuration over stdio.
89///
90/// This is the backward-compatible entry point. It is equivalent to calling
91/// `serve_with(config, Transport::Stdio)`.
92///
93/// # Errors
94///
95/// Returns an error if:
96/// - MCP server setup fails
97/// - Configuration is invalid
98///
99/// # Graceful Degradation
100///
101/// - **All servers succeed**: Service runs normally
102/// - **Partial success**: Logs warnings for failures, continues with available servers
103/// - **All servers fail**: Keeps serving; tools for the affected languages return `Error::ServerFailedToStart` with the spawn failure
104///
105/// # Shutdown
106///
107/// See [`serve_with`]'s "Shutdown" section — this function uses
108/// [`Transport::Stdio`], so the same `std::process::exit` requirement
109/// applies to callers.
110pub async fn serve(config: ServerConfig) -> Result<(), Error> {
111    serve_with(config, Transport::Stdio).await
112}
113
114/// Resolves the workspace roots and plans the server starts on the blocking
115/// pool, since both walk the file system.
116///
117/// `initialize` still waits for the plan: it is needed to build the router the
118/// MCP server answers from. Only the runtime workers stay free.
119async fn plan_off_runtime(
120    config: &ServerConfig,
121    redactions: &Arc<redaction::Redactions>,
122) -> Result<(WorkspaceRoots, StartPlan), Error> {
123    let (config, redactions) = (config.clone(), Arc::clone(redactions));
124    on_blocking_pool(move || {
125        let roots = WorkspaceRoots::from_configured(&config.workspace.roots)?;
126        let plan = plan_server_starts(&config, &roots, &redactions);
127        Ok((roots, plan))
128    })
129    .await
130    .map_err(|source| Error::TaskFailed {
131        task: error::BackgroundTask::ServerPlanning,
132        source,
133    })?
134}
135
136/// Runs `work` on the blocking pool and returns its result, re-raising a panic
137/// in it on the caller. A join that failed for any other reason (the runtime
138/// shutting down cancelled it) is returned as the error.
139pub(crate) async fn on_blocking_pool<T: Send + 'static>(
140    work: impl FnOnce() -> T + Send + 'static,
141) -> Result<T, tokio::task::JoinError> {
142    match tokio::task::spawn_blocking(work).await {
143        Ok(value) => Ok(value),
144        Err(error) => match error.try_into_panic() {
145            Ok(payload) => std::panic::resume_unwind(payload),
146            Err(cancelled) => Err(cancelled),
147        },
148    }
149}
150
151/// Start the MCPLS server with an explicit transport.
152///
153/// Performs all shared setup (workspace discovery, LSP spawning, translator
154/// initialization, diagnostic pump tasks) and then delegates to the
155/// appropriate transport runner.
156///
157/// # Errors
158///
159/// Returns an error if:
160/// - The MCP server or transport fails to start
161/// - Configuration is invalid, including two applicable `[[lsp_servers]]`
162///   entries whose per-tool routing is ambiguous in this workspace (shared
163///   routing identity, two catch-alls, or the same tool claimed by both) --
164///   see `config::ToolRouter::from_configs`
165///
166/// # DNS rebinding protection (HTTP transport)
167///
168/// When using `Transport::Http`, the underlying rmcp service validates the
169/// inbound `Host` header against an allowlist that defaults to loopback
170/// addresses only (`localhost`, `127.0.0.1`, `::1`). Requests with any other
171/// `Host` value are rejected with `421 Misdirected Request`.
172///
173/// If you bind to a non-loopback address (e.g. `0.0.0.0:3000`) and expose the
174/// service through a reverse proxy, the proxy must forward `Host: localhost`
175/// (or another loopback alias) to the mcpls process. Direct non-loopback
176/// access is intentionally blocked to prevent DNS-rebinding attacks.
177///
178/// # Shutdown
179///
180/// [`Transport::Stdio`] is backed by `tokio::io::stdin()`, which internally
181/// parks an uncancellable blocking-pool thread in a raw `read()` syscall
182/// that only returns on more input or EOF. If your `main` uses
183/// `#[tokio::main]` and simply returns after awaiting this function, the
184/// macro-generated runtime-shutdown wrapper blocks waiting for that thread
185/// -- hanging indefinitely on `SIGTERM`/`SIGINT` as long as the MCP
186/// client's stdin write end is still open, since that never triggers EOF.
187/// Call `std::process::exit` right after this function resolves instead of
188/// returning normally from `main`, as in the example below (see mcpls's own
189/// `mcpls-cli` binary; tracked as #308). This does not apply to
190/// `Transport::Http`, which never touches `tokio::io::stdin()`.
191///
192/// # Examples
193///
194/// ```rust,ignore
195/// use mcpls_core::{serve_with, Transport, ServerConfig};
196///
197/// #[tokio::main]
198/// async fn main() {
199///     let config = ServerConfig::load().expect("failed to load config");
200///     let exit_code = match serve_with(config, Transport::Stdio).await {
201///         Ok(()) => 0,
202///         Err(_) => 1,
203///     };
204///     // See "Shutdown" above: process::exit avoids a runtime-shutdown hang.
205///     std::process::exit(exit_code);
206/// }
207/// ```
208pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
209    info!("Starting MCPLS server...");
210
211    // Registered before any other startup work -- including
212    // `spawn_lsp_servers_background` below, which spawns LSP child processes
213    // concurrently on another worker thread -- so a `SIGTERM`/`SIGINT`
214    // arriving during config validation, workspace-root heuristics, or LSP
215    // spawning is caught rather than hitting the OS's default disposition
216    // (immediate termination, skipping the `shutdown()` cleanup below
217    // entirely; any LSP child mid-spawn still dies with the lifeline/job
218    // binding, see #270 and #526). See
219    // `ShutdownSignal`'s docs for why this must be a single instance carried
220    // through by value rather than re-registered later.
221    let shutdown_signal = ShutdownSignal::new();
222
223    // A programmatic `ServerConfig` skips the allowlist check the `load_*` functions run.
224    config.validate()?;
225
226    let language_map = config.build_effective_language_map();
227
228    let startup_redactions = Arc::new(redaction::Redactions::for_servers(
229        &config.lsp_servers,
230        lsp::current_environment(),
231    ));
232    let (workspace_roots, plan) = plan_off_runtime(&config, &startup_redactions).await?;
233    let (applicable_configs, refused, refusals) = plan.into_parts();
234
235    info!(
236        "Attempting to spawn {} applicable LSP server(s)...",
237        applicable_configs.len()
238    );
239
240    // Built over the applicable (post-heuristics) configs only: this is where
241    // #174's workspace-scoped routing rules (duplicate ServerId, conflicting
242    // `handles` claims) are enforced -- a startup error naming the
243    // conflicting `[[lsp_servers]]` entries, not a silent drop.
244    let router = ToolRouter::from_configs(
245        applicable_configs
246            .iter()
247            .map(lsp::ServerInitConfig::server_config)
248            .chain(&refused),
249    )?;
250
251    // Built here (rather than alongside `subscription_registry` below) so
252    // it can be handed to the translator, which uses it to invalidate a
253    // respawned server's stale cached diagnostics -- see
254    // `Translator::with_notification_cache`. Independent of `translator`
255    // itself, which holds no outer lock: the pump only ever locks this
256    // cache, so it never contends with a request handler running an
257    // in-flight LSP round-trip.
258    let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
259
260    let mut translator = Translator::new()
261        .with_startup_redactions(Arc::clone(&startup_redactions))
262        .with_resource_limits(config.workspace.resource_limits())
263        .with_workspace_trust(config.workspace_trust.clone())
264        .with_extensions(language_map)
265        .with_file_patterns(
266            config
267                .lsp_servers
268                .iter()
269                .flat_map(|server| server.file_patterns.iter().cloned()),
270        )
271        .with_router(router)
272        .with_notification_cache(Arc::clone(&notification_cache))
273        .with_indexing_ready_timeout(config.workspace.indexing_ready_timeout_seconds);
274    // moved, not cloned -- `config`'s last use is above
275    let (project_config_status, mcp) = (config.project_config_status, config.mcp);
276    let max_concurrent_server_starts = config.workspace.max_concurrent_server_starts;
277    translator.set_workspace_roots(workspace_roots.clone());
278
279    // Mark applicable servers as "expected" so a tool call that arrives while
280    // its server is still initializing gets a clear "still initializing" error
281    // (instead of "no server configured"), telling the caller to wait and retry.
282    let expected_servers: HashSet<ServerId> = applicable_configs
283        .iter()
284        .map(|c| c.server_config().id())
285        .collect();
286    translator.set_expected_servers(expected_servers);
287    translator.record_refusals(&refusals);
288
289    // Shared state, built BEFORE LSP initialization so the MCP server can answer
290    // `initialize` immediately. LSP servers (which can take minutes to initialize
291    // on a large solution, e.g. a 130-project Unity .sln via OmniSharp) are spawned
292    // in a background task and registered into this shared translator once ready.
293    // Blocking the MCP handshake on LSP init makes slow servers exceed the client's
294    // initialize-request timeout (Claude Code: ~60s) -> "Request timed out".
295    // `workspace_roots` is fixed for the server's lifetime and cheap to clone,
296    // so cache-only handlers (e.g. `get_cached_diagnostics`, `read_resource`)
297    // validate a path from their own copy without locking `translator` below.
298
299    let translator = Arc::new(translator);
300    // Shared across every session (one per HTTP session, or the sole stdio
301    // session): each joins it on its first subscribe -- see
302    // `SubscriptionRegistry`.
303    let subscription_registry = SubscriptionRegistry::new();
304
305    // Cancels the pump and startup tasks; dropping `serve_with` cancels it too.
306    let cancel = CancellationToken::new();
307    let _cancel_on_drop = cancel.clone().drop_guard();
308
309    let lsp_init_handle = if applicable_configs.is_empty() {
310        warn_protocol_only(if refusals.is_empty() {
311            ProtocolOnlyCause::NothingConfigured
312        } else {
313            ProtocolOnlyCause::AllRefused
314        });
315        None
316    } else {
317        info!(
318            "Spawning {} LSP server(s) in the background...",
319            applicable_configs.len()
320        );
321        Some(spawn_lsp_servers_background(
322            applicable_configs,
323            Arc::clone(&translator),
324            Arc::clone(&notification_cache),
325            subscription_registry.clone(),
326            cancel.clone(),
327            workspace_roots.clone(),
328            max_concurrent_server_starts,
329        ))
330    };
331    let disk_sweep = spawn_disk_sweep(
332        &translator,
333        &notification_cache,
334        &subscription_registry,
335        &cancel,
336    );
337
338    info!("Starting MCP server with rmcp...");
339    let mcp_server = mcp::McplsServer::new(
340        Arc::clone(&translator),
341        Arc::clone(&notification_cache),
342        workspace_roots,
343        subscription_registry,
344        project_config_status,
345        mcp,
346    );
347    info!("MCPLS server initialized successfully");
348
349    let result = match transport {
350        Transport::Stdio => {
351            info!("Listening for MCP requests on stdio...");
352            run_stdio(mcp_server, shutdown_signal).await
353        }
354        #[cfg(feature = "transport-http")]
355        Transport::Http(cfg) => run_http(mcp_server, cfg, shutdown_signal).await,
356    };
357
358    stop_disk_sweep(disk_sweep).await;
359    shutdown(&cancel, &translator, lsp_init_handle).await;
360
361    info!("MCPLS server shutting down");
362    result
363}
364
365/// Why no LSP server is started.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367enum ProtocolOnlyCause {
368    /// The configuration has no server applicable to the workspace.
369    NothingConfigured,
370    /// An untrusted workspace refused every applicable server.
371    AllRefused,
372}
373
374/// Logs why no LSP server is started.
375fn warn_protocol_only(cause: ProtocolOnlyCause) {
376    match cause {
377        ProtocolOnlyCause::NothingConfigured => {
378            warn!("No applicable LSP servers configured — starting in protocol-only mode");
379        }
380        ProtocolOnlyCause::AllRefused => warn!(
381            "The workspace is untrusted and no applicable LSP server was allowed — starting \
382             in protocol-only mode; allow servers with `--allow-server <id>`"
383        ),
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use std::assert_matches;
390
391    use super::*;
392    use crate::config::{
393        IndexingReadyTimeoutSecs, LanguageId, PositionEncodings, ServerStartConcurrency,
394        TimeoutSecs,
395    };
396
397    /// #659: a panic in the planning work reaches the caller instead of
398    /// being swallowed by the blocking pool.
399    #[tokio::test]
400    async fn test_on_blocking_pool_returns_the_value_and_propagates_a_panic() {
401        assert_eq!(on_blocking_pool(|| 7).await.unwrap(), 7);
402
403        let panicked =
404            crate::util::catch_panic(on_blocking_pool(|| -> u8 { panic!("planning exploded") }))
405                .await
406                .unwrap_err();
407        assert_eq!(panicked.message(), "planning exploded");
408    }
409
410    /// #679: the work runs on a blocking-pool thread, not on the caller's.
411    #[tokio::test]
412    async fn test_on_blocking_pool_runs_off_the_calling_thread() {
413        let caller = std::thread::current().id();
414        let worker = on_blocking_pool(|| std::thread::current().id())
415            .await
416            .unwrap();
417        assert_ne!(caller, worker);
418    }
419
420    // Tests for graceful degradation behavior
421    mod graceful_degradation_tests {
422        use super::*;
423        use crate::config::{DocumentLimit, FilePattern, SearchDepth, ServerCommand, SizeLimit};
424
425        #[tokio::test]
426        async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
427            use crate::config::{LspServerConfig, ServerStartConcurrency, WorkspaceConfig};
428
429            // A configured server whose command cannot spawn used to make serve()
430            // fail synchronously with AllServersFailedToInit.
431            // LSP initialization now runs in a background task so the MCP
432            // `initialize` handshake is never blocked, which means the spawn
433            // failure is handled in the background instead: serve() starts the MCP
434            // server in degraded mode (mirroring `test_serve_starts_with_empty_config`)
435            // rather than failing fast. Any error it surfaces must therefore be a
436            // transport/MCP error from the closed test connection, NOT a fail-fast
437            // server-availability error.
438            let config = ServerConfig {
439                mcp: crate::config::McpConfig::default(),
440                workspace: WorkspaceConfig {
441                    roots: vec![crate::config::ConfiguredRoot::new("/tmp/test-workspace").unwrap()],
442                    position_encodings: PositionEncodings::DEFAULT,
443                    language_extensions: vec![],
444                    heuristics_max_depth: SearchDepth::DEFAULT,
445                    max_documents: DocumentLimit::DEFAULT,
446                    max_file_size: SizeLimit::DEFAULT,
447                    indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
448                    max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
449                },
450                lsp_servers: vec![LspServerConfig {
451                    language_id: LanguageId::from_static("rust"),
452                    command: ServerCommand::from_static("nonexistent-command-that-will-fail-12345")
453                        .into(),
454                    args: crate::config::ServerArgs::default(),
455                    env: crate::config::ServerEnv::default(),
456                    file_patterns: vec![FilePattern::from_static("**/*.rs")],
457                    initialization_options: None,
458                    settings: None,
459                    timeout_seconds: TimeoutSecs::new(10).unwrap(),
460                    request_timeout_seconds: TimeoutSecs::new(10).unwrap(),
461                    heuristics: None,
462                    name: None,
463                    handles: None,
464                    indexing: crate::bridge::IndexingPolicy::Auto,
465                }],
466                project_config_status: ProjectConfigStatus::NotIgnored,
467                workspace_trust: crate::config::WorkspaceTrust::default(),
468            };
469
470            // serve() proceeds to run the MCP server and blocks on the stdio
471            // transport until EOF; bound it so the test can't hang if stdin stays
472            // open (e.g. under multi-threaded `cargo test`, where several serve()
473            // tests share the process stdin).
474            let outcome =
475                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
476
477            match outcome {
478                // Still serving after the deadline => it did not fail fast. Good.
479                Err(_elapsed) => {}
480                // Transport closed cleanly. Also fine.
481                Ok(Ok(())) => {}
482                // It returned an error: it must not be a fail-fast availability error.
483                Ok(Err(err)) => assert!(
484                    !matches!(err, Error::AllServersFailedToInit { .. }),
485                    "serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
486                ),
487            }
488        }
489
490        /// #674: dropping the `serve_with` future cancels startup, so a server
491        /// still initializing is abandoned and its process killed, instead of
492        /// lingering until its init timeout.
493        #[tokio::test]
494        #[cfg(all(unix, feature = "transport-http"))]
495        async fn test_dropping_serve_with_abandons_a_server_still_initializing() {
496            use crate::config::{LspServerConfig, ServerStartConcurrency, WorkspaceConfig};
497
498            let dir = tempfile::TempDir::new().unwrap();
499            let root = dunce::canonicalize(dir.path()).unwrap();
500            let pid_file = root.join("pid");
501            let script = format!("echo $$ > {}; exec sleep 600", pid_file.display());
502            let config = ServerConfig {
503                mcp: crate::config::McpConfig::default(),
504                workspace: WorkspaceConfig {
505                    roots: vec![crate::config::ConfiguredRoot::new(&root).unwrap()],
506                    position_encodings: PositionEncodings::DEFAULT,
507                    language_extensions: vec![],
508                    heuristics_max_depth: SearchDepth::DEFAULT,
509                    max_documents: DocumentLimit::DEFAULT,
510                    max_file_size: SizeLimit::DEFAULT,
511                    indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
512                    max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
513                },
514                lsp_servers: vec![LspServerConfig {
515                    language_id: LanguageId::from_static("rust"),
516                    command: ServerCommand::from_static("sh").into(),
517                    args: crate::config::ServerArgs::new(["-c".to_owned(), script]).unwrap(),
518                    env: crate::config::ServerEnv::default(),
519                    file_patterns: vec![FilePattern::from_static("**/*.rs")],
520                    initialization_options: None,
521                    settings: None,
522                    timeout_seconds: TimeoutSecs::new(30).unwrap(),
523                    request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
524                    heuristics: None,
525                    name: None,
526                    handles: None,
527                    indexing: crate::bridge::IndexingPolicy::Auto,
528                }],
529                project_config_status: ProjectConfigStatus::NotIgnored,
530                workspace_trust: crate::config::WorkspaceTrust::default(),
531            };
532            let http = crate::transport::HttpConfig::new("127.0.0.1:0".parse().unwrap());
533            let serving = tokio::spawn(serve_with(config, Transport::Http(http)));
534
535            let pid = tokio::time::timeout(std::time::Duration::from_secs(15), async {
536                loop {
537                    if let Ok(text) = std::fs::read_to_string(&pid_file)
538                        && let Some(pid) = text.trim().parse::<u32>().ok()
539                    {
540                        break pid;
541                    }
542                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
543                }
544            })
545            .await
546            .expect("the server was never spawned");
547
548            serving.abort();
549            drop(serving.await);
550
551            let alive = |pid: u32| {
552                std::process::Command::new("kill")
553                    .args(["-0", &pid.to_string()])
554                    .stderr(std::process::Stdio::null())
555                    .status()
556                    .is_ok_and(|status| status.success())
557            };
558            tokio::time::timeout(std::time::Duration::from_secs(10), async {
559                while alive(pid) {
560                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
561                }
562            })
563            .await
564            .expect("the abandoned server process is still running");
565        }
566
567        #[tokio::test]
568        async fn test_serve_starts_with_empty_config() {
569            use crate::config::WorkspaceConfig;
570
571            // Server starts in protocol-only mode when no LSP servers are configured.
572            // serve() blocks until the MCP transport closes, so it will error with a
573            // connection/transport error — not AllServersFailedToInit.
574            let config = ServerConfig {
575                mcp: crate::config::McpConfig::default(),
576                workspace: WorkspaceConfig {
577                    roots: vec![crate::config::ConfiguredRoot::new("/tmp/test-workspace").unwrap()],
578                    position_encodings: PositionEncodings::DEFAULT,
579                    language_extensions: vec![],
580                    heuristics_max_depth: SearchDepth::DEFAULT,
581                    max_documents: DocumentLimit::DEFAULT,
582                    max_file_size: SizeLimit::DEFAULT,
583                    indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
584                    max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
585                },
586                lsp_servers: vec![],
587                project_config_status: ProjectConfigStatus::NotIgnored,
588                workspace_trust: crate::config::WorkspaceTrust::default(),
589            };
590
591            let result = serve(config).await;
592
593            // serve() may succeed or fail with a transport error, but must NOT
594            // return AllServersFailedToInit when the config simply has no servers.
595            if let Err(ref err) = result {
596                assert!(
597                    !matches!(err, Error::AllServersFailedToInit { .. }),
598                    "serve() must not return AllServersFailedToInit for empty lsp_servers config"
599                );
600            }
601        }
602
603        /// #348 case 1 (tester-flagged coverage gap): proves `serve_with`
604        /// itself skips `current_dir()` for an all-absolute
605        /// `workspace.roots`, not just that `WorkspaceRoots::from_configured_with`
606        /// tolerates an unread cwd when called directly (see
607        /// `test_from_configured_ignores_cwd_when_all_absolute` in
608        /// `bridge::workspace_roots`, which never exercises `serve_with`'s
609        /// wiring). Mutates the process cwd (chdir into a directory,
610        /// then remove it -- `current_dir()` reliably fails afterward on
611        /// Unix), so it uses the crate-shared `test_support::CwdGuard` --
612        /// the same lock/restore-on-drop `config::tests` uses -- rather than
613        /// a one-off guard, since both modules' tests mutate cwd and compile
614        /// into one binary. Unix-only since removing a directory that is
615        /// still a live process's cwd is a Windows-specific error case, not
616        /// the same reproducible `current_dir()` failure.
617        #[tokio::test]
618        #[cfg(unix)]
619        async fn test_serve_with_all_absolute_roots_skips_current_dir() {
620            use crate::config::WorkspaceConfig;
621            use crate::test_support::CwdGuard;
622
623            // Kept alive for the whole test so the configured workspace root
624            // stays a valid, existing absolute directory distinct from the
625            // cwd this test is about to remove.
626            let workspace_root_dir = tempfile::TempDir::new().unwrap();
627            let workspace_root = dunce::canonicalize(workspace_root_dir.path()).unwrap();
628
629            let doomed_cwd = tempfile::TempDir::new().unwrap();
630            let _guard = CwdGuard::enter(doomed_cwd.path());
631            doomed_cwd.close().unwrap();
632
633            let config = ServerConfig {
634                mcp: crate::config::McpConfig::default(),
635                workspace: WorkspaceConfig {
636                    roots: vec![crate::config::ConfiguredRoot::new(workspace_root).unwrap()],
637                    position_encodings: PositionEncodings::DEFAULT,
638                    language_extensions: vec![],
639                    heuristics_max_depth: SearchDepth::DEFAULT,
640                    max_documents: DocumentLimit::DEFAULT,
641                    max_file_size: SizeLimit::DEFAULT,
642                    indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
643                    max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
644                },
645                lsp_servers: vec![],
646                project_config_status: ProjectConfigStatus::NotIgnored,
647                workspace_trust: crate::config::WorkspaceTrust::default(),
648            };
649
650            // serve() with no LSP servers configured blocks on the stdio
651            // transport, same as `test_serve_starts_with_empty_config`;
652            // bound it so the test can't hang.
653            let outcome =
654                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
655
656            match outcome {
657                // Still serving after the deadline => it did not fail fast. Good.
658                Err(_elapsed) => {}
659                // Transport closed cleanly. Also fine.
660                Ok(Ok(())) => {}
661                // It returned an error: it must not be the `current_dir()`
662                // failure this test set up (`ErrorKind::NotFound` from the
663                // removed cwd). Narrowed to that specific `io::ErrorKind`
664                // rather than any `Error::Io`, since the latter would also
665                // match an unrelated IO error from the stdio transport
666                // within the timeout window.
667                Ok(Err(err)) => assert!(
668                    !matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
669                    "serve() must not need a working process cwd for an all-absolute \
670                     workspace.roots; got: {err:?}"
671                ),
672            }
673        }
674
675        /// #282: a `ServerConfig` built programmatically (not via `load`/
676        /// `load_from`, which already run `validate()`) previously skipped
677        /// validation entirely, so `serve`/`serve_with` never rejected it —
678        /// misconfiguration only surfaced later as silent accessor-level
679        /// clamping. `serve` delegates straight to `serve_with`, so
680        /// exercising it here also covers `serve_with`'s own `validate()`
681        /// call. `validate()` runs before any LSP spawn or transport setup,
682        /// so this returns immediately without needing a timeout guard.
683        #[tokio::test]
684        async fn test_serve_rejects_invalid_caller_supplied_config() {
685            use crate::config::{LspServerConfig, ServerStartConcurrency, WorkspaceConfig};
686
687            let config = ServerConfig {
688                mcp: crate::config::McpConfig::default(),
689                workspace: WorkspaceConfig {
690                    roots: vec![],
691                    position_encodings: PositionEncodings::DEFAULT,
692                    language_extensions: vec![],
693                    heuristics_max_depth: SearchDepth::DEFAULT,
694                    max_documents: DocumentLimit::DEFAULT,
695                    max_file_size: SizeLimit::DEFAULT,
696                    indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
697                    max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
698                },
699                lsp_servers: vec![LspServerConfig {
700                    language_id: LanguageId::from_static("rust"),
701                    command: ServerCommand::from_static("rust-analyzer").into(),
702                    args: crate::config::ServerArgs::default(),
703                    env: crate::config::ServerEnv::default(),
704                    file_patterns: vec![FilePattern::from_static("**/*.rs")],
705                    initialization_options: None,
706                    settings: None,
707                    timeout_seconds: TimeoutSecs::new(10).unwrap(),
708                    request_timeout_seconds: TimeoutSecs::new(10).unwrap(),
709                    heuristics: None,
710                    name: None,
711                    handles: None,
712                    indexing: crate::bridge::IndexingPolicy::Auto,
713                }],
714                project_config_status: ProjectConfigStatus::NotIgnored,
715                workspace_trust: crate::config::WorkspaceTrust::untrusted([
716                    crate::config::ServerId::from_static("not-configured"),
717                ]),
718            };
719
720            // `validate()` runs before any spawn/transport work and should
721            // return immediately; bound it anyway so a regression that lets
722            // an invalid config reach the stdio transport fails fast with a
723            // clear timeout instead of hanging nextest for the default 120s
724            // (mirroring the guard on `test_serve_degrades_when_all_servers_fail_to_spawn`).
725            let outcome =
726                tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
727
728            match outcome {
729                Err(elapsed) => panic!(
730                    "serve() must reject the invalid config immediately, not hang until \
731                     timeout: {elapsed}"
732                ),
733                Ok(result) => assert_matches!(
734                    result,
735                    Err(Error::Config(_)),
736                    "serve() must reject a caller-supplied config that allows an unconfigured server \
737                     via Error::Config, matching the load_from path; got: {result:?}"
738                ),
739            }
740        }
741    }
742}