diff --git a/CHANGELOG.md b/CHANGELOG.md index 7161d623..ce955475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`workspace.max_documents`/`workspace.max_file_size` TOML config fields** — expose `DocumentTracker`'s previously hardcoded resource limits (100 open documents, 10MB max file size) for configuration, following the existing `heuristics_max_depth` flat-field-on-`[workspace]` pattern. `0` disables either limit, matching `ResourceLimits`'s existing semantics; omitting either field preserves today's defaults unchanged. New `WorkspaceConfig::resource_limits()` maps the two fields onto `bridge::ResourceLimits`, and new `Translator::with_resource_limits` builder wires the resolved limits into `serve()`'s `Translator` construction alongside the existing `with_extensions` builder — the two builders now read each other's already-set field when rebuilding `document_tracker`, so they can be called in either order without one silently discarding the other's effect. `Error::DocumentLimitExceeded`/`FileSizeLimitExceeded` messages gained a static hint pointing at the relevant config field. Documented under "Workspace Section" in `docs/user-guide/configuration.md`. Note: `bridge::ResourceLimits` is now re-exported from `bridge` (previously private to `bridge::state`), which as a side effect makes the already-`pub` `DocumentTracker::new` constructible from outside the crate for the first time — this narrows the rationale given in the `DocumentState` encapsulation entry below (#304), which assumed `ResourceLimits`'s privacy made `DocumentTracker` uninstantiable externally; `DocumentState`'s own field privacy and invariant-enforcing methods are unaffected. (#315) + - **`scripts/install.sh` and `scripts/install.ps1`** — one-command installers for Linux/macOS (`curl -fsSL .../install.sh | sh`) and Windows (`irm .../install.ps1 | iex`). Both detect OS/architecture, resolve the latest (or a pinned `MCPLS_VERSION`/`-Version`) GitHub Release via the `/releases/latest/download/` convention, verify the published SHA256 checksum before extracting, and install `mcpls` to `~/.local/bin` (`$HOME\.local\bin` on Windows) without requiring `sudo`. `install.sh` is POSIX `sh` and shellcheck-clean; a `shellcheck` CI job lints it, gated by a new `detect-changes` `scripts` output (`scripts/**`, `.github/workflows/ci.yml`) so it only runs when those paths change. The `security` (cargo-deny) job is now likewise gated on the existing `run-full-ci` output, so a docs-only or scripts-only change no longer triggers a full dependency/license audit. README's Installation section now documents both scripts as the primary install method, keeps `cargo install mcpls` as an alternative, and fixes the pre-built-binaries table to the real target-triple archive names produced by `release.yml` (the previous table referenced stale/nonexistent names, including a musl build that has never been produced by CI). (#288) - **`skills/mcpls/` Agent Skill** — a spec-compliant [Agent Skill](https://agentskills.io/specification) (`SKILL.md` + `references/configuration.md`) teaching an AI coding agent to install mcpls, choose CLI flags/`MCPLS_*` environment variables, register mcpls with an MCP client, and write `mcpls.toml`, including the per-platform config-path table, the project-config trust model, and the `--listen`/`transport-http` feature-gate asymmetry. (#252) @@ -23,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **`rmcp` bumped from 3.0.0 to 3.1.0.** Breaking-for-affected-clients: `transport-http`'s stateless (non-`initialize`) POST handling now unconditionally rejects a `/mcp` request carrying `MCP-Protocol-Version: 2026-07-28` or later that omits `_meta.protocolVersion`/`_meta.clientCapabilities` from the request body, returning `HTTP 400` / JSON-RPC `-32602` where 3.0.0 accepted it; this check is not gated by `rmcp`'s new `stateless_protocol_metadata_required` option, which mcpls does not set (default `false`). Scope: only a hand-rolled or non-`rmcp` HTTP client sending a `2026-07-28`+ protocol header without `_meta` is affected — `rmcp`-based clients at that protocol version already attach `_meta`, `2025-11-25` and earlier protocol headers are unaffected, and `transport-http` is an opt-in, off-by-default feature. (#296) +- **`NotificationCache::get_logs`/`get_messages` renamed to `logs`/`messages`** — drops the redundant `get_` prefix; `get_diagnostics(&self, uri: &str)`, a keyed lookup rather than a plain accessor, is unchanged. BREAKING CHANGE: any caller of `NotificationCache::get_logs`/`get_messages` must switch to `logs`/`messages`. (#293) - **`DocumentState`'s six fields (`uri`, `language_id`, `version`, `content`, `disk`, `synced`) are now private** — internal encapsulation improvement, not an externally-reachable breaking change: `DocumentTracker::new`'s only parameter, `ResourceLimits`, is not re-exported outside `bridge::state`, so no code outside that module could construct a `DocumentTracker` (and therefore never obtain a `DocumentState`) either before or after this change. Previously the type had no constructor and let any caller writing a struct literal inside `bridge::state` violate its documented invariants (monotonic `version`, `disk` provenance, per-server `synced` tracking) by touching fields directly; those invariants are now enforced by the type itself, via internal methods (`apply_local_edit`, `commit_reload`, `set_disk`, `mark_synced`, `forget_server`) rather than documentation alone. Read access is now via `#[must_use]` getters: `uri()`, `language_id()`, `version()`, `content()`, `synced_version(&ServerId) -> Option` (there is no public `disk()`, since it would leak the crate-internal `DiskSync` type). (#304) - Sort `[workspace.dependencies]` in root `Cargo.toml` alphabetically (#232) - **`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) diff --git a/README.md b/README.md index 9113f32c..9b7c2b03 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,8 @@ project_markers = ["Cargo.toml", "rust-toolchain.toml", ".rust-version"] [workspace] roots = ["/path/to/project"] heuristics_max_depth = 10 +max_documents = 100 # 0 = unlimited +max_file_size = 10485760 # bytes, 0 = unlimited [[lsp_servers]] language_id = "rust" diff --git a/crates/mcpls-core/src/bridge/mod.rs b/crates/mcpls-core/src/bridge/mod.rs index 557d68c1..8be23aad 100644 --- a/crates/mcpls-core/src/bridge/mod.rs +++ b/crates/mcpls-core/src/bridge/mod.rs @@ -17,7 +17,10 @@ pub use notifications::{ }; pub use resources::ResourceSubscriptions; pub(crate) use state::try_path_to_uri; -pub use state::{DocumentState, DocumentTracker, path_to_uri, uri_to_path}; +pub use state::{ + DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE, DocumentState, DocumentTracker, ResourceLimits, + path_to_uri, uri_to_path, +}; pub(crate) use translator::validate_path_against_roots; pub use translator::{ Completion, CompletionsResult, DefinitionResult, Diagnostic, DiagnosticSeverity, diff --git a/crates/mcpls-core/src/bridge/notifications.rs b/crates/mcpls-core/src/bridge/notifications.rs index 46b07288..630e67ad 100644 --- a/crates/mcpls-core/src/bridge/notifications.rs +++ b/crates/mcpls-core/src/bridge/notifications.rs @@ -417,17 +417,17 @@ impl NotificationCache { self.diagnostics_owners.get(uri_cache_key(uri).as_ref()) } - /// Get all stored log entries. + /// All stored log entries. #[inline] #[must_use] - pub const fn get_logs(&self) -> &VecDeque { + pub const fn logs(&self) -> &VecDeque { &self.logs } - /// Get all stored server messages. + /// All stored server messages. #[inline] #[must_use] - pub const fn get_messages(&self) -> &VecDeque { + pub const fn messages(&self) -> &VecDeque { &self.messages } @@ -628,7 +628,7 @@ mod tests { cache.store_log(LogLevel::Error, "error message".to_string()); cache.store_log(LogLevel::Info, "info message".to_string()); - let logs = cache.get_logs(); + let logs = cache.logs(); assert_eq!(logs.len(), 2); assert_eq!(logs[0].level, LogLevel::Error); assert_eq!(logs[0].message, "error message"); @@ -648,7 +648,7 @@ mod tests { assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES); // Oldest entries should be removed (FIFO) - let logs = cache.get_logs(); + let logs = cache.logs(); assert_eq!(logs.front().unwrap().message, "message 10"); assert_eq!( logs.back().unwrap().message, @@ -673,7 +673,7 @@ mod tests { cache.store_message(MessageType::Error, "error msg".to_string()); cache.store_message(MessageType::Warning, "warning msg".to_string()); - let messages = cache.get_messages(); + let messages = cache.messages(); assert_eq!(messages.len(), 2); assert_eq!(messages[0].message_type, MessageType::Error); assert_eq!(messages[0].message, "error msg"); @@ -693,7 +693,7 @@ mod tests { assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES); // Oldest entries should be removed (FIFO) - let messages = cache.get_messages(); + let messages = cache.messages(); assert_eq!(messages.front().unwrap().message, "message 10"); assert_eq!( messages.back().unwrap().message, @@ -720,7 +720,7 @@ mod tests { cache.store_log(LogLevel::Info, "info".to_string()); cache.store_log(LogLevel::Debug, "debug".to_string()); - let logs = cache.get_logs(); + let logs = cache.logs(); assert_eq!(logs[0].level, LogLevel::Error); assert_eq!(logs[1].level, LogLevel::Warning); assert_eq!(logs[2].level, LogLevel::Info); @@ -736,7 +736,7 @@ mod tests { cache.store_message(MessageType::Info, "info".to_string()); cache.store_message(MessageType::Log, "log".to_string()); - let messages = cache.get_messages(); + let messages = cache.messages(); assert_eq!(messages[0].message_type, MessageType::Error); assert_eq!(messages[1].message_type, MessageType::Warning); assert_eq!(messages[2].message_type, MessageType::Info); @@ -751,7 +751,7 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(10)); cache.store_log(LogLevel::Info, "second".to_string()); - let logs = cache.get_logs(); + let logs = cache.logs(); assert!(logs[0].timestamp < logs[1].timestamp); } @@ -842,7 +842,7 @@ mod tests { cache.store_log(LogLevel::Info, "overflow".to_string()); assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES); - assert_eq!(cache.get_logs().front().unwrap().message, "message 1"); + assert_eq!(cache.logs().front().unwrap().message, "message 1"); } #[test] @@ -856,7 +856,7 @@ mod tests { cache.store_message(MessageType::Info, "overflow".to_string()); assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES); - assert_eq!(cache.get_messages().front().unwrap().message, "message 1"); + assert_eq!(cache.messages().front().unwrap().message, "message 1"); } #[test] diff --git a/crates/mcpls-core/src/bridge/state.rs b/crates/mcpls-core/src/bridge/state.rs index 42ae232b..c8e40945 100644 --- a/crates/mcpls-core/src/bridge/state.rs +++ b/crates/mcpls-core/src/bridge/state.rs @@ -234,6 +234,14 @@ impl DocumentState { } } +/// Default value for [`ResourceLimits::max_documents`], also used as the +/// TOML default for `workspace.max_documents` (`config::default_max_documents`). +pub const DEFAULT_MAX_DOCUMENTS: usize = 100; + +/// Default value for [`ResourceLimits::max_file_size`] (10MB), also used as +/// the TOML default for `workspace.max_file_size` (`config::default_max_file_size`). +pub const DEFAULT_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; + /// Resource limits for document tracking. #[derive(Debug, Clone, Copy)] pub struct ResourceLimits { @@ -246,8 +254,8 @@ pub struct ResourceLimits { impl Default for ResourceLimits { fn default() -> Self { Self { - max_documents: 100, - max_file_size: 10 * 1024 * 1024, // 10MB + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, } } } diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs index b3301c26..aa14ab57 100644 --- a/crates/mcpls-core/src/bridge/translator.rs +++ b/crates/mcpls-core/src/bridge/translator.rs @@ -52,6 +52,12 @@ pub struct Translator { lsp_servers: Arc>>, /// Document state tracker. Locks its own state internally, per path. document_tracker: Arc, + /// Resource limits `document_tracker` was last built with. Kept + /// alongside `document_tracker` so [`Self::with_extensions`] and + /// [`Self::with_resource_limits`] can each rebuild the tracker from + /// whichever of (limits, extension map) the other has already set, + /// regardless of call order -- see [`Self::with_resource_limits`]. + resource_limits: ResourceLimits, /// Allowed workspace roots for path validation. Read-only after `serve()` /// setup, so no lock is needed. workspace_roots: Arc>, @@ -141,6 +147,7 @@ impl Translator { ResourceLimits::default(), HashMap::new(), )), + resource_limits: ResourceLimits::default(), workspace_roots: Arc::new(Vec::new()), extension_map: Arc::new(HashMap::new()), expected_servers: Arc::new(StdMutex::new(HashSet::new())), @@ -236,6 +243,23 @@ impl Translator { } } + /// Rebuilds `document_tracker` from `self.resource_limits` and + /// `self.extension_map`, whatever the two are currently set to. + /// + /// Called by every builder that touches either input ([`Self::with_extensions`], + /// [`Self::with_resource_limits`]), so each one only needs to set its own + /// field and call this -- it always reads *both* current values, so the + /// builders remain order-independent (see [`Self::with_resource_limits`]) + /// without each one needing to know the other's field. A future builder + /// that adds a third tracker input should follow the same pattern: + /// update its own field, then call this. + fn rebuild_document_tracker(&mut self) { + self.document_tracker = Arc::new(DocumentTracker::new( + self.resource_limits, + (*self.extension_map).clone(), + )); + } + /// Configure custom file extension mappings. /// /// This method sets the extension map and updates the document tracker @@ -245,11 +269,26 @@ impl Translator { /// shared, so this replaces the `Arc`-wrapped fields wholesale. #[must_use] pub fn with_extensions(mut self, extension_map: HashMap) -> Self { - self.document_tracker = Arc::new(DocumentTracker::new( - ResourceLimits::default(), - extension_map.clone(), - )); self.extension_map = Arc::new(extension_map); + self.rebuild_document_tracker(); + self + } + + /// Configure resource limits (max open documents, max file size) for the + /// document tracker. + /// + /// Only called during single-owner setup, before the translator is + /// shared. This builder and [`Self::with_extensions`] may be called in + /// either order -- each rebuilds `document_tracker` from *both* of + /// `self.resource_limits`/`self.extension_map`'s current values, + /// instead of one of them starting fresh from + /// `ResourceLimits::default()`/an empty extension map, which previously + /// meant whichever builder ran last silently discarded the other's + /// effect. + #[must_use] + pub fn with_resource_limits(mut self, limits: ResourceLimits) -> Self { + self.resource_limits = limits; + self.rebuild_document_tracker(); self } @@ -2558,7 +2597,7 @@ impl Translator { None }; - let all_logs = cache.get_logs(); + let all_logs = cache.logs(); let logs: Vec<_> = all_logs .iter() @@ -2586,7 +2625,7 @@ impl Translator { cache: &NotificationCache, limit: usize, ) -> Result { - let all_messages = cache.get_messages(); + let all_messages = cache.messages(); let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect(); Ok(ServerMessagesResult { messages }) } @@ -3262,6 +3301,7 @@ mod tests { use tempfile::TempDir; use url::Url; + use super::super::state::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE}; use super::*; /// A UTF-16 `EncodingCtx`, matching the pre-negotiation behavior: no @@ -3305,6 +3345,66 @@ mod tests { assert_eq!(lock_std(&translator.lsp_servers).len(), 0); } + /// `with_resource_limits` called before `with_extensions` (the order + /// `serve()` uses) must reach `document_tracker`. + #[test] + fn test_with_resource_limits_applies_before_with_extensions() { + let limits = ResourceLimits { + max_documents: 1, + max_file_size: 0, + }; + let translator = Translator::new() + .with_resource_limits(limits) + .with_extensions(HashMap::new()); + + translator + .document_tracker + .open(PathBuf::from("/tmp/a.rs"), "a".to_string()) + .unwrap(); + let err = translator + .document_tracker + .open(PathBuf::from("/tmp/b.rs"), "b".to_string()) + .unwrap_err(); + assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. })); + } + + /// `with_resource_limits` called *after* `with_extensions` (the reverse + /// of `serve()`'s order) must still reach `document_tracker` -- the two + /// builders must not clobber each other regardless of call order. See + /// `Translator::with_resource_limits`'s docs. + /// + /// Uses a non-empty extension map (unlike the "before" test above) and + /// asserts it survived `with_resource_limits`'s rebuild by checking the + /// tracked document's resolved `language_id` -- a bug that dropped the + /// extension map (e.g. rebuilding from `HashMap::new()` instead of + /// `self.extension_map`) would leave `max_documents` correct but the + /// extension map silently empty, which the "before" test alone cannot + /// detect. + #[test] + fn test_with_resource_limits_applies_after_with_extensions() { + let limits = ResourceLimits { + max_documents: 1, + max_file_size: 0, + }; + let translator = Translator::new() + .with_extensions(HashMap::from([("rs".to_string(), "rust".to_string())])) + .with_resource_limits(limits); + + let path = PathBuf::from("/tmp/a.rs"); + translator + .document_tracker + .open(path.clone(), "a".to_string()) + .unwrap(); + let err = translator + .document_tracker + .open(PathBuf::from("/tmp/b.rs"), "b".to_string()) + .unwrap_err(); + assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. })); + + let state = translator.document_tracker.close(&path).unwrap(); + assert_eq!(state.language_id(), "rust"); + } + #[test] fn test_set_workspace_roots() { let mut translator = Translator::new(); @@ -5696,6 +5796,8 @@ fi position_encodings: vec!["utf-8".to_string()], language_extensions: language_extensions.clone(), heuristics_max_depth: 10, + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, }, lsp_servers: vec![], project_config_ignored: false, diff --git a/crates/mcpls-core/src/config/mod.rs b/crates/mcpls-core/src/config/mod.rs index 08af1787..9d607c6d 100644 --- a/crates/mcpls-core/src/config/mod.rs +++ b/crates/mcpls-core/src/config/mod.rs @@ -17,6 +17,7 @@ pub use server::{ DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, MAX_TIMEOUT_SECONDS, ServerHeuristics, }; +use crate::bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE, ResourceLimits}; use crate::error::{Error, Result}; /// Maps file extensions to LSP language identifiers. @@ -85,6 +86,25 @@ pub struct WorkspaceConfig { /// Default: 10 #[serde(default = "default_heuristics_max_depth")] pub heuristics_max_depth: usize, + + /// Maximum number of documents `DocumentTracker` will keep open + /// simultaneously. A `textDocument/didOpen`-triggering tool call (hover, + /// definition, diagnostics, etc.) for a document beyond this count fails + /// with `DocumentLimitExceeded`. Documents stay tracked for the whole + /// mcpls process lifetime (there is no eviction), so once the ceiling is + /// reached, opening any further new path fails until either the process + /// is restarted or this limit is raised; already-tracked paths are + /// unaffected. `0` disables the limit. + /// Default: 100 + #[serde(default = "default_max_documents")] + pub max_documents: usize, + + /// Maximum size, in bytes, of a single file `DocumentTracker` will open. + /// A file larger than this fails with `FileSizeLimitExceeded`. `0` + /// disables the limit. + /// Default: 10485760 (10MB) + #[serde(default = "default_max_file_size")] + pub max_file_size: u64, } impl Default for WorkspaceConfig { @@ -94,6 +114,8 @@ impl Default for WorkspaceConfig { position_encodings: default_position_encodings(), language_extensions: default_language_extensions(), heuristics_max_depth: default_heuristics_max_depth(), + max_documents: default_max_documents(), + max_file_size: default_max_file_size(), } } } @@ -102,6 +124,14 @@ const fn default_heuristics_max_depth() -> usize { DEFAULT_HEURISTICS_MAX_DEPTH } +const fn default_max_documents() -> usize { + DEFAULT_MAX_DOCUMENTS +} + +const fn default_max_file_size() -> u64 { + DEFAULT_MAX_FILE_SIZE +} + impl WorkspaceConfig { /// Build a map of file extensions to language IDs from the configuration. /// @@ -138,6 +168,16 @@ impl WorkspaceConfig { } None } + + /// Maps the configured `max_documents`/`max_file_size` onto the bridge + /// layer's [`ResourceLimits`], for [`Translator::with_resource_limits`](crate::bridge::Translator::with_resource_limits). + #[must_use] + pub const fn resource_limits(&self) -> ResourceLimits { + ResourceLimits { + max_documents: self.max_documents, + max_file_size: self.max_file_size, + } + } } /// Extract a file extension from a glob-like file pattern. @@ -1276,6 +1316,8 @@ mod tests { }, ], heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH, + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, }; let map = workspace.build_extension_map(); @@ -1425,6 +1467,8 @@ mod tests { }, ], heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH, + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, }; assert_eq!( @@ -1769,4 +1813,140 @@ mod tests { DEFAULT_HEURISTICS_MAX_DEPTH ); } + + #[test] + fn test_max_documents_default() { + let config = WorkspaceConfig::default(); + assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS); + } + + #[test] + fn test_max_file_size_default() { + let config = WorkspaceConfig::default(); + assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE); + } + + #[test] + fn test_max_documents_from_config() { + let tmp_dir = TempDir::new().unwrap(); + let config_path = tmp_dir.path().join("limits.toml"); + + let toml_content = r" + [workspace] + max_documents = 500 + "; + + fs::write(&config_path, toml_content).unwrap(); + + let config = ServerConfig::load_from(&config_path).unwrap(); + assert_eq!(config.workspace.max_documents, 500); + } + + #[test] + fn test_max_file_size_from_config() { + let tmp_dir = TempDir::new().unwrap(); + let config_path = tmp_dir.path().join("limits.toml"); + + let toml_content = r" + [workspace] + max_file_size = 20971520 + "; + + fs::write(&config_path, toml_content).unwrap(); + + let config = ServerConfig::load_from(&config_path).unwrap(); + assert_eq!(config.workspace.max_file_size, 20_971_520); + } + + #[test] + fn test_max_documents_uses_default_when_not_specified() { + let tmp_dir = TempDir::new().unwrap(); + let config_path = tmp_dir.path().join("no_limits.toml"); + + let toml_content = r" + [workspace] + roots = [] + "; + + fs::write(&config_path, toml_content).unwrap(); + + let config = ServerConfig::load_from(&config_path).unwrap(); + assert_eq!(config.workspace.max_documents, DEFAULT_MAX_DOCUMENTS); + assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE); + } + + /// `max_file_size = 0` is the documented "unlimited" sentinel (see + /// `ResourceLimits::max_file_size`'s doc comment); config loading must + /// pass it through unchanged rather than treating `0` as "unset". + #[test] + fn test_max_file_size_zero_means_unlimited() { + let tmp_dir = TempDir::new().unwrap(); + let config_path = tmp_dir.path().join("unlimited.toml"); + + let toml_content = r" + [workspace] + max_file_size = 0 + "; + + fs::write(&config_path, toml_content).unwrap(); + + let config = ServerConfig::load_from(&config_path).unwrap(); + assert_eq!(config.workspace.max_file_size, 0); + assert_eq!(config.workspace.resource_limits().max_file_size, 0); + } + + #[test] + fn test_workspace_config_resource_limits_maps_fields() { + let workspace = WorkspaceConfig { + max_documents: 250, + max_file_size: 0, + ..WorkspaceConfig::default() + }; + + let limits = workspace.resource_limits(); + assert_eq!(limits.max_documents, 250); + assert_eq!(limits.max_file_size, 0); + } + + #[test] + fn test_workspace_config_toml_round_trip() { + let original = WorkspaceConfig { + roots: vec![PathBuf::from("/tmp/round-trip")], + position_encodings: vec!["utf-8".to_string()], + language_extensions: vec![LanguageExtensionMapping { + extensions: vec!["nu".to_string()], + language_id: "nushell".to_string(), + }], + heuristics_max_depth: 5, + max_documents: 500, + max_file_size: 0, + }; + + let toml_content = toml::to_string_pretty(&original).unwrap(); + let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap(); + + assert_eq!(round_tripped.roots, original.roots); + assert_eq!( + round_tripped.position_encodings, + original.position_encodings + ); + assert_eq!( + round_tripped.language_extensions.len(), + original.language_extensions.len() + ); + assert_eq!( + round_tripped.language_extensions[0].extensions, + original.language_extensions[0].extensions + ); + assert_eq!( + round_tripped.language_extensions[0].language_id, + original.language_extensions[0].language_id + ); + assert_eq!( + round_tripped.heuristics_max_depth, + original.heuristics_max_depth + ); + assert_eq!(round_tripped.max_documents, original.max_documents); + assert_eq!(round_tripped.max_file_size, original.max_file_size); + } } diff --git a/crates/mcpls-core/src/error.rs b/crates/mcpls-core/src/error.rs index c961dc65..7551db26 100644 --- a/crates/mcpls-core/src/error.rs +++ b/crates/mcpls-core/src/error.rs @@ -212,7 +212,9 @@ pub enum Error { PathOutsideWorkspace(PathBuf), /// Document limit exceeded. - #[error("document limit exceeded: {current}/{max}")] + #[error( + "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)" + )] DocumentLimitExceeded { /// Current number of documents. current: usize, @@ -221,7 +223,9 @@ pub enum Error { }, /// File size limit exceeded. - #[error("file size limit exceeded: {size} bytes (max: {max} bytes)")] + #[error( + "file size limit exceeded: {size} bytes, max {max} bytes (raise workspace.max_file_size in config to increase this)" + )] FileSizeLimitExceeded { /// Actual file size. size: u64, @@ -339,7 +343,10 @@ mod tests { current: 150, max: 100, }; - assert_eq!(err.to_string(), "document limit exceeded: 150/100"); + assert_eq!( + err.to_string(), + "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)" + ); } #[test] @@ -348,7 +355,10 @@ mod tests { size: 20_000_000, max: 10_000_000, }; - assert!(err.to_string().contains("file size limit exceeded")); + assert_eq!( + err.to_string(), + "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)" + ); } #[test] diff --git a/crates/mcpls-core/src/lib.rs b/crates/mcpls-core/src/lib.rs index 83c87116..fa189ceb 100644 --- a/crates/mcpls-core/src/lib.rs +++ b/crates/mcpls-core/src/lib.rs @@ -535,6 +535,7 @@ pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<() let notification_cache = Arc::new(Mutex::new(NotificationCache::new())); let mut translator = Translator::new() + .with_resource_limits(config.workspace.resource_limits()) .with_extensions(extension_map) .with_router(router) .with_notification_cache(Arc::clone(¬ification_cache)); @@ -812,6 +813,8 @@ fn spawn_lsp_servers_background( #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { + use bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE}; + use super::*; #[test] @@ -1187,6 +1190,8 @@ mod tests { position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()], language_extensions: vec![], heuristics_max_depth: 10, + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, }, lsp_servers: vec![LspServerConfig { language_id: "rust".to_string(), @@ -1238,6 +1243,8 @@ mod tests { position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()], language_extensions: vec![], heuristics_max_depth: 10, + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, }, lsp_servers: vec![], project_config_ignored: false, @@ -1273,6 +1280,8 @@ mod tests { position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()], language_extensions: vec![], heuristics_max_depth: 10, + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, }, lsp_servers: vec![LspServerConfig { language_id: "rust".to_string(), diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 5197a230..ed3ba4a9 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -200,6 +200,34 @@ language_id = "python" This reduces memory usage compared to loading all 30 default mappings. +### `workspace.max_documents` + +**Type**: Integer +**Default**: `100` + +Maximum number of documents mcpls will keep open simultaneously. A tool call (hover, definition, diagnostics, etc.) that would open a document beyond this count fails with a "document limit exceeded" error. Documents stay tracked for the whole mcpls process lifetime — there is no automatic eviction — so once the ceiling is reached, opening any further new file fails until you restart mcpls or raise this limit; already-open files are unaffected. Set to `0` to disable the limit. + +```toml +[workspace] +max_documents = 500 +``` + +Raising this limit increases mcpls's steady-state memory usage, since each open document's full content is held in memory. This is most useful for long-running agent sessions or broad-scope work (large monorepo audits, repo-wide refactors) that touch more than 100 distinct files. + +### `workspace.max_file_size` + +**Type**: Integer (bytes) +**Default**: `10485760` (10MB) + +Maximum size, in bytes, of a single file mcpls will open. A file larger than this fails with a "file size limit exceeded" error. Set to `0` to disable the limit. + +```toml +[workspace] +max_file_size = 0 # unlimited +``` + +Useful when a project contains files larger than 10MB (e.g. generated code, data fixtures) that still need LSP-backed tools to work against them. + ## LSP Server Configuration Each `[[lsp_servers]]` section defines a language server.