diff --git a/CHANGELOG.md b/CHANGELOG.md index b52b0fd5..f62c2ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Shared `DEFAULT_LSP_TIMEOUT` constant** — the 16 handler methods in `bridge::translator` that each duplicated `Duration::from_secs(30)` now share one module-level constant; `get_completions`'s intentionally shorter timeout is now the named `COMPLETIONS_LSP_TIMEOUT` constant. No behavior change. (#231) +- Regression test pinning RFC 3986 §2.2 percent-encoding of `[`, `]`, `^`, `|`, `{`, `}`, and backtick in the `try_path_to_uri`/`encode_rfc3986_path_chars` `file://` URI conversion; confirms the `url` crate already encodes `{`, `}`, and backtick, so no code change was needed for those three. Scope: covers `file://` URI conversion only — `bridge::resources`'s separate `lsp-diagnostics://` URI construction does not call `encode_rfc3986_path_chars` and is not covered here. (#168) +- Unit tests for `LspClient::should_retrigger` and its wiring into the `ServerCancelled` (-32802) retry loop: full retry exhaustion returns the original error, `retriggerRequest: false` returns immediately without retrying, and a cancelled-then-successful retry resolves normally. (#161) - **`HttpConfig` gains request body and session limits** — Breaking change: HTTP transport now caps POST request body size (`413 Payload Too Large` on overflow, wired into `rmcp`'s built-in body-size enforcement) and concurrent HTTP sessions. The session cap is a hard bound enforced atomically at session creation by a semaphore-backed `SessionManager` wrapper (not inferred from request headers, which cannot reliably distinguish session-creating requests across `rmcp`'s legacy and stateless protocol paths); requests rejected once the cap is reached receive `429 Too Many Requests` with a `Retry-After` header. `HttpConfig` gains a `new(bind, path)` constructor plus `with_max_request_body_bytes`/`with_max_concurrent_sessions` builders and is now `#[non_exhaustive]`; existing `HttpConfig { .. }` struct-literal construction must switch to `HttpConfig::new(..)`. (#243) - **Spawned LSP servers no longer inherit mcpls's full environment** — Breaking change: previously `LspServer::spawn` called `tokio::process::Command::new` with no `.env_clear()`/`.env()`/`.envs()`, so every LSP server process (and every tool *it* invokes, e.g. rust-analyzer's `cargo`/`rustc`/`build.rs` children) inherited the entire parent environment by default; separately, the `env` field on `[[lsp_servers]]` config entries was parsed but never applied (dead code). `spawn` now clears the child's environment and passes through only a minimal allowlist — `PATH`, `HOME`, `USERPROFILE`, `TMPDIR`/`TEMP`/`TMP` on every platform, plus `SystemRoot`, `SystemDrive`, `windir`, `APPDATA`, `LOCALAPPDATA`, `ProgramData`, `ProgramFiles`, `COMSPEC`, `PATHEXT`, `NUMBER_OF_PROCESSORS`, `USERNAME` on Windows — for variables actually present in the parent process, then applies `[lsp_servers.env]` on top so configured entries can override the passthrough. If your server relies on an inherited variable outside this allowlist (proxy settings, `SSH_AUTH_SOCK`, toolchain env like `DATABASE_URL`/`LIBCLANG_PATH` read by a `build.rs`, custom `PATH` entries, etc.), add it explicitly under that server's `[lsp_servers.env]` in `mcpls.toml` — see `docs/user-guide/configuration.md#env`. This closes a real information-disclosure risk: any secret or token present in mcpls's own environment was previously leaked to every third-party LSP binary, whether or not it had a legitimate need for it. (#236, #246, #247) - **Capability-gated tool dispatch** — every tool with a corresponding optional `ServerCapabilities` field now checks it before dispatching: `get_hover` (`hoverProvider`), `get_definition` (`definitionProvider`), `get_references` (`referencesProvider`), `rename_symbol` (`renameProvider`), `get_completions` (`completionProvider`), `get_document_symbols` (`documentSymbolProvider`), `format_document` (`documentFormattingProvider`), `workspace_symbol_search` (`workspaceSymbolProvider`), `get_code_actions` (`codeActionProvider`), `prepare_call_hierarchy`/`get_incoming_calls`/`get_outgoing_calls` (`callHierarchyProvider`), `get_signature_help` (`signatureHelpProvider`), `go_to_implementation` (`implementationProvider`), `go_to_type_definition` (`typeDefinitionProvider`), and `get_inlay_hints` (`inlayHintProvider`) — returning `Error::CapabilityNotSupported` instead of sending a request the server never claimed to support. `get_diagnostics` is deliberately left ungated, since it already falls back to the push-notification cache on error. For the six handlers that open a document, the check now runs before `textDocument/didOpen` is sent, so a rejected server never observes the open notification. The check is based solely on the `ServerCapabilities` snapshot taken during `initialize` — a server that only advertises a capability later via dynamic `client/registerCapability` will still be rejected as unsupported. (#240) diff --git a/crates/mcpls-core/src/bridge/state.rs b/crates/mcpls-core/src/bridge/state.rs index 987d9b68..650140fd 100644 --- a/crates/mcpls-core/src/bridge/state.rs +++ b/crates/mcpls-core/src/bridge/state.rs @@ -833,6 +833,12 @@ fn windows_rooted_path_to_file_url(path: &Path) -> Option { Some(file_url) } +/// Percent-encodes the RFC 3986 §2.2 "other reserved" characters that the +/// `url` crate's default WHATWG path percent-encode set leaves untouched: +/// `[`, `]`, `^`, `|`. The remaining three characters in that set -- `{`, +/// `}`, and backtick -- are already encoded by `url` on serialization, so +/// they need no handling here; see +/// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`. fn encode_rfc3986_path_chars(url: &Url) -> String { let prefix = url[..url::Position::BeforePath].to_owned(); let encoded = url[url::Position::BeforePath..] @@ -1408,6 +1414,39 @@ mod tests { assert_eq!(uri_to_path(&uri).as_deref(), Some(path)); } + #[test] + fn test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars() { + // RFC 3986 §2.2 "other reserved" characters. The `url` crate already + // percent-encodes `{`, `}`, and backtick when serializing; `[`, `]`, + // `^`, `|` are handled explicitly by `encode_rfc3986_path_chars`. + #[cfg(windows)] + let path = Path::new(r"C:\home\user\test[]^|{}`.ts"); + #[cfg(not(windows))] + let path = Path::new("/home/user/test[]^|{}`.ts"); + + let uri = try_path_to_uri(path).unwrap(); + let uri_str = uri.as_str(); + + for (raw, encoded) in [ + ('[', "%5B"), + (']', "%5D"), + ('^', "%5E"), + ('|', "%7C"), + ('{', "%7B"), + ('}', "%7D"), + ('`', "%60"), + ] { + assert!( + uri_str.contains(encoded), + "expected {raw:?} to be percent-encoded as {encoded} in {uri_str}" + ); + } + assert!( + !uri_str.contains(['[', ']', '^', '|', '{', '}', '`']), + "no raw reserved characters should remain in {uri_str}" + ); + } + #[test] fn test_document_tracker_concurrent_operations() { let mut map = HashMap::new(); diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs index e357189a..42070561 100644 --- a/crates/mcpls-core/src/bridge/translator.rs +++ b/crates/mcpls-core/src/bridge/translator.rs @@ -679,6 +679,16 @@ const MAX_POSITION_VALUE: u32 = 1_000_000; /// Maximum allowed range size in lines. const MAX_RANGE_LINES: u32 = 10_000; +/// Default timeout for most LSP request/response round trips. +/// +/// Independent of `LspServerConfig::timeout_seconds`, which only bounds the +/// `initialize` handshake (see `lsp::lifecycle`) and has no effect on +/// per-request timeouts. There is currently no way to configure this value. +const DEFAULT_LSP_TIMEOUT: Duration = Duration::from_secs(30); +/// Timeout for `textDocument/completion`, kept shorter than +/// [`DEFAULT_LSP_TIMEOUT`] since completions are latency-sensitive. +const COMPLETIONS_LSP_TIMEOUT: Duration = Duration::from_secs(10); + /// Validate that `path` is within one of `workspace_roots`. /// /// Free function (rather than a `Translator` method) so callers that only need @@ -1270,9 +1280,8 @@ impl Translator { work_done_progress_params: WorkDoneProgressParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/hover", params, timeout_duration) + .request("textDocument/hover", params, DEFAULT_LSP_TIMEOUT) .await?; let result = match response { @@ -1326,9 +1335,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/definition", params, timeout_duration) + .request("textDocument/definition", params, DEFAULT_LSP_TIMEOUT) .await?; let locations = match response { @@ -1397,9 +1405,8 @@ impl Translator { }, }; - let timeout_duration = Duration::from_secs(30); let response: Option> = client - .request("textDocument/references", params, timeout_duration) + .request("textDocument/references", params, DEFAULT_LSP_TIMEOUT) .await?; let locations = response.unwrap_or_default(); @@ -1454,9 +1461,8 @@ impl Translator { let params = diagnostic_request_params(TextDocumentIdentifier { uri: uri.clone() }); - let timeout_duration = Duration::from_secs(30); let pull_response: Result = client - .request("textDocument/diagnostic", params, timeout_duration) + .request("textDocument/diagnostic", params, DEFAULT_LSP_TIMEOUT) .await; let diag_info = { @@ -1523,9 +1529,8 @@ impl Translator { work_done_progress_params: WorkDoneProgressParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/rename", params, timeout_duration) + .request("textDocument/rename", params, DEFAULT_LSP_TIMEOUT) .await?; let changes = if let Some(edit) = response { @@ -1627,9 +1632,8 @@ impl Translator { context, }; - let timeout_duration = Duration::from_secs(10); let response: Option = client - .request("textDocument/completion", params, timeout_duration) + .request("textDocument/completion", params, COMPLETIONS_LSP_TIMEOUT) .await?; let items = match response { @@ -1686,9 +1690,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/documentSymbol", params, timeout_duration) + .request("textDocument/documentSymbol", params, DEFAULT_LSP_TIMEOUT) .await?; let symbols = match response { @@ -1747,9 +1750,8 @@ impl Translator { work_done_progress_params: WorkDoneProgressParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option> = client - .request("textDocument/formatting", params, timeout_duration) + .request("textDocument/formatting", params, DEFAULT_LSP_TIMEOUT) .await?; let edits = response.unwrap_or_default(); @@ -1860,9 +1862,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option> = client - .request("workspace/symbol", params, timeout_duration) + .request("workspace/symbol", params, DEFAULT_LSP_TIMEOUT) .await?; let mut symbols: Vec = response @@ -1956,9 +1957,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/codeAction", params, timeout_duration) + .request("textDocument/codeAction", params, DEFAULT_LSP_TIMEOUT) .await?; let response_vec = response.unwrap_or_default(); let mut actions = Vec::with_capacity(response_vec.len()); @@ -2031,12 +2031,11 @@ impl Translator { work_done_progress_params: WorkDoneProgressParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option> = client .request( "textDocument/prepareCallHierarchy", params, - timeout_duration, + DEFAULT_LSP_TIMEOUT, ) .await?; @@ -2084,9 +2083,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option> = client - .request("callHierarchy/incomingCalls", params, timeout_duration) + .request("callHierarchy/incomingCalls", params, DEFAULT_LSP_TIMEOUT) .await?; // Pre-allocate and build result @@ -2142,9 +2140,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option> = client - .request("callHierarchy/outgoingCalls", params, timeout_duration) + .request("callHierarchy/outgoingCalls", params, DEFAULT_LSP_TIMEOUT) .await?; // Pre-allocate and build result @@ -2383,9 +2380,8 @@ impl Translator { context: None, }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/signatureHelp", params, timeout_duration) + .request("textDocument/signatureHelp", params, DEFAULT_LSP_TIMEOUT) .await?; let result = match response { @@ -2466,9 +2462,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/implementation", params, timeout_duration) + .request("textDocument/implementation", params, DEFAULT_LSP_TIMEOUT) .await?; Ok(LocationsResult { @@ -2518,9 +2513,8 @@ impl Translator { partial_result_params: PartialResultParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option = client - .request("textDocument/typeDefinition", params, timeout_duration) + .request("textDocument/typeDefinition", params, DEFAULT_LSP_TIMEOUT) .await?; Ok(LocationsResult { @@ -2573,9 +2567,8 @@ impl Translator { work_done_progress_params: WorkDoneProgressParams::default(), }; - let timeout_duration = Duration::from_secs(30); let response: Option> = client - .request("textDocument/inlayHint", params, timeout_duration) + .request("textDocument/inlayHint", params, DEFAULT_LSP_TIMEOUT) .await?; let hints = response diff --git a/crates/mcpls-core/src/lsp/client.rs b/crates/mcpls-core/src/lsp/client.rs index 982ee506..742520cf 100644 --- a/crates/mcpls-core/src/lsp/client.rs +++ b/crates/mcpls-core/src/lsp/client.rs @@ -912,4 +912,266 @@ mod tests { assert!(matches!(rx1.await.unwrap(), Err(Error::ServerTerminated))); assert!(matches!(rx2.await.unwrap(), Err(Error::ServerTerminated))); } + + #[test] + fn test_should_retrigger_defaults_to_true_when_data_absent() { + assert!(LspClient::should_retrigger(None)); + } + + #[test] + fn test_should_retrigger_false_when_flag_false() { + assert!(!LspClient::should_retrigger(Some(&serde_json::json!({ + "retriggerRequest": false + })))); + } + + #[test] + fn test_should_retrigger_true_when_flag_true() { + assert!(LspClient::should_retrigger(Some(&serde_json::json!({ + "retriggerRequest": true + })))); + } + + mod retry_behavior { + use std::process::Stdio; + + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + + use super::*; + use crate::config::LspServerConfig; + + struct FakeServer { + _write_half: Child, + _read_half: Child, + read_half_stdin: ChildStdin, + write_stdout: ChildStdout, + } + + fn fake_lsp_client() -> (LspClient, FakeServer) { + let mut write_half = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let write_stdin = write_half.stdin.take().unwrap(); + let write_stdout = write_half.stdout.take().unwrap(); + + let mut read_half = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let read_stdout = read_half.stdout.take().unwrap(); + let read_stdin = read_half.stdin.take().unwrap(); + + let transport = LspTransport::new(write_stdin, read_stdout); + let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport); + + ( + client, + FakeServer { + _write_half: write_half, + _read_half: read_half, + read_half_stdin: read_stdin, + write_stdout, + }, + ) + } + + /// Reads one `Content-Length`-framed JSON-RPC message off `reader`. + async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> Value { + let mut content_length = None; + let mut line = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).await.unwrap(); + if line == "\r\n" || line == "\n" { + break; + } + if let Some((key, value)) = line.trim_end().split_once(':') + && key.trim().eq_ignore_ascii_case("content-length") + { + content_length = Some(value.trim().parse::().unwrap()); + } + } + let mut buf = vec![0u8; content_length.unwrap()]; + reader.read_exact(&mut buf).await.unwrap(); + serde_json::from_slice(&buf).unwrap() + } + + /// Writes a framed JSON-RPC `ServerCancelled` (-32802) error response. + async fn write_server_cancelled_response( + stdin: &mut ChildStdin, + id: &Value, + retrigger: bool, + ) { + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": SERVER_CANCELLED_CODE, + "message": "server cancelled the request", + "data": { "retriggerRequest": retrigger }, + }, + }); + let content = serde_json::to_string(&response).unwrap(); + let header = format!("Content-Length: {}\r\n\r\n", content.len()); + stdin.write_all(header.as_bytes()).await.unwrap(); + stdin.write_all(content.as_bytes()).await.unwrap(); + stdin.flush().await.unwrap(); + } + + /// Writes a framed JSON-RPC success response. + async fn write_success_response(stdin: &mut ChildStdin, id: &Value, result: Value) { + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); + let content = serde_json::to_string(&response).unwrap(); + let header = format!("Content-Length: {}\r\n\r\n", content.len()); + stdin.write_all(header.as_bytes()).await.unwrap(); + stdin.write_all(content.as_bytes()).await.unwrap(); + stdin.flush().await.unwrap(); + } + + // Not `start_paused`: the retry loop's real backoff sleeps + // interleave with real subprocess pipe I/O below, and paused + // virtual time does not reliably auto-advance across both. + #[tokio::test] + async fn test_retry_exhaustion_returns_original_server_cancelled_error() { + let (client, mut server) = fake_lsp_client(); + + let request_task = tokio::spawn(async move { + client + .request::<_, Value>( + "textDocument/hover", + serde_json::json!({}), + Duration::from_secs(30), + ) + .await + }); + + let mut reader = BufReader::new(&mut server.write_stdout); + // Initial attempt plus SERVER_CANCELLED_MAX_RETRIES retries: every + // attempt gets ServerCancelled, so retries must exhaust rather + // than loop forever or swallow the error. + for _ in 0..=SERVER_CANCELLED_MAX_RETRIES { + let request = read_framed_message(&mut reader).await; + let id = request["id"].clone(); + write_server_cancelled_response(&mut server.read_half_stdin, &id, true).await; + } + + let result = request_task.await.unwrap(); + + match result { + Err(Error::LspServerError { + code, + message, + data, + }) => { + // Assert the exact original error surfaces, not merely + // "some error with this code" -- a freshly constructed + // placeholder error would satisfy a code-only check. + assert_eq!(code, SERVER_CANCELLED_CODE); + assert_eq!(message, "server cancelled the request"); + assert_eq!(data, Some(serde_json::json!({ "retriggerRequest": true }))); + } + other => panic!("expected exhausted ServerCancelled error, got {other:?}"), + } + } + + #[tokio::test] + async fn test_retrigger_false_returns_immediately_without_retry() { + let (client, mut server) = fake_lsp_client(); + + let request_task = tokio::spawn(async move { + client + .request::<_, Value>( + "textDocument/hover", + serde_json::json!({}), + Duration::from_secs(30), + ) + .await + }); + + let mut reader = BufReader::new(&mut server.write_stdout); + let request = read_framed_message(&mut reader).await; + let id = request["id"].clone(); + write_server_cancelled_response(&mut server.read_half_stdin, &id, false).await; + + // With `retriggerRequest: false`, `should_retrigger`'s gate on + // the retry branch must short-circuit the loop: the error + // returns well under the first 500ms backoff, and no second + // request is ever sent. If the `&& Self::should_retrigger(..)` + // guard were ever dropped from the retry match arm, this would + // instead retry and both assertions below would fail. + let result = tokio::time::timeout(Duration::from_millis(200), request_task) + .await + .unwrap() + .unwrap(); + + match result { + Err(Error::LspServerError { code, .. }) => { + assert_eq!(code, SERVER_CANCELLED_CODE); + } + other => panic!("expected immediate ServerCancelled error, got {other:?}"), + } + + let second_request = + tokio::time::timeout(Duration::from_millis(200), read_framed_message(&mut reader)) + .await; + assert!( + second_request.is_err(), + "no retry should have been sent after retriggerRequest: false" + ); + } + + #[tokio::test] + async fn test_retry_succeeds_after_one_server_cancelled_response() { + let (client, mut server) = fake_lsp_client(); + + let request_task = tokio::spawn(async move { + client + .request::<_, Value>( + "textDocument/hover", + serde_json::json!({}), + Duration::from_secs(30), + ) + .await + }); + + let mut reader = BufReader::new(&mut server.write_stdout); + + // First attempt is cancelled and must retrigger. + let first = read_framed_message(&mut reader).await; + write_server_cancelled_response( + &mut server.read_half_stdin, + &first["id"].clone(), + true, + ) + .await; + + // Second attempt (after backoff) succeeds -- proves the loop + // genuinely re-sends the request rather than just counting down. + let second = read_framed_message(&mut reader).await; + assert_ne!( + first["id"], second["id"], + "retry must use a fresh request id" + ); + let expected_result = serde_json::json!({ "contents": "resolved on retry" }); + write_success_response( + &mut server.read_half_stdin, + &second["id"].clone(), + expected_result.clone(), + ) + .await; + + let result = request_task.await.unwrap(); + assert_eq!(result.unwrap(), expected_result); + } + } } diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index c8a1b367..fdc40e3a 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -272,14 +272,19 @@ Glob pattern syntax: **Type**: Integer **Default**: `30` -Timeout in seconds for LSP server operations, including the initial `initialize` -handshake. Servers that load a large project before answering `initialize` -(e.g. OmniSharp on a big Unity/C# solution) need this raised - the default 30 s -can otherwise cut the server off mid-initialization. +Timeout in seconds for the initial `initialize` handshake only. Servers that +load a large project before answering `initialize` (e.g. OmniSharp on a big +Unity/C# solution) need this raised - the default 30 s can otherwise cut the +server off mid-initialization. + +This does **not** bound individual tool-call requests (hover, definition, +references, etc.) sent after initialization - those use a fixed internal +timeout (30 s for most requests, 10 s for completions) that `timeout_seconds` +does not affect. ```toml [[lsp_servers]] -timeout_seconds = 60 # Increase for slow servers or large projects +timeout_seconds = 60 # Increase for servers slow to complete `initialize` ``` ### `initialization_options` diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md index 64f651c2..78f28229 100644 --- a/docs/user-guide/troubleshooting.md +++ b/docs/user-guide/troubleshooting.md @@ -210,14 +210,24 @@ mcpls --log-level debug - Large projects time out - Tools return timeout errors -**Solution 1**: Increase timeout in configuration: +**Note**: `timeout_seconds` only bounds the initial `initialize` handshake - it +does **not** affect the timeout on individual tool-call requests (hover, +definition, references, etc.), which is a fixed 30 s internally (10 s for +completions) and not configurable. If a server needs minutes to load a large +solution, Solution 1 below is what helps; while it's still initializing, tool +calls for that language return a "server is still initializing - wait and +retry" message rather than a hard "no server configured" error. If requests +are timing out *after* initialization completes, Solution 2 or 3 are the +relevant fixes. + +**Solution 1**: Increase the `initialize` handshake timeout: ```toml [[lsp_servers]] language_id = "rust" command = "rust-analyzer" args = [] file_patterns = ["**/*.rs"] -timeout_seconds = 120 # Increase from default 30 +timeout_seconds = 120 # Give a slow `initialize` handshake more time ``` **Solution 2**: Wait for initial indexing to complete: @@ -234,8 +244,6 @@ mcpls --log-level debug roots = ["/Users/username/current-project"] ``` -**Note**: `timeout_seconds` also bounds the initial `initialize` handshake, so raising it (Solution 1) is what helps a server that needs minutes to load a large solution. While a configured server is still initializing, tool calls for that language return a "server is still initializing - wait and retry" message (loading a large solution may take a few minutes) rather than a hard "no server configured" error. - ### "rust-analyzer indexing takes forever" **Problem**: Large codebase with many dependencies