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