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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **`ToolAnnotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`) plus top-level `Tool.title` on all 20 `#[tool]` definitions** in `mcp/server.rs` — MCP clients can now use these hints to decide when to skip confirmation dialogs. All 20 tools are marked `readOnlyHint=true`: mcpls has no write-back path today, so even `rename_symbol`, `format_document`, and `get_code_actions` only return a proposed edit rather than applying one — revisit their classification if a write-back path is added. (#136)
- **Shared `PositionParams`/`RangeParams` structs** in `mcp/tools.rs`, embedded via `#[serde(flatten)]` in the eleven tool-parameter structs that previously repeated the `file_path`/`line`/`character` trio or the `start_line`/`start_character`/`end_line`/`end_character` quad verbatim. The MCP wire format (flat JSON) and generated JSON schema are unchanged. (#235)
- **`LspServerConfig::request_timeout_seconds`** — per-request LSP timeout, configurable per server and separate from the handshake-only `timeout_seconds`. Defaults to 30s (bit-identical to the previous hardcoded behavior). Bounds a single request attempt, not a whole tool call: on a `-32802` (`ServerCancelled`) response, `LspClient::request` retries up to 4 attempts total, so the worst-case latency for one tool call is `4 * request_timeout_seconds + 3.5s`. `LspClient::request_timeout()`/`completion_timeout()` accessors expose the effective value; `completion_timeout()` clamps to at most 10s regardless of the configured value — an explicit MVP ceiling, not an oversight. See `docs/user-guide/configuration.md#request_timeout_seconds`. (#267)
- **`Error::CapabilityNotSupported`** — new `Error` variant returned when the LSP server routed for a request does not advertise the `ServerCapabilities` field a capability-gated tool needs. (#240)
- **In-band notice when a project-local `mcpls.toml` is ignored as untrusted** — `ServerInfo.instructions` (`McplsServer::get_info`) now appends a note when a CWD-discovered `./mcpls.toml` was skipped because it wasn't trusted, so MCP clients that swallow stderr (where the existing `tracing::warn!` goes) can still see the ignore decision and act on it. New `ServerConfig::project_config_ignored` field (load-time metadata, not TOML-configurable) carries the signal from `ServerConfig::load_with_trust` through to the MCP layer. (#248)
- **Automatic LSP server respawn on crash** — `Translator` now detects when a routed LSP server's child process has exited and transparently respawns and re-initializes it before resolving the next tool call for that server, instead of leaving the session degraded until mcpls itself is restarted. Concurrent callers that observe the same dead server single-flight on a per-server lock so only one respawn happens; any requests still parked on the old connection are failed immediately instead of waiting out their own timeout. A crash-looping server backs off exponentially (1s up to 30s) instead of retrying on every tool call — including the more realistic "starts, initializes, then dies again a moment later" loop, not just an outright spawn failure. New `LspServer::has_exited`, `DocumentTracker::forget_server` (resets per-server document sync state; the commit is checked against a per-server sync generation while still holding the document lock, so an in-flight sync against the old connection can never land after a concurrent respawn clears it), and `Translator::with_notification_cache` (lets the respawn path invalidate the crashed connection's cached diagnostics — triggered only when the crashed server was the diagnostics route for its language, not for a non-route server like a dedicated hover server; note that the clear itself is currently workspace-wide across *all* languages, not scoped to just the crashed one, since the diagnostics cache has no per-language clear yet — a crashed rust-analyzer also clears a healthy pyright's cached entries, though `handle_diagnostics`'s authoritative pull path is unaffected, only the cached-only path degrades until that server republishes). A respawned server's own push notifications are drained and discarded rather than reconnected to the existing notification pump — diagnostics push does not resume for it until the whole mcpls process restarts, a known scope trade-off. New `Error::ServerUnavailable` variant distinguishes "respawn could not proceed" (no config registered, or backing off) from a plain `Error::ServerTerminated`. (#249)

### Changed

- Sort `[workspace.dependencies]` in root `Cargo.toml` alphabetically (#232)
- **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)
- **`bridge::translator`'s fixed `DEFAULT_LSP_TIMEOUT`/`COMPLETIONS_LSP_TIMEOUT` constants (added in #231 below) removed** in favor of the new per-server `request_timeout_seconds` config field (see Added) — all 17 call sites now read `client.request_timeout()`/`client.completion_timeout()`. Breaking change: `LspServerConfig` gained a field, so existing `LspServerConfig { .. }` struct-literal construction (not behind `#[non_exhaustive]`) must add `request_timeout_seconds`. Also breaking: `ServerConfig::validate()` now rejects `timeout_seconds == 0` in addition to the new `request_timeout_seconds == 0` check — no working config could previously set `timeout_seconds` to 0 (it made `initialize` fail instantly), so no functioning setup is affected. (#267)
- **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. Superseded by #267 above, which replaces both constants with the configurable `request_timeout_seconds`. (#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)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ command = "rust-analyzer"
args = []
file_patterns = ["**/*.rs"]
timeout_seconds = 30
request_timeout_seconds = 30

[lsp_servers.heuristics]
project_markers = ["Cargo.toml", "rust-toolchain.toml"]
Expand Down
80 changes: 53 additions & 27 deletions crates/mcpls-core/src/bridge/translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -745,16 +745,6 @@ 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
Expand Down Expand Up @@ -1347,7 +1337,7 @@ impl Translator {
};

let response: Option<Hover> = client
.request("textDocument/hover", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/hover", params, client.request_timeout())
.await?;

let result = match response {
Expand Down Expand Up @@ -1402,7 +1392,7 @@ impl Translator {
};

let response: Option<lsp_types::GotoDefinitionResponse> = client
.request("textDocument/definition", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/definition", params, client.request_timeout())
.await?;

let locations = match response {
Expand Down Expand Up @@ -1472,7 +1462,7 @@ impl Translator {
};

let response: Option<Vec<lsp_types::Location>> = client
.request("textDocument/references", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/references", params, client.request_timeout())
.await?;

let locations = response.unwrap_or_default();
Expand Down Expand Up @@ -1528,7 +1518,7 @@ impl Translator {
let params = diagnostic_request_params(TextDocumentIdentifier { uri: uri.clone() });

let pull_response: Result<lsp_types::DocumentDiagnosticReportResult> = client
.request("textDocument/diagnostic", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/diagnostic", params, client.request_timeout())
.await;

let diag_info = {
Expand Down Expand Up @@ -1596,7 +1586,7 @@ impl Translator {
};

let response: Option<WorkspaceEdit> = client
.request("textDocument/rename", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/rename", params, client.request_timeout())
.await?;

let changes = if let Some(edit) = response {
Expand Down Expand Up @@ -1699,7 +1689,11 @@ impl Translator {
};

let response: Option<lsp_types::CompletionResponse> = client
.request("textDocument/completion", params, COMPLETIONS_LSP_TIMEOUT)
.request(
"textDocument/completion",
params,
client.completion_timeout(),
)
.await?;

let items = match response {
Expand Down Expand Up @@ -1757,7 +1751,11 @@ impl Translator {
};

let response: Option<lsp_types::DocumentSymbolResponse> = client
.request("textDocument/documentSymbol", params, DEFAULT_LSP_TIMEOUT)
.request(
"textDocument/documentSymbol",
params,
client.request_timeout(),
)
.await?;

let symbols = match response {
Expand Down Expand Up @@ -1817,7 +1815,7 @@ impl Translator {
};

let response: Option<Vec<lsp_types::TextEdit>> = client
.request("textDocument/formatting", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/formatting", params, client.request_timeout())
.await?;

let edits = response.unwrap_or_default();
Expand Down Expand Up @@ -1898,7 +1896,7 @@ impl Translator {
};

let response: Option<Vec<lsp_types::SymbolInformation>> = client
.request("workspace/symbol", params, DEFAULT_LSP_TIMEOUT)
.request("workspace/symbol", params, client.request_timeout())
.await?;

let mut symbols: Vec<WorkspaceSymbol> = response
Expand Down Expand Up @@ -1993,7 +1991,7 @@ impl Translator {
};

let response: Option<lsp_types::CodeActionResponse> = client
.request("textDocument/codeAction", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/codeAction", params, client.request_timeout())
.await?;
let response_vec = response.unwrap_or_default();
let mut actions = Vec::with_capacity(response_vec.len());
Expand Down Expand Up @@ -2070,7 +2068,7 @@ impl Translator {
.request(
"textDocument/prepareCallHierarchy",
params,
DEFAULT_LSP_TIMEOUT,
client.request_timeout(),
)
.await?;

Expand Down Expand Up @@ -2119,7 +2117,11 @@ impl Translator {
};

let response: Option<Vec<CallHierarchyIncomingCall>> = client
.request("callHierarchy/incomingCalls", params, DEFAULT_LSP_TIMEOUT)
.request(
"callHierarchy/incomingCalls",
params,
client.request_timeout(),
)
.await?;

// Pre-allocate and build result
Expand Down Expand Up @@ -2176,7 +2178,11 @@ impl Translator {
};

let response: Option<Vec<CallHierarchyOutgoingCall>> = client
.request("callHierarchy/outgoingCalls", params, DEFAULT_LSP_TIMEOUT)
.request(
"callHierarchy/outgoingCalls",
params,
client.request_timeout(),
)
.await?;

// Pre-allocate and build result
Expand Down Expand Up @@ -2416,7 +2422,11 @@ impl Translator {
};

let response: Option<lsp_types::SignatureHelp> = client
.request("textDocument/signatureHelp", params, DEFAULT_LSP_TIMEOUT)
.request(
"textDocument/signatureHelp",
params,
client.request_timeout(),
)
.await?;

let result = match response {
Expand Down Expand Up @@ -2498,7 +2508,11 @@ impl Translator {
};

let response: Option<lsp_types::GotoDefinitionResponse> = client
.request("textDocument/implementation", params, DEFAULT_LSP_TIMEOUT)
.request(
"textDocument/implementation",
params,
client.request_timeout(),
)
.await?;

Ok(LocationsResult {
Expand Down Expand Up @@ -2549,7 +2563,11 @@ impl Translator {
};

let response: Option<lsp_types::GotoDefinitionResponse> = client
.request("textDocument/typeDefinition", params, DEFAULT_LSP_TIMEOUT)
.request(
"textDocument/typeDefinition",
params,
client.request_timeout(),
)
.await?;

Ok(LocationsResult {
Expand Down Expand Up @@ -2603,7 +2621,7 @@ impl Translator {
};

let response: Option<Vec<lsp_types::InlayHint>> = client
.request("textDocument/inlayHint", params, DEFAULT_LSP_TIMEOUT)
.request("textDocument/inlayHint", params, client.request_timeout())
.await?;

let hints = response
Expand Down Expand Up @@ -3264,6 +3282,7 @@ sleep __SLEEP__
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 5,
request_timeout_seconds: 5,
heuristics: None,
name: Some(id.to_string()),
handles: None,
Expand Down Expand Up @@ -3585,6 +3604,7 @@ fi
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 5,
request_timeout_seconds: 5,
heuristics: None,
name: Some("hover-only".to_string()),
handles: Some(vec![ToolKind::Hover]),
Expand All @@ -3597,6 +3617,7 @@ fi
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 5,
request_timeout_seconds: 5,
heuristics: None,
name: Some("diag-catchall".to_string()),
handles: None,
Expand Down Expand Up @@ -3829,6 +3850,7 @@ fi
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: Some("pyright".to_string()),
handles: Some(vec![ToolKind::Hover]),
Expand Down Expand Up @@ -5155,6 +5177,7 @@ fi
file_patterns: vec!["**/*.tsx".to_string()],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
Expand Down Expand Up @@ -5201,6 +5224,7 @@ fi
file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
Expand Down Expand Up @@ -5586,6 +5610,7 @@ fi
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: Some("pyright".to_string()),
handles: Some(vec![ToolKind::Hover]),
Expand All @@ -5598,6 +5623,7 @@ fi
file_patterns: vec![],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: Some("pylsp".to_string()),
handles: Some(vec![ToolKind::Diagnostics]),
Expand Down
Loading
Loading