Skip to content

Commit 491aa8c

Browse files
xlemaitre-pocbug-ops
authored andcommitted
feat: background LSP init + 'server initializing' error for large solutions
- serve_with spawns/initializes LSP servers in a background task instead of awaiting them before starting the MCP server, so a server that takes minutes to initialize no longer blocks the MCP initialize response (clients such as Claude Code time out the initialize request at ~60s). - Requests for a configured language whose server is still initializing return a clear ServerInitializing error ('still initializing, retry') instead of NoServerForLanguage/NoServerConfigured, at both the per-file lookup and the workspace-symbol-search sites. - Error is now #[non_exhaustive] so future variants are non-breaking.
1 parent a942ee1 commit 491aa8c

5 files changed

Lines changed: 225 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- **RFC-3986 URI codec**`bridge::resources` module with percent-encoding via `url::Url::from_file_path`; empty-authority injection is rejected to prevent UNC-path attacks on Windows
1717
- **Subscription cap**`ResourceSubscriptions` enforces a `MAX_SUBSCRIPTIONS = 1_000` limit per session to guard against memory exhaustion
1818
- **MCP tools**`get_signature_help` (`textDocument/signatureHelp`), `go_to_implementation` (`textDocument/implementation`), `go_to_type_definition` (`textDocument/typeDefinition`), and `get_inlay_hints` (`textDocument/inlayHint`) tools exposing LSP 3.6/3.15/3.17 capabilities (#116)
19+
- **Non-blocking startup for slow LSP servers**`serve_with` spawns LSP initialization in a background task and starts the MCP server immediately, so the MCP `initialize` handshake no longer waits for the language server to finish loading. Large solutions that take a long time to load (e.g. OmniSharp on a ~130-project Unity solution, ~86 s) no longer trip the MCP client's initialize timeout. (#172)
20+
- **`ServerInitializing` error** — a request for a configured language whose server is still loading returns a dedicated "still initializing, wait and retry" error instead of the misleading "no LSP server configured for language". (#172)
1921

2022
### Changed
2123

2224
- **LSP API** — Breaking change: `InboundMessage` is now non-exhaustive and includes a server-request variant for LSP server-to-client JSON-RPC requests. Downstream exhaustive matches must include a wildcard arm.
25+
- **Error API** — Breaking change: `Error` is now `#[non_exhaustive]` and gains a `ServerInitializing(String)` variant. Downstream exhaustive matches must include a wildcard arm. (#172)
2326

2427
### Fixed
2528

crates/mcpls-core/src/bridge/translator.rs

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! MCP to LSP translation layer.
22
3-
use std::collections::HashMap;
3+
use std::collections::{HashMap, HashSet};
44
use std::path::{Path, PathBuf};
55

66
use lsp_types::{
@@ -39,6 +39,10 @@ pub struct Translator {
3939
workspace_roots: Vec<PathBuf>,
4040
/// Custom file extension to language ID mappings.
4141
extension_map: HashMap<String, String>,
42+
/// Languages that are configured + applicable but whose LSP server may not
43+
/// have finished initializing yet (background init). Used to return a clear
44+
/// "still initializing" error instead of "no server configured".
45+
expected_languages: HashSet<String>,
4246
}
4347

4448
impl Translator {
@@ -52,6 +56,7 @@ impl Translator {
5256
notification_cache: NotificationCache::new(),
5357
workspace_roots: vec![],
5458
extension_map: HashMap::new(),
59+
expected_languages: HashSet::new(),
5560
}
5661
}
5762

@@ -60,6 +65,17 @@ impl Translator {
6065
self.workspace_roots = roots;
6166
}
6267

68+
/// Mark the set of languages whose LSP servers are expected (configured +
69+
/// applicable) but may still be initializing in the background.
70+
pub fn set_expected_languages(&mut self, languages: HashSet<String>) {
71+
self.expected_languages = languages;
72+
}
73+
74+
/// Clear the expected-languages set (e.g. after background init failed).
75+
pub fn clear_expected_languages(&mut self) {
76+
self.expected_languages.clear();
77+
}
78+
6379
/// Configure custom file extension mappings.
6480
///
6581
/// This method sets the extension map and updates the document tracker
@@ -555,10 +571,17 @@ impl Translator {
555571
/// Get a cloned LSP client for a file path based on language detection.
556572
fn get_client_for_file(&self, path: &Path) -> Result<LspClient> {
557573
let language_id = detect_language(path, &self.extension_map);
558-
self.lsp_clients
559-
.get(&language_id)
560-
.cloned()
561-
.ok_or(Error::NoServerForLanguage(language_id))
574+
self.lsp_clients.get(&language_id).cloned().ok_or_else(|| {
575+
// A configured+applicable language whose server has not registered
576+
// yet is still initializing (e.g. a large Unity solution loading via
577+
// OmniSharp); tell the caller to wait and retry rather than implying
578+
// no server is configured at all.
579+
if self.expected_languages.contains(&language_id) {
580+
Error::ServerInitializing(language_id)
581+
} else {
582+
Error::NoServerForLanguage(language_id)
583+
}
584+
})
562585
}
563586

564587
/// Parse and validate a file URI, returning the validated path.
@@ -1135,13 +1158,17 @@ impl Translator {
11351158
)));
11361159
}
11371160

1138-
// Workspace search requires at least one LSP client
1139-
let client = self
1140-
.lsp_clients
1141-
.values()
1142-
.next()
1143-
.cloned()
1144-
.ok_or(Error::NoServerConfigured)?;
1161+
// Workspace search requires at least one LSP client. If none are
1162+
// registered yet but a configured server is still initializing, tell the
1163+
// caller to wait and retry rather than implying nothing is configured.
1164+
let client = self.lsp_clients.values().next().cloned().ok_or_else(|| {
1165+
self.expected_languages
1166+
.iter()
1167+
.next()
1168+
.map_or(Error::NoServerConfigured, |lang| {
1169+
Error::ServerInitializing(lang.clone())
1170+
})
1171+
})?;
11451172

11461173
let params = LspWorkspaceSymbolParams {
11471174
query,
@@ -2092,6 +2119,53 @@ mod tests {
20922119
// This test verifies the data structure is properly initialized.
20932120
}
20942121

2122+
#[test]
2123+
fn test_get_client_for_file_server_initializing_when_expected() {
2124+
// A configured/applicable language whose LSP client has not registered
2125+
// yet (large solution still loading via OmniSharp) must surface
2126+
// ServerInitializing — "wait and retry" — not NoServerForLanguage.
2127+
let mut translator = Translator::new();
2128+
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2129+
let lang = detect_language(&path, &translator.extension_map);
2130+
2131+
let mut expected = HashSet::new();
2132+
expected.insert(lang.clone());
2133+
translator.set_expected_languages(expected);
2134+
2135+
let err = translator.get_client_for_file(&path).unwrap_err();
2136+
assert!(matches!(err, Error::ServerInitializing(ref l) if *l == lang));
2137+
}
2138+
2139+
#[test]
2140+
fn test_get_client_for_file_no_server_when_not_expected() {
2141+
// When the language is not in the expected set (no server configured for
2142+
// it at all), the error stays NoServerForLanguage.
2143+
let translator = Translator::new();
2144+
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2145+
let lang = detect_language(&path, &translator.extension_map);
2146+
2147+
let err = translator.get_client_for_file(&path).unwrap_err();
2148+
assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang));
2149+
}
2150+
2151+
#[test]
2152+
fn test_clear_expected_languages_reverts_to_no_server() {
2153+
// After initialization fails the expected set is cleared; subsequent
2154+
// lookups must fall back to NoServerForLanguage rather than keep
2155+
// implying the server is still on its way.
2156+
let mut translator = Translator::new();
2157+
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2158+
let lang = detect_language(&path, &translator.extension_map);
2159+
2160+
let mut expected = HashSet::new();
2161+
expected.insert(lang);
2162+
translator.set_expected_languages(expected);
2163+
translator.clear_expected_languages();
2164+
2165+
let err = translator.get_client_for_file(&path).unwrap_err();
2166+
assert!(matches!(err, Error::NoServerForLanguage(_)));
2167+
}
2168+
20952169
#[test]
20962170
fn test_diagnostic_request_params_omit_optional_null_fields() {
20972171
let uri = "file:///test.ts".parse().unwrap();

crates/mcpls-core/src/error.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ impl std::fmt::Display for ServerSpawnFailure {
2727
}
2828

2929
/// The main error type for mcpls-core operations.
30+
///
31+
/// This enum is `#[non_exhaustive]`: downstream crates that match on it must
32+
/// include a wildcard arm. New variants (such as [`Error::ServerInitializing`])
33+
/// can then be added without further breaking changes.
3034
#[derive(Debug, thiserror::Error)]
35+
#[non_exhaustive]
3136
pub enum Error {
3237
/// LSP server failed to initialize.
3338
#[error("LSP server initialization failed: {message}")]
@@ -59,6 +64,12 @@ pub enum Error {
5964
#[error("no LSP server configured for language: {0}")]
6065
NoServerForLanguage(String),
6166

67+
/// LSP server for the language is configured but still initializing.
68+
#[error(
69+
"LSP server for language '{0}' is still initializing (large project load in progress); wait a few seconds and retry the request"
70+
)]
71+
ServerInitializing(String),
72+
6273
/// No LSP server is currently configured.
6374
#[error("no LSP server configured")]
6475
NoServerConfigured,

0 commit comments

Comments
 (0)