Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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
- **Subscription cap** — `ResourceSubscriptions` enforces a `MAX_SUBSCRIPTIONS = 1_000` limit per session to guard against memory exhaustion
- **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)
- **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)
- **`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)

### Changed

- **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.
- **Error API** — Breaking change: `Error` is now `#[non_exhaustive]` and gains a `ServerInitializing(String)` variant. Downstream exhaustive matches must include a wildcard arm. (#172)

### Fixed

- **Initialize handshake timeout** — the LSP `initialize` request now honors the per-server `timeout_seconds` configuration instead of a hardcoded 30 s, so servers that need longer to load on large projects are no longer killed mid-initialization. (#172)
- **Startup `null` messages** — the LSP receive loop skips bare `null`/non-object JSON-RPC messages (emitted by OmniSharp during startup) and keeps reading, instead of treating them as a fatal protocol error that drops the connection. (#172)
- **Partial-success expected languages** — the "expected languages" set is cleared once background initialization completes, so a language whose server failed to spawn (when others succeeded) falls back to `NoServerForLanguage` instead of reporting `ServerInitializing` forever. (#172)

- **ServerCancelled retry** — `LspClient::request()` now retries up to 3 times with exponential backoff (500 ms → 1 s → 2 s) when an LSP server returns error code -32802 with `data.retriggerRequest: true`, instead of propagating the error immediately to the MCP caller (#128)
- **Integration test readiness gate** — Replaced `publishDiagnostics`-based readiness signal with hover-probe polling (3 consecutive successful hover responses required), matching the ra_e2e approach; fixes 3 of 5 integration tests that failed consistently in isolation after PR #123 (#127)
- **LSP server requests** — Handle server-to-client requests such as `client/registerCapability`, fixing tsgo timeouts.
Expand Down
98 changes: 86 additions & 12 deletions crates/mcpls-core/src/bridge/translator.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! MCP to LSP translation layer.

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

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

impl Translator {
Expand All @@ -52,6 +56,7 @@ impl Translator {
notification_cache: NotificationCache::new(),
workspace_roots: vec![],
extension_map: HashMap::new(),
expected_languages: HashSet::new(),
}
}

Expand All @@ -60,6 +65,17 @@ impl Translator {
self.workspace_roots = roots;
}

/// Mark the set of languages whose LSP servers are expected (configured +
/// applicable) but may still be initializing in the background.
pub fn set_expected_languages(&mut self, languages: HashSet<String>) {
self.expected_languages = languages;
}

/// Clear the expected-languages set (e.g. after background init failed).
pub fn clear_expected_languages(&mut self) {
self.expected_languages.clear();
}

/// Configure custom file extension mappings.
///
/// This method sets the extension map and updates the document tracker
Expand Down Expand Up @@ -555,10 +571,17 @@ impl Translator {
/// Get a cloned LSP client for a file path based on language detection.
fn get_client_for_file(&self, path: &Path) -> Result<LspClient> {
let language_id = detect_language(path, &self.extension_map);
self.lsp_clients
.get(&language_id)
.cloned()
.ok_or(Error::NoServerForLanguage(language_id))
self.lsp_clients.get(&language_id).cloned().ok_or_else(|| {
// A configured+applicable language whose server has not registered
// yet is still initializing (e.g. a large Unity solution loading via
// OmniSharp); tell the caller to wait and retry rather than implying
// no server is configured at all.
if self.expected_languages.contains(&language_id) {
Error::ServerInitializing(language_id)
} else {
Error::NoServerForLanguage(language_id)
}
})
}

/// Parse and validate a file URI, returning the validated path.
Expand Down Expand Up @@ -1135,13 +1158,17 @@ impl Translator {
)));
}

// Workspace search requires at least one LSP client
let client = self
.lsp_clients
.values()
.next()
.cloned()
.ok_or(Error::NoServerConfigured)?;
// Workspace search requires at least one LSP client. If none are
// registered yet but a configured server is still initializing, tell the
// caller to wait and retry rather than implying nothing is configured.
let client = self.lsp_clients.values().next().cloned().ok_or_else(|| {
self.expected_languages
.iter()
.next()
.map_or(Error::NoServerConfigured, |lang| {
Error::ServerInitializing(lang.clone())
})
})?;

let params = LspWorkspaceSymbolParams {
query,
Expand Down Expand Up @@ -2092,6 +2119,53 @@ mod tests {
// This test verifies the data structure is properly initialized.
}

#[test]
fn test_get_client_for_file_server_initializing_when_expected() {
// A configured/applicable language whose LSP client has not registered
// yet (large solution still loading via OmniSharp) must surface
// ServerInitializing — "wait and retry" — not NoServerForLanguage.
let mut translator = Translator::new();
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &translator.extension_map);

let mut expected = HashSet::new();
expected.insert(lang.clone());
translator.set_expected_languages(expected);

let err = translator.get_client_for_file(&path).unwrap_err();
assert!(matches!(err, Error::ServerInitializing(ref l) if *l == lang));
}

#[test]
fn test_get_client_for_file_no_server_when_not_expected() {
// When the language is not in the expected set (no server configured for
// it at all), the error stays NoServerForLanguage.
let translator = Translator::new();
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &translator.extension_map);

let err = translator.get_client_for_file(&path).unwrap_err();
assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang));
}

#[test]
fn test_clear_expected_languages_reverts_to_no_server() {
// After initialization fails the expected set is cleared; subsequent
// lookups must fall back to NoServerForLanguage rather than keep
// implying the server is still on its way.
let mut translator = Translator::new();
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = detect_language(&path, &translator.extension_map);

let mut expected = HashSet::new();
expected.insert(lang);
translator.set_expected_languages(expected);
translator.clear_expected_languages();

let err = translator.get_client_for_file(&path).unwrap_err();
assert!(matches!(err, Error::NoServerForLanguage(_)));
}

#[test]
fn test_diagnostic_request_params_omit_optional_null_fields() {
let uri = "file:///test.ts".parse().unwrap();
Expand Down
11 changes: 11 additions & 0 deletions crates/mcpls-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ impl std::fmt::Display for ServerSpawnFailure {
}

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

/// LSP server for the language is configured but still initializing.
#[error(
"LSP server for language '{0}' is still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
)]
ServerInitializing(String),
Comment thread
xldeveloper marked this conversation as resolved.

/// No LSP server is currently configured.
#[error("no LSP server configured")]
NoServerConfigured,
Expand Down
Loading
Loading