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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<i32>` (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)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion crates/mcpls-core/src/bridge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 13 additions & 13 deletions crates/mcpls-core/src/bridge/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogEntry> {
pub const fn logs(&self) -> &VecDeque<LogEntry> {
&self.logs
}

/// Get all stored server messages.
/// All stored server messages.
#[inline]
#[must_use]
pub const fn get_messages(&self) -> &VecDeque<ServerMessage> {
pub const fn messages(&self) -> &VecDeque<ServerMessage> {
&self.messages
}

Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand All @@ -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");
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
}

Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down
12 changes: 10 additions & 2 deletions crates/mcpls-core/src/bridge/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand Down
114 changes: 108 additions & 6 deletions crates/mcpls-core/src/bridge/translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ pub struct Translator {
lsp_servers: Arc<StdMutex<HashMap<ServerId, LspServer>>>,
/// Document state tracker. Locks its own state internally, per path.
document_tracker: Arc<DocumentTracker>,
/// 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<Vec<PathBuf>>,
Expand Down Expand Up @@ -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())),
Expand Down Expand Up @@ -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
Expand All @@ -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<String, String>) -> 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
}

Expand Down Expand Up @@ -2558,7 +2597,7 @@ impl Translator {
None
};

let all_logs = cache.get_logs();
let all_logs = cache.logs();

let logs: Vec<_> = all_logs
.iter()
Expand Down Expand Up @@ -2586,7 +2625,7 @@ impl Translator {
cache: &NotificationCache,
limit: usize,
) -> Result<ServerMessagesResult> {
let all_messages = cache.get_messages();
let all_messages = cache.messages();
let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
Ok(ServerMessagesResult { messages })
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Loading