#![cfg_attr(
not(test),
warn(clippy::arithmetic_side_effects, clippy::indexing_slicing)
)]
#![cfg_attr(docsrs, feature(doc_cfg))]
pub mod bridge;
pub mod config;
pub mod error;
pub mod lsp;
pub mod mcp;
mod redaction;
pub(crate) mod runtime;
pub mod transport;
mod util;
#[cfg(test)]
mod test_lsp;
#[cfg(test)]
mod test_support;
use std::collections::HashSet;
use std::sync::Arc;
use bridge::{NotificationCache, Translator, WorkspaceRoots};
pub use config::{
ProjectConfigStatus, ProjectConfigTrust, ServerAllowlist, ServerConfig, WorkspaceTrust,
};
use config::{ServerId, ToolRouter};
pub use error::Error;
use mcp::SubscriptionRegistry;
use runtime::disk_sweep::{spawn_disk_sweep, stop_disk_sweep};
use runtime::{StartPlan, plan_server_starts, shutdown, spawn_lsp_servers_background};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
pub use transport::Transport;
#[cfg(feature = "transport-http")]
use transport::run_http;
#[cfg(feature = "transport-http")]
#[cfg_attr(docsrs, doc(cfg(feature = "transport-http")))]
pub use transport::{
AllowedHost, AllowedOrigin, ConnectionLimit, HeaderReadTimeout, HttpConfig, HttpPath,
InvalidAllowedHost, InvalidAllowedOrigin, InvalidHttpPath, LeaseWindow, ListenLease,
ProbeDeadline, ProbeInterval, RequestBodyLimit, ResponseStreamDeadline, SessionLimit,
StreamLiveness, WriteStallTimeout,
};
use transport::{ShutdownSignal, run_stdio};
pub use util::{escape_control, needs_control_escape};
pub async fn serve(config: ServerConfig) -> Result<(), Error> {
serve_with(config, Transport::Stdio).await
}
async fn plan_off_runtime(
config: &ServerConfig,
redactions: &Arc<redaction::Redactions>,
) -> Result<(WorkspaceRoots, StartPlan), Error> {
let (config, redactions) = (config.clone(), Arc::clone(redactions));
on_blocking_pool(move || {
let roots = WorkspaceRoots::from_configured(&config.workspace.roots)?;
let plan = plan_server_starts(&config, &roots, &redactions);
Ok((roots, plan))
})
.await
.map_err(|source| Error::TaskFailed {
task: error::BackgroundTask::ServerPlanning,
source,
})?
}
pub(crate) async fn on_blocking_pool<T: Send + 'static>(
work: impl FnOnce() -> T + Send + 'static,
) -> Result<T, tokio::task::JoinError> {
match tokio::task::spawn_blocking(work).await {
Ok(value) => Ok(value),
Err(error) => match error.try_into_panic() {
Ok(payload) => std::panic::resume_unwind(payload),
Err(cancelled) => Err(cancelled),
},
}
}
pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
info!("Starting MCPLS server...");
let shutdown_signal = ShutdownSignal::new();
config.validate()?;
let language_map = config.build_effective_language_map();
let startup_redactions = Arc::new(redaction::Redactions::for_servers(
&config.lsp_servers,
lsp::current_environment(),
));
let (workspace_roots, plan) = plan_off_runtime(&config, &startup_redactions).await?;
let (applicable_configs, refused, refusals) = plan.into_parts();
info!(
"Attempting to spawn {} applicable LSP server(s)...",
applicable_configs.len()
);
let router = ToolRouter::from_configs(
applicable_configs
.iter()
.map(lsp::ServerInitConfig::server_config)
.chain(&refused),
)?;
let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
let mut translator = Translator::new()
.with_startup_redactions(Arc::clone(&startup_redactions))
.with_resource_limits(config.workspace.resource_limits())
.with_workspace_trust(config.workspace_trust.clone())
.with_extensions(language_map)
.with_file_patterns(
config
.lsp_servers
.iter()
.flat_map(|server| server.file_patterns.iter().cloned()),
)
.with_router(router)
.with_notification_cache(Arc::clone(¬ification_cache))
.with_indexing_ready_timeout(config.workspace.indexing_ready_timeout_seconds);
let (project_config_status, mcp) = (config.project_config_status, config.mcp);
let max_concurrent_server_starts = config.workspace.max_concurrent_server_starts;
translator.set_workspace_roots(workspace_roots.clone());
let expected_servers: HashSet<ServerId> = applicable_configs
.iter()
.map(|c| c.server_config().id())
.collect();
translator.set_expected_servers(expected_servers);
translator.record_refusals(&refusals);
let translator = Arc::new(translator);
let subscription_registry = SubscriptionRegistry::new();
let cancel = CancellationToken::new();
let _cancel_on_drop = cancel.clone().drop_guard();
let lsp_init_handle = if applicable_configs.is_empty() {
warn_protocol_only(if refusals.is_empty() {
ProtocolOnlyCause::NothingConfigured
} else {
ProtocolOnlyCause::AllRefused
});
None
} else {
info!(
"Spawning {} LSP server(s) in the background...",
applicable_configs.len()
);
Some(spawn_lsp_servers_background(
applicable_configs,
Arc::clone(&translator),
Arc::clone(¬ification_cache),
subscription_registry.clone(),
cancel.clone(),
workspace_roots.clone(),
max_concurrent_server_starts,
))
};
let disk_sweep = spawn_disk_sweep(
&translator,
¬ification_cache,
&subscription_registry,
&cancel,
);
info!("Starting MCP server with rmcp...");
let mcp_server = mcp::McplsServer::new(
Arc::clone(&translator),
Arc::clone(¬ification_cache),
workspace_roots,
subscription_registry,
project_config_status,
mcp,
);
info!("MCPLS server initialized successfully");
let result = match transport {
Transport::Stdio => {
info!("Listening for MCP requests on stdio...");
run_stdio(mcp_server, shutdown_signal).await
}
#[cfg(feature = "transport-http")]
Transport::Http(cfg) => run_http(mcp_server, cfg, shutdown_signal).await,
};
stop_disk_sweep(disk_sweep).await;
shutdown(&cancel, &translator, lsp_init_handle).await;
info!("MCPLS server shutting down");
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProtocolOnlyCause {
NothingConfigured,
AllRefused,
}
fn warn_protocol_only(cause: ProtocolOnlyCause) {
match cause {
ProtocolOnlyCause::NothingConfigured => {
warn!("No applicable LSP servers configured — starting in protocol-only mode");
}
ProtocolOnlyCause::AllRefused => warn!(
"The workspace is untrusted and no applicable LSP server was allowed — starting \
in protocol-only mode; allow servers with `--allow-server <id>`"
),
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
use crate::config::{
IndexingReadyTimeoutSecs, LanguageId, PositionEncodings, ServerStartConcurrency,
TimeoutSecs,
};
#[tokio::test]
async fn test_on_blocking_pool_returns_the_value_and_propagates_a_panic() {
assert_eq!(on_blocking_pool(|| 7).await.unwrap(), 7);
let panicked =
crate::util::catch_panic(on_blocking_pool(|| -> u8 { panic!("planning exploded") }))
.await
.unwrap_err();
assert_eq!(panicked.message(), "planning exploded");
}
#[tokio::test]
async fn test_on_blocking_pool_runs_off_the_calling_thread() {
let caller = std::thread::current().id();
let worker = on_blocking_pool(|| std::thread::current().id())
.await
.unwrap();
assert_ne!(caller, worker);
}
mod graceful_degradation_tests {
use super::*;
use crate::config::{DocumentLimit, FilePattern, SearchDepth, ServerCommand, SizeLimit};
#[tokio::test]
async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
use crate::config::{LspServerConfig, ServerStartConcurrency, WorkspaceConfig};
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![crate::config::ConfiguredRoot::new("/tmp/test-workspace").unwrap()],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: vec![],
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
},
lsp_servers: vec![LspServerConfig {
language_id: LanguageId::from_static("rust"),
command: ServerCommand::from_static("nonexistent-command-that-will-fail-12345")
.into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![FilePattern::from_static("**/*.rs")],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(10).unwrap(),
request_timeout_seconds: TimeoutSecs::new(10).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
}],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
match outcome {
Err(_elapsed) => {}
Ok(Ok(())) => {}
Ok(Err(err)) => assert!(
!matches!(err, Error::AllServersFailedToInit { .. }),
"serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
),
}
}
#[tokio::test]
#[cfg(all(unix, feature = "transport-http"))]
async fn test_dropping_serve_with_abandons_a_server_still_initializing() {
use crate::config::{LspServerConfig, ServerStartConcurrency, WorkspaceConfig};
let dir = tempfile::TempDir::new().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let pid_file = root.join("pid");
let script = format!("echo $$ > {}; exec sleep 600", pid_file.display());
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![crate::config::ConfiguredRoot::new(&root).unwrap()],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: vec![],
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
},
lsp_servers: vec![LspServerConfig {
language_id: LanguageId::from_static("rust"),
command: ServerCommand::from_static("sh").into(),
args: crate::config::ServerArgs::new(["-c".to_owned(), script]).unwrap(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![FilePattern::from_static("**/*.rs")],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
}],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let http = crate::transport::HttpConfig::new("127.0.0.1:0".parse().unwrap());
let serving = tokio::spawn(serve_with(config, Transport::Http(http)));
let pid = tokio::time::timeout(std::time::Duration::from_secs(15), async {
loop {
if let Ok(text) = std::fs::read_to_string(&pid_file)
&& let Some(pid) = text.trim().parse::<u32>().ok()
{
break pid;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("the server was never spawned");
serving.abort();
drop(serving.await);
let alive = |pid: u32| {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
};
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while alive(pid) {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await
.expect("the abandoned server process is still running");
}
#[tokio::test]
async fn test_serve_starts_with_empty_config() {
use crate::config::WorkspaceConfig;
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![crate::config::ConfiguredRoot::new("/tmp/test-workspace").unwrap()],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: vec![],
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
},
lsp_servers: vec![],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let result = serve(config).await;
if let Err(ref err) = result {
assert!(
!matches!(err, Error::AllServersFailedToInit { .. }),
"serve() must not return AllServersFailedToInit for empty lsp_servers config"
);
}
}
#[tokio::test]
#[cfg(unix)]
async fn test_serve_with_all_absolute_roots_skips_current_dir() {
use crate::config::WorkspaceConfig;
use crate::test_support::CwdGuard;
let workspace_root_dir = tempfile::TempDir::new().unwrap();
let workspace_root = dunce::canonicalize(workspace_root_dir.path()).unwrap();
let doomed_cwd = tempfile::TempDir::new().unwrap();
let _guard = CwdGuard::enter(doomed_cwd.path());
doomed_cwd.close().unwrap();
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![crate::config::ConfiguredRoot::new(workspace_root).unwrap()],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: vec![],
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
},
lsp_servers: vec![],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
match outcome {
Err(_elapsed) => {}
Ok(Ok(())) => {}
Ok(Err(err)) => assert!(
!matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
"serve() must not need a working process cwd for an all-absolute \
workspace.roots; got: {err:?}"
),
}
}
#[tokio::test]
async fn test_serve_rejects_invalid_caller_supplied_config() {
use crate::config::{LspServerConfig, ServerStartConcurrency, WorkspaceConfig};
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: vec![],
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
},
lsp_servers: vec![LspServerConfig {
language_id: LanguageId::from_static("rust"),
command: ServerCommand::from_static("rust-analyzer").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![FilePattern::from_static("**/*.rs")],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(10).unwrap(),
request_timeout_seconds: TimeoutSecs::new(10).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
}],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::untrusted([
crate::config::ServerId::from_static("not-configured"),
]),
};
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
match outcome {
Err(elapsed) => panic!(
"serve() must reject the invalid config immediately, not hang until \
timeout: {elapsed}"
),
Ok(result) => assert_matches!(
result,
Err(Error::Config(_)),
"serve() must reject a caller-supplied config that allows an unconfigured server \
via Error::Config, matching the load_from path; got: {result:?}"
),
}
}
}
}