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(¬ification_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(¬ification_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(¬ification_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}