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